feat: add reactive()

This commit is contained in:
littensy 2025-11-18 20:39:37 -08:00
parent 7bad215560
commit bd23d54374
26 changed files with 1129 additions and 569 deletions

View file

@ -40,6 +40,31 @@ test("should merge patch", function()
assert(a().c == 2, "should not change c")
end)
test("should merge patch (reactive)", function()
local a = Charm.reactive({ b = 1, c = 2 })
local runs = 0
Charm.effect(function()
local _ = a.b + a.c
runs += 1
end)
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = { b = a.b, c = a.c } } },
})
assert(runs == 1, "should not trigger effect for redundant init")
client.patch({
{ type = "patch", data = { a = { b = 3 } } },
})
assert(a.b == 3, "should set b to the new value")
assert(a.c == 2, "should not change c")
assert(runs == 2, "should trigger effect for patch")
end)
test("should fix sparse array patches", function()
local a = Charm.atom({ 1 })

View file

@ -16,9 +16,9 @@ end)
test("should replicate server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
@ -30,27 +30,32 @@ test("should replicate server state to client", function()
server.flush()
assert(clientA().count == 0, "Client atom A should be initialized to 0")
assert(clientB().count == 0, "Client atom B should be initialized to 0")
assert(clientB.count == 0, "Client reactive B should be initialized to 0")
serverA({ count = 1 })
serverB({ count = 1 })
serverB.count = 1
server.flush()
assert(clientA().count == 1, "Client atom A should be updated to 1")
assert(clientB().count == 1, "Client atom B should be updated to 1")
assert(clientB.count == 1, "Client reactive B should be updated to 1")
local runs = -1
Charm.effect(function()
local _ = clientB.count
runs += 1
end)
local prevClientB = clientB()
serverA({ count = 2 })
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(clientB() == prevClientB, "Client atom B should not be updated")
assert(runs == 0, "Client reactive B should not trigger update")
end)
test("should replicate historical server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
config.preserveHistory = true
@ -67,30 +72,28 @@ test("should replicate historical server state to client", function()
Charm.effect(function()
local expected = table.remove(expectedA, 1)
assert(expected == clientA().count, `Client atom A should be {expected}`)
assert(expected == clientA().count, `Client reactive A should be {expected}`)
end)
Charm.effect(function()
local expected = table.remove(expectedB, 1)
assert(expected == clientB().count, `Client atom B should be {expected}`)
assert(expected == clientB.count, `Client reactive B should be {expected}`)
end)
server.flush()
serverA({ count = 1 })
serverA({ count = 2 })
serverB({ count = 1 })
serverB({ count = 2 })
serverB.count = 1
serverB.count = 2
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(clientB().count == 2, "Client atom B should be updated to 2")
assert(clientB.count == 2, "Client reactive B should be updated to 2")
local prevClientB = clientB()
serverA({ count = 3 })
serverA({ count = 4 })
server.flush()
assert(clientA().count == 4, "Client atom A should be updated to 4")
assert(clientB() == prevClientB, "Client atom B should not be updated")
end)
return {}

View file

@ -31,17 +31,17 @@ end)
test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState)
local diffs = patch.diff(prevState, nextState)
assert(patches ~= nil, "should return a patch")
assert(patches.baz == "quux", "should return the changed value")
assert(patches.foo == nil, "should omit unchanged values")
assert(diffs ~= nil, "should return a patch")
assert(diffs.baz == "quux", "should return the changed value")
assert(diffs.foo == nil, "should omit unchanged values")
end)
test("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches)
local diffs = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, diffs)
assert(applied ~= nil, "should return a new state")
assert(applied.baz == "quux", "should apply the changed value")
@ -49,11 +49,11 @@ test("applies a patch", function()
assert(applied.qaz == "qux", "should add the new value")
end)
test("allows nested patches", function()
test("allows nested diffs", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local patches = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, patches)
local diffs = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, diffs)
assert(applied.foo.bar == "qux", "should apply the nested patch")
end)
@ -78,8 +78,8 @@ test("preserves array density after serdes", function()
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
local patches = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, diffs)
for index in prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
@ -96,10 +96,10 @@ test("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
local patches = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, diffs)
-- Not enough information is available to determine whether 'patches'
-- Not enough information is available to determine whether 'diffs'
-- should keep the string keys added during remote event serialization.
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
assert(applied[2] == nil, "will fail to convert string keys to numbers")
@ -119,20 +119,12 @@ test("optionally fixes sparse arrays", function()
end
end)
test("optionally validates state", function()
local success = pcall(function()
config.validatePatches = true
patch.diff({}, { [1] = 1, a = true })
end)
assert(not success, "should not allow mixed tables")
success = pcall(function()
config.validatePatches = false
patch.diff({}, { [1] = 1, a = true })
end)
assert(success, "should skip validation if not requested")
test("patches mutable state", function()
local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
assert(targetState.baz == "quux", "should apply the changed value")
assert(targetState.foo == "bar", "should keep the unchanged value")
assert(targetState.qaz == "qux", "should add the new value")
end)
return {}

View file

@ -69,6 +69,33 @@ test("should replicate partial state patches", function()
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
end)
test("should replicate partial state patches (reactive)", function()
local client = {}
local a: { [string]: number? } = Charm.reactive({ x = 0, y = 0, z = 0 })
local target, payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.x == a.x, "Expected init payload to contain value 'a.x'")
assert(payloads[1].data.a.y == a.y, "Expected init payload to contain value 'a.y'")
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
a.x = 1
a.z = nil
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.x == 1, "Expected patch payload to contain value 'a.x'")
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain value 'a.y'")
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}
@ -89,8 +116,8 @@ 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.addSignalsToClient(clientB, { a = a })
a(2)
server.addSignalsToClient(clientB, { a = a })
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
@ -166,20 +193,6 @@ test("should only replicate to synced clients", function()
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
end)
test("should validate state", function()
local client = {}
local a = Charm.atom({})
server.addSignalsToClient(client, { a = a })
server.connect(function() end)
a({ [0.5] = true })
assert(not pcall(server.flush), "Expected server.flush to fail")
a({ [0.5] = true, valid = true })
server.flush()
end)
test("should serialize state", function()
local client = {}
local a = Charm.atom({ 1, 2, 3, 4, 5 })
@ -236,6 +249,36 @@ describe("historical", function()
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
test("should replicate granular state changes (reactive)", function()
local client = {}
local a = Charm.reactive({ value = 0 })
local b = Charm.reactive({ value = 0 })
local c = Charm.reactive({ value = 0 })
local target, payloads
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
a.value = 1
a.value = 2
b.value = 2
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}

View file

@ -20,8 +20,4 @@ suite.describe("server", function()
require("./server.spec")
end)
suite.describe("validate", function()
require("./validate.spec")
end)
return {}

View file

@ -1,24 +0,0 @@
local suite = require("../../../../test/suite")
local validate = require("../validate")
local test = suite.test
test("validates inputs", function()
local cases: { [string]: { value: any, expected: any } } = {
array = { value = { 1, 2, 3 }, expected = true },
arraySparse = { value = { 1, nil :: any, 3 }, expected = true },
arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false },
mixedTable = { value = { [1] = "foo", a = true }, expected = false },
}
for key, case in cases do
local success, result: any = pcall(validate, case.value)
if case.expected and not success then
error(`Expected {key} to be valid, threw {result}`)
elseif not case.expected and success then
error(`Expected {key} to be invalid, but it was valid`)
end
end
end)
return {}

View file

@ -4,64 +4,89 @@ local types = require("./types")
type SyncPayload = types.SyncPayload
type Setter<T = any> = (update: ((T) -> T) | T) -> ()
type SignalSetter<T = any> = (update: ((T) -> T) | T) -> ()
local trackedSetters: { [string]: Setter } = {}
type ReactiveSetter<T = any> = (update: ((T) -> ()) | T) -> ()
--- 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.
---
--- Atoms, signals converted to atoms, and signal setter functions are allowed.
--- Note that `server.addSignalsToClient` still requires some way to get the
--- state.
---
--- @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
type Reactive<T = any> = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter<T> }))
local trackedSignals: { [string]: SignalSetter } = {}
local trackedReactives: { [string]: ReactiveSetter } = {}
--[[
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.
Atoms, signals converted to atoms, and signal setter functions are allowed.
Note that `server.addSignalsToClient` still requires some way to get the
state.
@param signals A map of setter functions to sync with the server.
]]
local function addSignals(signals: { [string]: SignalSetter | { [any]: any } })
for key, signal in signals do
if type(signal) == "function" then
trackedSignals[key] = signal
else
trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue
end
end
end
--- 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.
--[[
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 removeSignals(...: string)
for index = 1, select("#", ...) do
local key = select(index, ...)
trackedSetters[key] = nil
trackedSignals[key] = nil
trackedReactives[key] = nil
end
end
--- Unsubscribes from all server state updates.
--[[
Unsubscribes from all server state updates.
]]
local function removeAllSignals()
table.clear(trackedSetters)
table.clear(trackedSignals)
table.clear(trackedReactives)
end
local function applyInitPayload(data: { [string]: any })
for key, value in data do
local setter = trackedSetters[key]
if setter then
setter(value)
local trackedSignal: SignalSetter? = trackedSignals[key] or trackedReactives[key]
if trackedSignal then
trackedSignal(value)
end
end
end
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)
for key, diffs in data do
local trackedSignal = trackedSignals[key]
local trackedReactive = trackedReactives[key]
if trackedSignal then
trackedSignal(function(value)
return patch.apply(value, diffs)
end)
elseif trackedReactive then
trackedReactive(function(value)
patch.applyMutable(value, diffs)
end)
end
end
end
--- Merges the server's state patches into the client's state, either
--- initializing it with the full state or merging a partial patch.
---
--- @param payloads The state updates received from the server.
--[[
Merges the server's state patches into the client's state, either
initializing it with the full state or merging a partial patch.
@param payloads The state updates received from the server.
]]
local function patch(payloads: { SyncPayload })
for _, payload in payloads do
if payload.type == "init" then

View file

@ -1,20 +1,28 @@
return {
--- 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.
--[[
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 = 0,
--- Whether the history of state changes since the client's last update
--- should be preserved. This is useful for values that change multiple
--- times per frame, where each individual change is important. Defaults
--- to `false`.
--[[
Whether the history of state changes since the client's last update
should be preserved. This is useful for values that change multiple
times per frame, where each individual change is important. Defaults
to `false`.
]]
preserveHistory = false,
--- When `true`, Charm will apply validation and serialize unsafe arrays
--- to address remote event argument limitations. Defaults to `true`.
--- This option should be disabled if your network library uses a custom
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
--[[
When `true`, Charm will apply validation and serialize unsafe arrays
to address remote event argument limitations. Defaults to `true`.
This option should be disabled if your network library uses a custom
serialization method (i.e. Zap, ByteNet) to prevent interference.
]]
fixArrays = true,
--- When `true`, synced state containing unsafe sparse arrays or mixed
--- tables will throw an error. Only checked in strict mode and if
--- `fixArrays` is enabled. Defaults to `true`.
--[[
When `true`, synced state containing unsafe sparse arrays or mixed
tables will throw an error. Only checked in strict mode and if
`fixArrays` is enabled. Defaults to `true`.
]]
validatePatches = true,
}

View file

@ -11,8 +11,11 @@ export interface None {
export type MaybeNone<T> = undefined extends T ? None : never;
type GetterMap = Record<string, () => any>;
type SetterMap = Record<string, Setter<any>>;
type ReactiveObject = { [key: Key]: any };
type ServerSignalMap = Record<string, (() => any) | ReactiveObject>;
type ClientSignalMap = Record<string, Setter<any> | ReactiveObject>;
/**
* Infers the type of the return values produced by a map of functions.
@ -55,7 +58,7 @@ 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<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true> =
export type SyncPayload<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true> =
| { type: "init"; data: StateOfMap<Getters> }
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
@ -112,16 +115,18 @@ export namespace client {
* Note that `server.addSignalsToClient` still requires some way to get the
* state.
*
* @param setters A map of setter functions to sync with the server.
* @param signals A map of setter functions to sync with the server.
*/
export function addSignals<Setters extends SetterMap = SetterMap>(setters: Setters): void;
export function addSignals<Signals extends ClientSignalMap = ClientSignalMap>(signals: Signals): void;
/**
* 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 removeSignals<Setters extends GetterMap | SetterMap = SetterMap>(...keys: (keyof Setters)[]): void;
export function removeSignals<Signals extends ServerSignalMap | ClientSignalMap = ClientSignalMap>(
...keys: (keyof Signals)[]
): void;
/**
* Unsubscribes from all server state updates.
@ -134,7 +139,7 @@ export namespace client {
*
* @param payloads The state updates received from the server.
*/
export function patch<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
export function patch<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
payloads: SyncPayload<Getters, FixArrays>[],
): void;
}
@ -152,9 +157,12 @@ export namespace server {
* that `client.addSignals` still requires some way to set the state.
*
* @param client The client receiving state updates.
* @param getters A map of getter functions to sync with the client.
* @param signals A map of getter functions to sync with the client.
*/
export function addSignalsToClient<Getters extends GetterMap = GetterMap>(client: Player, getters: Getters): void;
export function addSignalsToClient<Signals extends ServerSignalMap = ServerSignalMap>(
client: Player,
signals: Signals,
): void;
/**
* Unsubscribes a client from receiving all state updates. To only unsubscribe
@ -170,9 +178,9 @@ export namespace server {
* @param client The client receiving state updates.
* @param keys The keys of the state to unsubscribe from.
*/
export function removeSignalsFromClient<Getters extends GetterMap = GetterMap>(
export function removeSignalsFromClient<Signals extends ServerSignalMap = ServerSignalMap>(
client: Player,
...keys: (keyof Getters)[]
...keys: (keyof Signals)[]
): void;
/**
@ -182,7 +190,7 @@ export namespace server {
*
* @param onSync Called when sending patches to a client.
*/
export function connect<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
export function connect<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
): void;
@ -276,5 +284,6 @@ export function isNone(value: unknown): value is None;
* @param getter A function that returns the current value of the signal.
* @param setter A function that sets the value of the signal.
* @returns An atom that can either get or set the signal's value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter
*/
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;

View file

@ -7,34 +7,38 @@ local flags = Charm.flags
export type None = types.None
--- A special symbol that denotes the absence of a value. Used to represent
--- deleted values in patches.
local None: None = { __none = "__none" }
--- Checks whether a value is `None`. If `true`, the value is scheduled to be
--- removed from the state when the patch is applied.
---
--- @param value The value to check.
--- @return `true` if the value is `None`, otherwise `false`.
--[[
Checks whether a value is `None`. If `true`, the value is scheduled to be
removed from the state when the patch is applied.
@param value The value to check.
@return `true` if the value is `None`, otherwise `false`.
]]
local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none"
end
--- Converts `nil` values to the special `None` symbol. This is useful when
--- preparing data for serialization, since `nil` can represent both the absence
--- of a value and the removal of a value in patches.
--[[
Converts `nil` values to the special `None` symbol. This is useful when
preparing data for serialization, since `nil` can represent both the absence
of a value and the removal of a value in patches.
]]
local function nilToNone<T>(value: T?): T | None
return if value == nil then None else value
end
--- JSON serialization can drop the last values of a sparse array. For example,
--- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
--- serialization of sparse arrays can be resolved by converting them to
--- dictionaries with string keys.
---
--- The `apply` function will convert these dictionaries back into arrays as
--- long as the target array is not sparse. This function ensures that the diff
--- function produces a patch that can be correctly applied.
--[[
JSON serialization can drop the last values of a sparse array. For example,
`{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
serialization of sparse arrays can be resolved by converting them to
dictionaries with string keys.
The `patch` functions will convert these dictionaries back into arrays as
long as the target array is not sparse. This function ensures that the diff
function produces a patch that can be correctly applied.
]]
local function stringifySparseArray(object: { any }): { [string | number]: any }
local maxn = table.maxn(object)
@ -52,68 +56,78 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
return dictionary
end
--- Compares two states and returns a patch that can be applied to the current
--- state to produce the next state.
---
--- @param currentState The current state.
--- @param nextState The next state.
--- @return A patch that can be applied to the current state.
local function diff(currentState: any, nextState: any): any
if currentState == nextState then
local function diff(oldState: any, newState: any): any
if oldState == newState then
return nil
elseif nextState == nil then
elseif newState == nil then
return None
elseif type(currentState) ~= "table" or type(nextState) ~= "table" then
return nextState
elseif type(oldState) ~= "table" or type(newState) ~= "table" then
return newState
end
local patches: { [any]: any } = table.clone(nextState)
local diffs: { [any]: any } = table.clone(newState)
for key, previous in currentState do
patches[key] = diff(previous, nextState[key])
for key, oldValue in oldState do
diffs[key] = diff(oldValue, newState[key])
end
if next(diffs) == nil then
return nil
end
if config.fixArrays then
-- Convert sparse array patches that could be misserialized
if currentState[1] ~= nil or nextState[1] ~= nil then
patches = stringifySparseArray(patches)
if oldState[1] ~= nil or newState[1] ~= nil then
diffs = stringifySparseArray(diffs)
end
if flags.strict and config.validatePatches then
validate(patches)
validate(diffs)
end
end
return patches
return diffs
end
--- Applies a patch to a state and returns the resulting value.
---
--- @param currentState The current state.
--- @param statePatch The patches to apply.
--- @return The new state with the patch applied.
local function apply(currentState: any, statePatch: any): any
if isNone(statePatch) then
local function apply(oldState: any, diffs: any): any
if isNone(diffs) then
return nil
elseif type(currentState) ~= "table" or type(statePatch) ~= "table" then
return statePatch
elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
return diffs
end
local nextState = table.clone(currentState)
local stateIsArray = currentState[1] ~= nil
local newState = table.clone(oldState)
local stateIsArray = oldState[1] ~= nil
for key, value in statePatch do
for key, value in diffs do
-- Diff-checking an array produces a sparse array, which will not be
-- preserved when converted to JSON. To prevent this, we turn string
-- keys back into numeric keys.
if stateIsArray and type(key) == "string" then
key = tonumber(key) or key
end
nextState[key] = apply(nextState[key], value)
newState[key] = apply(newState[key], value)
end
return nextState
return newState
end
local function applyMutable(targetState: any, sourceState: any): any
if type(targetState) == "table" and type(sourceState) == "table" then
local targetIsArray = targetState[1] ~= nil
for key, value in sourceState do
if targetIsArray and type(key) == "string" then
key = tonumber(key) or key
end
targetState[key] = applyMutable(targetState[key], value)
end
return targetState
elseif isNone(sourceState) then
return nil
else
return sourceState
end
end
return {
@ -121,4 +135,5 @@ return {
nilToNone = nilToNone,
diff = diff,
apply = apply,
applyMutable = applyMutable,
}

View file

@ -2,6 +2,7 @@ local Charm = require("../Charm")
local config = require("./config")
local patch = require("./patch")
local types = require("./types")
local utils = require("./utils")
type SyncPayload = types.SyncPayload
@ -9,11 +10,14 @@ type SyncStatus = number
type ClientKeys = { [string]: SyncStatus }
type GetterState<T = any> = {
getter: () -> T,
type Reactive<T> = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () }))
type TrackedState<T = any> = {
target: any,
currentValue: T,
lastSyncedValue: T,
dispose: () -> (),
isReactive: boolean?,
}
type StateUpdate = {
@ -25,17 +29,17 @@ type StateUpdate = {
local PENDING_INITIAL_STATE: SyncStatus = 0
local LISTENING_FOR_CHANGES: SyncStatus = 1
local trackedGetters: { [string]: GetterState } = {}
local trackedStates: { [string]: TrackedState } = {}
local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientKeys } = {}
local connection: RBXScriptConnection?
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
local function startTracking<T>(key: string, getter: () -> T)
if trackedGetters[key] then
local function trackSignal<T>(key: string, getter: () -> T)
if trackedStates[key] then
-- Ignore duplicates, but warn against name collisions
if trackedGetters[key].getter ~= getter then
if trackedStates[key].target ~= getter then
warn("[Charm] Attempted to sync different states with the same key:", key)
end
return
@ -43,7 +47,7 @@ local function startTracking<T>(key: string, getter: () -> T)
local value = getter()
local trackedState = {
getter = getter,
target = getter,
currentValue = value,
lastSyncedValue = value,
}
@ -61,14 +65,51 @@ local function startTracking<T>(key: string, getter: () -> T)
trackedState.currentValue = newValue
end)
trackedGetters[key] = trackedState
trackedStates[key] = trackedState
end
local function trackReactive<T>(key: string, reactive: Reactive<T>)
if trackedStates[key] then
if trackedStates[key].target ~= reactive then
warn("[Charm] Attempted to sync different states with the same key:", key)
end
return
end
local meta = getmetatable(reactive)
local snapshot = utils.deepCopy(meta.initialValue)
local firstRun = true
local trackedState = {
target = reactive,
currentValue = meta.initialValue,
lastSyncedValue = snapshot,
isReactive = true,
}
trackedState.dispose = Charm.effect(function()
meta.track()
if not config.preserveHistory or firstRun then
firstRun = false
else
local oldSnapshot = snapshot
snapshot = utils.deepCopy(meta.initialValue)
table.insert(globalUpdates, {
key = key,
newValue = snapshot,
oldValue = oldSnapshot,
})
end
end)
trackedStates[key] = trackedState
end
local function stopTracking(key: string)
local trackedState = trackedGetters[key]
local trackedState = trackedStates[key]
if trackedState then
trackedState.dispose()
trackedGetters[key] = nil
trackedStates[key] = nil
end
end
@ -81,7 +122,7 @@ local function purgeUntracked()
end
end
for key in trackedGetters do
for key in trackedStates do
if not active[key] then
stopTracking(key)
end
@ -139,7 +180,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
local initData: { [string]: any } = {}
for key, status in clientKeys do
local trackedState = trackedGetters[key]
local trackedState = trackedStates[key]
if status == PENDING_INITIAL_STATE and trackedState then
initData[key] = trackedState.currentValue
@ -158,10 +199,14 @@ local function diffGlobalState(): any
local currentGlobalState: { [string]: any } = {}
local previousGlobalState: { [string]: any } = {}
for key, trackedState in trackedGetters do
currentGlobalState[key] = trackedState.currentValue
for key, trackedState in trackedStates do
local currentValue = if trackedState.isReactive
then utils.deepCopy(trackedState.currentValue)
else trackedState.currentValue
currentGlobalState[key] = currentValue
previousGlobalState[key] = trackedState.lastSyncedValue
trackedState.lastSyncedValue = trackedState.currentValue
trackedState.lastSyncedValue = currentValue
end
return patch.diff(previousGlobalState, currentGlobalState)
@ -181,7 +226,7 @@ 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 = trackedGetters[key]
local trackedState = trackedStates[key]
if status == LISTENING_FOR_CHANGES then
patchData[key] = globalPatch[key]
@ -202,45 +247,55 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
return payloads
end
--- Subscribes a client to the given signals. When an update occurs, the client
--- 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 `client.addSignals` still requires some way to set the state.
---
--- @param client The client receiving state updates.
--- @param getters A map of getter functions to sync with the client.
local function addSignalsToClient(client: any, getters: { [string]: () -> any })
--[[
Subscribes a client to the given signals. When an update occurs, the client
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 `client.addSignals` still requires some way to set the state.
@param client The client receiving state updates.
@param signals A map of getter functions to sync with the client.
]]
local function addSignalsToClient(client: any, signals: { [string]: { [any]: any } | () -> any })
if not clients[client] then
clients[client] = {}
end
local clientKeys = clients[client]
for key, getter in getters do
startTracking(key, if type(getter) == "function" then getter else getter.get)
for key, signal in signals do
if type(signal) == "function" then
trackSignal(key, signal)
else
trackReactive(key, signal :: any)
end
if not clientKeys[key] then
-- Mark the getter as pending initial state so we send its full
-- Mark the signal as pending initial state so we send its full
-- value the next time we sync.
clientKeys[key] = PENDING_INITIAL_STATE
end
end
end
--- Unsubscribes a client from receiving all state updates. To only unsubscribe
--- from specific keys, use `unsubscribeClientFrom` instead.
---
--- @param client The client receiving state updates.
--[[
Unsubscribes a client from receiving all state updates. To only unsubscribe
from specific keys, use `unsubscribeClientFrom` instead.
@param client The client receiving state updates.
]]
local function removeClient(client: any)
clients[client] = nil
end
--- Unsubscribes a client from receiving updates for the given keys.
---
--- @param client The client receiving state updates.
--- @param keys The keys of the state to unsubscribe from.
--[[
Unsubscribes a client from receiving updates for the given keys.
@param client The client receiving state updates.
@param keys The keys of the state to unsubscribe from.
]]
local function removeSignalsFromClient(client: any, ...: string)
local clientKeys = clients[client]
@ -258,9 +313,11 @@ local function removeSignalsFromClient(client: any, ...: string)
end
end
--- Immediately sends pending updates to all clients. Normally, updates are
--- sent at the interval specified in `config.interval`, but this function
--- can be used to force an immediate sync.
--[[
Immediately sends pending updates to all clients. Normally, updates are
sent at the interval specified in `config.interval`, but this function
can be used to force an immediate sync.
]]
local function flush()
if not syncCallback then
return
@ -287,11 +344,13 @@ local function flush()
table.clear(globalUpdates)
end
--- Uses the callback to send state updates to clients that are subscribed
--- to state changes. Starts automatically sending updates at the interval
--- specified in `config.interval`.
---
--- @param onSync Called when sending patches to a client.
--[[
Uses the callback to send state updates to clients that are subscribed
to state changes. Starts automatically sending updates at the interval
specified in `config.interval`.
@param onSync Called when sending patches to a client.
]]
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
if connection then
connection:Disconnect()
@ -321,8 +380,10 @@ local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
end)
end
--- Stops syncing state updates to clients at the automatic interval.
--- Flushing can still be performed manually via `flush`.
--[[
Stops syncing state updates to clients at the automatic interval.
Flushing can still be performed manually via `flush`.
]]
local function disconnect()
if connection then
connection:Disconnect()
@ -330,10 +391,12 @@ local function disconnect()
end
end
--- Unsubscribes all clients, stops tracking all state, and disconnects
--- from the automatic sync interval.
--[[
Unsubscribes all clients, stops tracking all state, and disconnects
from the automatic sync interval.
]]
local function reset()
table.clear(trackedGetters)
table.clear(trackedStates)
table.clear(clients)
table.clear(globalUpdates)
disconnect()

View file

@ -1,5 +1,7 @@
--- A payload that can be sent from the server to the client to synchronize
--- the states of the client's atoms with the server.
--[[
A payload that can be sent from the server to the client to synchronize
the states of the client's atoms with the server.
]]
export type SyncPayload =
| {
type: "init",
@ -10,8 +12,10 @@ export type SyncPayload =
data: { [string]: any },
}
--- A special type that represents a removed value in a patch. This is used to
--- differentiate between a value that was set to `nil` and an unchanged value.
--[[
A special type that represents a removed value in a patch. This is used to
differentiate between a value that was set to `nil` and an unchanged value.
]]
export type None = { __none: "__none" }
return {}

View file

@ -10,11 +10,13 @@ type FlattenedTable = {
[any]: any,
}
--- Flattens a nested table into a single table with slash-separated keys.
--- Useful for recursively collecting atoms returned by modules.
---
--- @param input A nested table.
--- @return A flattened table 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 input A nested table.
@return A flattened table with slash-separated keys.
]]
local function flatten(input: NestedTable): FlattenedTable
local result: FlattenedTable = {}
@ -35,20 +37,36 @@ local function flatten(input: NestedTable): FlattenedTable
return result
end
--- Converts a signal getter and setter into an atom. This is useful for
--- syncing client signals with the server, as the sync system requires both
--- the getter and setter to update state.
---
--- @param getter A function that returns the current value of the signal.
--- @param setter A function that sets the value of the signal.
--- @return An atom that can either get or set the signal's value.
--[[
Converts a signal getter and setter into an atom. This is useful for
syncing client signals with the server, as the sync system requires both
the getter and setter to update state.
@param getter A function that returns the current value of the signal.
@param setter A function that sets the value of the signal.
@return An atom that can either get or set the signal's value.
]]
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
--[[
Returns a deep copy of the given table.
]]
local function deepCopy<T>(object: T): T
local copy: any = table.clone(object :: any)
for key, value in copy do
if type(value) == "table" then
copy[key] = deepCopy(value)
end
end
return copy
end
return {
flatten = flatten,
signalToAtom = signalToAtom,
deepCopy = deepCopy,
}

View file

@ -25,25 +25,23 @@ local function isTableUnsafe(object: { [any]: any })
return false
end
--- Throws an error if the value is a table that cannot be synced via
--- RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
--- with remote argument limitations, such as sparse arrays or mixed tables.
---
--- @param value The table value.
--- @param key The key of the value.
--- @error Throws an error if the value cannot be synced.
--[[
Emits a warning if the value is a table that cannot be synced via
RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
with remote argument limitations, such as sparse arrays or mixed tables.
@param value The table value.
@param key The key of the value.
]]
local function validate(value: { [any]: any })
if isTableUnsafe(value) then
-- Silence warnings during testing
if game then
warn("Cannot sync table:", value)
end
error(
`Cannot sync tables unsupported by remote events! See the warning above for more info.\n\n`
warn(
`Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`
.. "This can be for the following reasons:\n"
.. "1. The table is a sparse array with non-sequential indices\n"
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
value
)
end
end