From 33ecae2f501aa42fe220c46e63ab3ccf21c08e3b Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Mon, 25 Mar 2024 19:53:28 -0700 Subject: [PATCH] refactor!: design overhaul (#1) * refactor!: use atom functions * docs: reformat api reference * docs: showcase atom functions * docs: remove unused import * refactor: finalize updates * chore: misc edits --- README.md | 42 ++--- package.json | 4 +- src/__tests__/atom.spec.luau | 62 ++----- src/__tests__/benchmarks/atom.bench.luau | 73 ++++---- src/__tests__/capture.spec.luau | 22 +++ src/__tests__/derive.spec.luau | 72 +++----- src/__tests__/effect.spec.luau | 30 ++++ src/__tests__/init.spec.luau | 4 +- .../{mapAtom.spec.luau => map.spec.luau} | 25 +-- src/__tests__/observe.spec.luau | 9 +- src/__tests__/react/helpers/renderHook.luau | 2 +- src/__tests__/react/useSetAtom.spec.luau | 8 +- src/__tests__/subscribe.spec.luau | 55 +----- src/__tests__/sync/server.spec.luau | 13 +- src/__tests__/unwrap.spec.luau | 23 +++ src/atom.luau | 132 +++------------ src/capture.luau | 25 +++ src/derive.luau | 72 ++------ src/effect.luau | 22 +++ src/index.d.ts | 159 ++++++++---------- src/init.luau | 16 +- src/isAtom.luau | 4 +- src/map.luau | 54 ++++++ src/mapAtom.luau | 56 ------ src/modules/React.luau | 9 +- src/observe.luau | 22 ++- src/react/useAtom.luau | 4 +- src/react/useAtomState.luau | 21 +-- src/react/useSetAtom.luau | 10 +- src/store.luau | 23 +++ src/subscribe.luau | 49 ++---- src/sync/client.luau | 16 +- src/sync/init.luau | 3 - src/sync/patch.luau | 29 ++-- src/sync/server.luau | 60 +++---- src/sync/types.luau | 6 - src/types.luau | 16 ++ src/unwrap.luau | 23 +++ src/utils/reference.luau | 31 ---- 39 files changed, 577 insertions(+), 729 deletions(-) create mode 100644 src/__tests__/capture.spec.luau create mode 100644 src/__tests__/effect.spec.luau rename src/__tests__/{mapAtom.spec.luau => map.spec.luau} (75%) create mode 100644 src/__tests__/unwrap.spec.luau create mode 100644 src/capture.luau create mode 100644 src/effect.luau create mode 100644 src/map.luau delete mode 100644 src/mapAtom.luau create mode 100644 src/store.luau delete mode 100644 src/sync/types.luau create mode 100644 src/types.luau create mode 100644 src/unwrap.luau delete mode 100644 src/utils/reference.luau diff --git a/README.md b/README.md index 2f447fc..f4db5e8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- Logo + Logo

Charm

@@ -45,37 +45,23 @@ Add `littensy/charm` to your `wally.toml` file. Charm = "littensy/charm@VERSION" ``` -## 📚 Documentation +## 📚 API Reference -### Core +### Charm #### `atom(state)` -#### `derive(...atoms, combiner)` +#### `effect(callback)` #### `subscribe(atom, listener)` +#### `derive(atom, equals?)` + #### `observe(atom, observer)` -#### `mapAtom(atom, mapper)` +#### `map(atom, mapper)` -### Atom - -#### `Atom(state?)` - -#### `Atom.get(selector?)` - -#### `Atom.set(state)` - -#### `Atom.memo(areEqual)` - -#### `Atom.readonly()` - -#### `Atom.writable(onSet?)` - -#### `Atom.named(label)` - -#### `Atom.unmount()` +#### `unwrap(atom)` ### React @@ -93,7 +79,7 @@ Charm = "littensy/charm@VERSION" #### `sync.server(options)` -#### `Server.onSync(callback)` +#### `Server.connect(callback)` #### `Server.hydrate(player)` @@ -102,10 +88,10 @@ Charm = "littensy/charm@VERSION" ### Counter atom ```ts -import { atom, derive, subscribe } from "@rbxts/charm"; +import { atom, subscribe } from "@rbxts/charm"; const counterAtom = atom(0); -const doubleCounterAtom = derive(counterAtom, (counter) => counter * 2); +const doubleCounterAtom = () => counterAtom() * 2; subscribe(doubleCounterAtom, (value) => { print(value); @@ -137,12 +123,6 @@ function Counter() { } ``` -## 🙋 Concerns - -In Jotai, `atom()` returns an [atom config](https://jotai.org/docs/core/atom), and its state is managed entirely by the Provider. However, in Charm, `atom()` returns something closer to a store, and its state is managed by the atom itself. - -Charm does this for simplicity in use cases outside of React, but this might go against Jotai's focus on pure functional programming. More insight is needed to determine if this is a good idea. - ---

diff --git a/package.json b/package.json index 0936d98..c302271 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,7 @@ "homepage": "https://github.com/littensy/charm", "files": [ "src", - "default.project.json", - "images/logo.png", - "!src/**/*.spec.ts" + "!src/**/*.spec.luau" ], "publishConfig": { "access": "public" diff --git a/src/__tests__/atom.spec.luau b/src/__tests__/atom.spec.luau index c41fa63..09cfbb7 100644 --- a/src/__tests__/atom.spec.luau +++ b/src/__tests__/atom.spec.luau @@ -1,4 +1,5 @@ local atom = require(script.Parent.Parent.atom) +local store = require(script.Parent.Parent.store) return function() it("updates state", function() @@ -14,10 +15,10 @@ return function() it("calls listeners", function() local source = atom(1) local value - local function listener(state) - value = state + local function listener() + value = source() end - source.listeners[listener] = true + store.listeners[source][listener] = true source(2) expect(value).to.equal(2) end) @@ -31,9 +32,9 @@ return function() local function listener() inner += 1 end - source.listeners[listener] = true + store.listeners[source][listener] = true end - source.listeners[listener] = true + store.listeners[source][listener] = true source(2) expect(outer).to.equal(1) expect(inner).to.equal(0) @@ -42,44 +43,17 @@ return function() expect(inner).to.equal(1) end) - it("can be readonly", function() - local readonly = atom(1):readonly() - - expect(function() - (readonly :: any)(2) - end).to.throw() - - local writable = readonly:writable() - - writable(2) - expect(writable()).to.equal(2) - end) - - it("calls the setter", function() - local source = atom(1):writable(function(_, value: number) - return value + 1 - end) - source(1) - expect(source()).to.equal(2) - end) - - it("cancels if setter returns nil", function() - local source = atom(1):writable(function(value: number) - return if value == 2 then nil else value - end) - source(2) - expect(source()).to.equal(1) - source(3) - expect(source()).to.equal(3) - end) - - it("can be memoized", function() - local source = atom(1):memo(function(prev, next) - return next < prev - end) - source(2) - expect(source()).to.equal(2) - source(1) - expect(source()).to.equal(2) + it("takes a custom comparator", function() + local max = atom(1, { + equals = function(prev, next) + return next < prev + end, + }) + max(2) + expect(max()).to.equal(2) + max(1) + expect(max()).to.equal(2) + max(3) + expect(max()).to.equal(3) end) end diff --git a/src/__tests__/benchmarks/atom.bench.luau b/src/__tests__/benchmarks/atom.bench.luau index 2bca275..4f44024 100644 --- a/src/__tests__/benchmarks/atom.bench.luau +++ b/src/__tests__/benchmarks/atom.bench.luau @@ -6,48 +6,41 @@ local Package = game:GetService("ReplicatedStorage").Packages.charm local atom = require(Package.atom) local derive = require(Package.derive) -return { - ParameterGenerator = function() - return atom({ count = 0 }) +local function parameterGenerator() + return atom({ count = 0 }) +end + +local benchmarks = { + atom = function(_, source: any) + for _ = 1, 500 do + source({ count = math.random(1, 2) }) + end end, - Functions = { - atom = function(_, source: any) - for _ = 1, 500 do - source({ count = math.random(1, 2) }) - end - end, + derived = function(_, source: any) + derive(function() + return { count = 2 * source().count } + end) + for _ = 1, 500 do + source({ count = math.random(1, 2) }) + end + end, - derived = function(_, source: any) - derive(source, function(state) - return { count = 2 * state.count } - end) - for _ = 1, 500 do - source({ count = math.random(1, 2) }) - end - end, - - derived_memo = function(_, source: any) - source:memo(function(previous, current) + derived_memo = function(_, source: any) + derive(function() + return { count = 2 * source().count } + end, { + equals = function(previous, current) return previous.count == current.count - end) - derive(source, function(state) - return { count = 2 * state.count } - end) - for _ = 1, 500 do - source({ count = math.random(1, 2) }) - end - end, - - derived_writable = function(_, source: any) - local derived = derive(source, function(state) - return { count = 2 * state.count } - end):writable(function(update) - source({ count = 0.5 * update.count }) - end) - for _ = 1, 500 do - derived({ count = 2 * math.random(1, 2) }) - end - end, - }, + end, + }) + for _ = 1, 500 do + source({ count = math.random(1, 2) }) + end + end, +} + +return { + ParameterGenerator = parameterGenerator, + Functions = benchmarks, } diff --git a/src/__tests__/capture.spec.luau b/src/__tests__/capture.spec.luau new file mode 100644 index 0000000..ae33d99 --- /dev/null +++ b/src/__tests__/capture.spec.luau @@ -0,0 +1,22 @@ +local atom = require(script.Parent.Parent.atom) +local capture = require(script.Parent.Parent.capture) + +return function() + it("captures atoms from a function", function() + local a, b, c = atom(1), atom(1), atom(2) + local captured, result = capture(function() + return a() + b() + end) + expect(captured[a]).to.be.ok() + expect(captured[b]).to.be.ok() + expect(captured[c]).to.never.be.ok() + expect(result).to.equal(2) + end) + + it("captures an atom by itself", function() + local a = atom(1) + local captured, result = capture(a) + expect(captured[a]).to.be.ok() + expect(result).to.equal(1) + end) +end diff --git a/src/__tests__/derive.spec.luau b/src/__tests__/derive.spec.luau index 00b60a2..3021a76 100644 --- a/src/__tests__/derive.spec.luau +++ b/src/__tests__/derive.spec.luau @@ -1,64 +1,40 @@ local atom = require(script.Parent.Parent.atom) local derive = require(script.Parent.Parent.derive) +local store = require(script.Parent.Parent.store) return function() - it("derives from dependencies", function() - local a = atom(1) - local b = atom(2) - local sum = derive(a, b, function(a: number, b: number) - return a + b - end) - expect(sum()).to.equal(3) - a(2) - expect(sum()).to.equal(4) - b(3) - expect(sum()).to.equal(5) - expect(function() - (sum :: any)(2) - end).to.throw() - end) - it("memoizes the result", function() local calls = 0 - local source = atom(1) - local double = derive(source, function(value: number) + local a = atom(1) + local b = atom(2) + local double = derive(function() calls += 1 - return value * 2 + return a() * b() end) expect(calls).to.equal(1) - source(1) + a(1) expect(calls).to.equal(1) - source(2) + a(2) expect(calls).to.equal(2) + b(3) + expect(calls).to.equal(3) double() - expect(calls).to.equal(2) + expect(calls).to.equal(3) end) - it("can be a dependency", function() + it("can be nested", function() local source = atom(1) - local double = derive(source, function(value: number) - return value * 2 + local double = derive(function() + return source() * 2 end) - local quadruple = derive(double, function(value: number) - return value * 2 + local quadruple = derive(function() + return double() * 2 end) expect(quadruple()).to.equal(4) source(2) expect(quadruple()).to.equal(8) end) - it("allows custom setter", function() - local source = atom(1) - local double = derive(source, function(value: number) - return value * 2 - end):writable(function(update: number) - source(update / 2) - end) - double(4) - expect(source()).to.equal(2) - expect(double()).to.equal(4) - end) - it("garbage collects", function() -- large values coerce the garbage collector to run local source = atom(string.rep("x", 1e5)) @@ -66,7 +42,7 @@ return function() local function listenersDecreased() local count = 0 - for _ in source.listeners do + for _ in pairs(store.listeners[source]) do count += 1 end max = math.max(max, count) @@ -74,11 +50,11 @@ return function() end for index = 1, 500 do - derive(source, function(value) - return string.upper(value) + derive(function() + return string.upper(source()) end) if index % 50 == 0 then - -- derived atoms check if they are collected when they are updated + -- derived atoms should be collected when memory is stressed source(source() .. "x") task.wait() end @@ -89,14 +65,4 @@ return function() error("None of the derived atoms were collected") end) - - it("unmounts from parent atom", function() - local source = atom(1) - local derived = derive(source, function(value) - return value - end) - expect((next(source.listeners))).to.be.ok() - derived:unmount() - expect((next(source.listeners))).to.never.be.ok() - end) end diff --git a/src/__tests__/effect.spec.luau b/src/__tests__/effect.spec.luau new file mode 100644 index 0000000..d1b0337 --- /dev/null +++ b/src/__tests__/effect.spec.luau @@ -0,0 +1,30 @@ +local atom = require(script.Parent.Parent.atom) +local effect = require(script.Parent.Parent.effect) + +return function() + it("runs on state change", function() + local a = atom(0) + local b = atom(0) + local current + local unsubscribe = effect(function() + current = a() + b() + end) + + expect(current).to.equal(0) + + for index = 1, 3 do + a(index) + expect(current).to.equal(index) + end + + for index = 1, 3 do + b(index) + expect(current).to.equal(a() + index) + end + + unsubscribe() + a(4) + + expect(current).to.equal(6) + end) +end diff --git a/src/__tests__/init.spec.luau b/src/__tests__/init.spec.luau index c37cf45..a7878dc 100644 --- a/src/__tests__/init.spec.luau +++ b/src/__tests__/init.spec.luau @@ -1,4 +1,4 @@ -local reference = require(script.Parent.Parent.utils.reference) +local store = require(script.Parent.Parent.store) return function() local previous = _G.__DEV__ @@ -12,6 +12,6 @@ return function() end) afterEach(function() - reference.clear() + table.clear(store.listeners :: any) end) end diff --git a/src/__tests__/mapAtom.spec.luau b/src/__tests__/map.spec.luau similarity index 75% rename from src/__tests__/mapAtom.spec.luau rename to src/__tests__/map.spec.luau index 05ec132..79b5916 100644 --- a/src/__tests__/mapAtom.spec.luau +++ b/src/__tests__/map.spec.luau @@ -1,16 +1,17 @@ local atom = require(script.Parent.Parent.atom) -local mapAtom = require(script.Parent.Parent.mapAtom) +local map = require(script.Parent.Parent.map) local observe = require(script.Parent.Parent.observe) +local store = require(script.Parent.Parent.store) return function() it("returns mapped atoms", function() local itemsAtom = atom({ "a", "b", "c" }) - local mappedAtom = mapAtom(itemsAtom, function(value, key) + local mappedAtom = map(itemsAtom, function(value, key) return string.upper(value), key end) local updates = 0 - mappedAtom.listeners[function() + store.listeners[mappedAtom][function() updates += 1 end] = true @@ -34,7 +35,7 @@ return function() it("uses key extractor", function() local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } }) - local mappedAtom = mapAtom(itemsAtom, function(item) + local mappedAtom = map(itemsAtom, function(item) return item, item.key end) @@ -60,14 +61,14 @@ return function() { key = 3, value = "c" }, }) - local mappedAtom = mapAtom(itemsAtom, function(item) + local mappedAtom = map(itemsAtom, function(item) return item, item.key end) local added = 0 local deleted = 0 - local _, flush = observe(mappedAtom, function(item, key) + observe(mappedAtom, function(item, key) expect(item).to.equal(itemsAtom()[key]) added += 1 @@ -81,25 +82,13 @@ return function() expect(deleted).to.equal(0) itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) - flush() expect(added).to.equal(3) expect(deleted).to.equal(1) itemsAtom({}) - flush() expect(added).to.equal(3) expect(deleted).to.equal(3) end) - - it("unmounts from parent atom", function() - local itemsAtom = atom({}) - local mappedAtom = mapAtom(itemsAtom, function(item, key) - return item, key - end) - expect((next(itemsAtom.listeners))).to.be.ok() - mappedAtom:unmount() - expect((next(itemsAtom.listeners))).to.never.be.ok() - end) end diff --git a/src/__tests__/observe.spec.luau b/src/__tests__/observe.spec.luau index 79e8953..a4e6b63 100644 --- a/src/__tests__/observe.spec.luau +++ b/src/__tests__/observe.spec.luau @@ -6,7 +6,7 @@ return function() local itemsAtom = atom({ "a", "b", "c" }) local state = {} - local cleanup, flush = observe(itemsAtom, function(item, key) + local cleanup = observe(itemsAtom, function(item, key) state[key] = item return function() state[key] = nil @@ -14,14 +14,13 @@ return function() end) for key = 1, 3 do - expect(state[key]).to.equal(itemsAtom:get()[key]) + expect(state[key]).to.equal(itemsAtom()[key]) end - itemsAtom:set({ [4] = "d", [5] = "e", [6] = "f" }) - flush() + itemsAtom({ [4] = "d", [5] = "e", [6] = "f" }) for key = 1, 6 do - expect(state[key]).to.equal(itemsAtom:get()[key]) + expect(state[key]).to.equal(itemsAtom()[key]) end cleanup() diff --git a/src/__tests__/react/helpers/renderHook.luau b/src/__tests__/react/helpers/renderHook.luau index 1714a66..020b2ae 100644 --- a/src/__tests__/react/helpers/renderHook.luau +++ b/src/__tests__/react/helpers/renderHook.luau @@ -2,7 +2,7 @@ _G.__ROACT_17_MOCK_SCHEDULER__ = true local Package = script:FindFirstAncestor("__tests__").Parent -local React = require(Package.modules.React) +local React = require(Package.modules.React)() local ReactRoblox = require(Package.modules.ReactRoblox) type RenderedHook = { diff --git a/src/__tests__/react/useSetAtom.spec.luau b/src/__tests__/react/useSetAtom.spec.luau index c838be9..325af5f 100644 --- a/src/__tests__/react/useSetAtom.spec.luau +++ b/src/__tests__/react/useSetAtom.spec.luau @@ -24,11 +24,11 @@ return function() end) it("memoizes the function", function() - local hook = renderHook(function(source) - return useSetAtom(source) - end, atom(0)) + local hook = renderHook(function(props) + return useSetAtom(props.source) + end, { source = atom(0) }) local fn = hook.result - hook.rerender(atom(1)) + hook.rerender({ source = atom(1) }) expect(hook.result).to.never.equal(fn) end) end diff --git a/src/__tests__/subscribe.spec.luau b/src/__tests__/subscribe.spec.luau index 99c33f5..cf6d379 100644 --- a/src/__tests__/subscribe.spec.luau +++ b/src/__tests__/subscribe.spec.luau @@ -6,62 +6,23 @@ return function() it("runs on state change", function() local current, previous local source = atom(0) - local unsubscribe, flush = subscribe(source, function(...) + local unsubscribe = subscribe(source, function(...) current, previous = ... end) for index = 1, 3 do source(index) - flush() expect(current).to.equal(index) expect(previous).to.equal(index - 1) end unsubscribe() source(4) - flush() expect(current).to.equal(3) expect(previous).to.equal(2) end) - it("batches updates", function() - local current, previous - local source = atom(0) - local _, flush = subscribe(source, function(...) - current, previous = ... - end) - - source(1) - source(2) - flush() - - expect(current).to.equal(2) - expect(previous).to.equal(0) - end) - - it("checks equality", function() - local current, previous - local source = atom(0):memo(function(previous, current) - return current < previous - end) - local _, flush = subscribe(source, function(...) - current, previous = ... - end) - - source(1) - flush() - - expect(current).to.equal(1) - expect(previous).to.equal(0) - - source(0) - flush() - - expect(current).to.equal(1) - expect(previous).to.equal(0) - end) - it("prevents garbage collect", function() local source = atom(1) local updates = 0 @@ -70,15 +31,13 @@ return function() -- The idea here is to test whether subscribe() prevents every derived -- atom in a chain from being garbage collected. If any are GCed, then -- the subscription will stop updating and the test will fail. + local derived = derive(function() + return source() + end) subscribe( - derive( - derive(source, function(x) - return x - end), - function(x) - return 2 * x - end - ), + derive(function() + return derived() * 2 + end), function() updates += 1 end diff --git a/src/__tests__/sync/server.spec.luau b/src/__tests__/sync/server.spec.luau index ec00984..972d78b 100644 --- a/src/__tests__/sync/server.spec.luau +++ b/src/__tests__/sync/server.spec.luau @@ -1,5 +1,6 @@ local atom = require(script.Parent.Parent.Parent.atom) local sync = require(script.Parent.Parent.Parent.sync) +local types = require(script.Parent.Parent.Parent.types) return function() local target: Player = {} :: any @@ -7,9 +8,9 @@ return function() it("sends state to new players", function() local a = atom(1) local server = sync.server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: sync.SyncPayload + local player: Player, payload: types.SyncPayload - server:onSync(function(...) + local disconnect = server:connect(function(...) player, payload = ... end) @@ -18,14 +19,16 @@ return function() expect(player).to.equal(target) expect(payload.type).to.equal("set") expect(payload.data.a).to.equal(1) + + disconnect() end) it("sends state patches", function() local a = atom({ b = 1, c = 2 }) local server = sync.server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: sync.SyncPayload + local player: Player, payload: types.SyncPayload - server:onSync(function(...) + local disconnect = server:connect(function(...) player, payload = ... end) @@ -36,5 +39,7 @@ return function() expect(payload.type).to.equal("patch") expect(payload.data.a.b).to.never.be.ok() expect(payload.data.a.c).to.equal(5) + + disconnect() end) end diff --git a/src/__tests__/unwrap.spec.luau b/src/__tests__/unwrap.spec.luau new file mode 100644 index 0000000..41b2b12 --- /dev/null +++ b/src/__tests__/unwrap.spec.luau @@ -0,0 +1,23 @@ +local atom = require(script.Parent.Parent.atom) +local capture = require(script.Parent.Parent.capture) +local unwrap = require(script.Parent.Parent.unwrap) + +return function() + it("calls an atom without capturing it", function() + local a, b, c = atom(1), atom(1), atom(1) + local captured, result = capture(function() + return a() + unwrap(b) + c() + end) + expect(captured[a]).to.be.ok() + expect(captured[b]).to.never.be.ok() + expect(captured[c]).to.be.ok() + expect(result).to.equal(3) + end) + + it("passes through arguments", function() + expect(unwrap(1)).to.equal(1) + expect(unwrap(function() + return 1 + end)).to.equal(1) + end) +end diff --git a/src/atom.luau b/src/atom.luau index d26d976..3dd550d 100644 --- a/src/atom.luau +++ b/src/atom.luau @@ -1,126 +1,34 @@ -local reference = require(script.Parent.utils.reference) +local store = require(script.Parent.store) +local types = require(script.Parent.types) +type Atom = types.Atom +type AtomOptions = types.AtomOptions -type AtomInternal = { - setForced: (self: AtomInternal, state: T | ((T) -> T)) -> (), - listeners: { [(T) -> ()]: true }, - debugTraceback: string, - debugLabel: string, - owners: { [any]: true }, -} +local function atom(state: T, options: AtomOptions?): Atom + local equals = options and options.equals -export type Atom = (() -> T) & ((state: T | ((T) -> T)) -> ()) & { - get: ((self: Atom) -> T) & ((self: Atom, selector: (T) -> Result) -> Result), - set: (self: Atom, state: T | ((T) -> T)) -> (), - memo: (self: Atom, areEqual: (previous: T, current: T) -> boolean) -> Atom, - writable: (self: Atom, onSet: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom, - readonly: (self: Atom) -> ReadonlyAtom, - named: (self: Atom, label: string) -> Atom, - unmount: (self: Atom) -> (), -} & AtomInternal - -export type ReadonlyAtom = (() -> T) & { - get: ((self: ReadonlyAtom) -> T) & ((self: ReadonlyAtom, selector: (T) -> Result) -> Result), - memo: (self: ReadonlyAtom, areEqual: (previous: T, current: T) -> boolean) -> ReadonlyAtom, - writable: ((self: Atom) -> Atom) - & ((self: Atom, onSet: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom), - readonly: (self: ReadonlyAtom) -> ReadonlyAtom, - named: (self: ReadonlyAtom, label: string) -> ReadonlyAtom, - unmount: (self: ReadonlyAtom) -> (), -} & AtomInternal - -local atomCount = 0 - -local function atom(state: State): Atom - atomCount += 1 - - local atom = { - listeners = {}, - debugTraceback = debug.traceback(nil, 2), - debugLabel = `atom{atomCount}`, - owners = {}, - } :: Atom - - local equals: ((State, State) -> boolean)? - local setter: ((State, State) -> State?)? - - function atom:get(selector: ((State) -> Result)?): any - return if selector then selector(state) else state - end - - function atom:set(update: State | ((State) -> State)) - local nextState = if type(update) == "function" then update(state) else update - - if setter then - local result = setter(nextState, state) - - if result ~= nil then - nextState = result - else - return + local function atom(...) + if select("#", ...) == 0 then + if store.shouldCapture() then + store.captured[atom] = true end + + return state end - if not (if equals then equals(state, nextState) else state == nextState) then + local nextState = if type(...) == "function" then (...)(state) else ... + + if state ~= nextState and not (equals and equals(state, nextState)) then state = nextState - for listener in table.clone(self.listeners) do - listener(state) + for listener in pairs(table.clone(store.listeners[atom])) do + listener() end end + + return state end - function atom:setForced(update: State | ((State) -> State)) - local nextState = if type(update) == "function" then update(state) else update - - if not (if equals then equals(state, nextState) else state == nextState) then - state = nextState - - for listener in table.clone(self.listeners) do - listener(state) - end - end - end - - function atom:memo(areEqual: (State, State) -> boolean): Atom - equals = areEqual - return self - end - - function atom:writable(onSet: ((State, State) -> State?)?): Atom - setter = onSet - return self - end - - function atom:readonly(): ReadonlyAtom - function setter(_, _) - if _G.__DEV__ then - error(`Attempt to set read-only atom '{self.debugLabel}' created at: {self.debugTraceback}`, 3) - end - end - return self - end - - function atom:named(name: string): Atom - self.debugLabel = name - return self - end - - function atom:unmount() - table.clear(self.listeners) - table.clear(self.owners) - reference.release(self) - end - - setmetatable(atom :: AtomInternal, { - __type = "Atom", - __call = function(self, ...) - if select("#", ...) > 0 then - return self:set((...)) - else - return self:get() - end - end, - }) + store.listeners[atom] = setmetatable({}, { __mode = "v" }) return atom end diff --git a/src/capture.luau b/src/capture.luau new file mode 100644 index 0000000..111e52c --- /dev/null +++ b/src/capture.luau @@ -0,0 +1,25 @@ +local store = require(script.Parent.store) +local types = require(script.Parent.types) +type Atom = types.Atom +type Source = types.Source + +local function capture(source: Source): ({ [Atom]: true }, T) + if store.listeners[source] then + return { [source] = true }, source() + end + + store.setCapturing(true) + local success, result = pcall(source) + store.setCapturing(false) + + local dependencies = table.clone(store.captured) + table.clear(store.captured) + + if not success then + warn("An error occurred while calling an atom:", result) + end + + return dependencies, result +end + +return capture diff --git a/src/derive.luau b/src/derive.luau index b67d2c9..444db17 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -1,65 +1,27 @@ local atom = require(script.Parent.atom) -type Atom = atom.Atom -type ReadonlyAtom = atom.ReadonlyAtom -local reference = require(script.Parent.utils.reference) +local capture = require(script.Parent.capture) +local store = require(script.Parent.store) +local types = require(script.Parent.types) +type Atom = types.Atom +type AtomOptions = types.AtomOptions +type Source = types.Source -type Derive = - ((Atom, (A) -> T) -> ReadonlyAtom) - & ((Atom, Atom, (A, B) -> T) -> ReadonlyAtom) - & ((Atom, Atom, Atom, (A, B, C) -> T) -> ReadonlyAtom) - & ((Atom, Atom, Atom, Atom, (A, B, C, D) -> T) -> ReadonlyAtom) - & ((Atom, Atom, Atom, Atom, Atom, (A, B, C, D, E) -> T) -> ReadonlyAtom) - & ((...(Atom | ((...any) -> T))) -> ReadonlyAtom) +local function derive(source: Source, options: AtomOptions?): Source + local atoms, state = capture(source) + local derived = atom(state, options) + local derivedRef = setmetatable({ current = derived }, { __mode = "v" }) -local function derive(...): ReadonlyAtom - local atoms: { Atom } = { ... } - local combiner: (...unknown) -> Result = table.remove(atoms) :: any - local size = 0 - local dependencies: { unknown } = {} - local listeners: { [() -> ()]: boolean } = {} - - for index, atom in atoms do - dependencies[index] = atom:get() - size += 1 - end - - local derivedAtom = atom(combiner(table.unpack(dependencies, 1, size))):readonly() - local derivedAtomUnmount = derivedAtom.unmount - local getDerivedAtom = reference.weak(derivedAtom) - - function derivedAtom:unmount() - derivedAtomUnmount(self) - - for listener in listeners do - for _, atom in atoms do - atom.listeners[listener] = nil - end + local function listener() + if derivedRef.current then + derivedRef.current(source()) end end - for index, atom in atoms do - local function listener(state: unknown) - -- Remove the listener if the derived atom has been garbage collected, - -- typically because no subscriptions to it exist. - local derivedAtom = getDerivedAtom() - - if derivedAtom then - dependencies[index] = state - derivedAtom:setForced(combiner(table.unpack(dependencies, 1, size))) - else - atom.listeners[listener] = nil - end - end - - -- Tie the lifetime of the derived atom to the lifetime of its dependencies - -- without creating a strong reference cycle. As long as an atom is in use, - -- its derived atoms will be in use as well. - derivedAtom.owners[atom] = true - atom.listeners[listener] = true - listeners[listener] = true + for atom in atoms do + store.listeners[atom][listener] = derived end - return derivedAtom + return derived end -return derive :: Derive +return derive diff --git a/src/effect.luau b/src/effect.luau new file mode 100644 index 0000000..8a6e685 --- /dev/null +++ b/src/effect.luau @@ -0,0 +1,22 @@ +local capture = require(script.Parent.capture) +local store = require(script.Parent.store) + +local function effect(source: () -> nil): () -> () + local atoms = capture(source) + + local function listener() + source() + end + + for atom in atoms do + store.listeners[atom][listener] = true + end + + return function() + for atom in atoms do + store.listeners[atom][listener] = nil + end + end +end + +return effect diff --git a/src/index.d.ts b/src/index.d.ts index 127055e..89ebe1c 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,134 +1,109 @@ export = Charm; export as namespace Charm; +type Cleanup = () => void; + +type AnyMap = + | Map + | ReadonlyMap + | (K extends string | number | symbol ? { readonly [Key in K]: V } : never); + declare namespace Charm { - interface InternalAtom { - /** @hidden */ - readonly listeners: Set<(value: State) => void>; - /** @hidden */ - readonly debugTraceback: string; - /** @hidden */ - readonly debugLabel: string; - /** @hidden */ - setForced(state: State | ((current: State) => State)): void; + interface Atom extends Source { + readonly __nominal: unique symbol; + (state: T | ((prev: T) => T)): void; } - interface BaseAtom extends InternalAtom { - get(): State; - get(selector: (state: State) => Result): Result; - set(state: State | ((current: State) => State)): void; - memo(areEqual: (previous: State, current: State) => boolean): this; - writable(onSet?: (update: State, previous: State) => State | void): Atom; - readonly(): ReadonlyAtom; - named(label: string): this; - unmount(): void; + type Source = () => T; + + interface AtomOptions { + equals?: (prev: State, next: State) => boolean; } - interface Atom extends BaseAtom { - (): State; - (state: State | ((previous: State) => State)): void; - } - - interface ReadonlyAtom extends BaseAtom { - (): State; - set: never; - } - - type AtomArrayToStates[]> = { - [Key in keyof Atoms]: Atoms[Key] extends Atom ? State : never; - }; - - function atom(state: State): Atom; + function atom(state: State, options?: AtomOptions): Atom; function isAtom(value: unknown): value is Atom; - function derive[], Result>( - ...args: [...atoms: Atoms, combiner: (...atoms: AtomArrayToStates) => Result] - ): ReadonlyAtom; + function derive(atom: Source, options?: AtomOptions): Source; - function subscribe(atom: Atom, callback: (state: State) => void): () => void; + function subscribe(atom: Source, callback: (state: State, prev: State) => void): Cleanup; - function observe( - atom: Atom | ReadonlyMap>, - factory: (value: V, key: K) => (() => void) | void, - ): () => void; + function effect(callback: Source): Cleanup; - function observe(atom: Atom, factory: (value: unknown, key: unknown) => (() => void) | void): () => void; + function unwrap(atom: State | Source): State; - function mapAtom( - atom: Atom | Atom, - mapper: (value: V0, index: number) => LuaTuple<[V1 | undefined, K1]>, - ): ReadonlyAtom>; + function capture(atom: Source): LuaTuple<[captured: Set>, state: State]>; - function mapAtom( - atom: Atom | Atom, - mapper: (value: V0, index: number) => V1, - ): ReadonlyAtom; + function observe( + atom: Source, + factory: (item: Item, index: number) => Cleanup | void, + ): Cleanup; - function mapAtom( - atom: Atom> | Atom>, - mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1, - ): ReadonlyAtom>; + function observe( + atom: Source>, + factory: (item: Item, key: Key) => Cleanup | void, + ): Cleanup; - function mapAtom( - atom: Atom<{ readonly [K in K0]: V0 }>, - mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1, - ): ReadonlyAtom>; + function map( + atom: Source, + mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>, + ): Source>; - function useAtomState(atom: Atom): State; + function map(atom: Source, mapper: (value: V0, index: number) => V1): Source; - function useAtomState(atom: Atom, selector: (state: State) => Result): Result; + function map( + atom: Source>, + mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, + ): Source>; - function useSetAtom(atom: Atom): (state: State | ((previous: State) => State)) => void; + // React + + function useAtomState(atom: Source): State; + + function useAtomState(atom: Source, selector: (state: State) => Result): Result; + + function useSetAtom(atom: Source): (state: State | ((prev: State) => State)) => void; function useAtom( - atom: Atom, - ): LuaTuple<[state: State, setState: (state: State | ((previous: State) => State)) => void]>; + atom: Source, + ): LuaTuple<[state: State, setState: (state: State | ((prev: State) => State)) => void]>; function useAtom( - atom: Atom, + atom: Source, selector: (state: State) => Result, - ): LuaTuple<[state: Result, setState: (state: Result | ((previous: Result) => Result)) => void]>; + ): LuaTuple<[state: Result, setState: (state: Result | ((prev: Result) => Result)) => void]>; - interface None { - readonly __none: "__none"; - } + // Sync - type StatePatch = { - readonly [K in keyof States]?: (States[K] extends object ? StatePatch : States[K]) | None; + const sync: { + client(options: ClientOptions): ClientSyncer; + server(options: ServerOptions): ServerSyncer; }; - type SyncPayload = { type: "set"; data: States } | { type: "patch"; data: StatePatch }; - - interface AtomMap { - readonly [key: string]: Atom; + interface SyncPatch { + [key: string | number | symbol]: SyncPatch | { __none: "__none" } | string | number | boolean | undefined; } - type AtomMapToStates = { - readonly [Key in keyof Atoms]: Atoms[Key] extends Atom ? State : never; - }; - - interface ClientSyncOptions { - atoms: Atoms; + interface SyncPayload { + type: "set" | "patch"; + data: SyncPatch; } - interface ClientSyncer { - sync(payload: SyncPayload): void; + interface ClientOptions { + atoms: Record>; } - interface ServerSyncOptions { - atoms: Atoms; + interface ServerOptions { + atoms: Record>; interval?: number; } - interface ServerSyncer { - onSync(callback: (player: Player, payload: SyncPayload) => void): () => void; + interface ClientSyncer { + sync(payload: SyncPayload): void; + } + + interface ServerSyncer { + connect(callback: (player: Player, payload: SyncPayload) => void): Cleanup; hydrate(player: Player): void; } - - namespace sync { - function client(options: ClientSyncOptions): ClientSyncer>; - - function server(options: ServerSyncOptions): ServerSyncer>; - } } diff --git a/src/init.luau b/src/init.luau index 95549b4..9747c78 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,26 +1,34 @@ _G[script.modules.Promise] = _G[script] local atom = require(script.atom) +local capture = require(script.capture) local derive = require(script.derive) +local effect = require(script.effect) local isAtom = require(script.isAtom) -local mapAtom = require(script.mapAtom) +local map = require(script.map) local observe = require(script.observe) local useAtom = require(script.react.useAtom) local useAtomState = require(script.react.useAtomState) local useSetAtom = require(script.react.useSetAtom) local subscribe = require(script.subscribe) local sync = require(script.sync) +local types = require(script.types) +local unwrap = require(script.unwrap) -export type Atom = atom.Atom -export type ReadonlyAtom = atom.ReadonlyAtom +export type Atom = types.Atom +export type Source = types.Source +export type SyncPayload = types.SyncPayload return { atom = atom, derive = derive, + effect = effect, isAtom = isAtom, + map = map, observe = observe, - mapAtom = mapAtom, subscribe = subscribe, + unwrap = unwrap, + capture = capture, sync = sync, useAtom = useAtom, useAtomState = useAtomState, diff --git a/src/isAtom.luau b/src/isAtom.luau index bd96e97..2f269f3 100644 --- a/src/isAtom.luau +++ b/src/isAtom.luau @@ -1,5 +1,7 @@ +local store = require(script.Parent.store) + local function isAtom(value: any) - return type(value) == "table" and getmetatable(value) and getmetatable(value).__type == "Atom" or false + return value ~= nil and store.listeners[value] :: any ~= nil end return isAtom diff --git a/src/map.luau b/src/map.luau new file mode 100644 index 0000000..3024d64 --- /dev/null +++ b/src/map.luau @@ -0,0 +1,54 @@ +local atom = require(script.Parent.atom) +local subscribe = require(script.Parent.subscribe) +local types = require(script.Parent.types) +type Atom = types.Atom +type Source = types.Source + +type Map = + ((atom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Source<{ [K1]: V1 }>) + & ((atom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Source<{ [K0]: V1 }>) + & ((atom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Source<{ [K1]: V1 }>) + +local function map(itemsAtom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Source<{ [K1]: V1 }> + local mappedAtom = atom({}) + local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" }) + local prevMappedItems: { [K1]: V1 } = {} + local unsubscribe + + local function listener(items: { [K0]: V0 }) + if not mappedAtomRef.current then + return unsubscribe() + end + + local mappedItems = table.clone(mappedAtomRef.current()) + local mappedKeys = {} + + for key, item in items do + local newItem, newKey = mapper(item, key) + if newKey == nil then + newKey = key :: any + end + if mappedItems[newKey :: K1] ~= newItem then + mappedItems[newKey :: K1] = newItem :: V1 + else + mappedKeys[newKey] = key + end + end + + for key in prevMappedItems do + if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then + mappedItems[key] = nil + end + end + + prevMappedItems = mappedItems + mappedAtom(mappedItems) + end + + unsubscribe = subscribe(itemsAtom, listener) + listener(itemsAtom()) + + return mappedAtom +end + +return map :: Map diff --git a/src/mapAtom.luau b/src/mapAtom.luau deleted file mode 100644 index 572455d..0000000 --- a/src/mapAtom.luau +++ /dev/null @@ -1,56 +0,0 @@ -local atom = require(script.Parent.atom) -type Atom = atom.Atom -type ReadonlyAtom = atom.ReadonlyAtom -local reference = require(script.Parent.utils.reference) - -local function mapAtom(itemsAtom: Atom<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): ReadonlyAtom<{ [K1]: V1 }> - local mappedAtom: ReadonlyAtom<{ [K1]: V1 }> = atom({}):readonly() - local mappedAtomUnmount = mappedAtom.unmount - local getMappedAtom = reference.weak(mappedAtom) - local previousMappedItems: { [K1]: V1 } = {} - - local function listener(items) - local mappedAtom = getMappedAtom() - - if not mappedAtom then - itemsAtom.listeners[listener] = nil - return - end - - local mappedItems = table.clone(mappedAtom:get()) - local mappedKeys = {} - - for key, item in items do - local newItem, newKey = mapper(item, key) - if newKey == nil then - newKey = key :: any - end - if mappedItems[newKey :: K1] ~= newItem then - mappedItems[newKey :: K1] = newItem :: V1 - else - mappedKeys[newKey] = key - end - end - - for key in previousMappedItems do - if mappedKeys[key] == nil and mappedItems[key] == previousMappedItems[key] then - mappedItems[key] = nil - end - end - - previousMappedItems = mappedItems - mappedAtom:setForced(mappedItems) - end - - function mappedAtom:unmount() - mappedAtomUnmount(self) - itemsAtom.listeners[listener] = nil - end - - itemsAtom.listeners[listener] = true - listener(itemsAtom:get()) - - return mappedAtom -end - -return mapAtom diff --git a/src/modules/React.luau b/src/modules/React.luau index 0ae6f64..1f24cb1 100644 --- a/src/modules/React.luau +++ b/src/modules/React.luau @@ -5,4 +5,11 @@ local Packages = if Package.Parent:FindFirstChild("React") type React = typeof(require(Package.Parent.React)) -return (if Packages then require(Packages.React) else nil) :: React +local React: React + +return function() + if not React then + React = Packages and require(Packages.React) :: React + end + return React +end diff --git a/src/observe.luau b/src/observe.luau index a8619df..7954a39 100644 --- a/src/observe.luau +++ b/src/observe.luau @@ -1,11 +1,13 @@ -local atom = require(script.Parent.atom) -type Atom = atom.Atom -local subscribe = require(script.Parent.subscribe) +local effect = require(script.Parent.effect) +local types = require(script.Parent.types) +type Source = types.Source -local function observe(atom: Atom<{ [Key]: Item }>, factory: (value: Item, key: Key) -> () -> ()): (() -> (), () -> ()) - local connections: { [Key]: () -> () } = {} +local function observe(source: Source<{ [K]: V }>, factory: (value: V, key: K) -> () -> ()): () -> () + local connections: { [K]: () -> () } = {} + + local unsubscribe = effect(function() + local state = source() - local function update(state: { [Key]: Item }) for key, disconnect in connections do if state[key] == nil then connections[key] = nil @@ -18,9 +20,7 @@ local function observe(atom: Atom<{ [Key]: Item }>, factory: (value: connections[key] = factory(value, key) end end - end - - local unsubscribe, flush = subscribe(atom, update) + end) local function cleanup() unsubscribe() @@ -29,9 +29,7 @@ local function observe(atom: Atom<{ [Key]: Item }>, factory: (value: end end - update(atom:get()) - - return cleanup, flush + return cleanup end return observe diff --git a/src/react/useAtom.luau b/src/react/useAtom.luau index ed06093..c2f9f29 100644 --- a/src/react/useAtom.luau +++ b/src/react/useAtom.luau @@ -1,5 +1,5 @@ -local atom = require(script.Parent.Parent.atom) -type Atom = atom.Atom +local types = require(script.Parent.Parent.types) +type Atom = types.Atom local useAtomState = require(script.Parent.useAtomState) local useSetAtom = require(script.Parent.useSetAtom) diff --git a/src/react/useAtomState.luau b/src/react/useAtomState.luau index bcedd38..e04f557 100644 --- a/src/react/useAtomState.luau +++ b/src/react/useAtomState.luau @@ -1,7 +1,8 @@ -local React = require(script.Parent.Parent.modules.React) +local requireReact = require(script.Parent.Parent.modules.React) +local subscribe = require(script.Parent.Parent.subscribe) -local atom = require(script.Parent.Parent.atom) -type Atom = atom.Atom +local types = require(script.Parent.Parent.types) +type Atom = types.Atom type UseAtomState = ((atom: Atom) -> State) @@ -9,24 +10,20 @@ type UseAtomState = & ((atom: Atom, selector: (State) -> Result) -> Result) local function useAtomState(atom: Atom, selector: ((State) -> State)?): State + local React = requireReact() + local state, setState = React.useState(function() - return atom:get(selector :: (State) -> State) + return if selector then atom(selector) else atom() end) local latestSelector = React.useRef(selector) latestSelector.current = selector React.useEffect(function() - local function listener(state: State) + return subscribe(atom, function(state) local selector = latestSelector.current setState(if selector then selector(state) else state) - end - - atom.listeners[listener] = true - - return function() - atom.listeners[listener] = nil - end + end) end, { atom }) return state diff --git a/src/react/useSetAtom.luau b/src/react/useSetAtom.luau index debb1ca..8d3ff7b 100644 --- a/src/react/useSetAtom.luau +++ b/src/react/useSetAtom.luau @@ -1,13 +1,13 @@ -local React = require(script.Parent.Parent.modules.React) +local requireReact = require(script.Parent.Parent.modules.React) -local atom = require(script.Parent.Parent.atom) -type Atom = atom.Atom +local types = require(script.Parent.Parent.types) +type Atom = types.Atom export type SetState = (T | ((T) -> T)) -> () local function useSetAtom(atom: Atom): SetState - return React.useCallback(function(value) - atom:set(value) + return requireReact().useCallback(function(state: State | ((State) -> State)): () + atom(state) end, { atom }) end diff --git a/src/store.luau b/src/store.luau new file mode 100644 index 0000000..215c3d6 --- /dev/null +++ b/src/store.luau @@ -0,0 +1,23 @@ +local types = require(script.Parent.types) +type Atom = types.Atom +type Source = types.Source +type WeakMap = types.WeakMap + +local listeners: WeakMap, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" }) +local captured: { [Atom]: true } = {} +local capturing = false + +local function setCapturing(value: boolean) + capturing = value +end + +local function shouldCapture() + return capturing +end + +return { + listeners = listeners, + captured = captured, + setCapturing = setCapturing, + shouldCapture = shouldCapture, +} diff --git a/src/subscribe.luau b/src/subscribe.luau index 1f80104..e1d3786 100644 --- a/src/subscribe.luau +++ b/src/subscribe.luau @@ -1,45 +1,26 @@ -local atom = require(script.Parent.atom) -type Atom = atom.Atom -local reference = require(script.Parent.utils.reference) +local capture = require(script.Parent.capture) +local store = require(script.Parent.store) +local types = require(script.Parent.types) +type Source = types.Source -local function subscribe(atom: Atom, callback: (state: State, previous: State) -> ()): (() -> (), () -> ()) - local pending: thread? = nil - local state = atom:get() +local function subscribe(source: Source, callback: (state: T, prev: T) -> ()): () -> () + local atoms, state = capture(source) - local function emit() - local lastState = state - state = atom:get() - callback(state, lastState) + local function listener() + local prevState = state + state = source() + callback(state, prevState) end - local function update() - pending = pending or task.defer(function() - pending = nil - emit() - end) + for atom in atoms do + store.listeners[atom][listener] = true end - local function flush() - if pending then - pending = task.cancel(pending) - emit() + return function() + for atom in atoms do + store.listeners[atom][listener] = nil end end - - local function unsubscribe() - reference.release(atom) - atom.listeners[update] = nil - - if pending then - pending = task.cancel(pending) - end - end - - -- Ensure the atom is held in memory while the subscription is active - reference.hold(atom) - atom.listeners[update] = true - - return unsubscribe, flush end return subscribe diff --git a/src/sync/client.luau b/src/sync/client.luau index d651a5c..d52d4fd 100644 --- a/src/sync/client.luau +++ b/src/sync/client.luau @@ -1,26 +1,26 @@ -local atom = require(script.Parent.Parent.atom) -type Atom = atom.Atom +local types = require(script.Parent.Parent.types) +type Atom = types.Atom +type SyncPayload = types.SyncPayload local patch = require(script.Parent.patch) -local types = require(script.Parent.types) -type ClientSyncOptions = { +type ClientOptions = { atoms: { [string]: Atom }, } type ClientSyncer = { - sync: (self: ClientSyncer, payload: types.SyncPayload) -> (), + sync: (self: ClientSyncer, payload: SyncPayload) -> (), } -local function client(options: ClientSyncOptions): ClientSyncer +local function client(options: ClientOptions): ClientSyncer local atoms = options.atoms local self = {} :: ClientSyncer function self:sync(payload) for key, state in payload.data do if payload.type == "patch" then - atoms[key]:set(patch.apply(atoms[key]:get(), state)) + atoms[key](patch.apply(atoms[key](), state)) else - atoms[key]:set(state) + atoms[key](state) end end end diff --git a/src/sync/init.luau b/src/sync/init.luau index d1a2d3b..ee75671 100644 --- a/src/sync/init.luau +++ b/src/sync/init.luau @@ -1,8 +1,5 @@ local client = require(script.client) local server = require(script.server) -local types = require(script.types) - -export type SyncPayload = types.SyncPayload return { client = client, diff --git a/src/sync/patch.luau b/src/sync/patch.luau index 8827645..df2eda5 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -2,43 +2,48 @@ local validate = require(script.Parent.validate) local NONE = { __none = "__none" } -local function diff(previousState: { [any]: any }, nextState: { [any]: any }) - local patch = table.clone(nextState) +local warnings = {} - for key, previous in previousState do +local function diff(prevState: { [any]: any }, nextState: { [any]: any }) + local patches = table.clone(nextState) + + for key, previous in prevState do local next = nextState[key] if previous == next then - patch[key] = nil + patches[key] = nil elseif next == nil then - patch[key] = NONE + patches[key] = NONE elseif type(previous) == "table" and type(next) == "table" then - patch[key] = diff(previous, next) + patches[key] = diff(previous, next) end end if _G.__DEV__ then local success, result = pcall(function() - for key, value in previousState do + for key, value in prevState do validate(value, key) end for key, value in nextState do - validate(value, key) + if prevState[key] ~= value then + validate(value, key) + end end end) - if not success then + if not success and not warnings[result] then + warnings[result] = true warn(result) end end - return patch + return patches end -local function apply(state: { [any]: any }, patchState: { [any]: any }) +local function apply(state: { [any]: any }, patches: { [any]: any }) local nextState = table.clone(state) - for key, patch in patchState do + for key, patch in patches do local current = nextState[key] if type(patch) == "table" and patch.__none == "__none" then diff --git a/src/sync/server.luau b/src/sync/server.luau index 55c6ef0..db6645e 100644 --- a/src/sync/server.luau +++ b/src/sync/server.luau @@ -1,69 +1,69 @@ local Players = game:GetService("Players") -local atom = require(script.Parent.Parent.atom) -type Atom = atom.Atom +local types = require(script.Parent.Parent.types) +type Atom = types.Atom +type SyncPayload = types.SyncPayload local setInterval = require(script.Parent.Parent.utils.setInterval) local patch = require(script.Parent.patch) -local types = require(script.Parent.types) -type ServerSyncOptions = { +type ServerOptions = { atoms: { [string]: Atom }, interval: number?, } type ServerSyncer = { - onSync: (self: ServerSyncer, callback: (player: Player, payload: types.SyncPayload) -> ()) -> () -> (), - flush: (self: ServerSyncer, player: Player?) -> (), + connect: (self: ServerSyncer, callback: (player: Player, payload: SyncPayload) -> ()) -> () -> (), + flush: (self: ServerSyncer, player: Player) -> (), hydrate: (self: ServerSyncer, player: Player) -> (), } -local function server(options: ServerSyncOptions): ServerSyncer +local function server(options: ServerOptions): ServerSyncer local atoms = options.atoms local interval = options.interval or 0 + local self = {} :: ServerSyncer - local sync: (player: Player, payload: types.SyncPayload) -> () + local sync: (player: Player, payload: SyncPayload) -> () local state: {} local function getState() local state = {} for key, atom in atoms do - state[key] = atom:get() + state[key] = atom() end return state end - function self:onSync(callback) + function self:connect(callback) sync = callback state = getState() return setInterval(function() - self:flush() + local previous = state + state = getState() + + if state ~= previous then + for _, player in Players:GetPlayers() do + callback(player, { + type = "patch", + data = patch.diff(previous, state), + }) + end + end end, interval) end - function self:flush(player: Player?) - local current = getState() - local previous = state or current - state = current + function self:flush(player: Player) + local previous = state + state = getState() - if current ~= previous then - local payload: types.SyncPayload = { - type = "patch", - data = patch.diff(previous, current), - } - - if player then - return sync(player, payload) - end - - for _, player in Players:GetPlayers() do - sync(player, payload) - end - end + sync(player, { + type = "patch", + data = patch.diff(previous, state), + }) end function self:hydrate(player) - assert(sync, "onSync must be called before onJoin") + assert(sync, "connect() must be called before hydrate()") sync(player, { type = "set", diff --git a/src/sync/types.luau b/src/sync/types.luau deleted file mode 100644 index ca27e02..0000000 --- a/src/sync/types.luau +++ /dev/null @@ -1,6 +0,0 @@ -export type SyncPayload = { - type: "patch" | "set", - data: { [any]: any }, -} - -return nil diff --git a/src/types.luau b/src/types.luau new file mode 100644 index 0000000..ee08b65 --- /dev/null +++ b/src/types.luau @@ -0,0 +1,16 @@ +export type WeakMap = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" })) + +export type Atom = (() -> T) & (state: T | (T) -> T) -> T + +export type Source = () -> T + +export type AtomOptions = { + equals: (prev: T, next: T) -> boolean, +} + +export type SyncPayload = { + type: "patch" | "set", + data: { [any]: any }, +} + +return nil diff --git a/src/unwrap.luau b/src/unwrap.luau new file mode 100644 index 0000000..390188e --- /dev/null +++ b/src/unwrap.luau @@ -0,0 +1,23 @@ +local store = require(script.Parent.store) + +local function unwrap(atom: (() -> T) | T): T + if type(atom) ~= "function" then + return atom + end + + if not store.shouldCapture() then + return atom() + end + + store.setCapturing(false) + local success, result = pcall(atom) + store.setCapturing(true) + + if not success then + error(`Failed to unwrap atom: ${result}`, 2) + end + + return result +end + +return unwrap diff --git a/src/utils/reference.luau b/src/utils/reference.luau deleted file mode 100644 index de4cdca..0000000 --- a/src/utils/reference.luau +++ /dev/null @@ -1,31 +0,0 @@ -local refs = {} - ---- Prevents the garbage collector from collecting a value -local function hold(value: unknown) - refs[value] = true -end - ---- Allows the garbage collector to collect a value -local function release(value: unknown) - refs[value] = nil -end - ---- Clears all held references -local function clear() - table.clear(refs) -end - ---- Returns a function that retrieves the value through a weak reference -local function weak(value: T): () -> T? - local set = setmetatable({ value }, { __mode = "v" }) - return function() - return set[1] - end -end - -return { - hold = hold, - release = release, - clear = clear, - weak = weak, -}