feat: finalize sync docs

This commit is contained in:
littensy 2025-11-03 00:12:06 -08:00
parent 37f79ecc3f
commit ea40833d3b
15 changed files with 400 additions and 206 deletions

View file

@ -5,6 +5,5 @@ end
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
export type Selector<T = any> = Charm.Selector<T>
return Charm

View file

@ -6,13 +6,13 @@ local test = suite.test
local afterEach = suite.afterEach
afterEach(function()
client.reset()
client.removeAllSignals()
end)
test("should hydrate atom", function()
local a = Charm.atom(1)
client.register({ a = a })
client.addSignals({ a = a })
client.hydrate({
{ type = "init", data = { a = 2 } },
})
@ -24,7 +24,7 @@ test("should merge patch", function()
local a = Charm.atom({ b = 1, c = 2 })
local previous = a()
client.register({ a = a })
client.addSignals({ a = a })
client.hydrate({
{ type = "init", data = { a = a() } },
})
@ -43,7 +43,7 @@ end)
test("should fix sparse array patches", function()
local a = Charm.atom({ 1 })
client.register({ a = a })
client.addSignals({ a = a })
client.hydrate({
{ type = "init", data = { a = a() } },
})
@ -63,7 +63,7 @@ test("should merge multiple patches", function()
local a = Charm.atom(1)
local calls = {}
client.register({ a = a })
client.addSignals({ a = a })
Charm.subscribe(a, function(value)
table.insert(calls, value)
@ -87,7 +87,7 @@ test("should batch patches", function()
local b = Charm.atom(0)
local states = {}
client.register({ a = a, b = b })
client.addSignals({ a = a, b = b })
Charm.subscribe(a, function()
states.b = b()

View file

@ -9,7 +9,7 @@ local test = suite.test
afterEach(function()
server.reset()
client.reset()
client.removeAllSignals()
config.preserveHistory = false
end)
@ -20,8 +20,8 @@ test("should replicate server state to client", function()
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.atom({ count = -1 })
client.register({ a = clientA, b = clientB })
server.subscribeClient(target, { a = serverA, b = serverB })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
server.connect(function(syncTarget, payloads)
if syncTarget == target then
client.hydrate(payloads)
@ -54,8 +54,8 @@ test("should replicate historical server state to client", function()
config.preserveHistory = true
client.register({ a = clientA, b = clientB })
server.subscribeClient(target, { a = serverA, b = serverB })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
server.connect(function(syncTarget, payloads)
if syncTarget == target then
client.hydrate(payloads)

View file

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

View file

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

View file

@ -2,60 +2,59 @@ local Charm = require("../Charm")
local patch = require("./patch")
local types = require("./types")
type Atom<T = any> = Charm.Atom<T>
type SyncPayload = types.SyncPayload
local trackedAtoms: { [string]: Atom } = {}
type Setter<T = any> = (update: ((T) -> T) | T) -> ()
--- Registers a map of atoms to the state with the corresponding keys on the
--- server. When the server sends updates, these atoms will be hydrated with
local trackedSetters: { [string]: Setter } = {}
--- Registers a map of setters to the state with the corresponding keys on the
--- server. When the server sends updates, these setters will be called with
--- the new values.
---
--- If syncing signals instead of atoms, you can use `signalToAtom` to sync
--- signal getters and setters.
--- Atoms, signals converted to atoms, and signal setter functions are allowed.
--- Note that `server.addSignalsToClient` still requires some way to get the
--- state.
---
--- @param atoms A map of atoms to sync with the server.
local function register(atoms: { [string]: Atom })
for key, atom in atoms do
trackedAtoms[key] = atom
--- @param setters A map of setter functions to sync with the server.
local function addSignals(setters: { [string]: Setter })
for key, setter in setters do
trackedSetters[key] = setter
end
end
--- Unregisters from server state updates for the given keys. The atoms
--- Unregisters from server state updates for the given keys. The signals
--- will retain their current values, but will no longer receive updates
--- from the server.
local function unregister(...: string)
local function removeSignals(...: string)
for index = 1, select("#", ...) do
local key = select(index, ...)
trackedAtoms[key] = nil
trackedSetters[key] = nil
end
end
--- Unsubscribes from all server state updates.
local function reset()
table.clear(trackedAtoms)
local function removeAllSignals()
table.clear(trackedSetters)
end
local function applyInitPayload(data: { [string]: any })
for key, value in data do
local atom = trackedAtoms[key]
if atom then
atom(value)
local setter = trackedSetters[key]
if setter then
setter(value)
end
end
end
local function applyPatchPayload(data: { [string | number]: any })
local target = {}
for key, atom in trackedAtoms do
target[key] = atom()
end
target = patch.apply(target, data)
for key, atom in trackedAtoms do
atom(target[key])
local function applyPatchPayload(data: { [string]: any })
for key, value in data do
local setter = trackedSetters[key]
if setter then
setter(function(current)
return patch.apply(current, value)
end)
end
end
end
@ -74,8 +73,8 @@ local function hydrate(payloads: { SyncPayload })
end
return {
register = register,
unregister = unregister,
reset = reset,
addSignals = addSignals,
removeSignals = removeSignals,
removeAllSignals = removeAllSignals,
hydrate = hydrate,
}

View file

@ -1,4 +1,4 @@
import { Atom } from "@rbxts/charm";
import { Atom, Getter, Setter } from "@rbxts/charm";
type Key = string | number | symbol;
@ -11,7 +11,8 @@ export interface None {
export type MaybeNone<T> = undefined extends T ? None : never;
type SelectorMap = Record<string, () => any>;
type GetterMap = Record<string, Getter<any>>;
type SetterMap = Record<string, Setter<any>>;
/**
* Infers the type of the return values produced by a map of functions.
@ -33,6 +34,7 @@ type DataType = DataTypes[keyof DataTypes];
* If a value was removed, it is replaced with `None`. This can be checked
* using the `isNone` function.
*/
// TODO: Optimize type, this looks overly complex
export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends
| ReadonlyMap<infer K, infer V>
| Map<infer K, infer V>
@ -53,9 +55,9 @@ export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State
* A payload that can be sent from the server to the client to synchronize
* state between the two.
*/
export type SyncPayload<Selectors extends SelectorMap = SelectorMap, FixArrays extends boolean = true> =
| { type: "init"; data: StateOfMap<Selectors> }
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>, FixArrays> };
export type SyncPayload<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true> =
| { type: "init"; data: StateOfMap<Getters> }
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
/**
* Global configuration options that affect the behavior of Charm Sync.
@ -102,28 +104,29 @@ export const config: {
*/
export namespace client {
/**
* Registers a map of atoms to the state with the corresponding keys on the
* server. When the server sends updates, these atoms will be hydrated with
* Registers a map of setters to the state with the corresponding keys on the
* server. When the server sends updates, these setters will be called with
* the new values.
*
* If syncing signals instead of atoms, you can use `signalToAtom` to sync
* signal getters and setters as atoms.
* Atoms, signals converted to atoms, and signal setter functions are allowed.
* Note that `server.addSignalsToClient` still requires some way to get the
* state.
*
* @param atoms A map of atoms to sync with the server.
* @param setters A map of setter functions to sync with the server.
*/
export function register(atoms: { [key: string]: Atom<any> }): void;
export function addSignals<Setters extends SetterMap = SetterMap>(setters: Setters): void;
/**
* Unregisters from server state updates for the given keys. The atoms
* Unregisters from server state updates for the given keys. The signals
* will retain their current values, but will no longer receive updates
* from the server.
*/
export function unregister(...keys: string[]): void;
export function removeSignals<Setters extends GetterMap | SetterMap = SetterMap>(...keys: (keyof Setters)[]): void;
/**
* Unsubscribes from all server state updates.
*/
export function reset(): void;
export function removeAllSignals(): void;
/**
* Merges the server's state patches into the client's state, either
@ -131,8 +134,8 @@ export namespace client {
*
* @param payloads The state updates received from the server.
*/
export function hydrate<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>(
payloads: SyncPayload<Atoms, FixArrays>[],
export function hydrate<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
payloads: SyncPayload<Getters, FixArrays>[],
): void;
}
@ -145,22 +148,21 @@ export namespace server {
* will receive a partial state patch to merge into their local state. May be
* called multiple times to subscribe to additional keys.
*
* Atoms, signals converted to atoms, and signal getter functions are allowed.
* Note that the client still requires some way to set the state, so
* registering signals on the client should be done through `signalToAtom()`.
* Atoms, computed signals, and signal getter functions are allowed. Note
* that `client.addSignals` still requires some way to set the state.
*
* @param client The client receiving state updates.
* @param signals A map of signals to sync with the client.
* @param getters A map of getter functions to sync with the client.
*/
export function subscribeClient(client: Player, signals: { [key: string]: () => any }): void;
export function addSignalsToClient<Getters extends GetterMap = GetterMap>(client: Player, getters: Getters): void;
/**
* Unsubscribes a client from receiving all state updates. To only unsubscribe
* from specific keys, use `unsubscribeClientFrom` instead.
* from specific keys, use `removeSignalsFromClient` instead.
*
* @param client The client receiving state updates.
*/
export function unsubscribeClient(client: Player): void;
export function removeClient(client: Player): void;
/**
* Unsubscribes a client from receiving updates for the given keys.
@ -168,7 +170,10 @@ export namespace server {
* @param client The client receiving state updates.
* @param keys The keys of the state to unsubscribe from.
*/
export function unsubscribeClientFrom(client: Player, ...keys: string[]): void;
export function removeSignalsFromClient<Getters extends GetterMap = GetterMap>(
client: Player,
...keys: (keyof Getters)[]
): void;
/**
* Uses the callback to send state updates to clients that are subscribed
@ -177,8 +182,8 @@ export namespace server {
*
* @param onSync Called when sending patches to a client.
*/
export function connect<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Atoms, FixArrays>[]) => void,
export function connect<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
): void;
/**
@ -235,39 +240,24 @@ export namespace patch {
): State;
}
interface NestedAtomMap {
readonly [key: string]: NestedAtomMap | Atom<any>;
}
type DeepFlattenImpl<T, Prefix extends string = ""> = {
[K in keyof T]: T[K] extends DataType | Callback
? { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] }
: T[K] extends object
? DeepFlattenImpl<T[K], `${Prefix}${Exclude<K, symbol>}/`>
: { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] };
}[keyof T];
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
? Name
: Name extends string
? Path extends string
? `${Path}/${Name}`
: never
: never;
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
export type FlattenNestedAtoms<
Atoms extends NestedAtomMap,
Path extends Key | undefined = undefined,
> = IntersectValues<{
readonly [Name in keyof Atoms as AppendPath<Path, Name>]: Atoms[Name] extends Atom<any>
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
: Atoms[Name] extends NestedAtomMap
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
: never;
}>;
type DeepFlatten<T> = Reconstruct<UnionToIntersection<DeepFlattenImpl<T>>>;
/**
* Flattens a nested atom map into a single object with slash-separated keys.
* Flattens a nested table into a single table with slash-separated keys.
* Useful for recursively collecting atoms returned by modules.
*
* @param atoms The nested atom map to flatten.
* @returns A flattened atom map.
* @param input A nested table.
* @return A flattened table with slash-separated keys.
*/
export function flattenAtoms<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
export function flatten<T>(input: T): DeepFlatten<T>;
/**
* Checks whether a value is `None`. If `true`, the value is scheduled to be

View file

@ -14,6 +14,6 @@ return {
server = server,
patch = patch,
isNone = patch.isNone,
flattenState = utils.flattenState,
flatten = utils.flatten,
signalToAtom = utils.signalToAtom,
}

View file

@ -8,12 +8,9 @@ type SyncPayload = types.SyncPayload
local globals = Charm.globals
type SyncStatus = number
type ClientKeys = { [string]: SyncStatus }
type ClientKeys = {
[string]: SyncStatus,
}
type TrackedState<T = any> = {
type GetterState<T = any> = {
getter: () -> T,
currentValue: T,
previousValue: T,
@ -29,7 +26,7 @@ type StateUpdate = {
local PENDING_INITIAL_STATE: SyncStatus = 0
local LISTENING_FOR_CHANGES: SyncStatus = 1
local trackedSignals: { [string]: TrackedState } = {}
local trackedGetters: { [string]: GetterState } = {}
local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientKeys } = {}
@ -37,9 +34,9 @@ local connection: RBXScriptConnection?
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
local function startTracking<T>(key: string, getter: () -> T)
if trackedSignals[key] then
if trackedGetters[key] then
-- Ignore duplicates, but warn against name collisions
if trackedSignals[key].getter ~= getter then
if trackedGetters[key].getter ~= getter then
warn("[Charm] Attempted to sync different states with the same key:", key)
end
return
@ -68,14 +65,14 @@ local function startTracking<T>(key: string, getter: () -> T)
trackedState.currentValue = newValue
end)
trackedSignals[key] = trackedState
trackedGetters[key] = trackedState
end
local function stopTracking(key: string)
local tracker = trackedSignals[key]
local tracker = trackedGetters[key]
if tracker then
tracker.dispose()
trackedSignals[key] = nil
trackedGetters[key] = nil
end
end
@ -88,7 +85,7 @@ local function purgeUntracked()
end
end
for key in trackedSignals do
for key in trackedGetters do
if not active[key] then
stopTracking(key)
end
@ -106,11 +103,11 @@ local function diffGlobalUpdateBuffer(): { any }
return patches
end
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }?
local clientKeys = clients[client]
if not clientKeys then
return {}
return nil
end
local payloads: { SyncPayload } = {}
@ -146,10 +143,10 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
local initData: { [string]: any } = {}
for key, status in clientKeys do
local trackedState = trackedSignals[key]
local getterState = trackedGetters[key]
if status == PENDING_INITIAL_STATE and trackedState then
initData[key] = trackedState.currentValue
if status == PENDING_INITIAL_STATE and getterState then
initData[key] = getterState.currentValue
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
@ -165,7 +162,7 @@ local function diffGlobalState(): any
local currentGlobalState: { [string]: any } = {}
local previousGlobalState: { [string]: any } = {}
for key, tracker in trackedSignals do
for key, tracker in trackedGetters do
currentGlobalState[key] = tracker.currentValue
previousGlobalState[key] = tracker.previousValue
tracker.previousValue = tracker.currentValue
@ -174,11 +171,11 @@ local function diffGlobalState(): any
return patch.diff(previousGlobalState, currentGlobalState)
end
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }?
local clientKeys = clients[client]
if not clientKeys then
return {}
return nil
end
local payloads: { SyncPayload } = {}
@ -188,12 +185,12 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
-- Create patches for initialized signals that changed, and add signals
-- that are still pending initial state to the init payload.
for key, status in clientKeys do
local trackedState = trackedSignals[key]
local getterState = trackedGetters[key]
if status == LISTENING_FOR_CHANGES then
patchData[key] = globalPatch[key]
elseif status == PENDING_INITIAL_STATE then
initData[key] = trackedState.currentValue
initData[key] = getterState.currentValue
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
@ -214,23 +211,22 @@ end
--- called multiple times to subscribe to additional keys.
---
--- Atoms, signals converted to atoms, and signal getter functions are allowed.
--- Note that the client still requires some way to set the state, so
--- registering signals on the client should be done through `signalToAtom()`.
--- Note that `client.addSignals` still requires some way to set the state.
---
--- @param client The client receiving state updates.
--- @param signals A map of signals to sync with the client.
local function subscribeClient(client: any, signals: { [string]: () -> any })
--- @param getters A map of getter functions to sync with the client.
local function addSignalsToClient(client: any, getters: { [string]: () -> any })
if not clients[client] then
clients[client] = {}
end
local clientKeys = clients[client]
for key, state in signals do
startTracking(key, if type(state) == "function" then state else state.get)
for key, getter in getters do
startTracking(key, if type(getter) == "function" then getter else getter.get)
if not clientKeys[key] then
-- Mark the signal as pending initial state so we send its full
-- Mark the getter as pending initial state so we send its full
-- value the next time we sync.
clientKeys[key] = PENDING_INITIAL_STATE
end
@ -241,7 +237,7 @@ end
--- from specific keys, use `unsubscribeClientFrom` instead.
---
--- @param client The client receiving state updates.
local function unsubscribeClient(client: any)
local function removeClient(client: any)
clients[client] = nil
end
@ -249,7 +245,7 @@ end
---
--- @param client The client receiving state updates.
--- @param keys The keys of the state to unsubscribe from.
local function unsubscribeClientFrom(client: any, ...: string)
local function removeSignalsFromClient(client: any, ...: string)
local clientKeys = clients[client]
if not clientKeys then
@ -262,7 +258,7 @@ local function unsubscribeClientFrom(client: any, ...: string)
end
if next(clientKeys) == nil then
clients[client] = nil
removeClient(client)
end
end
@ -274,7 +270,8 @@ local function flush()
return
end
local globalPatch = if config.preserveHistory then diffGlobalUpdateBuffer() else diffGlobalState()
local granular = config.preserveHistory
local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
-- While payload objects are technically mutable as they are unique to
-- each client, the data inside them is not safe to mutate.
@ -283,11 +280,11 @@ local function flush()
end
for client in clients do
local payloads: { SyncPayload } = if config.preserveHistory
local payloads = if granular
then selectFromGlobalPatchList(client, globalPatch)
else selectFromGlobalPatch(client, globalPatch)
if payloads[1] then
if payloads and payloads[1] then
if task then
task.spawn(syncCallback, client, payloads)
else
@ -346,16 +343,16 @@ end
--- Unsubscribes all clients, stops tracking all state, and disconnects
--- from the automatic sync interval.
local function reset()
table.clear(trackedSignals)
table.clear(trackedGetters)
table.clear(clients)
table.clear(globalUpdates)
disconnect()
end
return {
subscribeClient = subscribeClient,
unsubscribeClient = unsubscribeClient,
unsubscribeClientFrom = unsubscribeClientFrom,
addSignalsToClient = addSignalsToClient,
removeClient = removeClient,
removeSignalsFromClient = removeSignalsFromClient,
connect = connect,
disconnect = disconnect,
flush = flush,

View file

@ -2,40 +2,35 @@ local Charm = require("../Charm")
type Atom<T> = Charm.Atom<T>
type NestedState = {
[string]: NestedState | () -> any,
type NestedTable = {
[any]: NestedTable | any,
}
type FlattenedState = {
[string]: () -> any,
type FlattenedTable = {
[any]: 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.
--- Flattens a nested table into a single table 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 = {}
--- @param input A nested table.
--- @return A flattened table with slash-separated keys.
local function flatten(input: NestedTable): FlattenedTable
local result: FlattenedTable = {}
local function visit(node: NestedState, path: string)
local function visit(node: NestedTable, 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
if type(value) == "table" then
visit(value :: NestedTable, location)
else
visit(value :: NestedState, location)
result[location] = value
end
end
end
visit(states, "")
visit(input, "")
return result
end
@ -54,6 +49,6 @@ local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T)
end
return {
flattenState = flattenState,
flatten = flatten,
signalToAtom = signalToAtom,
}

View file

@ -2,17 +2,17 @@ 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.
* A reactive signal that acts as both a getter and setter. Calling the signal
* with an argument sets its value, while calling it with no arguments returns
* its current value.
*/
export interface Atom<T> {
(newValue: T | ((currentValue: T) => T)): T;
(): T;
}
export type Selector<T> = () => T;
export type Getter<T> = () => T;
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
export type Equals<T> = (current: T, incoming: T) => boolean;
/**

View file

@ -6,13 +6,11 @@ local utils = require("@self/utils")
type Update<T> = ((previous: T) -> T) | T
--- A reactive atom that acts as both a getter and setter. Calling the atom with
--- an argument sets its value, while calling it with no arguments returns its
--- current value.
--- A reactive signal that acts as both a getter and setter. Calling the signal
--- with an argument sets its value, while calling it with no arguments returns
--- its current value.
export type Atom<T = any> = (...Update<T>) -> T
export type Selector<T = any> = () -> T
return {
core = core,
globals = globals,

View file

@ -1,6 +1,5 @@
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
export type Selector<T = any> = Charm.Selector<T>
return Charm

View file

@ -1,6 +1,5 @@
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
export type Selector<T = any> = Charm.Selector<T>
return Charm