From 7a639932930facc46316d51f64a84d566a95df13 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:45:01 -0700 Subject: [PATCH] refactor: add docs, remove some modules --- packages/charm-sync/src/client.luau | 17 ++ packages/charm-sync/src/flatten.luau | 5 + packages/charm-sync/src/index.d.ts | 128 +++++++++++-- packages/charm-sync/src/patch.luau | 36 ++-- packages/charm-sync/src/server.luau | 40 +++- packages/charm-sync/src/types.luau | 2 +- .../src/__tests__/core/computed.spec.luau | 9 +- .../charm/src/{system.luau => alien.luau} | 0 packages/charm/src/batched.luau | 23 --- packages/charm/src/core.luau | 165 ++++++++++++++--- packages/charm/src/index.d.ts | 172 +++++++++++++++++- packages/charm/src/init.luau | 13 +- packages/charm/src/listen.luau | 14 +- packages/charm/src/mapped.luau | 19 +- packages/charm/src/observe.luau | 16 +- packages/charm/src/peek.luau | 27 --- packages/charm/src/subscribe.luau | 14 +- packages/charm/src/types.luau | 2 + packages/charm/src/untracked.luau | 36 ---- 19 files changed, 558 insertions(+), 180 deletions(-) rename packages/charm/src/{system.luau => alien.luau} (100%) delete mode 100644 packages/charm/src/batched.luau delete mode 100644 packages/charm/src/peek.luau delete mode 100644 packages/charm/src/untracked.luau diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 3610056..5b40ba0 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -6,6 +6,10 @@ type SyncPayload = types.SyncPayload local atomRegister: { [string]: Charm.Atom } = {} +--- Registers atoms to be kept in sync with the server. Atoms with matching +--- keys on the server will be synced to these atoms. +--- +--- @param atoms An object mapping string keys to atoms to be synced. local function syncAtoms(atoms: { [string]: Charm.Atom }) for key, atom in atoms do if atomRegister[key] and atomRegister[key] ~= atom then @@ -17,6 +21,11 @@ local function syncAtoms(atoms: { [string]: Charm.Atom }) end end +--- Unregisters one or more atoms from being kept in sync with the server. +--- The atoms will retain their current values, but will no longer receive +--- updates from the server. +--- +--- @param keys The keys of the atoms to stop syncing. local function unsyncAtoms(...: string) for index = 1, select("#", ...) do local key = select(index, ...) @@ -24,6 +33,9 @@ local function unsyncAtoms(...: string) end end +--- Unregisters all atoms from being kept in sync with the server. The +--- atoms will retain their current values, but will no longer receive +--- updates from the server. local function reset() table.clear(atomRegister) end @@ -51,6 +63,11 @@ local function applyPatchPayload(data: { [string | number]: any }) end end +--- Applies one or more payloads received from the server to the synced +--- atoms. Payloads are state patches or initial state updates sent by the +--- server, and are usually received via a remote event. +--- +--- @param payloads The payloads to apply. local function applyPayloads(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau index 23a4e5d..06335f4 100644 --- a/packages/charm-sync/src/flatten.luau +++ b/packages/charm-sync/src/flatten.luau @@ -2,6 +2,11 @@ type NestedAtomMap = { [string]: NestedAtomMap | () -> any, } +--- Flattens a nested atom map into a single object with slash-separated keys. +--- Useful for recursively collecting atoms returned by modules. +--- +--- @param atoms The nested atom map to flatten. +--- @return A flattened atom map. local function flatten(atoms: NestedAtomMap): { [string]: () -> any } local result: { [string]: () -> any } = {} diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index edbfc39..7f565ed 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -26,9 +26,6 @@ type DataTypes = { [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; }; -/** - * A type that should not be made partial in patches. - */ type DataType = DataTypes[keyof DataTypes]; /** @@ -63,14 +60,8 @@ export type SyncPayload, Serialize> }; /** - * Checks whether a value is `None`. If `true`, the value is scheduled to be - * removed from the state when the patch is applied. - * - * @param value The value to check. - * @returns `true` if the value is `None`, otherwise `false`. + * Global configuration options that affect the behavior of Charm Sync. */ -export function isNone(value: unknown): value is None; - export const config: { /** * The interval at which to send patches to the client, in seconds. @@ -108,49 +99,142 @@ export const config: { validatePatches: boolean; }; +/** + * @client + */ export namespace client { + /** + * Registers atoms to be kept in sync with the server. Atoms with matching + * keys on the server will be synced to these atoms. + * + * @param atoms An object mapping string keys to atoms to be synced. + */ export function syncAtoms(atoms: { [key: string]: Atom }): void; + /** + * Unregisters one or more atoms from being kept in sync with the server. + * The atoms will retain their current values, but will no longer receive + * updates from the server. + * + * @param keys The keys of the atoms to stop syncing. + */ export function unsyncAtoms(...keys: string[]): void; + /** + * Unregisters all atoms from being kept in sync with the server. The + * atoms will retain their current values, but will no longer receive + * updates from the server. + */ export function reset(): void; + /** + * Applies one or more payloads received from the server to the synced + * atoms. Payloads are state patches or initial state updates sent by the + * server, and are usually received via a remote event. + * + * @param payloads The payloads to apply. + */ export function applyPayloads }, Serialize extends boolean = true>( payloads: SyncPayload[], ): void; } +/** + * @server + */ export namespace server { + /** + * Registers atoms to be kept in sync with a specific client. Should be + * called when a player joins the game to start syncing atoms to them. Call + * `unsyncAtomsFromClient` or `unsyncClient` to stop syncing. + * + * Each atom should be assigned a unique key, allowing the server to re-use + * patches between multiple clients. + * + * @param client The player to sync atoms to. + * @param atoms An object mapping string keys to atoms to be synced. + */ export function syncAtomsToClient(client: Player, atoms: { [key: string]: Atom }): void; + /** + * Unregisters one or more atoms from being kept in sync with a specific + * client. + * + * @param client The player to stop syncing atoms to. + * @param keys The keys of the atoms to stop syncing. + */ export function unsyncAtomsFromClient(client: Player, ...keys: string[]): void; + /** + * Unregisters all atoms from being kept in sync with a specific client. + * Should be called when a player leaves the game. + * + * @param client The player to stop syncing atoms to. + */ export function unsyncClient(client: Player): void; + /** + * 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. + * @returns `stopSync()` + */ export function startSync }, Serialize extends boolean = true>( onSync: (client: Player, payloads: SyncPayload[]) => void, ): Cleanup; + /** + * Stops automatically syncing registered atoms to clients. Patches will + * no longer be sent to clients until `startSync` is called again. + */ export function stopSync(): void; + /** + * Immediately sends pending updates and initial states to all clients. + * Normally, updates are sent at the interval specified in `config.interval`. + * Calling this function forces all pending updates to be sent immediately. + */ export function flush(): void; + /** + * Unregisters all atoms from being kept in sync with all clients, and + * stops automatic syncing. + */ export function reset(): void; } export namespace patch { - export function isNone(value: unknown): value is None; - + /** + * Converts `nil` values to the special `None` symbol. This is useful when + * preparing data for serialization, since `nil` can represent both the absence + * of a value and the removal of a value in patches. + */ export function nilToNone(value: T | undefined): T | None; + /** + * Compares two states and returns a patch that can be applied to the + * current state to produce the next state. + * + * @param currentState The current state. + * @param nextState The next state. + * @returns A patch that can be applied to the current state. + */ export function diff( currentState: State, nextState: State, ): SyncPatch; + /** + * Applies a patch to a state and returns the resulting value. + * + * @param currentState The current state. + * @param statePatch The patches to apply. + * @returns The new state with the patch applied. + */ export function apply( - state: State, - patch: SyncPatch, + currentState: State, + statePatch: SyncPatch, ): State; } @@ -170,7 +254,10 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; -type FlattenNestedAtoms = IntersectValues<{ +export type FlattenNestedAtoms< + Atoms extends NestedAtomMap, + Path extends Key | undefined = undefined, +> = IntersectValues<{ readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom ? { readonly [K in AppendPath]: Atoms[Name] } : Atoms[Name] extends NestedAtomMap @@ -183,6 +270,15 @@ type FlattenNestedAtoms(atoms: Atoms): FlattenNestedAtoms; + +/** + * Checks whether a value is `None`. If `true`, the value is scheduled to be + * removed from the state when the patch is applied. + * + * @param value The value to check. + * @returns `true` if the value is `None`, otherwise `false`. + */ +export function isNone(value: unknown): value is None; diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 617efad..7f017ee 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -11,7 +11,11 @@ export type None = types.None --- deleted values in patches. local None: None = { __none = "__none" } ---- Checks if a value is the special `None` symbol. +--- Checks whether a value is `None`. If `true`, the value is scheduled to be +--- removed from the state when the patch is applied. +--- +--- @param value The value to check. +--- @return `true` if the value is `None`, otherwise `false`. local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" end @@ -48,30 +52,30 @@ local function stringifySparseArray(object: { any }): { [string | number]: any } return dictionary end ---- Compares two states and returns a patch that can be applied to the previous +--- Compares two states and returns a patch that can be applied to the current --- state to produce the next state. --- ---- @param prevState The previous state. +--- @param currentState The current state. --- @param nextState The next state. ---- @returns A patch that can be applied to the previous state. -local function diff(prevState: any, nextState: any): any - if prevState == nextState then +--- @return A patch that can be applied to the current state. +local function diff(currentState: any, nextState: any): any + if currentState == nextState then return nil elseif nextState == nil then return None - elseif type(prevState) ~= "table" or type(nextState) ~= "table" then + elseif type(currentState) ~= "table" or type(nextState) ~= "table" then return nextState end local patches: { [any]: any } = table.clone(nextState) - for key, previous in prevState do + for key, previous in currentState do patches[key] = diff(previous, nextState[key]) end if config.autoSerialize then -- Coerce sparse array patches into dictionaries - if prevState[1] ~= nil or nextState[1] ~= nil then + if currentState[1] ~= nil or nextState[1] ~= nil then patches = stringifySparseArray(patches) end @@ -85,20 +89,20 @@ local function diff(prevState: any, nextState: any): any return patches end ---- Applies a patch to a state and returns the next state. +--- Applies a patch to a state and returns the resulting value. --- ---- @param prevState The current state. +--- @param currentState The current state. --- @param statePatch The patches to apply. ---- @returns The next state. -local function apply(prevState: any, statePatch: any): any +--- @return The new state with the patch applied. +local function apply(currentState: any, statePatch: any): any if isNone(statePatch) then return nil - elseif type(prevState) ~= "table" or type(statePatch) ~= "table" then + elseif type(currentState) ~= "table" or type(statePatch) ~= "table" then return statePatch end - local nextState = table.clone(prevState) - local stateIsArray = prevState[1] ~= nil + local nextState = table.clone(currentState) + local stateIsArray = currentState[1] ~= nil for key, value in statePatch do -- Diff-checking an array produces a sparse array, which will not be diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index d9009b1..5f628d7 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -20,7 +20,9 @@ local LISTENING_FOR_CHANGES: SyncStatus = 1 local atomRegister: { [string]: RegisteredAtom } = {} local clientRegister: { [any]: { [string]: SyncStatus } } = {} local globalUpdates: { { key: string, value: any, previousValue: any } } = {} + local connection: RBXScriptConnection? +local onSyncCallback: (client: any, payloads: { SyncPayload }) -> ()? local function registerAtom(key: string, atom: () -> T) if atomRegister[key] then @@ -196,6 +198,15 @@ local function createPayloadsForClient(client: any, patchData: any): { SyncPaylo return payloads end +--- Registers atoms to be kept in sync with a specific client. Should be +--- called when a player joins the game to start syncing atoms to them. Call +--- `unsyncAtomsFromClient` or `unsyncClient` to stop syncing. +--- +--- Each atom should be assigned a unique key, allowing the server to re-use +--- patches between multiple clients. +--- +--- @param client The player to sync atoms to. +--- @param atoms An object mapping string keys to atoms to be synced. local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) if not clientRegister[client] then clientRegister[client] = {} @@ -214,10 +225,19 @@ local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) end end +--- Unregisters all atoms from being kept in sync with a specific client. +--- Should be called when a player leaves the game. +--- +--- @param client The player to stop syncing atoms to. local function unsyncClient(client: any) clientRegister[client] = nil end +--- Unregisters one or more atoms from being kept in sync with a specific +--- client. +--- +--- @param client The player to stop syncing atoms to. +--- @param keys The keys of the atoms to stop syncing. local function unsyncAtomsFromClient(client: any, ...: string) local syncedAtoms = clientRegister[client] @@ -235,11 +255,14 @@ local function unsyncAtomsFromClient(client: any, ...: string) end end -local function onSyncCallback(_client: any, _payloads: { SyncPayload }) - warn("[Charm] Override the onSync function by passing a custom function to startSync") -end - +--- Immediately sends pending updates and initial states to all clients. +--- Normally, updates are sent at the interval specified in `config.interval`. +--- Calling this function forces all pending updates to be sent immediately. local function flush() + if not onSyncCallback then + return + end + local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() -- While payload objects are technically mutable as they are unique to @@ -266,6 +289,8 @@ local function flush() table.clear(globalUpdates) end +--- Stops automatically syncing registered atoms to clients. Patches will +--- no longer be sent to clients until `startSync` is called again. local function stopSync() if connection then connection:Disconnect() @@ -273,6 +298,8 @@ local function stopSync() end end +--- Unregisters all atoms from being kept in sync with all clients, and +--- stops automatic syncing. local function reset() table.clear(atomRegister) table.clear(clientRegister) @@ -280,6 +307,11 @@ 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() diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 482f91d..ac8ffb4 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -14,4 +14,4 @@ export type SyncPayload = --- differentiate between a value that was set to `nil` and an unchanged value. export type None = { __none: "__none" } -return nil +return {} diff --git a/packages/charm/src/__tests__/core/computed.spec.luau b/packages/charm/src/__tests__/core/computed.spec.luau index 9c6e078..43c5fad 100644 --- a/packages/charm/src/__tests__/core/computed.spec.luau +++ b/packages/charm/src/__tests__/core/computed.spec.luau @@ -81,13 +81,12 @@ test("should not update if the atom value is reverted", function() assert(times == 1, "computed ran when it should not") end) -test("should accept equality function", function() +test("should receive previous value", function() local calls = {} local src = atom(0) - local c1 = computed(function() - return src() - end, function(current, incoming) - return current == incoming or incoming % 2 == 1 + local c1 = computed(function(current): number + local incoming = src() + return if current == incoming or incoming % 2 == 1 then current or incoming else incoming end) effect(function() diff --git a/packages/charm/src/system.luau b/packages/charm/src/alien.luau similarity index 100% rename from packages/charm/src/system.luau rename to packages/charm/src/alien.luau diff --git a/packages/charm/src/batched.luau b/packages/charm/src/batched.luau deleted file mode 100644 index 29a3d28..0000000 --- a/packages/charm/src/batched.luau +++ /dev/null @@ -1,23 +0,0 @@ -local core = require("./core") -local flags = require("./flags") -local utils = require("./utils") - -local startBatch = core.startBatch -local endBatch = core.endBatch -local noYield = utils.noYield - -local function handleBatched(ok: boolean, ...) - endBatch() - if ok then - return ... - else - error((...)) - end -end - -local function batched(callback: (Args...) -> Results..., ...: Args...): Results... - startBatch() - return handleBatched((if flags.strict then noYield else pcall)(callback, ...)) -end - -return batched diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index f430260..3ff1604 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -2,16 +2,16 @@ -- https://github.com/stackblitz/alien-signals -- https://github.com/Nicell/alien-signals-luau +local alien = require("./alien") local config = require("./flags") -local system = require("./system") local utils = require("./utils") -export type ReactiveFlags = system.ReactiveFlags -export type ReactiveNode = system.ReactiveNode -export type Link = system.Link +export type ReactiveFlags = alien.ReactiveFlags +export type ReactiveNode = alien.ReactiveNode +export type Link = alien.Link -local ReactiveFlags = system.ReactiveFlags -local createReactiveSystem = system.createReactiveSystem +local ReactiveFlags = alien.ReactiveFlags +local createReactiveSystem = alien.createReactiveSystem local deepFreeze = utils.deepFreeze local noYield = utils.noYield @@ -23,11 +23,11 @@ local PENDING = 0b0100000 local QUEUED = 0b1000000 local UNTRACKED = 0b10000000 -export type EffectScope = ReactiveNode & { +type EffectScope = ReactiveNode & { cleanups: { () -> () }, } -export type Effect = ReactiveNode & { +type Effect = ReactiveNode & { fn: (dispose: () -> ()) -> ...(() -> ())?, cleanups: { () -> () }, cleanup: () -> (), @@ -36,7 +36,6 @@ export type Effect = ReactiveNode & { type Computed = ReactiveNode & { value: T?, getter: (previousValue: T?) -> T, - equals: ((T, T) -> boolean)?, } type Atom = ReactiveNode & { @@ -94,16 +93,6 @@ local function runCleanups(effect: EffectScope) table.clear(effect.cleanups) end -local function onCleanup(cleanup: () -> (), failSilently: boolean?) - if activeSub and (activeSub :: Effect).cleanups then - table.insert((activeSub :: Effect).cleanups, cleanup) - elseif activeScope then - table.insert(activeScope.cleanups, cleanup) - elseif not failSilently then - warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) - end -end - local function run(effect: Effect, flags: ReactiveFlags): () if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then runCleanups(effect) @@ -171,8 +160,7 @@ local function updateComputed(computed: Computed): boolean if not success then error(result) end - local equals = computed.equals - if if equals then not equals(oldValue, result) else oldValue ~= result then + if oldValue ~= result then computed.value = result return true else @@ -321,26 +309,63 @@ local function unwatched(node: ReactiveNode) end end -local function atom(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T +--- 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-options +local function atom(value: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T if config.frozen then - deepFreeze(initialValue) + deepFreeze(value) end return atomGetterSetter({ - previousValue = initialValue, - value = initialValue, + previousValue = value, + value = value, flags = MUTABLE, equals = equals, }) end -local function computed(getter: (previousValue: T?) -> T, equals: ((T, T) -> boolean)?): () -> T +--- 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. +--- +--- If an initial value is provided, the getter will receive this value as its +--- first argument on the initial run, instead of `undefined`. +--- +--- @example +--- ```ts +--- const count = atom(1); +--- const plusOne = computed(() => count() + 1); +--- print(plusOne()); // 2 +--- ``` +--- +--- @param getter A function that produces the next value. +--- @param value An optional initial value for the selector. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options +local function computed(getter: (previousValue: T?) -> T, value: T?): () -> T return computedGetter({ flags = bit32.bor(MUTABLE, DIRTY), getter = getter, - equals = equals, + value = value, }) end +--- Creates an effect that runs a callback in response to atom state changes. +--- An effect tracks which atoms are accessed during its execution, and re-runs +--- the callback when those atoms change. +--- +--- The effect callback may return a cleanup function, which gets called once, +--- either before the effect re-runs or when it is disposed. +--- +--- If the effect is called within another effect, it will be disposed when the +--- outer effect re-runs. +--- +--- @param fn The function to run when dependencies change. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback local function effect(fn: (dispose: () -> ()) -> ...(() -> ())?): () -> () local self: Effect = { fn = fn, @@ -365,6 +390,14 @@ local function effect(fn: (dispose: () -> ()) -> ...(() -> ())?): () -> () return self.cleanup end +--- Creates an effect scope that can capture multiple reactive effects (i.e. +--- computed, effect, subscribe) created within it so that they can be disposed +--- together. +--- +--- @param fn A function that may create reactive effects. +--- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. +--- @return A function for disposing all effects created within the scope. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () local self: EffectScope = { flags = NONE, @@ -386,6 +419,81 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> return effectStop(self) end +--- Registers a cleanup function to the current active effect or effect scope. +--- If there is no active effect or scope, a warning is logged and the callback +--- is not registered, unless `failSilently` is set to true. +--- +--- @param callback The cleanup function to register. +--- @param failSilently If true, suppresses the warning when there is no active effect or scope. Defaults to false. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently +local function onCleanup(cleanup: () -> (), failSilently: boolean?) + if activeSub and (activeSub :: Effect).cleanups then + table.insert((activeSub :: Effect).cleanups, cleanup) + elseif activeScope then + table.insert(activeScope.cleanups, cleanup) + elseif not failSilently then + warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) + end +end + +--- Runs the callback without subscribing to atom updates or capturing inner +--- effects in the parent effect or scope. +--- +--- For an alternative that still cleans up inner effects, use `peek` instead. +--- +--- @param callback The callback function. +--- @param args Arguments to pass to the callback. +--- @return The return value of the callback. +local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... + local prevSub = setCurrentSub(nil) + local prevScope = setCurrentScope(nil) + local results: { any } = { pcall(callback, ...) } + setCurrentSub(prevSub) + setCurrentScope(prevScope) + if not results[1] then + error(results[2]) + end + return unpack(results, 2) +end + +--- Runs the callback without subscribing to atom updates, but still captures +--- inner effects so that they dispose with the parent effect. +--- +--- To avoid capturing inner effects, use `untracked` instead. +--- +--- @param callback The callback function. +--- @param args Arguments to pass to the callback. +--- @return The return value of the callback. +local function peek(callback: (Args...) -> Results..., ...: Args...): Results... + if not activeSub or bit32.btest(activeSub.flags, UNTRACKED) then + return callback(...) + else + activeSub.flags = bit32.bor(activeSub.flags, UNTRACKED) + local results: { any } = { pcall(callback, ...) } + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(UNTRACKED)) + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + end +end + +--- Combines multiple atom updates into a single "commit" that runs effects at +--- the end of the provided callback. +--- +--- @param callback A function that performs multiple atom updates. +--- @param args Arguments to pass to the callback. +--- @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, ...) } + endBatch() + if not results[1] then + error(results[2]) + end + return unpack(results, 2) +end + local reactiveSystem = createReactiveSystem({ update = update, notify = notify, @@ -415,4 +523,7 @@ return { computed = computed, effect = effect, effectScope = effectScope, + untracked = untracked, + peek = peek, + batched = batched, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index cfffd62..30d5410 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1,6 +1,11 @@ type Key = string | number | symbol; type Cleanup = () => void; +/** + * 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 interface Atom { (value: T | ((value: T) => T)): T; (): T; @@ -10,29 +15,130 @@ export type Selector = () => T; export type Equals = (current: T, incoming: T) => boolean; +/** + * Global flags that modify the behavior of Charm's reactive system. + */ export const flags: { + /** + * 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: boolean; + /** + * Enforces immutability of atoms by deep-freezing table values. Enabled + * if the optimization level is below 2, which is true in studio. + */ 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`. + */ trackInnerEffects: boolean; }; +/** + * 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-options + */ export function atom(value: T, equals?: Equals): Atom; -// Overload for undefined initial value export function atom(): Atom; -export function computed(getter: (previousValue?: T) => T, equals?: Equals): () => T; +/** + * 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. + * + * If an initial value is provided, the getter will receive this value as its + * first argument on the initial run, instead of `undefined`. + * + * @example + * ```ts + * const count = atom(1); + * const plusOne = computed(() => count() + 1); + * print(plusOne()); // 2 + * ``` + * + * @param getter A function that produces the next value. + * @param value An optional initial value for the selector. + * @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options + */ +export function computed(getter: (previousValue?: T) => T): () => T; +export function computed(getter: (previousValue: T) => T, value: T): () => T; +/** + * Creates an effect that runs a callback in response to atom state changes. + * An effect tracks which atoms are accessed during its execution, and re-runs + * the callback when those atoms change. + * + * The effect callback may return a cleanup function, which gets called once, + * either before the effect re-runs or when it is disposed. + * + * If the effect is called within another effect, it will be disposed when the + * outer effect re-runs. + * + * @param callback The function to run when dependencies change. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback + */ export function effect(callback: (dispose: Cleanup) => Cleanup | void): Cleanup; +/** + * Creates an effect scope that can capture multiple reactive effects (i.e. + * computed, effect, subscribe) created within it so that they can be disposed + * together. + * + * @param callback A function that may create reactive effects. + * @param detached If true, the scope will not be automatically disposed of + * when the parent scope is disposed. Defaults to false. + * @returns A function for disposing all effects created within the scope. + * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached + */ export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; +/** + * 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. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback + */ +export function subscribe(getter: () => T, callback: (value: T, previousValue: T) => void): Cleanup; + +/** + * 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. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback + */ export function listen( getter: () => T, - listener: (value: T, previousValue: T | undefined, dispose: Cleanup) => void, + callback: (value: T, previousValue: T | undefined, dispose: Cleanup) => void, ): Cleanup; -export function subscribe(getter: () => T, listener: (value: T, previousValue: T) => void): Cleanup; - +/** + * Creates a new atom that is computed by mapping over the entries of an + * existing atom. The returned atom is read-only, and its value is + * automatically updated when the source atom changes. + * + * @param getter A function that returns the table to map over. + * @param mapper A function that transforms each value-key pair from the source + * into a new value or value-key pair for the resulting table. + * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper + */ export function mapped( getter: () => Map | ReadonlyMap, mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, @@ -48,6 +154,19 @@ export function mapped( mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): () => { readonly [K in KO]: VO }; +/** + * 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 + * is added. + * + * The observer may return a cleanup function, which will be called when the + * key is removed or when the entire observer is disposed. + * + * @param getter A function that returns the table to observe. + * @param observer A function that is called for each key in the table. + * @returns A function for disposing the observer. + * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer + */ export function observe( getter: () => Map | ReadonlyMap | readonly V[], observer: (value: V, key: K) => Cleanup | void, @@ -58,13 +177,48 @@ export function observe( observer: (value: V, key: K) => Cleanup | void, ): Cleanup; -export function batched( - callback: (...args: Args) => Result, - ...args: Args -): (...args: Args) => Result; +/** + * Combines multiple atom updates into a single "commit" that runs effects at + * the end of the provided callback. + * + * @param callback A function that performs multiple atom updates. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + */ +export function batched(callback: (...args: Args) => Result, ...args: Args): Result; +/** + * Runs the callback without subscribing to atom updates or capturing inner + * effects in the parent effect or scope. + * + * For an alternative that still cleans up inner effects, use `peek` instead. + * + * @param callback The callback function. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + */ export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; +/** + * Runs the callback without subscribing to atom updates, but still captures + * inner effects so that they dispose with the parent effect. + * + * To avoid capturing inner effects, use `untracked` instead. + * + * @param callback The callback function. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + */ export function peek(callback: (...args: Args) => Result, ...args: Args): Result; +/** + * Registers a cleanup function to the current active effect or effect scope. + * If there is no active effect or scope, a warning is logged and the callback + * is not registered, unless `failSilently` is set to true. + * + * @param callback The cleanup function to register. + * @param failSilently If true, suppresses the warning when there is no active + * effect or scope. Defaults to false. + * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently + */ export function onCleanup(callback: Cleanup, failSilently?: boolean): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 50350b5..470312a 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,32 +1,33 @@ -local batched = require("@self/batched") local core = require("@self/core") local flags = require("@self/flags") local listen = require("@self/listen") local mapped = require("@self/mapped") local observe = require("@self/observe") -local peek = require("@self/peek") local subscribe = require("@self/subscribe") local types = require("@self/types") -local untracked = require("@self/untracked") local utils = require("@self/utils") +--- 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 Selector = types.Selector return { core = core, flags = flags, atom = core.atom, + batched = core.batched, computed = core.computed, effect = core.effect, effectScope = core.effectScope, onCleanup = core.onCleanup, - batched = batched, + peek = core.peek, + untracked = core.untracked, listen = listen, mapped = mapped, observe = observe, - peek = peek, subscribe = subscribe, - untracked = untracked, deepFreeze = utils.deepFreeze, } diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 90a6b4f..9489297 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -1,10 +1,18 @@ local core = require("./core") -local peek = require("./peek") local computed = core.computed local effect = core.effect +local peek = core.peek -local function listen(getter: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () +--- 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) @@ -19,7 +27,7 @@ local function listen(getter: () -> T, listener: (state: T, previous: T?, dis dispose = effect(function() local previous = current current = tracked() - peek(listener, current, previous, dispose) + peek(callback, current, previous, dispose) end) if disposed then diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index 2447adb..ece9ebd 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -36,7 +36,9 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: local cachedKey = keyCache[key] if cachedKey then - currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) + end currentOutput[cachedKey] = nil keyCache[key] = nil end @@ -50,7 +52,9 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: local valueOut, keyOut = map(value, key) if currentOutput[keyOut] ~= valueOut then - currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) + end currentOutput[keyOut] = valueOut keyCache[key] = keyOut end @@ -60,4 +64,13 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: end) end -return mapped :: Mapped +--- Creates a new atom that is computed by mapping over the entries of an +--- existing atom. The returned atom is read-only, and its value is +--- automatically updated when the source atom changes. +--- +--- @param getter A function that returns the table to map over. +--- @param mapper A function that transforms each value-key pair from the source into a new value or value-key pair for the resulting table. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper +local mapped = mapped :: Mapped + +return mapped diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index bf7a9b8..bbdf359 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -1,4 +1,5 @@ local core = require("./core") +local flags = require("./flags") local effect = core.effect local effectScope = core.effectScope @@ -6,6 +7,17 @@ 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 +--- is added. +--- +--- The observer may return a cleanup function, which will be called when the +--- key is removed or when the entire observer is disposed. +--- +--- @param getter A function that returns the table to observe. +--- @param observer A function that is called for each key in the table. +--- @return A function for disposing the observer. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer local function observe(getter: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () local scopes: { [K]: () -> () } = {} local currentItems: { [K]: V } = {} @@ -45,7 +57,9 @@ local function observe(getter: () -> { [K]: V }, observer: (item: V, key: end end) - onCleanup(disposeScopes, true) + if flags.trackInnerEffects then + onCleanup(disposeScopes, true) + end return function() disposeEffect() diff --git a/packages/charm/src/peek.luau b/packages/charm/src/peek.luau deleted file mode 100644 index 27c7e5a..0000000 --- a/packages/charm/src/peek.luau +++ /dev/null @@ -1,27 +0,0 @@ -local core = require("./core") - -local UNTRACKED = core.ReactiveFlags.Untracked -local getCurrentSub = core.getCurrentSub - -local function handlePeek(sub: core.ReactiveNode?, ok: boolean, ...) - if sub then - sub.flags = bit32.band(sub.flags, bit32.bnot(UNTRACKED)) - end - if ok then - return ... - else - error((...)) - end -end - -local function peek(callback: (Args...) -> Results..., ...: Args...): Results... - local sub = getCurrentSub() - if not sub or bit32.btest(sub.flags, UNTRACKED) then - return callback(...) - else - sub.flags = bit32.bor(sub.flags, UNTRACKED) - end - return handlePeek(sub, pcall(callback, ...)) -end - -return peek diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index 9c46c0a..3d4ec41 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -1,10 +1,18 @@ local core = require("./core") -local peek = require("./peek") local computed = core.computed local effect = core.effect +local peek = core.peek -local function subscribe(getter: () -> T, listener: (state: T, lastState: T) -> ()): () -> () +--- 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) @@ -18,7 +26,7 @@ local function subscribe(getter: () -> T, listener: (state: T, lastState: T) if mounted then mounted = false else - peek(listener, current, previous) + peek(callback, current, previous) end end) end diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau index d5c0314..4769ad2 100644 --- a/packages/charm/src/types.luau +++ b/packages/charm/src/types.luau @@ -1,5 +1,7 @@ type Update = ((previous: T) -> T) | T + export type Atom = (...Update) -> T + export type Selector = () -> T return {} diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau deleted file mode 100644 index 6038fec..0000000 --- a/packages/charm/src/untracked.luau +++ /dev/null @@ -1,36 +0,0 @@ -local core = require("./core") - -local setCurrentSub = core.setCurrentSub -local setCurrentScope = core.setCurrentScope -local getCurrentSub = core.getCurrentSub -local getCurrentScope = core.getCurrentScope - -local function handleUntracked( - prevSub: core.ReactiveNode?, - prevScope: core.Effect | core.EffectScope?, - ok: boolean, - ... -) - if prevSub then - setCurrentSub(prevSub) - elseif prevScope then - setCurrentScope(prevScope) - end - if ok then - return ... - else - error((...)) - end -end - -local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - if getCurrentSub() then - return handleUntracked(setCurrentSub(nil), nil, pcall(callback, ...)) - elseif getCurrentScope() then - return handleUntracked(nil, setCurrentScope(nil), pcall(callback, ...)) - else - return callback(...) - end -end - -return untracked