From 1e0b10aef6853a00e43209df0e71dd84732a19b3 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 31 Oct 2025 00:36:55 -0700 Subject: [PATCH] feat: add signal api --- benches/mapped.luau | 16 +- benches/propagate.luau | 10 +- .../charm-sync/src/__tests__/patch.spec.luau | 6 +- packages/charm-sync/src/client.luau | 1 - packages/charm-sync/src/config.luau | 4 +- packages/charm-sync/src/index.d.ts | 11 +- packages/charm-sync/src/patch.luau | 6 +- packages/charm-sync/src/server.luau | 154 +++++++------ .../core/{atom.spec.luau => signal.spec.luau} | 16 +- packages/charm/src/__tests__/spec.luau | 6 +- packages/charm/src/core.luau | 208 ++++++++++++------ .../charm/src/{flags.luau => globals.luau} | 11 +- packages/charm/src/index.d.ts | 27 ++- packages/charm/src/init.luau | 18 +- packages/charm/src/listen.luau | 40 ---- packages/charm/src/observe.luau | 11 +- packages/charm/src/subscribe.luau | 34 --- packages/charm/src/types.luau | 7 - packages/react-charm/src/init.luau | 2 + packages/vide-charm/src/init.luau | 1 + test/spec.server.luau | 4 +- 21 files changed, 300 insertions(+), 293 deletions(-) rename packages/charm/src/__tests__/core/{atom.spec.luau => signal.spec.luau} (79%) rename packages/charm/src/{flags.luau => globals.luau} (63%) delete mode 100644 packages/charm/src/listen.luau delete mode 100644 packages/charm/src/subscribe.luau delete mode 100644 packages/charm/src/types.luau diff --git a/benches/mapped.luau b/benches/mapped.luau index 539310c..d9758fc 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -1,10 +1,10 @@ local Charm = require("../packages/charm/src") local bench = require("./bench") -local atom = Charm.atom local computed = Charm.computed local mapped = Charm.mapped -local setCurrentSub = Charm.core.setCurrentSub +local setActiveSub = Charm.core.setActiveSub +local signal = Charm.signal type Entry = { number } @@ -25,9 +25,9 @@ local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> local nextState = {} for index, value in state do - local currentSub = setCurrentSub(nil) + local currentSub = setActiveSub(nil) local success, valueOut, keyOut = pcall(mapper, value, index) - setCurrentSub(currentSub) + setActiveSub(currentSub) if success then nextState[keyOut] = valueOut @@ -52,26 +52,26 @@ end for _, percent in { 0.1, 0.5, 1 } do bench.run(`mapped {percent}`, function() - local src = atom(initialState) + local src, setSrc = signal(initialState) local out = mapped(src, mapper) bench.profilebegin("init") out() bench.profileend() - src(updatePercentage(percent)) + setSrc(updatePercentage(percent)) bench.profilebegin("update") out() bench.profileend() end) bench.run(`control {percent}`, function() - local src = atom(initialState) + local src, setSrc = signal(initialState) local out = simpleMapped(src, mapper) bench.profilebegin("init") out() bench.profileend() - src(updatePercentage(percent)) + setSrc(updatePercentage(percent)) bench.profilebegin("update") out() bench.profileend() diff --git a/benches/propagate.luau b/benches/propagate.luau index 7473b6d..463ee2e 100644 --- a/benches/propagate.luau +++ b/benches/propagate.luau @@ -1,15 +1,15 @@ local Charm = require("../packages/charm/src") local bench = require("./bench") -local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect local listen = Charm.listen +local signal = Charm.signal for _, w in { 10, 100 } do for _, h in { 10, 100 } do bench.run(`propagate {w}*{h}`, function() - local src = atom(1) + local src, setSrc = signal(1) for _ = 1, w do local last = src for _ = 1, h do @@ -22,7 +22,7 @@ for _, w in { 10, 100 } do last() end) end - src(src() + 1) + setSrc(src() + 1) end) end end @@ -30,7 +30,7 @@ end for _, w in { 10, 100 } do for _, h in { 10, 100 } do bench.run(`propagate (listen) {w}*{h}`, function() - local src = atom(1) + local src, setSrc = signal(1) for _ = 1, w do local last = src for _ = 1, h do @@ -41,7 +41,7 @@ for _, w in { 10, 100 } do end listen(last, function() end) end - src(src() + 1) + setSrc(src() + 1) end) end end diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index 4a68750..fbf5607 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -24,7 +24,7 @@ local function mockArraySerdes(object: { [any]: any }): { [any]: any } end afterEach(function() - config.autoSerialize = true + config.fixArrays = true config.validatePatches = true end) @@ -104,13 +104,13 @@ test("cannot preserve sparse arrays", function() end) test("optionally fixes sparse arrays", function() - config.autoSerialize = true + config.fixArrays = true for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "string", `expected {type(index)} {index} to be string`) end - config.autoSerialize = false + config.fixArrays = false for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "number", `expected {type(index)} {index} to be number`) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 5b40ba0..57b03ec 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -16,7 +16,6 @@ local function syncAtoms(atoms: { [string]: Charm.Atom }) warn("[Charm] Attempted to sync different atoms with the same key:", key) continue end - atomRegister[key] = atom end end diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau index 3023971..6a71e12 100644 --- a/packages/charm-sync/src/config.luau +++ b/packages/charm-sync/src/config.luau @@ -12,9 +12,9 @@ return { --- to address remote event argument limitations. Defaults to `true`. --- This option should be disabled if your network library uses a custom --- serialization method (i.e. Zap, ByteNet) to prevent interference. - autoSerialize = true, + fixArrays = true, --- When `true`, patches will be validated before being sent to clients to --- enforce remote event limitations. Only checked in strict mode and if - --- `autoSerialize` is enabled. Defaults to `true`. + --- `fixArrays` is enabled. Defaults to `true`. validatePatches = true, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 7f565ed..bbd4923 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -1,7 +1,5 @@ import { Atom } from "@rbxts/charm"; -type Cleanup = () => void; - type Key = string | number | symbol; /** @@ -18,7 +16,7 @@ type SelectorMap = Record any>; /** * Infers the type of the return values produced by a map of functions. */ -type StateOfMap = { +export type StateOfMap = { readonly [P in keyof T]: T[P] extends () => infer State ? State : never; }; @@ -88,11 +86,11 @@ export const config: { * * @server */ - autoSerialize: boolean; + fixArrays: boolean; /** * When `true`, patches will be validated before being sent to clients to * enforce remote event limitations. Only checked in strict mode and if - * `autoSerialize` is enabled. Defaults to `true`. + * `fixArrays` is enabled. Defaults to `true`. * * @server */ @@ -178,11 +176,10 @@ export namespace server { * be sent to clients whenever the atoms are updated. * * @param onSync The function to call when patches can be sent to a client. - * @returns `stopSync()` */ export function startSync }, Serialize extends boolean = true>( onSync: (client: Player, payloads: SyncPayload[]) => void, - ): Cleanup; + ): void; /** * Stops automatically syncing registered atoms to clients. Patches will diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 7f017ee..88f414b 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -3,7 +3,7 @@ local config = require("./config") local types = require("./types") local validate = require("./validate") -local flags = Charm.flags +local globals = Charm.globals export type None = types.None @@ -73,13 +73,13 @@ local function diff(currentState: any, nextState: any): any patches[key] = diff(previous, nextState[key]) end - if config.autoSerialize then + if config.fixArrays then -- Coerce sparse array patches into dictionaries if currentState[1] ~= nil or nextState[1] ~= nil then patches = stringifySparseArray(patches) end - if flags.strict and config.validatePatches then + if globals.strict and config.validatePatches then for key, value in patches do validate(value, key) end diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 5f628d7..ba021f6 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -5,11 +5,13 @@ local types = require("./types") type SyncPayload = types.SyncPayload +local globals = Charm.globals + type RegisteredAtom = { - atom: () -> T, - value: T, - valueLastSynced: T, - stop: () -> (), + callback: () -> T, + currentValue: T, + previousValue: T, + dispose: () -> (), } type SyncStatus = number @@ -19,82 +21,82 @@ local LISTENING_FOR_CHANGES: SyncStatus = 1 local atomRegister: { [string]: RegisteredAtom } = {} local clientRegister: { [any]: { [string]: SyncStatus } } = {} -local globalUpdates: { { key: string, value: any, previousValue: any } } = {} +local globalUpdates: { { key: string, currentValue: any, previousValue: any } } = {} local connection: RBXScriptConnection? local onSyncCallback: (client: any, payloads: { SyncPayload }) -> ()? -local function registerAtom(key: string, atom: () -> T) +local function syncGlobalAtom(key: string, callback: () -> T) if atomRegister[key] then - if atomRegister[key].atom ~= atom then + if atomRegister[key].callback ~= callback then warn("[Charm] Attempted to sync different atoms with the same key:", key) end return end local registeredAtom: RegisteredAtom = { - atom = atom, - value = atom(), - valueLastSynced = atom(), - stop = function() end, + callback = callback, + currentValue = callback(), + previousValue = callback(), + dispose = function() end, } - registeredAtom.stop = Charm.effect(function() - local value = atom() + registeredAtom.dispose = Charm.effect(function() + local newValue = callback() -- When preserving history, we need to keep track of previous values -- so we can create granular patches for each change. - if config.preserveHistory and registeredAtom.value ~= value then + if config.preserveHistory and registeredAtom.currentValue ~= newValue then table.insert(globalUpdates, { key = key, - value = value, - previousValue = registeredAtom.value, + currentValue = newValue, + previousValue = registeredAtom.currentValue, }) end - registeredAtom.value = value + registeredAtom.currentValue = newValue end) atomRegister[key] = registeredAtom end -local function unregisterAtom(key: string) +local function unsyncGlobalAtom(key: string) local registeredAtom = atomRegister[key] if registeredAtom then - registeredAtom.stop() + registeredAtom.dispose() atomRegister[key] = nil end end -local function unregisterUnusedAtoms() - local used: { [string]: boolean } = {} +local function unsyncStaleGlobalAtoms() + local active: { [string]: boolean } = {} for _, clientAtoms in clientRegister do for key in clientAtoms do - used[key] = true + active[key] = true end end for key in atomRegister do - if not used[key] then - unregisterAtom(key) + if not active[key] then + unsyncGlobalAtom(key) end end end -local function createHistoricalPatches(): { any } +local function createGlobalPatchHistory(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) for index, update in globalUpdates do - patches[index] = patch.diff(update.previousValue, update.value) + patches[index] = patch.diff(update.previousValue, update.currentValue) end return patches end -local function createHistoricalPayloadsForClient(client: any, patches: { any }): { SyncPayload } +local function createPayloadHistoryForClient(client: any, patches: { any }): { SyncPayload } local syncedAtoms = clientRegister[client] if not syncedAtoms then @@ -123,9 +125,8 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }): elseif status == PENDING_INITIAL_STATE then table.insert(payloads, { type = "init", - data = { [key] = update.value }, + data = { [key] = update.currentValue }, }) - syncedAtoms[key] = LISTENING_FOR_CHANGES end end @@ -138,7 +139,7 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }): local registeredAtom = atomRegister[key] if status == PENDING_INITIAL_STATE and registeredAtom then - initPayload[key] = registeredAtom.value + initPayload[key] = registeredAtom.currentValue syncedAtoms[key] = LISTENING_FOR_CHANGES end end @@ -150,20 +151,20 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }): return payloads end -local function createPatch(): any +local function createGlobalPatch(): any local current: { [string]: any } = {} local previous: { [string]: any } = {} for key, registeredAtom in atomRegister do - current[key] = registeredAtom.value - previous[key] = registeredAtom.valueLastSynced - registeredAtom.valueLastSynced = registeredAtom.value + current[key] = registeredAtom.currentValue + previous[key] = registeredAtom.previousValue + registeredAtom.previousValue = registeredAtom.currentValue end return patch.diff(previous, current) end -local function createPayloadsForClient(client: any, patchData: any): { SyncPayload } +local function createPayloadForClient(client: any, patchData: any): { SyncPayload } local syncedAtoms = clientRegister[client] local payloads: { SyncPayload } = {} @@ -182,7 +183,7 @@ local function createPayloadsForClient(client: any, patchData: any): { SyncPaylo if status == LISTENING_FOR_CHANGES then patchPayload[key] = patchData[key] elseif status == PENDING_INITIAL_STATE then - initPayload[key] = registeredAtom.value + initPayload[key] = registeredAtom.currentValue syncedAtoms[key] = LISTENING_FOR_CHANGES end end @@ -215,7 +216,7 @@ local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) local syncedAtoms = clientRegister[client] for key, atom in atoms do - registerAtom(key, atom) + syncGlobalAtom(key, atom) if not syncedAtoms[key] then -- Mark the atom as pending initial state so we send its full value @@ -263,18 +264,18 @@ local function flush() return end - local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() + local patches = if config.preserveHistory then createGlobalPatchHistory() else createGlobalPatch() -- While payload objects are technically mutable as they are unique to -- each client, the data inside them is not safe to mutate. - if Charm.flags.frozen then + if globals.frozen then Charm.deepFreeze(patches) end for client in clientRegister do local payloads: { SyncPayload } = if config.preserveHistory - then createHistoricalPayloadsForClient(client, patches) - else createPayloadsForClient(client, patches) + then createPayloadHistoryForClient(client, patches) + else createPayloadForClient(client, patches) if payloads[1] then if task then @@ -285,10 +286,43 @@ local function flush() end end - unregisterUnusedAtoms() + unsyncStaleGlobalAtoms() table.clear(globalUpdates) end +--- Starts automatically syncing registered atoms to clients. Patches will +--- be sent to clients whenever the atoms are updated. +--- +--- @param onSync The function to call when patches can be sent to a client. +local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) + if connection then + connection:Disconnect() + end + + onSyncCallback = onSync + + if not game then + return + end + + local timer = 0 + + connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) + if config.interval < 0 then + return + end + + timer += deltaTime + + if timer >= config.interval then + timer = 0 + debug.profilebegin("CharmSync") + flush() + debug.profileend() + end + end) +end + --- Stops automatically syncing registered atoms to clients. Patches will --- no longer be sent to clients until `startSync` is called again. local function stopSync() @@ -307,42 +341,6 @@ local function reset() stopSync() end ---- Starts automatically syncing registered atoms to clients. Patches will ---- be sent to clients whenever the atoms are updated. ---- ---- @param onSync The function to call when patches can be sent to a client. ---- @return `stopSync()` -local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()): () -> () - if connection then - connection:Disconnect() - end - - onSyncCallback = onSync - - if not game then - return stopSync - end - - local timer = 0 - - connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) - if config.interval < 0 then - return - end - - timer += deltaTime - - if timer >= config.interval then - timer = 0 - debug.profilebegin("CharmSync") - flush() - debug.profileend() - end - end) - - return stopSync -end - return { syncAtomsToClient = syncAtomsToClient, unsyncClient = unsyncClient, diff --git a/packages/charm/src/__tests__/core/atom.spec.luau b/packages/charm/src/__tests__/core/signal.spec.luau similarity index 79% rename from packages/charm/src/__tests__/core/atom.spec.luau rename to packages/charm/src/__tests__/core/signal.spec.luau index 1c8f8d0..e63b4f6 100644 --- a/packages/charm/src/__tests__/core/atom.spec.luau +++ b/packages/charm/src/__tests__/core/signal.spec.luau @@ -1,13 +1,13 @@ local core = require("../../core") local suite = require("../../../../../test/suite") -local atom = core.atom +local signal = core.signal local effect = core.effect local test = suite.test test("should accept equality function", function() local calls = {} - local src = atom(0, function(current, incoming) + local src, setSrc = signal(0, function(current, incoming) return current == incoming or incoming % 2 == 1 end) @@ -15,23 +15,23 @@ test("should accept equality function", function() table.insert(calls, src()) end) - src(2) - src(3) + setSrc(2) + setSrc(3) assert(src() == 2, "should skip odd value") - src(4) - src(5) + setSrc(4) + setSrc(5) assert(src() == 4, "should skip odd value") assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) end) test("should enforce immutability", function() - local src = atom({ nested = {} }) + local src, setSrc = signal({ nested = {} }) assert(not pcall(function() src().nested.a = 1 end), "should not allow mutation of initial value") - src({ nested = { a = 1 } }) + setSrc({ nested = { a = 1 } }) assert(not pcall(function() src().nested.a = 2 end), "should not allow mutation of new value") diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index 8273125..4560796 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -1,9 +1,6 @@ local suite = require("../../../../test/suite") suite.describe("core", function() - suite.describe("atom", function() - require("./core/atom.spec") - end) suite.describe("computed", function() require("./core/computed.spec") end) @@ -16,6 +13,9 @@ suite.describe("core", function() suite.describe("gc", function() require("./core/gc.spec") end) + suite.describe("signal", function() + require("./core/signal.spec") + end) suite.describe("topology", function() require("./core/topology.spec") end) diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 7a9409a..410f4ff 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -1,5 +1,5 @@ local alien = require("./alien") -local config = require("./flags") +local globals = require("./globals") local utils = require("./utils") export type ReactiveFlags = alien.ReactiveFlags @@ -74,7 +74,7 @@ end local function runCleanups(effect: EffectScope) for _, cleanup in effect.cleanups do local prevSub = setActiveSub(nil) - local success, result: any = (if config.strict then noYield else pcall)(cleanup) + local success, result: any = (if globals.strict then noYield else pcall)(cleanup) activeSub = prevSub if not success then error(result) @@ -87,18 +87,17 @@ local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) sub.flags = bit32.band(flags, bit32.bnot(PENDING)) end -local function run(e: Effect | EffectScope, flags: ReactiveFlags): () +local function run(e: Effect, flags: ReactiveFlags): () if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and (checkDirty(e.deps :: Link, e) or setNotPending(e, flags))) then runCleanups(e) - local e = e :: Effect cycle += 1 e.depsTail = nil e.flags = bit32.bor(WATCHING, RECURSED_CHECK) local prevSub = setActiveSub(e) - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(e.fn, e.stop) + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn, e.stop) activeSub = prevSub e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) purgeDeps(e) @@ -155,7 +154,7 @@ local function updateComputed(c: Computed): boolean c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) local prevSub = setActiveSub(c) local oldValue = c.value - local success, result: unknown = (if config.strict then noYield else pcall)(c.getter, oldValue) + local success, result: unknown = (if globals.strict then noYield else pcall)(c.getter, oldValue) activeSub = prevSub c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) purgeDeps(c) @@ -163,7 +162,7 @@ local function updateComputed(c: Computed): boolean error(result) end if oldValue ~= result then - if config.frozen then + if globals.frozen then deepFreeze(result) end c.value = result @@ -205,61 +204,65 @@ local function computedOper(this: Computed): () -> T end end -local function signalOper(this: Signal, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T - return function(...: ((T) -> T) | T): T - if select("#", ...) ~= 0 then - local update = ... - local value: T - if type(update) == "function" then - local prevSub = setActiveSub(nil) - local success, result: T = (if config.strict then noYield else pcall)(update, this.currentValue) - activeSub = prevSub - if not success then - error(result) - end - value = result - else - value = update +local function signalOper( + this: Signal, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) + local function setter(update: ((T) -> T) | T): T + local value: T + if type(update) == "function" then + local prevSub = setActiveSub(nil) + local success, result: T = (if globals.strict then noYield else pcall)(update, this.currentValue) + activeSub = prevSub + if not success then + error(result) end - if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then - if config.frozen then - deepFreeze(value) + value = result + else + value = update + end + if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then + if globals.frozen then + deepFreeze(value) + end + this.pendingValue = value + this.flags = bit32.bor(MUTABLE, DIRTY) + local subs = this.subs + if subs then + propagate(subs) + if batchDepth == 0 then + flush() end - this.pendingValue = value - this.flags = bit32.bor(MUTABLE, DIRTY) + end + return value + end + return this.currentValue + end + + local function getter(): T + if bit32.btest(this.flags, DIRTY) then + if updateSignal(this) then local subs = this.subs if subs then - propagate(subs) - if batchDepth == 0 then - flush() - end - end - return value - end - return this.currentValue - else - if bit32.btest(this.flags, DIRTY) then - if updateSignal(this) then - local subs = this.subs - if subs then - shallowPropagate(subs) - end + shallowPropagate(subs) end end - local sub = activeSub - while sub do - if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then - link(this, sub, cycle) - break - end - local subs = sub.subs - if subs then - sub = subs.sub - end - end - return this.currentValue end + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(this, sub, cycle) + break + end + local subs = sub.subs + if subs then + sub = subs.sub + end + end + return this.currentValue end + + return getter, setter end local function effectScopeOper(this: EffectScope): () -> () @@ -279,15 +282,18 @@ local function effectOper(this: Effect): () -> () return effectScopeOper(this) end ---- Creates a reactive atom that acts as both a getter and setter. Calling the ---- atom with an argument sets its value, while calling it with no arguments ---- returns its current value. +--- Creates a reactive signal that stores a value and notifies subscribers when +--- the value changes. The signal provides both a getter and a setter. --- ---- @param value The initial value of the atom. If empty, the atom's value will be `undefined`. ---- @param equals A comparator function to determine if the atom's value has changed. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options -local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T - if config.frozen then +--- @param initialValue The initial value of the signal. +--- @param equals A comparator function to determine if the signal's value has changed. +--- @return A tuple containing the getter and setter functions for the signal. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals +local function signal( + initialValue: T, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) + if globals.frozen then deepFreeze(initialValue) end return signalOper({ @@ -297,6 +303,20 @@ local function atom(initialValue: T, equals: ((current: T, incoming: T) -> bo }, equals) end +--- Creates a reactive atom that acts as both a getter and setter. Calling the +--- atom with an argument sets its value, while calling it with no arguments +--- returns its current value. +--- +--- @param value The initial value of the atom. If empty, the atom's value will be `undefined`. +--- @param equals A comparator function to determine if the atom's value has changed. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals +local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T + local getter, setter = signal(initialValue, equals) + return function(...: ((T) -> T) | T): T + return if select("#", ...) == 0 then getter() else setter((...)) + end +end + --- Creates a new atom that is computed based on the values of other atoms. --- The returned atom is read-only, and its value is automatically updated when --- any atoms accessed from within the getter function change. @@ -343,11 +363,11 @@ local function effect(fn: (stop: () -> ()) -> ()): () -> () } e.stop = effectOper(e) local prevSub = setActiveSub(e) - if prevSub and (config.trackInnerEffects or prevSub.flags == NONE) then + if prevSub and (globals.trackInnerEffects or prevSub.flags == NONE) then link(e, prevSub, 0) end local stopped = false - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn, function() + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn, function() stopped = true end) activeSub = prevSub @@ -379,7 +399,7 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> () if prevSub and not detached then link(e, prevSub, 0) end - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn) activeSub = prevSub if not success then error(result) @@ -456,7 +476,7 @@ end --- @return The return value of the callback. local function batched(callback: (Args...) -> Results..., ...: Args...): Results... startBatch() - local results: { any } = { (if config.strict then noYield else pcall)(callback, ...) } + local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) } endBatch() if not results[1] then error(results[2]) @@ -464,6 +484,59 @@ local function batched(callback: (Args...) -> Results..., . return unpack(results, 2) end +--- Creates an effect that only runs the callback when the value returned by the +--- getter changes. The callback receives the new value and the previous value +--- as arguments. +--- +--- @param getter A function that returns the value to watch. +--- @param callback The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback +local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () + local tracked = computed(function() + return getter() + end) + local current: T + local mounted = true + return effect(function() + local previous = current + current = tracked() + if mounted then + mounted = false + else + peek(callback, current, previous) + end + end) +end + +--- Creates an effect that runs the callback one immediately, and then again +--- when the value returned by the getter changes. The callback receives the new +--- value, the previous value, and a cleanup function as arguments. +--- +--- @param getter A function that returns the value to watch. +--- @param callback The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +local function listen(getter: () -> T, callback: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () + local tracked = computed(function() + return getter() + end) + local current: T + local disposed = false + local dispose = function() + disposed = true + end + dispose = effect(function() + local previous = current + current = tracked() + peek(callback, current, previous, dispose) + end) + if disposed then + dispose() + end + return dispose +end + local function notify(e: ReactiveNode) local flags = e.flags if not bit32.btest(flags, QUEUED) then @@ -513,6 +586,7 @@ return { startBatch = startBatch, endBatch = endBatch, atom = atom, + signal = signal, computed = computed, effect = effect, effectScope = effectScope, @@ -520,4 +594,6 @@ return { untracked = untracked, peek = peek, batched = batched, + subscribe = subscribe, + listen = listen, } diff --git a/packages/charm/src/flags.luau b/packages/charm/src/globals.luau similarity index 63% rename from packages/charm/src/flags.luau rename to packages/charm/src/globals.luau index 43475be..d1c1b17 100644 --- a/packages/charm/src/flags.luau +++ b/packages/charm/src/globals.luau @@ -3,17 +3,18 @@ local function inlineTest(): string end local isO2 = inlineTest() ~= "inlineTest" +local isDev = not isO2 or _G.__DEV__ == true return { --- Enforces synchronous, non-yielding behavior in atoms and effects. --- Also enables state validation in Charm Sync to catch sync errors early. --- Enabled if the optimization level is below 2, which is true in studio. - strict = not isO2, + strict = isDev, --- Enforces immutability of atoms by deep-freezing table values. Enabled --- if the optimization level is below 2, which is true in studio. - frozen = not isO2, - --- Whether inner effects should clean up after themselves when their - --- parent effect re-runs. This is usually desirable, but can be disabled - --- to revert to the old behavior. Defaults to `true`. + frozen = isDev, + --- Whether parent effects should track inner effects and clean them up + --- when the parent effect re-runs. This is usually desirable, but can be + --- disabled to revert to the old behavior. Defaults to `true`. trackInnerEffects = true, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 30d5410..03f37c3 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -7,7 +7,7 @@ type Cleanup = () => void; * current value. */ export interface Atom { - (value: T | ((value: T) => T)): T; + (newValue: T | ((currentValue: T) => T)): T; (): T; } @@ -18,7 +18,7 @@ export type Equals = (current: T, incoming: T) => boolean; /** * Global flags that modify the behavior of Charm's reactive system. */ -export const flags: { +export const globals: { /** * Enforces synchronous, non-yielding behavior in atoms and effects. * Also enables state validation in Charm Sync to catch sync errors early. @@ -31,13 +31,30 @@ export const flags: { */ frozen: boolean; /** - * Whether inner effects should clean up after themselves when their - * parent effect re-runs. This is usually desirable, but can be disabled - * to revert to the old behavior. Defaults to `true`. + * Whether parent effects should track inner effects and clean them up + * when the parent effect re-runs. This is usually desirable, but can be + * disabled to revert to the old behavior. Defaults to `true`. */ trackInnerEffects: boolean; }; +/** + * Creates a reactive signal that stores a value and notifies subscribers when + * the value changes. The signal provides both a getter and a setter. + * + * @param initialValue The initial value of the signal. + * @param equals A comparator function to determine if the signal's value has changed. + * @return A tuple containing the getter and setter functions for the signal. + * @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals + */ +export function signal( + value: T, + equals?: Equals, +): LuaTuple<[() => T, (newValue: T | ((currentValue: T) => T)) => T]>; +export function signal(): LuaTuple< + [() => T | undefined, (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined] +>; + /** * Creates a reactive atom that acts as both a getter and setter. Calling the * atom with an argument sets its value, while calling it with no arguments diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 470312a..1275509 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,33 +1,33 @@ local core = require("@self/core") -local flags = require("@self/flags") -local listen = require("@self/listen") +local globals = require("@self/globals") local mapped = require("@self/mapped") local observe = require("@self/observe") -local subscribe = require("@self/subscribe") -local types = require("@self/types") local utils = require("@self/utils") +type Update = ((previous: T) -> T) | T + --- A reactive atom that acts as both a getter and setter. Calling the atom with --- an argument sets its value, while calling it with no arguments returns its --- current value. -export type Atom = types.Atom +export type Atom = (...Update) -> T -export type Selector = types.Selector +export type Selector = () -> T return { core = core, - flags = flags, + globals = globals, atom = core.atom, batched = core.batched, computed = core.computed, effect = core.effect, effectScope = core.effectScope, + listen = core.listen, onCleanup = core.onCleanup, peek = core.peek, + signal = core.signal, + subscribe = core.subscribe, untracked = core.untracked, - listen = listen, mapped = mapped, observe = observe, - subscribe = subscribe, deepFreeze = utils.deepFreeze, } diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau deleted file mode 100644 index 9489297..0000000 --- a/packages/charm/src/listen.luau +++ /dev/null @@ -1,40 +0,0 @@ -local core = require("./core") - -local computed = core.computed -local effect = core.effect -local peek = core.peek - ---- Creates an effect that runs the callback one immediately, and then again ---- when the value returned by the getter changes. The callback receives the new ---- value, the previous value, and a cleanup function as arguments. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback -local function listen(getter: () -> T, callback: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () - local tracked = computed(function() - return getter() - end) - - local current: T - local disposed = false - - local function dispose() - disposed = true - end - - dispose = effect(function() - local previous = current - current = tracked() - peek(callback, current, previous, dispose) - end) - - if disposed then - dispose() - end - - return dispose -end - -return listen diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index bbdf359..5f5f9ba 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -1,11 +1,10 @@ local core = require("./core") -local flags = require("./flags") +local globals = require("./globals") +local batched = core.batched local effect = core.effect local effectScope = core.effectScope local onCleanup = core.onCleanup -local startBatch = core.startBatch -local endBatch = core.endBatch --- Calls the observer function for each unique key in the table returned by the --- getter. The observer is called once for each key, and again when a new key @@ -51,13 +50,11 @@ local function observe(getter: () -> { [K]: V }, observer: (item: V, key: local previousItems = currentItems currentItems = getter() if currentItems ~= previousItems then - startBatch() - updateScopes(currentItems, previousItems) - endBatch() + batched(updateScopes, currentItems, previousItems) end end) - if flags.trackInnerEffects then + if globals.trackInnerEffects then onCleanup(disposeScopes, true) end diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau deleted file mode 100644 index 3d4ec41..0000000 --- a/packages/charm/src/subscribe.luau +++ /dev/null @@ -1,34 +0,0 @@ -local core = require("./core") - -local computed = core.computed -local effect = core.effect -local peek = core.peek - ---- Creates an effect that only runs the callback when the value returned by the ---- getter changes. The callback receives the new value and the previous value ---- as arguments. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback -local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () - local tracked = computed(function() - return getter() - end) - - local current: T - local mounted = true - - return effect(function() - local previous = current - current = tracked() - if mounted then - mounted = false - else - peek(callback, current, previous) - end - end) -end - -return subscribe diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau deleted file mode 100644 index 4769ad2..0000000 --- a/packages/charm/src/types.luau +++ /dev/null @@ -1,7 +0,0 @@ -type Update = ((previous: T) -> T) | T - -export type Atom = (...Update) -> T - -export type Selector = () -> T - -return {} diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 44f43f9..a923ff5 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -16,6 +16,7 @@ local function useAtom(getter: () -> T, dependencies: { any }?): T React.useLayoutEffect(function() return Charm.effect(function() + -- Wrap in untracked to avoid tracking inner effect and atom calls Charm.untracked(setState, getter()) end) end, dependencies or {}) @@ -39,6 +40,7 @@ local function useAtomBinding(getter: () -> T, dependencies: { any }?) React.useLayoutEffect(function() return Charm.effect(function() + -- Wrap in untracked to avoid tracking inner effect and atom calls Charm.untracked(setBinding, getter()) end) end, dependencies or {}) diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index bf59c2e..d6d27ae 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -10,6 +10,7 @@ local function useAtom(getter: () -> State): () -> State local state = Vide.source(getter()) Vide.cleanup(Charm.effect(function() + -- Wrap in untracked to avoid tracking inner effect and atom calls Charm.untracked(state :: (State) -> State, getter()) end)) diff --git a/test/spec.server.luau b/test/spec.server.luau index dfb6d0b..bb1f7aa 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -1,8 +1,8 @@ local Charm = require("../packages/charm/src") local suite = require("./suite") -Charm.flags.strict = true -Charm.flags.frozen = true +Charm.globals.strict = true +Charm.globals.frozen = true suite.describe("charm", function() require("../packages/charm/src/__tests__/spec")