refactor: update charm sync api

This commit is contained in:
littensy 2025-10-31 21:56:02 -07:00
parent 1e0b10aef6
commit 32788b19e1
13 changed files with 359 additions and 335 deletions

View file

@ -2,8 +2,6 @@ local Charm = require("../../Charm")
local client = require("../client") local client = require("../client")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom
local subscribe = Charm.subscribe
local test = suite.test local test = suite.test
local afterEach = suite.afterEach local afterEach = suite.afterEach
@ -11,29 +9,29 @@ afterEach(function()
client.reset() client.reset()
end) end)
test("should sync state payload", function() test("should hydrate atom", function()
local a = atom(1) local a = Charm.atom(1)
client.syncAtoms({ a = a }) client.register({ a = a })
client.applyPayloads({ client.hydrate({
{ type = "init", data = { a = 2 } }, { type = "init", data = { a = 2 } },
}) })
assert(a() == 2, "should set a to the initial value") assert(a() == 2, "should set a to the initial value")
end) end)
test("should sync state patch", function() test("should merge patch", function()
local a = atom({ b = 1, c = 2 }) local a = Charm.atom({ b = 1, c = 2 })
local previous = a() local previous = a()
client.syncAtoms({ a = a }) client.register({ a = a })
client.applyPayloads({ client.hydrate({
{ type = "init", data = { a = a() } }, { type = "init", data = { a = a() } },
}) })
assert(a() == previous, "should set a to the initial value") assert(a() == previous, "should set a to the initial value")
client.applyPayloads({ client.hydrate({
{ type = "patch", data = { a = { b = 3 } } }, { type = "patch", data = { a = { b = 3 } } },
}) })
@ -43,17 +41,17 @@ test("should sync state patch", function()
end) end)
test("should fix sparse array patches", function() test("should fix sparse array patches", function()
local a = atom({ 1 }) local a = Charm.atom({ 1 })
client.syncAtoms({ a = a }) client.register({ a = a })
client.applyPayloads({ client.hydrate({
{ type = "init", data = { a = a() } }, { type = "init", data = { a = a() } },
}) })
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that -- This patch would be the result of a diff between { 1 } and { 1, 2 } that
-- was JSON-serialized and deserialized. The client should be able to catch -- was JSON-serialized and deserialized. The client should be able to catch
-- this and convert the string key back to a number. -- this and convert the string key back to a number.
client.applyPayloads({ client.hydrate({
{ type = "patch", data = { a = { ["2"] = 2 } } }, { type = "patch", data = { a = { ["2"] = 2 } } },
}) })
@ -61,21 +59,21 @@ test("should fix sparse array patches", function()
assert(a()[2] == 2, "should convert serialized arrays to use numbers") assert(a()[2] == 2, "should convert serialized arrays to use numbers")
end) end)
test("should apply multiple patches", function() test("should merge multiple patches", function()
local a = atom(1) local a = Charm.atom(1)
local calls = {} local calls = {}
client.syncAtoms({ a = a }) client.register({ a = a })
subscribe(a, function(value) Charm.subscribe(a, function(value)
table.insert(calls, value) table.insert(calls, value)
end) end)
client.applyPayloads({ client.hydrate({
{ type = "init", data = { a = a() } }, { type = "init", data = { a = a() } },
}) })
client.applyPayloads({ client.hydrate({
{ type = "patch", data = { a = 2 } }, { type = "patch", data = { a = 2 } },
{ type = "patch", data = { a = 3 } }, { type = "patch", data = { a = 3 } },
}) })
@ -85,25 +83,25 @@ test("should apply multiple patches", function()
end) end)
test("should batch patches", function() test("should batch patches", function()
local a = atom(0) local a = Charm.atom(0)
local b = atom(0) local b = Charm.atom(0)
local states = {} local states = {}
client.syncAtoms({ a = a, b = b }) client.register({ a = a, b = b })
subscribe(a, function() Charm.subscribe(a, function()
states.b = b() states.b = b()
end) end)
subscribe(b, function() Charm.subscribe(b, function()
states.a = a() states.a = a()
end) end)
client.applyPayloads({ client.hydrate({
{ type = "init", data = { a = a(), b = b() } }, { type = "init", data = { a = a(), b = b() } },
}) })
client.applyPayloads({ client.hydrate({
{ type = "patch", data = { a = 1, b = 2 } }, { type = "patch", data = { a = 1, b = 2 } },
}) })

View file

@ -20,11 +20,11 @@ test("should replicate server state to client", function()
local clientA = Charm.atom({ count = -1 }) local clientA = Charm.atom({ count = -1 })
local clientB = Charm.atom({ count = -1 }) local clientB = Charm.atom({ count = -1 })
client.syncAtoms({ a = clientA, b = clientB }) client.register({ a = clientA, b = clientB })
server.syncAtomsToClient(target, { a = serverA, b = serverB }) server.subscribeClient(target, { a = serverA, b = serverB })
server.startSync(function(syncTarget, payloads) server.connect(function(syncTarget, payloads)
if syncTarget == target then if syncTarget == target then
client.applyPayloads(payloads) client.hydrate(payloads)
end end
end) end)
@ -54,11 +54,11 @@ test("should replicate historical server state to client", function()
config.preserveHistory = true config.preserveHistory = true
client.syncAtoms({ a = clientA, b = clientB }) client.register({ a = clientA, b = clientB })
server.syncAtomsToClient(target, { a = serverA, b = serverB }) server.subscribeClient(target, { a = serverA, b = serverB })
server.startSync(function(syncTarget, payloads) server.connect(function(syncTarget, payloads)
if syncTarget == target then if syncTarget == target then
client.applyPayloads(payloads) client.hydrate(payloads)
end end
end) end)

View file

@ -20,8 +20,8 @@ test("should replicate state changes", function()
local c = Charm.atom(0) local c = Charm.atom(0)
local target, payloads local target, payloads
server.syncAtomsToClient(client, { a = a, b = b, c = c }) server.subscribeClient(client, { a = a, b = b, c = c })
server.startSync(function(...) server.connect(function(...)
target, payloads = ... target, payloads = ...
end) end)
@ -47,8 +47,8 @@ test("should replicate partial state patches", function()
local b = Charm.atom(0 :: number?) local b = Charm.atom(0 :: number?)
local target, payloads local target, payloads
server.syncAtomsToClient(client, { a = a, b = b }) server.subscribeClient(client, { a = a, b = b })
server.startSync(function(...) server.connect(function(...)
target, payloads = ... target, payloads = ...
end) end)
@ -75,8 +75,8 @@ test("should init new clients", function()
local a = Charm.atom(0) local a = Charm.atom(0)
local payloadsA, payloadsB local payloadsA, payloadsB
server.syncAtomsToClient(clientA, { a = a }) server.subscribeClient(clientA, { a = a })
server.startSync(function(target, payloads) server.connect(function(target, payloads)
if target == clientA then if target == clientA then
payloadsA = payloads payloadsA = payloads
elseif target == clientB then elseif target == clientB then
@ -89,7 +89,7 @@ test("should init new clients", function()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
server.syncAtomsToClient(clientB, { a = a }) server.subscribeClient(clientB, { a = a })
a(2) a(2)
server.flush() server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
@ -104,13 +104,13 @@ test("should init new atoms", function()
local b = Charm.atom(0) local b = Charm.atom(0)
local target, payloads local target, payloads
server.syncAtomsToClient(client, { a = a }) server.subscribeClient(client, { a = a })
server.startSync(function(...) server.connect(function(...)
target, payloads = ... target, payloads = ...
end) end)
server.flush() server.flush()
server.syncAtomsToClient(client, { b = b }) server.subscribeClient(client, { b = b })
a(1) a(1)
b(1) b(1)
server.flush() server.flush()
@ -129,9 +129,9 @@ test("should only replicate to synced clients", function()
local b = Charm.atom(0) local b = Charm.atom(0)
local payloadsA, payloadsB local payloadsA, payloadsB
server.syncAtomsToClient(clientA, { a = a }) server.subscribeClient(clientA, { a = a })
server.syncAtomsToClient(clientB, { b = b }) server.subscribeClient(clientB, { b = b })
server.startSync(function(target, payloads) server.connect(function(target, payloads)
if target == clientA then if target == clientA then
payloadsA = payloads payloadsA = payloads
elseif target == clientB then elseif target == clientB then
@ -170,8 +170,8 @@ test("should validate state", function()
local client = {} local client = {}
local a = Charm.atom({}) local a = Charm.atom({})
server.syncAtomsToClient(client, { a = a }) server.subscribeClient(client, { a = a })
server.startSync(function() end) server.connect(function() end)
a({ [0.5] = true }) a({ [0.5] = true })
assert(not pcall(server.flush), "Expected server.flush to fail") assert(not pcall(server.flush), "Expected server.flush to fail")
@ -185,8 +185,8 @@ test("should serialize state", function()
local a = Charm.atom({ 1, 2, 3, 4, 5 }) local a = Charm.atom({ 1, 2, 3, 4, 5 })
local payloads local payloads
server.syncAtomsToClient(client, { a = a }) server.subscribeClient(client, { a = a })
server.startSync(function(_, p) server.connect(function(_, p)
payloads = p payloads = p
end) end)
server.flush() server.flush()
@ -213,8 +213,8 @@ describe("historical", function()
local c = Charm.atom(0) local c = Charm.atom(0)
local target, payloads local target, payloads
server.syncAtomsToClient(client, { a = a, b = b, c = c }) server.subscribeClient(client, { a = a, b = b, c = c })
server.startSync(function(...) server.connect(function(...)
target, payloads = ... target, payloads = ...
end) end)
@ -242,8 +242,8 @@ describe("historical", function()
local a = Charm.atom(0) local a = Charm.atom(0)
local payloadsA, payloadsB local payloadsA, payloadsB
server.syncAtomsToClient(clientA, { a = a }) server.subscribeClient(clientA, { a = a })
server.startSync(function(target, payloads) server.connect(function(target, payloads)
if target == clientA then if target == clientA then
payloadsA = payloads payloadsA = payloads
elseif target == clientB then elseif target == clientB then
@ -259,7 +259,7 @@ describe("historical", function()
assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A") assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A")
assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A") assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A")
server.syncAtomsToClient(clientB, { a = a }) server.subscribeClient(clientB, { a = a })
a(3) a(3)
a(4) a(4)
server.flush() server.flush()
@ -278,8 +278,8 @@ describe("historical", function()
local b = Charm.atom(0) local b = Charm.atom(0)
local payloads local payloads
server.syncAtomsToClient(client, { a = a }) server.subscribeClient(client, { a = a })
server.startSync(function(_, ...) server.connect(function(_, ...)
payloads = ... payloads = ...
end) end)
@ -287,7 +287,7 @@ describe("historical", function()
assert(payloads[1].type == "init", "Expected first payload to be init") assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
server.syncAtomsToClient(client, { b = b }) server.subscribeClient(client, { b = b })
a(1) a(1)
a(2) a(2)
b(1) b(1)

View file

@ -8,8 +8,8 @@ suite.describe("e2e", function()
require("./e2e.spec") require("./e2e.spec")
end) end)
suite.describe("flatten", function() suite.describe("utils", function()
require("./flatten.spec") require("./utils.spec")
end) end)
suite.describe("patch", function() suite.describe("patch", function()

View file

@ -1,17 +1,18 @@
local Charm = require("../../Charm") local Charm = require("../../Charm")
local flatten = require("../flatten")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local utils = require("../utils")
local atom = Charm.atom local atom = Charm.atom
local test = suite.test local test = suite.test
test("should flatten atoms", function() test("should flatten atoms", function()
local getF, setF = Charm.signal(0)
local input = { local input = {
a = atom(0), a = atom(0),
b = { c = atom(0) }, b = { c = atom(0) },
d = { e = { f = atom(0) } }, d = { e = { f = utils.signalToAtom(getF, setF) } },
} }
local result = flatten(input) local result = utils.flattenState(input)
assert(result.a == input.a, "should flatten the first level") assert(result.a == input.a, "should flatten the first level")
assert(result["b/c"] == input.b.c, "should flatten the second level") assert(result["b/c"] == input.b.c, "should flatten the second level")

View file

@ -2,46 +2,43 @@ local Charm = require("../Charm")
local patch = require("./patch") local patch = require("./patch")
local types = require("./types") local types = require("./types")
type Atom<T = any> = Charm.Atom<T>
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
local atomRegister: { [string]: Charm.Atom } = {} local trackedAtoms: { [string]: Atom } = {}
--- Registers atoms to be kept in sync with the server. Atoms with matching --- Registers a map of atoms to the state with the corresponding keys on the
--- keys on the server will be synced to these atoms. --- server. When the server sends updates, these atoms will be hydrated with
--- the new values.
--- ---
--- @param atoms An object mapping string keys to atoms to be synced. --- If syncing signals instead of atoms, you can use `signalToAtom` to sync
local function syncAtoms(atoms: { [string]: Charm.Atom }) --- signal getters and setters.
---
--- @param atoms A map of atoms to sync with the server.
local function register(atoms: { [string]: Atom })
for key, atom in atoms do for key, atom in atoms do
if atomRegister[key] and atomRegister[key] ~= atom then trackedAtoms[key] = atom
warn("[Charm] Attempted to sync different atoms with the same key:", key)
continue
end
atomRegister[key] = atom
end end
end end
--- Unregisters one or more atoms from being kept in sync with the server. --- Unregisters from server state updates for the given keys. The atoms
--- The atoms will retain their current values, but will no longer receive --- will retain their current values, but will no longer receive updates
--- updates from the server. --- from the server.
--- local function unregister(...: string)
--- @param keys The keys of the atoms to stop syncing.
local function unsyncAtoms(...: string)
for index = 1, select("#", ...) do for index = 1, select("#", ...) do
local key = select(index, ...) local key = select(index, ...)
atomRegister[key] = nil trackedAtoms[key] = nil
end end
end end
--- Unregisters all atoms from being kept in sync with the server. The --- Unsubscribes from all server state updates.
--- atoms will retain their current values, but will no longer receive
--- updates from the server.
local function reset() local function reset()
table.clear(atomRegister) table.clear(trackedAtoms)
end end
local function applyInitPayload(data: { [string]: any }) local function applyInitPayload(data: { [string]: any })
for key, value in data do for key, value in data do
local atom = atomRegister[key] local atom = trackedAtoms[key]
if atom then if atom then
atom(value) atom(value)
end end
@ -51,23 +48,22 @@ end
local function applyPatchPayload(data: { [string | number]: any }) local function applyPatchPayload(data: { [string | number]: any })
local target = {} local target = {}
for key, atom in atomRegister do for key, atom in trackedAtoms do
target[key] = atom() target[key] = atom()
end end
target = patch.apply(target, data) target = patch.apply(target, data)
for key, atom in atomRegister do for key, atom in trackedAtoms do
atom(target[key]) atom(target[key])
end end
end end
--- Applies one or more payloads received from the server to the synced --- Merges the server's state patches into the client's state, either
--- atoms. Payloads are state patches or initial state updates sent by the --- initializing it with the full state or merging a partial patch.
--- server, and are usually received via a remote event.
--- ---
--- @param payloads The payloads to apply. --- @param payloads The state updates received from the server.
local function applyPayloads(payloads: { SyncPayload }) local function hydrate(payloads: { SyncPayload })
for _, payload in payloads do for _, payload in payloads do
if payload.type == "init" then if payload.type == "init" then
Charm.batched(applyInitPayload, payload.data) Charm.batched(applyInitPayload, payload.data)
@ -78,8 +74,8 @@ local function applyPayloads(payloads: { SyncPayload })
end end
return { return {
syncAtoms = syncAtoms, register = register,
unsyncAtoms = unsyncAtoms, unregister = unregister,
reset = reset, reset = reset,
applyPayloads = applyPayloads, hydrate = hydrate,
} }

View file

@ -1,30 +0,0 @@
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 } = {}
local function visit(node: NestedAtomMap, path: string)
for key, value in node do
local location = if path == "" then key else path .. "/" .. key
if type(value) == "table" then
visit(value, location)
else
result[location] = value
end
end
end
visit(atoms, "")
return result
end
return flatten

View file

@ -33,29 +33,29 @@ type DataType = DataTypes[keyof DataTypes];
* If a value was removed, it is replaced with `None`. This can be checked * If a value was removed, it is replaced with `None`. This can be checked
* using the `isNone` function. * using the `isNone` function.
*/ */
export type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends
| ReadonlyMap<infer K, infer V> | ReadonlyMap<infer K, infer V>
| Map<infer K, infer V> | Map<infer K, infer V>
? ReadonlyMap<K, SyncPatch<V, Serialize> | None> ? ReadonlyMap<K, SyncPatch<V, FixArrays> | None>
: State extends Set<infer T> | ReadonlySet<infer T> : State extends Set<infer T> | ReadonlySet<infer T>
? ReadonlyMap<T, true | None> ? ReadonlyMap<T, true | None>
: State extends readonly (infer T)[] : State extends readonly (infer T)[]
? Serialize extends true ? FixArrays extends true
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None> ? ReadonlyMap<string | number, SyncPatch<T, FixArrays> | None>
: readonly (SyncPatch<T, Serialize> | None | undefined)[] : readonly (SyncPatch<T, FixArrays> | None | undefined)[]
: State extends DataType : State extends DataType
? State ? State
: State extends object : State extends object
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> } ? { readonly [P in keyof State]?: SyncPatch<State[P], FixArrays> }
: State; : State;
/** /**
* A payload that can be sent from the server to the client to synchronize * A payload that can be sent from the server to the client to synchronize
* state between the two. * state between the two.
*/ */
export type SyncPayload<Selectors extends SelectorMap = SelectorMap, Serialize extends boolean = true> = export type SyncPayload<Selectors extends SelectorMap = SelectorMap, FixArrays extends boolean = true> =
| { type: "init"; data: StateOfMap<Selectors> } | { type: "init"; data: StateOfMap<Selectors> }
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>, Serialize> }; | { type: "patch"; data: SyncPatch<StateOfMap<Selectors>, FixArrays> };
/** /**
* Global configuration options that affect the behavior of Charm Sync. * Global configuration options that affect the behavior of Charm Sync.
@ -102,38 +102,37 @@ export const config: {
*/ */
export namespace client { export namespace client {
/** /**
* Registers atoms to be kept in sync with the server. Atoms with matching * Registers a map of atoms to the state with the corresponding keys on the
* keys on the server will be synced to these atoms. * server. When the server sends updates, these atoms will be hydrated with
* the new values.
* *
* @param atoms An object mapping string keys to atoms to be synced. * If syncing signals instead of atoms, you can use `signalToAtom` to sync
* signal getters and setters as atoms.
*
* @param atoms A map of atoms to sync with the server.
*/ */
export function syncAtoms(atoms: { [key: string]: Atom<any> }): void; export function register(atoms: { [key: string]: Atom<any> }): void;
/** /**
* Unregisters one or more atoms from being kept in sync with the server. * Unregisters from server state updates for the given keys. The atoms
* The atoms will retain their current values, but will no longer receive * will retain their current values, but will no longer receive updates
* updates from the server. * from the server.
*
* @param keys The keys of the atoms to stop syncing.
*/ */
export function unsyncAtoms(...keys: string[]): void; export function unregister(...keys: string[]): void;
/** /**
* Unregisters all atoms from being kept in sync with the server. The * Unsubscribes from all server state updates.
* atoms will retain their current values, but will no longer receive
* updates from the server.
*/ */
export function reset(): void; export function reset(): void;
/** /**
* Applies one or more payloads received from the server to the synced * Merges the server's state patches into the client's state, either
* atoms. Payloads are state patches or initial state updates sent by the * initializing it with the full state or merging a partial patch.
* server, and are usually received via a remote event.
* *
* @param payloads The payloads to apply. * @param payloads The state updates received from the server.
*/ */
export function applyPayloads<Atoms extends { [key: string]: Atom<any> }, Serialize extends boolean = true>( export function hydrate<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>(
payloads: SyncPayload<Atoms, Serialize>[], payloads: SyncPayload<Atoms, FixArrays>[],
): void; ): void;
} }
@ -142,61 +141,62 @@ export namespace client {
*/ */
export namespace server { export namespace server {
/** /**
* Registers atoms to be kept in sync with a specific client. Should be * Subscribes a client to the given atoms. When an update occurs, the client
* called when a player joins the game to start syncing atoms to them. Call * will receive a partial state patch to merge into their local state. May be
* `unsyncAtomsFromClient` or `unsyncClient` to stop syncing. * called multiple times to subscribe to additional keys.
* *
* Each atom should be assigned a unique key, allowing the server to re-use * Atoms, signals converted to atoms, and signal getter functions are allowed.
* patches between multiple clients. * Note that the client still requires some way to set the state, so
* registering signals on the client should be done through `signalToAtom()`.
* *
* @param client The player to sync atoms to. * @param client The client receiving state updates.
* @param atoms An object mapping string keys to atoms to be synced. * @param atoms A map of atoms to sync with the client.
*/ */
export function syncAtomsToClient(client: Player, atoms: { [key: string]: Atom<any> }): void; export function subscribeClient(client: Player, atoms: { [key: string]: Atom<any> }): void;
/** /**
* Unregisters one or more atoms from being kept in sync with a specific * Unsubscribes a client from receiving all state updates. To only unsubscribe
* client. * from specific keys, use `unsubscribeClientFrom` instead.
* *
* @param client The player to stop syncing atoms to. * @param client The client receiving state updates.
* @param keys The keys of the atoms to stop syncing.
*/ */
export function unsyncAtomsFromClient(client: Player, ...keys: string[]): void; export function unsubscribeClient(client: Player): void;
/** /**
* Unregisters all atoms from being kept in sync with a specific client. * Unsubscribes a client from receiving updates for the given keys.
* Should be called when a player leaves the game.
* *
* @param client The player to stop syncing atoms to. * @param client The client receiving state updates.
* @param keys The keys of the state to unsubscribe from.
*/ */
export function unsyncClient(client: Player): void; export function unsubscribeClientFrom(client: Player, ...keys: string[]): void;
/** /**
* Starts automatically syncing registered atoms to clients. Patches will * Uses the callback to send state updates to clients that are subscribed
* be sent to clients whenever the atoms are updated. * to state changes. Starts automatically sending updates at the interval
* specified in `config.interval`.
* *
* @param onSync The function to call when patches can be sent to a client. * @param onSync Called when sending patches to a client.
*/ */
export function startSync<Atoms extends { [key: string]: Atom<any> }, Serialize extends boolean = true>( export function connect<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Atoms, Serialize>[]) => void, onSync: (client: Player, payloads: SyncPayload<Atoms, FixArrays>[]) => void,
): void; ): void;
/** /**
* Stops automatically syncing registered atoms to clients. Patches will * Stops syncing state updates to clients at the automatic interval.
* no longer be sent to clients until `startSync` is called again. * Flushing can still be performed manually via `flush`.
*/ */
export function stopSync(): void; export function disconnect(): void;
/** /**
* Immediately sends pending updates and initial states to all clients. * Immediately sends pending updates to all clients. Normally, updates are
* Normally, updates are sent at the interval specified in `config.interval`. * sent at the interval specified in `config.interval`, but this function
* Calling this function forces all pending updates to be sent immediately. * can be used to force an immediate sync.
*/ */
export function flush(): void; export function flush(): void;
/** /**
* Unregisters all atoms from being kept in sync with all clients, and * Unsubscribes all clients, stops tracking all state, and disconnects
* stops automatic syncing. * from the automatic sync interval.
*/ */
export function reset(): void; export function reset(): void;
} }
@ -204,8 +204,8 @@ export namespace server {
export namespace patch { export namespace patch {
/** /**
* Converts `nil` values to the special `None` symbol. This is useful when * Converts `nil` values to the special `None` symbol. This is useful when
* preparing data for serialization, since `nil` can represent both the absence * preparing data for serialization, since `nil` can represent both the
* of a value and the removal of a value in patches. * absence of a value and the removal of a value in patches.
*/ */
export function nilToNone<T>(value: T | undefined): T | None; export function nilToNone<T>(value: T | undefined): T | None;
@ -217,10 +217,10 @@ export namespace patch {
* @param nextState The next state. * @param nextState The next state.
* @returns A patch that can be applied to the current state. * @returns A patch that can be applied to the current state.
*/ */
export function diff<State, Serialize extends boolean = true>( export function diff<State, FixArrays extends boolean = true>(
currentState: State, currentState: State,
nextState: State, nextState: State,
): SyncPatch<State, Serialize>; ): SyncPatch<State, FixArrays>;
/** /**
* Applies a patch to a state and returns the resulting value. * Applies a patch to a state and returns the resulting value.
@ -229,9 +229,9 @@ export namespace patch {
* @param statePatch The patches to apply. * @param statePatch The patches to apply.
* @returns The new state with the patch applied. * @returns The new state with the patch applied.
*/ */
export function apply<State, Serialize extends boolean = true>( export function apply<State, FixArrays extends boolean = true>(
currentState: State, currentState: State,
statePatch: SyncPatch<State, Serialize>, statePatch: SyncPatch<State, FixArrays>,
): State; ): State;
} }
@ -247,8 +247,6 @@ type AppendPath<Path extends Key | undefined, Name extends Key> = undefined exte
: never : never
: never; : never;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
export type FlattenNestedAtoms< export type FlattenNestedAtoms<
@ -269,7 +267,7 @@ export type FlattenNestedAtoms<
* @param atoms The nested atom map to flatten. * @param atoms The nested atom map to flatten.
* @returns A flattened atom map. * @returns A flattened atom map.
*/ */
export function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>; export function flattenAtoms<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
/** /**
* Checks whether a value is `None`. If `true`, the value is scheduled to be * Checks whether a value is `None`. If `true`, the value is scheduled to be

View file

@ -1,9 +1,9 @@
local client = require("@self/client") local client = require("@self/client")
local config = require("@self/config") local config = require("@self/config")
local flatten = require("@self/flatten")
local patch = require("@self/patch") local patch = require("@self/patch")
local server = require("@self/server") local server = require("@self/server")
local types = require("@self/types") local types = require("@self/types")
local utils = require("@self/utils")
export type None = types.None export type None = types.None
export type SyncPayload = types.SyncPayload export type SyncPayload = types.SyncPayload
@ -14,5 +14,6 @@ return {
server = server, server = server,
patch = patch, patch = patch,
isNone = patch.isNone, isNone = patch.isNone,
flatten = flatten, flattenState = utils.flattenState,
signalToAtom = utils.signalToAtom,
} }

View file

@ -7,85 +7,95 @@ type SyncPayload = types.SyncPayload
local globals = Charm.globals local globals = Charm.globals
type RegisteredAtom<T = any> = { type SyncStatus = number
callback: () -> T,
type ClientAtoms = {
[string]: SyncStatus,
}
type TrackedState<T = any> = {
getter: () -> T,
currentValue: T, currentValue: T,
previousValue: T, previousValue: T,
dispose: () -> (), dispose: () -> (),
} }
type SyncStatus = number type StateUpdate = {
key: string,
currentValue: any,
previousValue: any,
}
local PENDING_INITIAL_STATE: SyncStatus = 0 local PENDING_INITIAL_STATE: SyncStatus = 0
local LISTENING_FOR_CHANGES: SyncStatus = 1 local LISTENING_FOR_CHANGES: SyncStatus = 1
local atomRegister: { [string]: RegisteredAtom } = {} local trackedAtoms: { [string]: TrackedState } = {}
local clientRegister: { [any]: { [string]: SyncStatus } } = {} local globalUpdates: { StateUpdate } = {}
local globalUpdates: { { key: string, currentValue: any, previousValue: any } } = {} local clients: { [any]: ClientAtoms } = {}
local connection: RBXScriptConnection? local connection: RBXScriptConnection?
local onSyncCallback: (client: any, payloads: { SyncPayload }) -> ()? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
local function syncGlobalAtom<T>(key: string, callback: () -> T) local function startTracking<T>(key: string, getter: () -> T)
if atomRegister[key] then if trackedAtoms[key] then
if atomRegister[key].callback ~= callback then -- Ignore duplicates, but warn against name collisions
warn("[Charm] Attempted to sync different atoms with the same key:", key) if trackedAtoms[key].getter ~= getter then
warn("[Charm] Attempted to sync different states with the same key:", key)
end end
return return
end end
local registeredAtom: RegisteredAtom<T> = { local value = getter()
callback = callback, local trackedState = {
currentValue = callback(), getter = getter,
previousValue = callback(), currentValue = value,
dispose = function() end, previousValue = value,
} }
registeredAtom.dispose = Charm.effect(function() trackedState.dispose = Charm.effect(function()
local newValue = callback() local newValue = getter()
-- When preserving history, we need to keep track of previous values -- When preserving history, we need to keep track of previous values
-- so we can create granular patches for each change. -- so we can create granular patches for each change.
if config.preserveHistory and registeredAtom.currentValue ~= newValue then if config.preserveHistory and trackedState.currentValue ~= newValue then
table.insert(globalUpdates, { table.insert(globalUpdates, {
key = key, key = key,
currentValue = newValue, currentValue = newValue,
previousValue = registeredAtom.currentValue, previousValue = trackedState.previousValue,
}) })
end end
registeredAtom.currentValue = newValue trackedState.currentValue = newValue
end) end)
atomRegister[key] = registeredAtom trackedAtoms[key] = trackedState
end end
local function unsyncGlobalAtom(key: string) local function stopTracking(key: string)
local registeredAtom = atomRegister[key] local tracker = trackedAtoms[key]
if tracker then
if registeredAtom then tracker.dispose()
registeredAtom.dispose() trackedAtoms[key] = nil
atomRegister[key] = nil
end end
end end
local function unsyncStaleGlobalAtoms() local function purgeUntracked()
local active: { [string]: boolean } = {} local active: { [string]: boolean } = {}
for _, clientAtoms in clientRegister do for _, clientAtoms in clients do
for key in clientAtoms do for key in clientAtoms do
active[key] = true active[key] = true
end end
end end
for key in atomRegister do for key in trackedAtoms do
if not active[key] then if not active[key] then
unsyncGlobalAtom(key) stopTracking(key)
end end
end end
end end
local function createGlobalPatchHistory(): { any } local function diffGlobalUpdateBuffer(): { any }
local patchCount = #globalUpdates local patchCount = #globalUpdates
local patches = table.create(patchCount) local patches = table.create(patchCount)
@ -96,10 +106,10 @@ local function createGlobalPatchHistory(): { any }
return patches return patches
end end
local function createPayloadHistoryForClient(client: any, patches: { any }): { SyncPayload } local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }
local syncedAtoms = clientRegister[client] local clientAtoms = clients[client]
if not syncedAtoms then if not clientAtoms then
return {} return {}
end end
@ -108,14 +118,14 @@ local function createPayloadHistoryForClient(client: any, patches: { any }): { S
-- Iterate over all global updates and see if the client is interested in -- Iterate over all global updates and see if the client is interested in
-- any of them. If so, we create a patch payload for the update. -- any of them. If so, we create a patch payload for the update.
for index, update in globalUpdates do for index, update in globalUpdates do
local patchData = patches[index] local patchData = globalPatches[index]
if not patchData then if not patchData then
continue continue
end end
local key = update.key local key = update.key
local status = syncedAtoms[key] local status = clientAtoms[key]
if status == LISTENING_FOR_CHANGES then if status == LISTENING_FOR_CHANGES then
table.insert(payloads, { table.insert(payloads, {
@ -127,179 +137,180 @@ local function createPayloadHistoryForClient(client: any, patches: { any }): { S
type = "init", type = "init",
data = { [key] = update.currentValue }, data = { [key] = update.currentValue },
}) })
syncedAtoms[key] = LISTENING_FOR_CHANGES clientAtoms[key] = LISTENING_FOR_CHANGES
end end
end end
-- If a pending atom was never updated, we still need to send its initial -- If a pending atom was never updated, we still need to send its initial
-- state. This can happen if the atom was registered after its last change. -- state. This can happen if the atom was registered after its last change.
local initPayload: { [string]: any } = {} local initData: { [string]: any } = {}
for key, status in syncedAtoms do for key, status in clientAtoms do
local registeredAtom = atomRegister[key] local trackedState = trackedAtoms[key]
if status == PENDING_INITIAL_STATE and registeredAtom then if status == PENDING_INITIAL_STATE and trackedState then
initPayload[key] = registeredAtom.currentValue initData[key] = trackedState.currentValue
syncedAtoms[key] = LISTENING_FOR_CHANGES clientAtoms[key] = LISTENING_FOR_CHANGES
end end
end end
if next(initPayload) ~= nil then if next(initData) ~= nil then
table.insert(payloads, 1, { type = "init" :: "init", data = initPayload }) table.insert(payloads, 1, { type = "init" :: "init", data = initData })
end end
return payloads return payloads
end end
local function createGlobalPatch(): any local function diffGlobalState(): any
local current: { [string]: any } = {} local currentGlobalState: { [string]: any } = {}
local previous: { [string]: any } = {} local previousGlobalState: { [string]: any } = {}
for key, registeredAtom in atomRegister do for key, tracker in trackedAtoms do
current[key] = registeredAtom.currentValue currentGlobalState[key] = tracker.currentValue
previous[key] = registeredAtom.previousValue previousGlobalState[key] = tracker.previousValue
registeredAtom.previousValue = registeredAtom.currentValue tracker.previousValue = tracker.currentValue
end end
return patch.diff(previous, current) return patch.diff(previousGlobalState, currentGlobalState)
end
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }
local clientAtoms = clients[client]
if not clientAtoms then
return {}
end end
local function createPayloadForClient(client: any, patchData: any): { SyncPayload }
local syncedAtoms = clientRegister[client]
local payloads: { SyncPayload } = {} local payloads: { SyncPayload } = {}
local initData: { [string]: any } = {}
if not syncedAtoms then local patchData: { [string]: any } = {}
return payloads
end
local initPayload: { [string]: any } = {}
local patchPayload: { [string]: any } = {}
-- Create patches for initialized atoms that changed, and add atoms that -- Create patches for initialized atoms that changed, and add atoms that
-- are still pending initial state to the init payload. -- are still pending initial state to the init payload.
for key, status in syncedAtoms do for key, status in clientAtoms do
local registeredAtom = atomRegister[key] local trackedState = trackedAtoms[key]
if status == LISTENING_FOR_CHANGES then if status == LISTENING_FOR_CHANGES then
patchPayload[key] = patchData[key] patchData[key] = globalPatch[key]
elseif status == PENDING_INITIAL_STATE then elseif status == PENDING_INITIAL_STATE then
initPayload[key] = registeredAtom.currentValue initData[key] = trackedState.currentValue
syncedAtoms[key] = LISTENING_FOR_CHANGES clientAtoms[key] = LISTENING_FOR_CHANGES
end end
end end
if next(initPayload) ~= nil then if next(initData) ~= nil then
table.insert(payloads, { type = "init", data = initPayload }) table.insert(payloads, { type = "init", data = initData })
end end
if next(patchPayload) ~= nil then if next(patchData) ~= nil then
table.insert(payloads, { type = "patch", data = patchPayload }) table.insert(payloads, { type = "patch", data = patchData })
end end
return payloads return payloads
end end
--- Registers atoms to be kept in sync with a specific client. Should be --- Subscribes a client to the given atoms. When an update occurs, the client
--- called when a player joins the game to start syncing atoms to them. Call --- will receive a partial state patch to merge into their local state. May be
--- `unsyncAtomsFromClient` or `unsyncClient` to stop syncing. --- called multiple times to subscribe to additional keys.
--- ---
--- Each atom should be assigned a unique key, allowing the server to re-use --- Atoms, signals converted to atoms, and signal getter functions are allowed.
--- patches between multiple clients. --- Note that the client still requires some way to set the state, so
--- registering signals on the client should be done through `signalToAtom()`.
--- ---
--- @param client The player to sync atoms to. --- @param client The client receiving state updates.
--- @param atoms An object mapping string keys to atoms to be synced. --- @param atoms A map of atoms to sync with the client.
local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) local function subscribeClient(client: any, atoms: { [string]: () -> any })
if not clientRegister[client] then if not clients[client] then
clientRegister[client] = {} clients[client] = {}
end end
local syncedAtoms = clientRegister[client] local clientAtoms = clients[client]
for key, atom in atoms do for key, state in atoms do
syncGlobalAtom(key, atom) startTracking(key, if type(state) == "function" then state else state.get)
if not syncedAtoms[key] then if not clientAtoms[key] then
-- Mark the atom as pending initial state so we send its full value -- Mark the atom as pending initial state so we send its full value
-- the next time we sync. -- the next time we sync.
syncedAtoms[key] = PENDING_INITIAL_STATE clientAtoms[key] = PENDING_INITIAL_STATE
end end
end end
end end
--- Unregisters all atoms from being kept in sync with a specific client. --- Unsubscribes a client from receiving all state updates. To only unsubscribe
--- Should be called when a player leaves the game. --- from specific keys, use `unsubscribeClientFrom` instead.
--- ---
--- @param client The player to stop syncing atoms to. --- @param client The client receiving state updates.
local function unsyncClient(client: any) local function unsubscribeClient(client: any)
clientRegister[client] = nil clients[client] = nil
end end
--- Unregisters one or more atoms from being kept in sync with a specific --- Unsubscribes a client from receiving updates for the given keys.
--- client.
--- ---
--- @param client The player to stop syncing atoms to. --- @param client The client receiving state updates.
--- @param keys The keys of the atoms to stop syncing. --- @param keys The keys of the state to unsubscribe from.
local function unsyncAtomsFromClient(client: any, ...: string) local function unsubscribeClientFrom(client: any, ...: string)
local syncedAtoms = clientRegister[client] local clientAtoms = clients[client]
if not syncedAtoms then if not clientAtoms then
return return
end end
for index = 1, select("#", ...) do for index = 1, select("#", ...) do
local key = select(index, ...) local key = select(index, ...)
syncedAtoms[key] = nil clientAtoms[key] = nil
end end
if next(syncedAtoms) == nil then if next(clientAtoms) == nil then
clientRegister[client] = nil clients[client] = nil
end end
end end
--- Immediately sends pending updates and initial states to all clients. --- Immediately sends pending updates to all clients. Normally, updates are
--- Normally, updates are sent at the interval specified in `config.interval`. --- sent at the interval specified in `config.interval`, but this function
--- Calling this function forces all pending updates to be sent immediately. --- can be used to force an immediate sync.
local function flush() local function flush()
if not onSyncCallback then if not syncCallback then
return return
end end
local patches = if config.preserveHistory then createGlobalPatchHistory() else createGlobalPatch() local globalPatch = if config.preserveHistory then diffGlobalUpdateBuffer() else diffGlobalState()
-- While payload objects are technically mutable as they are unique to -- While payload objects are technically mutable as they are unique to
-- each client, the data inside them is not safe to mutate. -- each client, the data inside them is not safe to mutate.
if globals.frozen then if globals.frozen then
Charm.deepFreeze(patches) Charm.deepFreeze(globalPatch)
end end
for client in clientRegister do for client in clients do
local payloads: { SyncPayload } = if config.preserveHistory local payloads: { SyncPayload } = if config.preserveHistory
then createPayloadHistoryForClient(client, patches) then selectFromGlobalPatchList(client, globalPatch)
else createPayloadForClient(client, patches) else selectFromGlobalPatch(client, globalPatch)
if payloads[1] then if payloads[1] then
if task then if task then
task.spawn(onSyncCallback, client, payloads) task.spawn(syncCallback, client, payloads)
else else
onSyncCallback(client, payloads) syncCallback(client, payloads)
end end
end end
end end
unsyncStaleGlobalAtoms() purgeUntracked()
table.clear(globalUpdates) table.clear(globalUpdates)
end end
--- Starts automatically syncing registered atoms to clients. Patches will --- Uses the callback to send state updates to clients that are subscribed
--- be sent to clients whenever the atoms are updated. --- to state changes. Starts automatically sending updates at the interval
--- specified in `config.interval`.
--- ---
--- @param onSync The function to call when patches can be sent to a client. --- @param onSync Called when sending patches to a client.
local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
if connection then if connection then
connection:Disconnect() connection:Disconnect()
end end
onSyncCallback = onSync syncCallback = onSync
if not game then if not game then
return return
@ -323,30 +334,30 @@ local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ())
end) end)
end end
--- Stops automatically syncing registered atoms to clients. Patches will --- Stops syncing state updates to clients at the automatic interval.
--- no longer be sent to clients until `startSync` is called again. --- Flushing can still be performed manually via `flush`.
local function stopSync() local function disconnect()
if connection then if connection then
connection:Disconnect() connection:Disconnect()
connection = nil connection = nil
end end
end end
--- Unregisters all atoms from being kept in sync with all clients, and --- Unsubscribes all clients, stops tracking all state, and disconnects
--- stops automatic syncing. --- from the automatic sync interval.
local function reset() local function reset()
table.clear(atomRegister) table.clear(trackedAtoms)
table.clear(clientRegister) table.clear(clients)
table.clear(globalUpdates) table.clear(globalUpdates)
stopSync() disconnect()
end end
return { return {
syncAtomsToClient = syncAtomsToClient, subscribeClient = subscribeClient,
unsyncClient = unsyncClient, unsubscribeClient = unsubscribeClient,
unsyncAtomsFromClient = unsyncAtomsFromClient, unsubscribeClientFrom = unsubscribeClientFrom,
startSync = startSync, connect = connect,
stopSync = stopSync, disconnect = disconnect,
flush = flush, flush = flush,
reset = reset, reset = reset,
} }

View file

@ -0,0 +1,52 @@
local Charm = require("../Charm")
type Atom<T> = Charm.Atom<T>
type NestedState = {
[string]: NestedState | () -> any,
}
type FlattenedState = {
[string]: () -> any,
}
local function isAtomOrSignal(value: any): boolean
return type(value) ~= "table" or (type(value.get) == "function" and type(value.set) == "function")
end
--- Flattens a nested map of atoms or signals into a single object with
--- slash-separated keys. Useful for recursively collecting atoms returned by
--- modules.
---
--- @param states A nested table of atoms or signals.
--- @return A flattened map of atoms or signals.
local function flattenState(states: NestedState): FlattenedState
local result: FlattenedState = {}
local function visit(node: NestedState, path: string)
for key, value in node do
local location = if path == "" then key else path .. "/" .. key
if isAtomOrSignal(value) then
result[location] = value :: () -> any
else
visit(value :: NestedState, location)
end
end
end
visit(states, "")
return result
end
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
return function(...)
return if select("#", ...) == 0 then getter() else setter((...))
end
end
return {
flattenState = flattenState,
signalToAtom = signalToAtom,
}

View file

@ -312,7 +312,7 @@ end
--- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals --- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals
local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T
local getter, setter = signal(initialValue, equals) local getter, setter = signal(initialValue, equals)
return function(...: ((T) -> T) | T): T return function(...)
return if select("#", ...) == 0 then getter() else setter((...)) return if select("#", ...) == 0 then getter() else setter((...))
end end
end end
@ -321,9 +321,6 @@ end
--- The returned atom is read-only, and its value is automatically updated when --- The returned atom is read-only, and its value is automatically updated when
--- any atoms accessed from within the getter function change. --- 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 --- @example
--- ```ts --- ```ts
--- const count = atom(1); --- const count = atom(1);
@ -332,8 +329,7 @@ end
--- ``` --- ```
--- ---
--- @param getter A function that produces the next value. --- @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#computedgetter
--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options
local function computed<T>(getter: (previousValue: T?) -> T): () -> T local function computed<T>(getter: (previousValue: T?) -> T): () -> T
return computedOper({ return computedOper({
flags = bit32.bor(MUTABLE, DIRTY), flags = bit32.bor(MUTABLE, DIRTY),
@ -432,6 +428,7 @@ end
--- @param callback The callback function. --- @param callback The callback function.
--- @param args Arguments to pass to the callback. --- @param args Arguments to pass to the callback.
--- @return The return value of the callback. --- @return The return value of the callback.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results... local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
local prevSub = setActiveSub(nil) local prevSub = setActiveSub(nil)
local results: { any } = { pcall(callback, ...) } local results: { any } = { pcall(callback, ...) }
@ -450,6 +447,7 @@ end
--- @param callback The callback function. --- @param callback The callback function.
--- @param args Arguments to pass to the callback. --- @param args Arguments to pass to the callback.
--- @return The return value of the callback. --- @return The return value of the callback.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results... local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
if if
activeSub activeSub
@ -474,6 +472,7 @@ end
--- @param callback A function that performs multiple atom updates. --- @param callback A function that performs multiple atom updates.
--- @param args Arguments to pass to the callback. --- @param args Arguments to pass to the callback.
--- @return The return value of the callback. --- @return The return value of the callback.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback
local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results... local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
startBatch() startBatch()
local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) } local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) }

View file

@ -83,11 +83,9 @@ export function atom<T>(): Atom<T | undefined>;
* ``` * ```
* *
* @param getter A function that produces the next value. * @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 * @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options
*/ */
export function computed<T>(getter: (previousValue?: T) => T): () => T; export function computed<T>(getter: (previousValue?: T) => T): () => T;
export function computed<T>(getter: (previousValue: T) => T, value: T): () => T;
/** /**
* Creates an effect that runs a callback in response to atom state changes. * Creates an effect that runs a callback in response to atom state changes.