From b715b81feca8194480e06fa92bccee140d01cf6b Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 26 May 2024 21:31:28 -0700 Subject: [PATCH] refactor!: rename `derive`, `map` --- README.md | 13 ++- src/__tests__/batch.spec.luau | 14 +-- src/__tests__/benchmarks/atom.bench.luau | 12 +-- .../{derive.spec.luau => computed.spec.luau} | 29 +++--- .../{map.spec.luau => mapped.spec.luau} | 8 +- src/__tests__/subscribe.spec.luau | 18 ++-- src/__tests__/sync/server.spec.luau | 12 ++- src/atom.luau | 1 - src/computed.luau | 26 ++++++ src/derive.luau | 26 ------ src/effect.luau | 6 +- src/index.d.ts | 8 +- src/init.luau | 8 +- src/{map.luau => mapped.luau} | 17 ++-- src/subscribe.luau | 6 +- src/sync/client.luau | 1 + src/sync/patch.luau | 1 + src/sync/server.luau | 90 ++++++++++++------- 18 files changed, 163 insertions(+), 133 deletions(-) rename src/__tests__/{derive.spec.luau => computed.spec.luau} (58%) rename src/__tests__/{map.spec.luau => mapped.spec.luau} (90%) create mode 100644 src/computed.luau delete mode 100644 src/derive.luau rename src/{map.luau => mapped.luau} (76%) diff --git a/README.md b/README.md index 152dd70..624bcdd 100644 --- a/README.md +++ b/README.md @@ -18,18 +18,15 @@ -**Charm** is a Roblox state management library, inspired by [Jotai](https://jotai.org). Designed to be an alternative to [Reflex](https://littensy.github.io/reflex), Charm aims to bring an atomic and composable approach to your game's state. +**Charm** is an atomic state management library inspired by [Jotai](https://jotai.org). Designed to be a unique alternative to [Reflex](https://littensy.github.io/reflex), Charm aims to provide a simple and composable interface for your game's state. > [!NOTE] -> Charm is incomplete and not ready for use. +> Charm is incomplete and not ready for production use. ## 📦 Setup ### TypeScript -Install Charm through your package manager of choice. \ -[React](https://github.com/littensy/rbxts-react) is optional, but highly recommended. - ```sh npm install @rbxts/charm yarn add @rbxts/charm @@ -49,17 +46,17 @@ Charm = "littensy/charm@VERSION" ### Charm -#### `atom(state)` +#### `atom(state, options?)` #### `effect(callback)` #### `subscribe(atom, listener)` -#### `derive(atom, equals?)` +#### `computed(atom, options?)` #### `observe(atom, observer)` -#### `map(atom, mapper)` +#### `mapped(atom, mapper)` #### `peek(atom)` diff --git a/src/__tests__/batch.spec.luau b/src/__tests__/batch.spec.luau index 4929bed..319270b 100644 --- a/src/__tests__/batch.spec.luau +++ b/src/__tests__/batch.spec.luau @@ -1,5 +1,5 @@ local atom = require(script.Parent.Parent.atom) -local derive = require(script.Parent.Parent.derive) +local computed = require(script.Parent.Parent.computed) local effect = require(script.Parent.Parent.effect) local store = require(script.Parent.Parent.store) local subscribe = require(script.Parent.Parent.subscribe) @@ -37,15 +37,15 @@ return function() expect(value).to.equal(1) end) - it("batches derived atoms", function() + it("batches computed atoms", function() local source = atom(1) local updates = 0 - local computed = 0 - local double = derive(function() - computed += 1 + local computations = 0 + local double = computed(function() + computations += 1 return source() * 2 end) - computed = 0 -- derived atoms will run once initially + computations = 0 -- computed atoms will run once initially subscribe(double, function() updates += 1 end) @@ -55,7 +55,7 @@ return function() source(4) end) expect(updates).to.equal(1) - expect(computed).to.equal(1) + expect(computations).to.equal(1) expect(double()).to.equal(8) end) end diff --git a/src/__tests__/benchmarks/atom.bench.luau b/src/__tests__/benchmarks/atom.bench.luau index d37e513..e7819f9 100644 --- a/src/__tests__/benchmarks/atom.bench.luau +++ b/src/__tests__/benchmarks/atom.bench.luau @@ -4,7 +4,7 @@ local Package = game:GetService("ReplicatedStorage").Packages.charm local atom = require(Package.atom) -local derive = require(Package.derive) +local computed = require(Package.computed) local function parameterGenerator() return atom({ count = 0 }) @@ -17,8 +17,8 @@ local benchmarks = { end end, - derived = function(_, source: any) - derive(function() + computed = function(_, source: any) + computed(function() return { count = 2 * source().count } end) for _ = 1, 500 do @@ -26,11 +26,11 @@ local benchmarks = { end end, - derived_nested = function(_, source: any) - local double = derive(function() + computed_nested = function(_, source: any) + local double = computed(function() return { count = 2 * source().count } end) - derive(function() + computed(function() return { count = 2 * double().count } end) for _ = 1, 500 do diff --git a/src/__tests__/derive.spec.luau b/src/__tests__/computed.spec.luau similarity index 58% rename from src/__tests__/derive.spec.luau rename to src/__tests__/computed.spec.luau index 7dae523..b9b3a19 100644 --- a/src/__tests__/derive.spec.luau +++ b/src/__tests__/computed.spec.luau @@ -1,5 +1,5 @@ local atom = require(script.Parent.Parent.atom) -local derive = require(script.Parent.Parent.derive) +local computed = require(script.Parent.Parent.computed) local store = require(script.Parent.Parent.store) local collect = require(script.Parent.Parent.utils.collect) local count = require(script.Parent.Parent.utils.count) @@ -9,7 +9,7 @@ return function() local calls = 0 local a = atom(1) local b = atom(2) - local double = derive(function() + local double = computed(function() calls += 1 return a() * b() end) @@ -26,10 +26,10 @@ return function() it("can be nested", function() local source = atom(1) - local double = derive(function() + local double = computed(function() return source() * 2 end) - local quadruple = derive(function() + local quadruple = computed(function() return double() * 2 end) expect(quadruple()).to.equal(4) @@ -37,34 +37,35 @@ return function() expect(quadruple()).to.equal(8) end) - it("collects derived atoms", function() + it("collects computed atoms", function() local source = atom(0) - derive(function() + computed(function() return source() end) collect() - assert(count(store.listeners[source]) == 0, "derived atom did not gc") + assert(count(store.listeners[source]) == 0, "computed atom did not gc") end) - it("collects nested derived atoms", function() + it("collects nested computed atoms", function() local source = atom(0) do - -- Declare this in another scope to ensure no references are kept - local derived = derive(function() - return source() + -- Declare this in another scope to ensure no references exist at the + -- time of garbage collection. + local double = computed(function() + return source() * 2 end) - derive(function() - return derived() * 2 + computed(function() + return double() * 2 end) end collect() - assert(count(store.listeners[source]) == 0, "nested derived atom did not gc") + assert(count(store.listeners[source]) == 0, "nested computed atom did not gc") end) end diff --git a/src/__tests__/map.spec.luau b/src/__tests__/mapped.spec.luau similarity index 90% rename from src/__tests__/map.spec.luau rename to src/__tests__/mapped.spec.luau index 79b5916..ac33a85 100644 --- a/src/__tests__/map.spec.luau +++ b/src/__tests__/mapped.spec.luau @@ -1,12 +1,12 @@ local atom = require(script.Parent.Parent.atom) -local map = require(script.Parent.Parent.map) +local mapped = require(script.Parent.Parent.mapped) 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 = map(itemsAtom, function(value, key) + local mappedAtom = mapped(itemsAtom, function(value, key) return string.upper(value), key end) local updates = 0 @@ -35,7 +35,7 @@ return function() it("uses key extractor", function() local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } }) - local mappedAtom = map(itemsAtom, function(item) + local mappedAtom = mapped(itemsAtom, function(item) return item, item.key end) @@ -61,7 +61,7 @@ return function() { key = 3, value = "c" }, }) - local mappedAtom = map(itemsAtom, function(item) + local mappedAtom = mapped(itemsAtom, function(item) return item, item.key end) diff --git a/src/__tests__/subscribe.spec.luau b/src/__tests__/subscribe.spec.luau index 5973d35..5097213 100644 --- a/src/__tests__/subscribe.spec.luau +++ b/src/__tests__/subscribe.spec.luau @@ -1,5 +1,5 @@ local atom = require(script.Parent.Parent.atom) -local derive = require(script.Parent.Parent.derive) +local computed = require(script.Parent.Parent.computed) local store = require(script.Parent.Parent.store) local subscribe = require(script.Parent.Parent.subscribe) local collect = require(script.Parent.Parent.utils.collect) @@ -31,22 +31,22 @@ return function() local updates = 0 -- Create a new scope to encapsulate variables with a limited lifetime, like - -- 'unsubscribe' and 'derived', to ensure they are garbage collected. + -- 'unsubscribe' and 'computed', to ensure they are garbage collected. -- - -- The idea here is to test whether subscribe() prevents every derived + -- The idea here is to test whether subscribe() prevents every computed -- atom in a chain from being garbage collected. If any are GCed, then -- the subscription will stop updating and the test will fail. do local unsubscribe do - local derived = derive(function() - return source() + local double = computed(function() + return source() * 2 end) unsubscribe = subscribe( - derive(function() - return derived() * 2 + computed(function() + return double() * 2 end), function() updates += 1 @@ -58,13 +58,13 @@ return function() source(source() + 1) assert(updates == 1, "subscription stopped updating") - assert(count(store.listeners[source]) == 1, "derived atom was garbage collected") + assert(count(store.listeners[source]) == 1, "computed atom was garbage collected") unsubscribe() end collect() - assert(count(store.listeners[source]) == 0, "derived atom did not collect") + assert(count(store.listeners[source]) == 0, "computed atom did not collect") end) end diff --git a/src/__tests__/sync/server.spec.luau b/src/__tests__/sync/server.spec.luau index 206773c..dbffb48 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 patch = require(script.Parent.Parent.Parent.sync.patch) local types = require(script.Parent.Parent.Parent.types) return function() @@ -24,7 +25,7 @@ return function() end) it("sends state patches", function() - local a = atom({ b = 1, c = 2 }) + local a = atom({ b = 1, c = 2 } :: { [string]: number? }) local server = sync.server({ atoms = { a = a }, interval = -1 }) local player: Player, payload: types.SyncPayload @@ -33,13 +34,20 @@ return function() end) a({ b = 1, c = 5 }) - server:flush(target) + server:_sendPatch(target) expect(player).to.equal(target) expect(payload.type).to.equal("patch") expect(payload.data.a.b).to.never.be.ok() expect(payload.data.a.c).to.equal(5) + a({ b = 1, d = 1 }) + server:_sendPatch(target) + + expect(payload.data.a.b).to.never.be.ok() + expect(payload.data.a.c).to.equal(patch.NONE) + expect(payload.data.a.d).to.equal(1) + disconnect() end) end diff --git a/src/atom.luau b/src/atom.luau index 9c258d2..a1a2b3b 100644 --- a/src/atom.luau +++ b/src/atom.luau @@ -16,7 +16,6 @@ local function atom(state: T, options: AtomOptions?): Atom return state end - -- Atoms called in a function should not be a part of the dependency tree local nextState = peek(..., state) if state ~= nextState and not (equals and equals(state, nextState)) then diff --git a/src/computed.luau b/src/computed.luau new file mode 100644 index 0000000..8d90fa2 --- /dev/null +++ b/src/computed.luau @@ -0,0 +1,26 @@ +local atom = require(script.Parent.atom) +local store = require(script.Parent.store) +local types = require(script.Parent.types) +type Atom = types.Atom +type AtomOptions = types.AtomOptions +type Molecule = types.Molecule + +local function computed(molecule: Molecule, options: AtomOptions?): Molecule + local captured, state = store.capture(molecule) + local computedAtom = atom(state, options) + local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" }) + + local function listener() + if computedRef.current then + computedRef.current(molecule()) + end + end + + for atom in captured do + store.listeners[atom][listener] = computedAtom + end + + return computedAtom +end + +return computed diff --git a/src/derive.luau b/src/derive.luau deleted file mode 100644 index e43a6af..0000000 --- a/src/derive.luau +++ /dev/null @@ -1,26 +0,0 @@ -local atom = require(script.Parent.atom) -local store = require(script.Parent.store) -local types = require(script.Parent.types) -type Atom = types.Atom -type AtomOptions = types.AtomOptions -type Molecule = types.Molecule - -local function derive(molecule: Molecule, options: AtomOptions?): Molecule - local atoms, state = store.capture(molecule) - local derived = atom(state, options) - local derivedRef = setmetatable({ current = derived }, { __mode = "v" }) - - local function listener() - if derivedRef.current then - derivedRef.current(molecule()) - end - end - - for atom in atoms do - store.listeners[atom][listener] = derived - end - - return derived -end - -return derive diff --git a/src/effect.luau b/src/effect.luau index 684dd40..7dfa960 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -1,18 +1,18 @@ local store = require(script.Parent.store) local function effect(molecule: () -> nil): () -> () - local atoms = store.capture(molecule) + local captured = store.capture(molecule) local function listener() molecule() end - for atom in atoms do + for atom in captured do store.listeners[atom][listener] = true end return function() - for atom in atoms do + for atom in captured do store.listeners[atom][listener] = nil end end diff --git a/src/index.d.ts b/src/index.d.ts index e878179..83ac4a2 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -22,7 +22,7 @@ declare namespace Charm { function atom(state: State, options?: AtomOptions): Atom; - function derive(molecule: Molecule, options?: AtomOptions): Molecule; + function computed(molecule: Molecule, options?: AtomOptions): Molecule; function subscribe(molecule: Molecule, callback: (state: State, prev: State) => void): Cleanup; @@ -48,17 +48,17 @@ declare namespace Charm { factory: (item: Item, key: Key) => Cleanup | void, ): Cleanup; - function map( + function mapped( molecule: Molecule, mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>, ): Molecule>; - function map( + function mapped( molecule: Molecule, mapper: (value: V0, index: number) => V1, ): Molecule; - function map( + function mapped( molecule: Molecule>, mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, ): Molecule>; diff --git a/src/init.luau b/src/init.luau index 867529e..dc93dc9 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,9 +1,9 @@ _G[script.modules.Promise] = _G[script] local atom = require(script.atom) -local derive = require(script.derive) +local computed = require(script.computed) local effect = require(script.effect) -local map = require(script.map) +local mapped = require(script.mapped) local observe = require(script.observe) local peek = require(script.peek) local useAtom = require(script.react.useAtom) @@ -18,9 +18,9 @@ export type SyncPayload = types.SyncPayload return { atom = atom, - derive = derive, + computed = computed, effect = effect, - map = map, + mapped = mapped, observe = observe, peek = peek, useAtom = useAtom, diff --git a/src/map.luau b/src/mapped.luau similarity index 76% rename from src/map.luau rename to src/mapped.luau index 29bff08..805f2ad 100644 --- a/src/map.luau +++ b/src/mapped.luau @@ -1,5 +1,6 @@ local atom = require(script.Parent.atom) -local subscribe = require(script.Parent.subscribe) +local effect = require(script.Parent.effect) +local peek = require(script.Parent.peek) local types = require(script.Parent.types) type Atom = types.Atom type Molecule = types.Molecule @@ -9,18 +10,19 @@ type Map = & ((fn: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Molecule<{ [K0]: V1 }>) & ((fn: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Molecule<{ [K1]: V1 }>) -local function map(getItems: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Molecule<{ [K1]: V1 }> +local function mapped(molecule: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Molecule<{ [K1]: V1 }> local mappedAtom = atom({}) local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" }) local prevMappedItems: { [K1]: V1 } = {} local unsubscribe - local function listener(items: { [K0]: V0 }) + unsubscribe = effect(function() if not mappedAtomRef.current then return unsubscribe() end - local mappedItems = table.clone(mappedAtomRef.current()) + local items = molecule() + local mappedItems = table.clone(peek(mappedAtomRef.current)) local mappedKeys = {} for key, item in items do @@ -43,12 +45,9 @@ local function map(getItems: Molecule<{ [K0]: V0 }>, mapper: (V0 prevMappedItems = mappedItems mappedAtom(mappedItems) - end - - unsubscribe = subscribe(getItems, listener) - listener(getItems()) + end) return mappedAtom end -return map :: Map +return mapped :: Map diff --git a/src/subscribe.luau b/src/subscribe.luau index 756e9db..9b5accf 100644 --- a/src/subscribe.luau +++ b/src/subscribe.luau @@ -3,7 +3,7 @@ local types = require(script.Parent.types) type Molecule = types.Molecule local function subscribe(molecule: Molecule, callback: (state: T, prev: T) -> ()): () -> () - local atoms, state = store.capture(molecule) + local captured, state = store.capture(molecule) local function listener() local prevState = state @@ -11,12 +11,12 @@ local function subscribe(molecule: Molecule, callback: (state: T, prev: T) callback(state, prevState) end - for atom in atoms do + for atom in captured do store.listeners[atom][listener] = true end return function() - for atom in atoms do + for atom in captured do store.listeners[atom][listener] = nil end end diff --git a/src/sync/client.luau b/src/sync/client.luau index d52d4fd..9dd1629 100644 --- a/src/sync/client.luau +++ b/src/sync/client.luau @@ -15,6 +15,7 @@ local function client(options: ClientOptions): ClientSyncer local atoms = options.atoms local self = {} :: ClientSyncer + -- Apply the state changes sent by the server. function self:sync(payload) for key, state in payload.data do if payload.type == "patch" then diff --git a/src/sync/patch.luau b/src/sync/patch.luau index 6af6034..cf9d066 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -49,6 +49,7 @@ local function apply(state: any, patches: any): any end return { + NONE = NONE, diff = diff, apply = apply, } diff --git a/src/sync/server.luau b/src/sync/server.luau index 51dabb4..b1fbc21 100644 --- a/src/sync/server.luau +++ b/src/sync/server.luau @@ -1,5 +1,6 @@ local Players = game:GetService("Players") +local subscribe = require(script.Parent.Parent.subscribe) local types = require(script.Parent.Parent.types) type Atom = types.Atom type SyncPayload = types.SyncPayload @@ -13,8 +14,8 @@ type ServerOptions = { type ServerSyncer = { connect: (self: ServerSyncer, callback: (player: Player, payload: SyncPayload) -> ()) -> () -> (), - flush: (self: ServerSyncer, player: Player) -> (), hydrate: (self: ServerSyncer, player: Player) -> (), + _sendPatch: (self: ServerSyncer, player: Player) -> (), } local function server(options: ServerOptions): ServerSyncer @@ -23,53 +24,76 @@ local function server(options: ServerOptions): ServerSyncer local self = {} :: ServerSyncer local sync: (player: Player, payload: SyncPayload) -> () - local state: {} - - local function getState() - local state = {} - for key, atom in atoms do - state[key] = atom() - end - return state - end + local state: { [string]: any } = {} + local stateSnapshot = {} + local changed = false + -- Start the interval to send state patches over the network. function self:connect(callback) + local cleanups = {} sync = callback - state = getState() - return setInterval(function() - local previous = state - state = getState() + -- Populate the initial state and snapshot for each atom. + -- Subscribe to each atom and update the state when it changes. + for key, atom in atoms do + cleanups[key] = subscribe(atom, function(current) + state[key] = current + changed = true + end) - if state ~= previous then - local diffs = patch.diff(previous, state) + state[key] = atom() + stateSnapshot[key] = atom() + end - for _, player in Players:GetPlayers() do - callback(player, { - type = "patch", - data = diffs, - }) - end + local disconnect = setInterval(function() + if not changed then + return + end + + local diffs = patch.diff(stateSnapshot, state) + stateSnapshot = table.clone(state) + changed = false + + for _, player in Players:GetPlayers() do + callback(player, { + type = "patch", + data = diffs, + }) end end, interval) + + return function() + disconnect() + for _, cleanup in cleanups do + cleanup() + end + end end - function self:flush(player: Player) - local previous = state - state = getState() - - sync(player, { - type = "patch", - data = patch.diff(previous, state), - }) - end - + -- Send the initial state to a player when they join the server. function self:hydrate(player) assert(sync, "connect() must be called before hydrate()") sync(player, { type = "init", - data = getState(), + data = state, + }) + end + + function self:_sendPatch(player: Player) + assert(sync, "connect() must be called before _sendPatch()") + + if not changed then + return + end + + local diffs = patch.diff(stateSnapshot, state) + stateSnapshot = table.clone(state) + changed = false + + sync(player, { + type = "patch", + data = diffs, }) end