From 6a2241884aaa9e63b14682432c911831f9a3d2b0 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 1 Jun 2024 18:45:43 -0700 Subject: [PATCH] docs: add doc comments --- src/atom.luau | 10 +- src/computed.luau | 9 ++ src/index.d.ts | 190 ++++++++++++++++++++++++++++++++++++- src/init.luau | 5 +- src/mapped.luau | 15 ++- src/observe.luau | 9 ++ src/react/useAtom.luau | 12 +++ src/subscribe.luau | 13 ++- src/sync/client.luau | 16 ++++ src/sync/init.luau | 4 + src/sync/patch.luau | 4 + src/sync/server.luau | 39 +++++++- src/sync/validate.luau | 7 ++ src/types.luau | 25 +++++ src/utils/collect.luau | 4 + src/utils/count.luau | 6 ++ src/utils/setInterval.luau | 7 ++ 17 files changed, 361 insertions(+), 14 deletions(-) diff --git a/src/atom.luau b/src/atom.luau index a1a2b3b..52abf93 100644 --- a/src/atom.luau +++ b/src/atom.luau @@ -1,9 +1,15 @@ -local peek = require(script.Parent.peek) local store = require(script.Parent.store) local types = require(script.Parent.types) type Atom = types.Atom type AtomOptions = types.AtomOptions +--[=[ + Creates a new atom with the given state. + + @param state The initial state. + @param options Optional configuration. + @return A new atom. +]=] local function atom(state: T, options: AtomOptions?): Atom local equals = options and options.equals @@ -16,7 +22,7 @@ local function atom(state: T, options: AtomOptions?): Atom return state end - local nextState = peek(..., state) + local nextState = store.peek(..., state) if state ~= nextState and not (equals and equals(state, nextState)) then state = nextState diff --git a/src/computed.luau b/src/computed.luau index 8d90fa2..0b0a910 100644 --- a/src/computed.luau +++ b/src/computed.luau @@ -5,6 +5,15 @@ type Atom = types.Atom type AtomOptions = types.AtomOptions type Molecule = types.Molecule +--[=[ + Creates a read-only atom that derives its state from one or more atoms. + Used to avoid unnecessary recomputations if multiple listeners depend on + the same molecule. + + @param molecule The function that produces the state. + @param options Optional configuration. + @return A new read-only atom. +]=] local function computed(molecule: Molecule, options: AtomOptions?): Molecule local captured, state = store.capture(molecule) local computedAtom = atom(state, options) diff --git a/src/index.d.ts b/src/index.d.ts index 83ac4a2..8c03719 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -9,35 +9,138 @@ type AnyMap = | (K extends string | number | symbol ? { readonly [Key in K]: V } : never); declare namespace Charm { + /** + * A primitive state container that can be read from and written to. When the + * state changes, all subscribers are notified. + * + * @template State The type of the state. + * @param state The next state or a function that produces the next state. + * @returns The current state, if no arguments are provided. + */ interface Atom extends Molecule { + /** + * @deprecated This property is not meant to be accessed directly. + */ readonly __nominal: unique symbol; + /** + * @param state The next state or a function that produces the next state. + * @returns The current state, if no arguments are provided. + */ (state: State | ((prev: State) => State)): void; } + /** + * A function that depends on one or more atoms and produces a state. Can be + * used to derive state from atoms. + * + * @template State The type of the state. + * @returns The current state. + */ type Molecule = () => State; interface AtomOptions { + /** + * A function that determines whether the state has changed. By default, + * a strict equality check (`===`) is used. + */ equals?: (prev: State, next: State) => boolean; } + /** + * Creates a new atom with the given state. + * + * @template State The type of the state. + * @param state The initial state. + * @param options Optional configuration. + * @returns A new atom. + */ function atom(state: State, options?: AtomOptions): Atom; + /** + * Creates a read-only atom that derives its state from one or more atoms. + * Used to avoid unnecessary recomputations if multiple listeners depend on + * the same molecule. + * + * @param molecule The function that produces the state. + * @param options Optional configuration. + * @returns A new read-only atom. + */ function computed(molecule: Molecule, options?: AtomOptions): Molecule; + /** + * Subscribes to changes in the given atom or molecule. The callback is + * called with the current state and the previous state immediately after a + * change occurs. + * + * @param molecule The atom or molecule to subscribe to. + * @param callback The function to call when the state changes. + * @returns A function that unsubscribes the callback. + */ function subscribe(molecule: Molecule, callback: (state: State, prev: State) => void): Cleanup; + /** + * Runs the given callback immediately and whenever any atom it depends on + * changes. Returns a cleanup function that unsubscribes the callback. + * + * Note that dependencies are only evaluated once when the effect is created, + * conditional dependencies may not work as expected. + * + * @param callback The function to run. + * @returns A function that unsubscribes the callback. + */ function effect(callback: () => void): Cleanup; + /** + * Returns the result of the function without subscribing to changes. If a + * non-function value is provided, it is returned as is. + * + * @param molecule The atom or molecule to get the state of. + * @param args Arguments to pass to the molecule. + * @returns The current state. + */ function peek(molecule: State | ((...args: Args) => State), ...args: Args): State; + /** + * Returns whether the given value is an atom. + * + * @param value The value to check. + * @returns `true` if the value is an atom, otherwise `false`. + */ function isAtom(value: unknown): value is Atom; + /** + * Runs the given function and schedules listeners to be notified only once + * after the function has completed. Useful for batching multiple changes. + * + * @param callback The function to run. + */ function batch(callback: () => void): void; + /** + * Captures all atoms that are read during the function call and returns them + * along with the result of the function. Useful for tracking dependencies. + * + * @param molecule The function to run. + * @returns A tuple containing the captured atoms and the result of the function. + */ function capture(molecule: Molecule): LuaTuple<[captured: Set>, state: State]>; + /** + * Notifies all subscribers of the given atom that the state has changed. + * + * @param atom The atom to notify. + */ function notify(atom: Atom): void; + /** + * Creates an instance of `factory` for each item in the atom's state, and + * cleans up the instance when the item is removed. Returns a cleanup function + * that unsubscribes all instances. + * + * @param molecule The atom or molecule to observe. + * @param factory The function that tracks the lifecycle of each item. + * @returns A function that unsubscribes all instances. + */ function observe( molecule: Molecule, factory: (item: Item, index: number) => Cleanup | void, @@ -48,6 +151,16 @@ declare namespace Charm { factory: (item: Item, key: Key) => Cleanup | void, ): Cleanup; + /** + * Maps each entry in the atom's state to a new key-value pair. If the `mapper` + * function returns `undefined`, the entry is omitted from the resulting map. + * When the atom changes, the `mapper` is called for each entry in the state + * to compute the new state. + * + * @param molecule The atom or molecule to map. + * @param mapper The function that maps each entry. + * @returns A new atom with the mapped state. + */ function mapped( molecule: Molecule, mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>, @@ -65,41 +178,110 @@ declare namespace Charm { // React + /** + * A hook that subscribes to changes in the given atom or molecule. The + * component is re-rendered whenever the state changes. + * + * If the `dependencies` array is provided, the subscription to the atom or + * molecule is re-created whenever the dependencies change. Otherwise, the + * subscription is created once when the component is mounted. + * + * @param molecule The atom or molecule to subscribe to. + * @param dependencies An array of values that the subscription depends on. + * @returns The current state. + */ function useAtom(molecule: Molecule, dependencies?: unknown[]): State; - // Sync - + /** + * Synchronizes state between the client and server. The server sends patches + * to the client, which applies them to its local state. + */ const sync: { + /** + * Creates a `ClientSyncer` object that receives patches from the server and + * applies them to the local state. + * + * @param options The atoms to synchronize with the server. + * @returns A `ClientSyncer` object. + */ client: >>(options: ClientOptions) => ClientSyncer; + /** + * Creates a `ServerSyncer` object that sends patches to the client and + * hydrates the client's state. + * + * @param options The atoms to synchronize with the client. + * @returns A `ServerSyncer` object. + */ server: >>(options: ServerOptions) => ServerSyncer; - collect: (root: Instance) => Record>; }; + /** + * A special value that denotes the absence of a value. Used to represent + * undefined values in patches. + */ interface None { - __none: "__none"; + readonly __none: "__none"; } type SyncPatch = { readonly [P in keyof T]?: (T[P] extends object ? SyncPatch : T[P]) | (T[P] extends undefined ? None : never); }; + /** + * A payload that can be sent from the server to the client to synchronize + * state between the two. + */ type SyncPayload = { type: "init"; data: T } | { type: "patch"; data: SyncPatch }; interface ClientOptions>> { + /** + * The atoms to synchronize with the server. + */ atoms: T; } interface ServerOptions>> { + /** + * The atoms to synchronize with the client. + */ atoms: T; + /** + * The interval at which to send patches to the client, in seconds. + * Defaults to `0` (patches are sent up to once per frame). Set to a + * negative value to disable automatic syncing. + */ interval?: number; } interface ClientSyncer>> { + /** + * Applies a patch or initializes the state of the atoms with the given + * payload from the server. + * + * @param payload The patch or hydration payload to apply. + */ sync(payload: SyncPayload): void; } interface ServerSyncer>> { + /** + * Sets up a subscription to each atom that schedules a patch to be sent to + * the client whenever the state changes. When a change occurs, the `callback` + * is called with the player and the payload to send. + * + * Note that the `payload` object should not be mutated. If you need to + * modify the payload, apply the changes to a copy of the object. + * + * @param callback The function to call when the state changes. + * @returns A cleanup function that unsubscribes all listeners. + */ connect(callback: (player: Player, payload: SyncPayload) => void): Cleanup; + /** + * Hydrates the client's state with the server's state. This should be + * called when a player joins the game and requires the server's state. + * + * @param player The player to hydrate. + */ hydrate(player: Player): void; } } diff --git a/src/init.luau b/src/init.luau index dc93dc9..2e14c62 100644 --- a/src/init.luau +++ b/src/init.luau @@ -5,7 +5,6 @@ local computed = require(script.computed) local effect = require(script.effect) local mapped = require(script.mapped) local observe = require(script.observe) -local peek = require(script.peek) local useAtom = require(script.react.useAtom) local store = require(script.store) local subscribe = require(script.subscribe) @@ -22,12 +21,12 @@ return { effect = effect, mapped = mapped, observe = observe, - peek = peek, useAtom = useAtom, batch = store.batch, capture = store.capture, - notify = store.notify, isAtom = store.isAtom, + notify = store.notify, + peek = store.peek, subscribe = subscribe, sync = sync, } diff --git a/src/mapped.luau b/src/mapped.luau index 805f2ad..2df2305 100644 --- a/src/mapped.luau +++ b/src/mapped.luau @@ -1,6 +1,6 @@ local atom = require(script.Parent.atom) local effect = require(script.Parent.effect) -local peek = require(script.Parent.peek) +local store = require(script.Parent.store) local types = require(script.Parent.types) type Atom = types.Atom type Molecule = types.Molecule @@ -10,6 +10,16 @@ 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 }>) +--[=[ + Maps each entry in the atom's state to a new key-value pair. If the `mapper` + function returns `undefined`, the entry is omitted from the resulting map. + When the atom changes, the `mapper` is called for each entry in the state + to compute the new state. + + @param molecule The atom or molecule to map. + @param mapper The function that maps each entry. + @return A new atom with the mapped state. +]=] 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" }) @@ -22,9 +32,10 @@ local function mapped(molecule: Molecule<{ [K0]: V0 }>, mapper: end local items = molecule() - local mappedItems = table.clone(peek(mappedAtomRef.current)) + local mappedItems = table.clone(store.peek(mappedAtomRef.current)) local mappedKeys = {} + -- TODO: Only call mapper if the item has changed. for key, item in items do local newItem, newKey = mapper(item, key) if newKey == nil then diff --git a/src/observe.luau b/src/observe.luau index 52f8d19..f289c03 100644 --- a/src/observe.luau +++ b/src/observe.luau @@ -2,6 +2,15 @@ local effect = require(script.Parent.effect) local types = require(script.Parent.types) type Molecule = types.Molecule +--[=[ + Creates an instance of `factory` for each item in the atom's state, and + cleans up the instance when the item is removed. Returns a cleanup function + that unsubscribes all instances. + + @param molecule The atom or molecule to observe. + @param factory The function that tracks the lifecycle of each item. + @return A function that unsubscribes all instances. +]=] local function observe(molecule: Molecule<{ [K]: V }>, factory: (value: V, key: K) -> () -> ()): () -> () local connections: { [K]: () -> () } = {} diff --git a/src/react/useAtom.luau b/src/react/useAtom.luau index f35d7e7..ef7ce3c 100644 --- a/src/react/useAtom.luau +++ b/src/react/useAtom.luau @@ -3,6 +3,18 @@ local subscribe = require(script.Parent.Parent.subscribe) local types = require(script.Parent.Parent.types) type Molecule = types.Molecule +--[=[ + A hook that subscribes to changes in the given atom or molecule. The + component is re-rendered whenever the state changes. + + If the `dependencies` array is provided, the subscription to the atom or + molecule is re-created whenever the dependencies change. Otherwise, the + subscription is created once when the component is mounted. + + @param molecule The atom or molecule to subscribe to. + @param dependencies An array of values that the subscription depends on. + @return The current state. +]=] local function useAtom(molecule: Molecule, dependencies: { any }?): State local React = requireReact() diff --git a/src/subscribe.luau b/src/subscribe.luau index 9b5accf..f285c90 100644 --- a/src/subscribe.luau +++ b/src/subscribe.luau @@ -2,13 +2,24 @@ local store = require(script.Parent.store) local types = require(script.Parent.types) type Molecule = types.Molecule +--[=[ + Subscribes to changes in the given atom or molecule. The callback is + called with the current state and the previous state immediately after a + change occurs. + + @param molecule The atom or molecule to subscribe to. + @param callback The function to call when the state changes. + @return A function that unsubscribes the callback. +]=] local function subscribe(molecule: Molecule, callback: (state: T, prev: T) -> ()): () -> () local captured, state = store.capture(molecule) local function listener() local prevState = state state = molecule() - callback(state, prevState) + if state ~= prevState then + callback(state, prevState) + end end for atom in captured do diff --git a/src/sync/client.luau b/src/sync/client.luau index 9dd1629..f68a119 100644 --- a/src/sync/client.luau +++ b/src/sync/client.luau @@ -4,13 +4,29 @@ type SyncPayload = types.SyncPayload local patch = require(script.Parent.patch) type ClientOptions = { + --[=[ + The atoms to synchronize with the server. + ]=] atoms: { [string]: Atom }, } type ClientSyncer = { + --[=[ + Applies a patch or initializes the state of the atoms with the given + payload from the server. + + @param payload The patch or hydration payload to apply. + ]=] sync: (self: ClientSyncer, payload: SyncPayload) -> (), } +--[=[ + Creates a `ClientSyncer` object that receives patches from the server and + applies them to the local state. + + @param options The atoms to synchronize with the server. + @return A `ClientSyncer` object. +]=] local function client(options: ClientOptions): ClientSyncer local atoms = options.atoms local self = {} :: ClientSyncer diff --git a/src/sync/init.luau b/src/sync/init.luau index ee75671..b3e4e4c 100644 --- a/src/sync/init.luau +++ b/src/sync/init.luau @@ -1,6 +1,10 @@ local client = require(script.client) local server = require(script.server) +--[=[ + Synchronizes state between the client and server. The server sends patches + to the client, which applies them to its local state. +]=] return { client = client, server = server, diff --git a/src/sync/patch.luau b/src/sync/patch.luau index cf9d066..0ae53f1 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -1,5 +1,9 @@ local validate = require(script.Parent.validate) +--[=[ + A special value that denotes the absence of a value. Used to represent + undefined values in patches. +]=] local NONE = { __none = "__none" } local function diff(prevState: { [any]: any }, nextState: { [any]: any }) diff --git a/src/sync/server.luau b/src/sync/server.luau index b1fbc21..e88f4e9 100644 --- a/src/sync/server.luau +++ b/src/sync/server.luau @@ -8,16 +8,51 @@ local setInterval = require(script.Parent.Parent.utils.setInterval) local patch = require(script.Parent.patch) type ServerOptions = { + --[=[ + The atoms to synchronize with the client. + ]=] atoms: { [string]: Atom }, + --[=[ + The interval at which to send patches to the client, in seconds. + Defaults to `0` (patches are sent up to once per frame). Set to a + negative value to disable automatic syncing. + ]=] interval: number?, } type ServerSyncer = { + --[=[ + Sets up a subscription to each atom that schedules a patch to be sent to + the client whenever the state changes. When a change occurs, the `callback` + is called with the player and the payload to send. + + Note that the `payload` object should not be mutated. If you need to + modify the payload, apply the changes to a copy of the object. + + @param callback The function to call when the state changes. + @return A cleanup function that unsubscribes all listeners. + ]=] connect: (self: ServerSyncer, callback: (player: Player, payload: SyncPayload) -> ()) -> () -> (), + --[=[ + Hydrates the client's state with the server's state. This should be + called when a player joins the game and requires the server's state. + + @param player The player to hydrate. + ]=] hydrate: (self: ServerSyncer, player: Player) -> (), + --[=[ + @deprecated For internal use only. + ]=] _sendPatch: (self: ServerSyncer, player: Player) -> (), } +--[=[ + Creates a `ServerSyncer` object that sends patches to the client and + hydrates the client's state. + + @param options The atoms to synchronize with the client. + @return A `ServerSyncer` object. +]=] local function server(options: ServerOptions): ServerSyncer local atoms = options.atoms local interval = options.interval or 0 @@ -42,7 +77,7 @@ local function server(options: ServerOptions): ServerSyncer end) state[key] = atom() - stateSnapshot[key] = atom() + stateSnapshot[key] = state[key] end local disconnect = setInterval(function() @@ -51,7 +86,7 @@ local function server(options: ServerOptions): ServerSyncer end local diffs = patch.diff(stateSnapshot, state) - stateSnapshot = table.clone(state) + stateSnapshot = state changed = false for _, player in Players:GetPlayers() do diff --git a/src/sync/validate.luau b/src/sync/validate.luau index 2ae65c9..364a1cd 100644 --- a/src/sync/validate.luau +++ b/src/sync/validate.luau @@ -29,6 +29,13 @@ local function isUnsafeTable(object: { [any]: any }) return false end +--[=[ + Validates a value to ensure it can be synced over a remote event. + + @param value The value to validate. + @param key The key of the value in the table. + @error Throws an error if the value cannot be synced. +]=] local function validate(value: any, key: any) local typeOfKey = type(key) local typeOfValue = type(value) diff --git a/src/types.luau b/src/types.luau index 8c7dac4..846fd7f 100644 --- a/src/types.luau +++ b/src/types.luau @@ -1,13 +1,38 @@ export type WeakMap = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" })) +--[=[ + A primitive state container that can be read from and written to. When the + state changes, all subscribers are notified. + + @param state The next state or a function that produces the next state. + @return The current state, if no arguments are provided. +]=] export type Atom = (() -> T) & (state: T | (T) -> T) -> T +--[=[ + A function that depends on one or more atoms and produces a state. Can be + used to derive state from atoms. + + @template State The type of the state. + @return The current state. +]=] export type Molecule = () -> T +--[=[ + Optional configuration for creating an atom. +]=] export type AtomOptions = { + --[=[ + A function that determines whether the state has changed. By default, + a strict equality check (`===`) is used. + ]=] equals: (prev: T, next: T) -> boolean, } +--[=[ + A payload that can be sent from the server to the client to synchronize + state between the two. +]=] export type SyncPayload = { type: "patch" | "init", data: { [any]: any }, diff --git a/src/utils/collect.luau b/src/utils/collect.luau index 6a9515b..0f498aa 100644 --- a/src/utils/collect.luau +++ b/src/utils/collect.luau @@ -1,3 +1,7 @@ +--[=[ + Trigger a garbage collection cycle by allocating a large amount of junk memory + and then waiting for the next frame. +]=] local function collect() for _ = 1, 1e4 do local _ = table.create(1e3) diff --git a/src/utils/count.luau b/src/utils/count.luau index 821c395..251776a 100644 --- a/src/utils/count.luau +++ b/src/utils/count.luau @@ -1,3 +1,9 @@ +--[=[ + Counts the number of elements in a table. + + @param object The table to count. + @return The number of elements in the table. +]=] local function count(object: any): number local count = 0 for _ in next, object do diff --git a/src/utils/setInterval.luau b/src/utils/setInterval.luau index 5c8bdca..e4650e7 100644 --- a/src/utils/setInterval.luau +++ b/src/utils/setInterval.luau @@ -1,5 +1,12 @@ local RunService = game:GetService("RunService") +--[=[ + Schedules a function to be called at a fixed interval of `seconds`. + + @param callback The function to call. + @param seconds The interval in seconds. + @return A function that cancels the interval. +]=] local function setInterval(callback: () -> (), seconds: number): () -> () if seconds < 0 then return function() end