mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: add reactive()
This commit is contained in:
parent
7bad215560
commit
bd23d54374
26 changed files with 1129 additions and 569 deletions
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,4 @@ suite.describe("server", function()
|
|||
require("./server.spec")
|
||||
end)
|
||||
|
||||
suite.describe("validate", function()
|
||||
require("./validate.spec")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
33
packages/charm-sync/src/index.d.ts
vendored
33
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,26 +7,32 @@ local effect = Charm.effect
|
|||
local effectScope = Charm.effectScope
|
||||
local test = suite.test
|
||||
|
||||
--- Creates a weak reference to a value.
|
||||
--[[
|
||||
Creates a weak reference to a value.
|
||||
]]
|
||||
local function weakRef<T>(value: T): { value: T? }
|
||||
return setmetatable({ value = value }, { __mode = "v" }) :: any
|
||||
end
|
||||
|
||||
--- Triggers garbage collection by creating a large number of tables.
|
||||
--[[
|
||||
Triggers garbage collection by creating a large number of tables.
|
||||
]]
|
||||
local function gc()
|
||||
for _ = 1, 2000 do
|
||||
local _ = table.create(100, 0)
|
||||
end
|
||||
end
|
||||
|
||||
--- In alien-signals, a computed value unlinks from dependencies when its last
|
||||
--- parent scope is disposed. However, calling a computed value outside of a
|
||||
--- scope still links it to dependencies, which act as strong references.
|
||||
--- Computed values called in this way won't GC until unlinked from a scope.
|
||||
---
|
||||
--- This function releases a computed value by linking it to a scope and then
|
||||
--- immediately closing it, causing alien-signals to unlink the computed value
|
||||
--- from its dependencies and allow it to be garbage collected.
|
||||
--[[
|
||||
In alien-signals, a computed value unlinks from dependencies when its last
|
||||
parent scope is disposed. However, calling a computed value outside of a
|
||||
scope still links it to dependencies, which act as strong references.
|
||||
Computed values called in this way won't GC until unlinked from a scope.
|
||||
|
||||
This function releases a computed value by linking it to a scope and then
|
||||
immediately closing it, causing alien-signals to unlink the computed value
|
||||
from its dependencies and allow it to be garbage collected.
|
||||
]]
|
||||
local function unlink(getter: () -> ())
|
||||
effectScope(function()
|
||||
getter()
|
||||
|
|
|
|||
124
packages/charm/src/__tests__/reactive.spec.luau
Normal file
124
packages/charm/src/__tests__/reactive.spec.luau
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
local Charm = require("../")
|
||||
local suite = require("../../../../test/suite")
|
||||
|
||||
local effect = Charm.effect
|
||||
local reactive = Charm.reactive
|
||||
local test = suite.test
|
||||
|
||||
test("should track nested properties", function()
|
||||
local src, setSrc = reactive({ a = { b = { c = 0 } } })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, `{src.a.b.c}`)
|
||||
end)
|
||||
|
||||
setSrc(function(state)
|
||||
state.a.b.c = 1
|
||||
end)
|
||||
src.a.b.c = 2
|
||||
src.a.b.c = 2
|
||||
setSrc()
|
||||
assert(table.concat(calls) == "012", table.concat(calls))
|
||||
end)
|
||||
|
||||
test("should track object type changes", function()
|
||||
local src = reactive({ a = { b = 0 } } :: { a: any })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, tostring(if type(src.a) == "table" then src.a.b else src.a))
|
||||
end)
|
||||
|
||||
src.a = { b = 0 }
|
||||
src.a = 1
|
||||
src.a = { b = 2 }
|
||||
src.a = nil
|
||||
src.a = 3
|
||||
assert(table.concat(calls) == "0012nil3", table.concat(calls))
|
||||
end)
|
||||
|
||||
test("should unwrap proxy assignments", function()
|
||||
local src = reactive({ a = { x = 0 }, b = { x = 0 } })
|
||||
local runs = 0
|
||||
|
||||
effect(function()
|
||||
local _, _ = src.a.x, src.b.x
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
src.a, src.b = src.a, src.b
|
||||
assert(runs == 1, runs)
|
||||
|
||||
src.a, src.b = src.b, src.a
|
||||
assert(runs == 3, runs)
|
||||
end)
|
||||
|
||||
test("should track iteration", function()
|
||||
local src, setSrc = reactive({} :: { [string]: number })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
for key, value in src do
|
||||
table.insert(calls, `{key}={value};`)
|
||||
end
|
||||
end)
|
||||
|
||||
src.a = 1
|
||||
assert(table.concat(calls) == "a=1;", table.concat(calls))
|
||||
|
||||
table.clear(calls)
|
||||
src.b = 2
|
||||
assert(table.concat(calls) == "a=1;b=2;", table.concat(calls))
|
||||
|
||||
table.clear(calls)
|
||||
src.a = 2
|
||||
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
|
||||
|
||||
table.clear(calls)
|
||||
setSrc()
|
||||
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
|
||||
end)
|
||||
|
||||
test("should track array length", function()
|
||||
local src = reactive({ array = {} :: { number } })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, #src.array)
|
||||
end)
|
||||
|
||||
src.array[#src.array + 1] = 1
|
||||
src.array[#src.array + 1] = 2
|
||||
src.array[#src.array + 1] = 3
|
||||
src.array = { 1, 2, 3, 4 }
|
||||
assert(table.concat(calls) == "01234", table.concat(calls))
|
||||
end)
|
||||
|
||||
test("should not track unread properties", function()
|
||||
local src = reactive({ value = { a = 1, b = 2 } })
|
||||
local calls = 0
|
||||
|
||||
effect(function()
|
||||
local _ = src.value.a
|
||||
calls += 1
|
||||
end)
|
||||
assert(calls == 1, calls)
|
||||
|
||||
src.value.b = 3
|
||||
assert(calls == 1, calls)
|
||||
end)
|
||||
|
||||
test("should overwrite with table passed to setter", function()
|
||||
local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, `{src.a}{src.b}{src.c}`)
|
||||
end)
|
||||
|
||||
setSrc({ a = 2, c = 4 })
|
||||
assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
@ -24,6 +24,9 @@ end)
|
|||
suite.describe("peek", function()
|
||||
require("./peek.spec")
|
||||
end)
|
||||
suite.describe("reactive", function()
|
||||
require("./reactive.spec")
|
||||
end)
|
||||
suite.describe("signal", function()
|
||||
require("./signal.spec")
|
||||
end)
|
||||
|
|
|
|||
30
packages/charm/src/index.d.ts
vendored
30
packages/charm/src/index.d.ts
vendored
|
|
@ -280,6 +280,36 @@ export function observe<V, K extends Key>(
|
|||
*/
|
||||
export function recursive(): void;
|
||||
|
||||
/**
|
||||
* Creates a deep reactive value that tracks properties when they're accessed.
|
||||
* Returns a proxy object that subscribes to changes on any nested property
|
||||
* you read, and a function for mutating the original table.
|
||||
*
|
||||
* @example
|
||||
* ```luau
|
||||
* local users, setUsers = reactive({
|
||||
* user = { name = "John", surname = "Doe" },
|
||||
* })
|
||||
*
|
||||
* effect(function()
|
||||
* print(users.user and `{users.user.name} {users.user.surname}`)
|
||||
* end)
|
||||
*
|
||||
* setUsers(function(state)
|
||||
* state.user.name = "Jane"
|
||||
* state.user.surname = "Smith"
|
||||
* end)
|
||||
* ```
|
||||
*
|
||||
* @param initialValue The value to make deeply reactive.
|
||||
* @return A proxy table that tracks reads to nested properties.
|
||||
* @return A function for updating the original table.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||
*/
|
||||
export function reactive<T extends object>(
|
||||
initialValue: T,
|
||||
): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>;
|
||||
|
||||
export function getActiveSub(): ReactiveNode | undefined;
|
||||
|
||||
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,3 @@
|
|||
--[[
|
||||
Reactive system adapted from alien-signals at
|
||||
https://github.com/stackblitz/alien-signals/blob/master/src/index.ts
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Johnson Chu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
]]
|
||||
|
||||
local system = require("@self/system")
|
||||
|
||||
export type ReactiveFlags = system.ReactiveFlags
|
||||
|
|
@ -33,6 +6,13 @@ export type Link = system.Link
|
|||
|
||||
export type Atom<T = any> = (...((T) -> T) | T) -> T
|
||||
|
||||
type Target = { [any]: any }
|
||||
|
||||
type Proxy = {
|
||||
__target: Target,
|
||||
__signals: { [any]: () -> any },
|
||||
}
|
||||
|
||||
type EffectScopeNode = ReactiveNode & {
|
||||
cleanups: { () -> () },
|
||||
}
|
||||
|
|
@ -65,16 +45,22 @@ local function inlineTest(): string
|
|||
end
|
||||
|
||||
local flags = {
|
||||
--- Enforces synchronous, non-yielding behavior in signals and effects.
|
||||
--- Also enables state validation in Charm Sync to catch sync errors early.
|
||||
--- Enabled if the optimization level is below 2, which is true in studio.
|
||||
--[[
|
||||
Enforces synchronous, non-yielding behavior in signals and effects.
|
||||
Also enables state validation in Charm Sync to catch sync errors early.
|
||||
Enabled if the optimization level is below 2, which is true in studio.
|
||||
]]
|
||||
strict = inlineTest() == "inlineTest",
|
||||
--- Enforces immutability of tables by deep-freezing table values. Enabled
|
||||
--- if the optimization level is below 2, which is true in studio.
|
||||
--[[
|
||||
Enforces immutability of tables by deep-freezing table values. Enabled
|
||||
if the optimization level is below 2, which is true in studio.
|
||||
]]
|
||||
frozen = inlineTest() == "inlineTest",
|
||||
--- Whether parent effects should track inner effects and clean them up
|
||||
--- when the parent effect re-runs. This is usually desirable, but can be
|
||||
--- disabled to revert to the old behavior. Defaults to `true`.
|
||||
--[[
|
||||
Whether parent effects should track inner effects and clean them up
|
||||
when the parent effect re-runs. This is usually desirable, but can be
|
||||
disabled to revert to the old behavior. Defaults to `true`.
|
||||
]]
|
||||
trackInnerEffects = true,
|
||||
}
|
||||
|
||||
|
|
@ -142,10 +128,10 @@ local function runCleanups(effect: EffectScopeNode)
|
|||
local prevSub = setActiveSub(nil)
|
||||
local success, result: any = pcall(cleanup)
|
||||
activeSub = prevSub
|
||||
cleanups[key] = nil
|
||||
if not success then
|
||||
warn("An error occurred during effect cleanup:", result)
|
||||
end
|
||||
cleanups[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -164,7 +150,7 @@ local function run(e: EffectNode): ()
|
|||
if not success then
|
||||
error(result)
|
||||
elseif result then
|
||||
table.insert(e.cleanups, result)
|
||||
table.insert(e.cleanups, noYield(result))
|
||||
end
|
||||
else
|
||||
e.flags = WATCHING
|
||||
|
|
@ -207,9 +193,6 @@ local function updateComputed(c: ComputedNode): boolean
|
|||
error(result)
|
||||
end
|
||||
if oldValue ~= result then
|
||||
if flags.frozen then
|
||||
deepFreeze(result)
|
||||
end
|
||||
c.value = result
|
||||
return true
|
||||
else
|
||||
|
|
@ -337,13 +320,16 @@ local function effectOper(this: EffectScopeNode): () -> ()
|
|||
end
|
||||
end
|
||||
|
||||
--- Creates a reactive signal that stores a value and notifies subscribers when
|
||||
--- the value changes. The signal provides both a getter and a setter.
|
||||
---
|
||||
--- @param initialValue The initial value of the signal.
|
||||
--- @param equals A comparator function to determine if the signal's value has changed.
|
||||
--- @return A tuple containing the getter and setter functions for the signal.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
|
||||
--[[
|
||||
Creates a reactive signal that stores a value and notifies subscribers when
|
||||
the value changes. The signal provides both a getter and a setter.
|
||||
|
||||
@param initialValue The initial value of the signal.
|
||||
@param equals A comparator function to determine if the signal's value has changed.
|
||||
@return A function to get the current value of the signal.
|
||||
@return A function to set a new value and notify subscribers.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
|
||||
]]
|
||||
local function signal<T>(
|
||||
initialValue: T,
|
||||
equals: ((current: T, incoming: T) -> boolean)?
|
||||
|
|
@ -355,16 +341,18 @@ local function signal<T>(
|
|||
currentValue = initialValue,
|
||||
pendingValue = initialValue,
|
||||
flags = MUTABLE,
|
||||
}, equals and noYield(equals))
|
||||
}, equals)
|
||||
end
|
||||
|
||||
--- Creates a reactive atom that acts as both a signal getter and setter.
|
||||
--- Calling the atom with an argument sets its value, while calling it with no
|
||||
--- arguments returns its current value.
|
||||
---
|
||||
--- @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`.
|
||||
--- @param equals A comparator function to determine if the atom's value has changed.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
|
||||
--[[
|
||||
Creates a reactive atom that acts as both a signal getter and setter.
|
||||
Calling the atom with an argument sets its value, while calling it with no
|
||||
arguments returns its current value.
|
||||
|
||||
@param initialValue The initial value of the atom. If empty, the atom's value will be `nil`.
|
||||
@param equals A comparator function to determine if the atom's value has changed.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
|
||||
]]
|
||||
local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom<T>
|
||||
local getter, setter = signal(initialValue, equals)
|
||||
local function atomOper(...)
|
||||
|
|
@ -373,12 +361,14 @@ local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> bo
|
|||
return atomOper
|
||||
end
|
||||
|
||||
--- Creates a new read-only signal that is computed based on the values of
|
||||
--- other signals. The computed signal's value is automatically updated when a
|
||||
--- change is detected in any of its dependencies.
|
||||
---
|
||||
--- @param getter A function that produces the next value.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
|
||||
--[[
|
||||
Creates a new read-only signal that is computed based on the values of
|
||||
other signals. The computed signal's value is automatically updated when a
|
||||
change is detected in any of its dependencies.
|
||||
|
||||
@param getter A function that produces the next value.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
|
||||
]]
|
||||
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
|
||||
return computedOper({
|
||||
flags = NONE,
|
||||
|
|
@ -386,19 +376,21 @@ local function computed<T>(getter: (previousValue: T?) -> T): () -> T
|
|||
})
|
||||
end
|
||||
|
||||
--- Creates an effect that runs a callback in response to signal state changes.
|
||||
--- An effect tracks which signals are accessed during its execution, and runs
|
||||
--- the callback when those signals change.
|
||||
---
|
||||
--- The effect callback may return a cleanup function, which gets called once,
|
||||
--- either before the effect re-runs or when it is disposed.
|
||||
---
|
||||
--- If the effect is called within another effect, it will be disposed when the
|
||||
--- parent effect re-runs.
|
||||
---
|
||||
--- @param fn The function to run when dependencies change.
|
||||
--- @return A function for disposing the effect.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
|
||||
--[[
|
||||
Creates an effect that runs a callback in response to signal state changes.
|
||||
An effect tracks which signals are accessed during its execution, and runs
|
||||
the callback when those signals change.
|
||||
|
||||
The effect callback may return a cleanup function, which gets called once,
|
||||
either before the effect re-runs or when it is disposed.
|
||||
|
||||
If the effect is called within another effect, it will be disposed when the
|
||||
parent effect re-runs.
|
||||
|
||||
@param fn The function to run when dependencies change.
|
||||
@return A function for disposing the effect.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
|
||||
]]
|
||||
local function effect(fn: () -> ...(() -> ())?): () -> ()
|
||||
local e: EffectNode = {
|
||||
fn = noYield(fn),
|
||||
|
|
@ -420,13 +412,15 @@ local function effect(fn: () -> ...(() -> ())?): () -> ()
|
|||
return effectOper(e)
|
||||
end
|
||||
|
||||
--- Creates an effect scope that can capture reactive effects and computed
|
||||
--- signals created within it so that they can be disposed together.
|
||||
---
|
||||
--- @param fn A function that may create reactive effects.
|
||||
--- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
|
||||
--- @return A function for disposing all effects created within the scope.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
|
||||
--[[
|
||||
Creates an effect scope that can capture reactive effects and computed
|
||||
signals created within it so that they can be disposed together.
|
||||
|
||||
@param fn A function that may create reactive effects.
|
||||
@param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
|
||||
@return A function for disposing all effects created within the scope.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
|
||||
]]
|
||||
local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> ()
|
||||
local e: EffectScopeNode = {
|
||||
flags = NONE,
|
||||
|
|
@ -446,11 +440,13 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () ->
|
|||
return effectOper(e)
|
||||
end
|
||||
|
||||
--- Allows you to manually trigger updates for downstream dependencies when
|
||||
--- you've directly mutated a signal's value without using the signal setter.
|
||||
---
|
||||
--- @param fn The dependency or a function that calls multiple dependencies.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
|
||||
--[[
|
||||
Allows you to manually trigger updates for downstream dependencies when
|
||||
you've directly mutated a signal's value without using the signal setter.
|
||||
|
||||
@param fn The dependency or a function that calls multiple dependencies.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
|
||||
]]
|
||||
local function trigger(fn: () -> ())
|
||||
local sub: ReactiveNode = {
|
||||
flags = WATCHING,
|
||||
|
|
@ -475,13 +471,15 @@ local function trigger(fn: () -> ())
|
|||
end
|
||||
end
|
||||
|
||||
--- Binds the cleanup function to the current active effect or effect scope. If
|
||||
--- there is no active effect, a warning is logged, unless `failSilently` is
|
||||
--- set to `true`.
|
||||
---
|
||||
--- @param fn The cleanup function to register.
|
||||
--- @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
|
||||
--[[
|
||||
Binds the cleanup function to the current active effect or effect scope. If
|
||||
there is no active effect, a warning is logged, unless `failSilently` is
|
||||
set to `true`.
|
||||
|
||||
@param fn The cleanup function to register.
|
||||
@param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
|
||||
]]
|
||||
local function onCleanup(fn: () -> (), failSilently: boolean?)
|
||||
if activeSub and (activeSub :: EffectNode).cleanups then
|
||||
table.insert((activeSub :: EffectNode).cleanups, noYield(fn))
|
||||
|
|
@ -490,15 +488,17 @@ local function onCleanup(fn: () -> (), failSilently: boolean?)
|
|||
end
|
||||
end
|
||||
|
||||
--- Runs the function without subscribing to signal updates or capturing inner
|
||||
--- effects in the parent effect or scope.
|
||||
---
|
||||
--- For an alternative that still cleans up inner effects, use `peek` instead.
|
||||
---
|
||||
--- @param fn A function that may read signals or create effects.
|
||||
--- @param ...args Arguments to pass to the function.
|
||||
--- @return The return value of the function.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
|
||||
--[[
|
||||
Runs the function without subscribing to signal updates or capturing inner
|
||||
effects in the parent effect or scope.
|
||||
|
||||
For an alternative that still cleans up inner effects, use `peek` instead.
|
||||
|
||||
@param fn A function that may read signals or create effects.
|
||||
@param ...args Arguments to pass to the function.
|
||||
@return The return value of the function.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
|
||||
]]
|
||||
local function untracked<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
|
||||
if activeSub then
|
||||
local prevSub = setActiveSub(nil)
|
||||
|
|
@ -513,15 +513,17 @@ local function untracked<Args..., Results...>(fn: (Args...) -> Results..., ...:
|
|||
end
|
||||
end
|
||||
|
||||
--- Runs the function without subscribing to signal updates, but still captures
|
||||
--- inner effects so that they dispose with the parent effect.
|
||||
---
|
||||
--- To avoid capturing inner effects, use `untracked` instead.
|
||||
---
|
||||
--- @param fn A function that may read signals.
|
||||
--- @param ...args Arguments to pass to the function.
|
||||
--- @return The return value of the function.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
|
||||
--[[
|
||||
Runs the function without subscribing to signal updates, but still captures
|
||||
inner effects so that they dispose with the parent effect.
|
||||
|
||||
To avoid capturing inner effects, use `untracked` instead.
|
||||
|
||||
@param fn A function that may read signals.
|
||||
@param ...args Arguments to pass to the function.
|
||||
@return The return value of the function.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
|
||||
]]
|
||||
local function peek<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
|
||||
if
|
||||
activeSub
|
||||
|
|
@ -540,13 +542,15 @@ local function peek<Args..., Results...>(fn: (Args...) -> Results..., ...: Args.
|
|||
end
|
||||
end
|
||||
|
||||
--- Combines multiple updates into a single "commit" that runs effects and
|
||||
--- computed signals after the provided callback finishes running.
|
||||
---
|
||||
--- @param fn A function that performs multiple updates.
|
||||
--- @param ...args Arguments to pass to the function.
|
||||
--- @return The return value of the function.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
|
||||
--[[
|
||||
Combines multiple updates into a single "commit" that runs effects and
|
||||
computed signals after the provided callback finishes running.
|
||||
|
||||
@param fn A function that performs multiple updates.
|
||||
@param ...args Arguments to pass to the function.
|
||||
@return The return value of the function.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
|
||||
]]
|
||||
local function batch<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
|
||||
startBatch()
|
||||
local results: { any } = { pcall(noYield(fn), ...) }
|
||||
|
|
@ -557,28 +561,32 @@ local function batch<Args..., Results...>(fn: (Args...) -> Results..., ...: Args
|
|||
return unpack(results, 2)
|
||||
end
|
||||
|
||||
--- Converts a non-signal into a computed signal. If the function is already a
|
||||
--- signal, it is returned as-is. Useful for memoizing a getter function
|
||||
--- without creating unnecessary nested signals.
|
||||
---
|
||||
--- @param fn A function that returns the value to track.
|
||||
--- @return A computed signal that tracks the value returned by the getter.
|
||||
--[[
|
||||
Converts a non-signal into a computed signal. If the function is already a
|
||||
signal, it is returned as-is. Useful for memoizing a getter function
|
||||
without creating unnecessary nested signals.
|
||||
|
||||
@param fn A function that returns the value to track.
|
||||
@return A computed signal that tracks the value returned by the getter.
|
||||
]]
|
||||
local function toSignal<T>(fn: () -> T): () -> T
|
||||
local name = debug.info(fn, "n")
|
||||
return if name == "signalGetter" or name == "computedGetter" or name == "atomOper" then fn else computed(fn)
|
||||
end
|
||||
|
||||
--- Creates an effect that runs the callback one immediately, and then again
|
||||
--- when the value returned by the getter changes. The callback receives the new
|
||||
--- value and the previous value as arguments.
|
||||
---
|
||||
--- Effects created within the callback are cleaned up when the listener runs
|
||||
--- or the listener is disposed.
|
||||
---
|
||||
--- @param getter A function that returns the value to watch.
|
||||
--- @param fn The function to run when the value changes.
|
||||
--- @return A function for disposing the effect.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
||||
--[[
|
||||
Creates an effect that runs the callback one immediately, and then again
|
||||
when the value returned by the getter changes. The callback receives the new
|
||||
value and the previous value as arguments.
|
||||
|
||||
Effects created within the callback are cleaned up when the listener runs
|
||||
or the listener is disposed.
|
||||
|
||||
@param getter A function that returns the value to watch.
|
||||
@param fn The function to run when the value changes.
|
||||
@return A function for disposing the effect.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
||||
]]
|
||||
local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> ()
|
||||
local tracked = toSignal(getter)
|
||||
local current: T
|
||||
|
|
@ -589,17 +597,19 @@ local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): (
|
|||
end)
|
||||
end
|
||||
|
||||
--- Creates an effect that only runs the callback when the value returned by the
|
||||
--- getter changes. The callback receives the new value and the previous value
|
||||
--- as arguments.
|
||||
---
|
||||
--- Effects created within the callback are cleaned up when the listener runs
|
||||
--- or the listener is disposed.
|
||||
---
|
||||
--- @param getter A function that returns the value to watch.
|
||||
--- @param fn The function to run when the value changes.
|
||||
--- @return A function for disposing the effect.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
||||
--[[
|
||||
Creates an effect that only runs the callback when the value returned by the
|
||||
getter changes. The callback receives the new value and the previous value
|
||||
as arguments.
|
||||
|
||||
Effects created within the callback are cleaned up when the listener runs
|
||||
or the listener is disposed.
|
||||
|
||||
@param getter A function that returns the value to watch.
|
||||
@param fn The function to run when the value changes.
|
||||
@return A function for disposing the effect.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
||||
]]
|
||||
local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> ()
|
||||
local tracked = toSignal(getter)
|
||||
local current: T
|
||||
|
|
@ -615,16 +625,18 @@ local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ())
|
|||
end)
|
||||
end
|
||||
|
||||
--- Disables recursive checks for the current effect or computed signal. Useful
|
||||
--- for effects that intentionally update signals they depend on.
|
||||
---
|
||||
--- @example
|
||||
--- ```luau
|
||||
--- effect(function()
|
||||
--- recursive()
|
||||
--- setCount(getCount() + 1)
|
||||
--- end)
|
||||
--- ```
|
||||
--[[
|
||||
Disables recursive checks for the current effect or computed signal. Useful
|
||||
for effects that intentionally update signals they depend on.
|
||||
|
||||
@example
|
||||
```luau
|
||||
effect(function()
|
||||
recursive()
|
||||
setCount(getCount() + 1)
|
||||
end)
|
||||
```
|
||||
]]
|
||||
local function recursive()
|
||||
if activeSub then
|
||||
activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK))
|
||||
|
|
@ -633,18 +645,20 @@ local function recursive()
|
|||
end
|
||||
end
|
||||
|
||||
--- Calls the callback function for each unique key in the table returned by the
|
||||
--- getter. The callback is called once for each key, and again when a new key
|
||||
--- is added.
|
||||
---
|
||||
--- The callback may return a cleanup function, which will be called when the
|
||||
--- key is removed or the `observe()` effect is disposed. Effects created
|
||||
--- during the callback will also be bound to the lifetime of the key.
|
||||
---
|
||||
--- @param getter A function that returns the table to observe.
|
||||
--- @param fn A function that is called for each key in the table.
|
||||
--- @return A function for disposing the observer.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
||||
--[[
|
||||
Calls the callback function for each unique key in the table returned by the
|
||||
getter. The callback is called once for each key, and again when a new key
|
||||
is added.
|
||||
|
||||
The callback may return a cleanup function, which will be called when the
|
||||
key is removed or the `observe()` effect is disposed. Effects created
|
||||
during the callback will also be bound to the lifetime of the key.
|
||||
|
||||
@param getter A function that returns the table to observe.
|
||||
@param fn A function that is called for each key in the table.
|
||||
@return A function for disposing the observer.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
||||
]]
|
||||
local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> ()
|
||||
local innerScopes: { [K]: () -> () } = {}
|
||||
local disposed = false
|
||||
|
|
@ -659,31 +673,19 @@ local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) ->
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in data do
|
||||
if innerScopes[key] == nil then
|
||||
local disposeScope = effectScope(function()
|
||||
return fn(value, key)
|
||||
end, true)
|
||||
if disposed then
|
||||
disposeScope()
|
||||
return
|
||||
return disposeScope()
|
||||
end
|
||||
innerScopes[key] = disposeScope
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function disposeScopes()
|
||||
if not disposed then
|
||||
disposed = true
|
||||
for key, dispose in innerScopes do
|
||||
innerScopes[key] = nil
|
||||
dispose()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return effectScope(function()
|
||||
effect(function()
|
||||
-- Allow recursive updates in case the callback creates or removes
|
||||
|
|
@ -694,18 +696,28 @@ local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) ->
|
|||
-- interfere with the current update cycle.
|
||||
batch(updateScopes, getter())
|
||||
end)
|
||||
|
||||
return disposeScopes
|
||||
return function()
|
||||
if disposed then
|
||||
return
|
||||
end
|
||||
disposed = true
|
||||
for key, dispose in innerScopes do
|
||||
innerScopes[key] = nil
|
||||
dispose()
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Creates a new read-only signal that is computed by mapping over the entries
|
||||
--- of an existing atom. If the key is omitted from the mapper's return value,
|
||||
--- the original key is preserved.
|
||||
---
|
||||
--- @param getter Returns the table to map over.
|
||||
--- @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table.
|
||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
|
||||
--[[
|
||||
Creates a new read-only signal that is computed by mapping over the entries
|
||||
of an existing atom. If the key is omitted from the mapper's return value,
|
||||
the original key is preserved.
|
||||
|
||||
@param getter Returns the table to map over.
|
||||
@param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
|
||||
]]
|
||||
local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO }
|
||||
local currentInput: { [KI]: VI } = {}
|
||||
local keyCache: { [KI]: KO } = {}
|
||||
|
|
@ -761,6 +773,111 @@ local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item:
|
|||
end)
|
||||
end
|
||||
|
||||
--[[
|
||||
Creates a deep reactive value that tracks properties when they're accessed.
|
||||
Returns a proxy object that subscribes to changes on any nested property
|
||||
you read, and a function for mutating the original table.
|
||||
|
||||
@example
|
||||
```luau
|
||||
local users, setUsers = reactive({
|
||||
user = { name = "John", surname = "Doe" },
|
||||
})
|
||||
|
||||
effect(function()
|
||||
print(users.user and `{users.user.name} {users.user.surname}`)
|
||||
end)
|
||||
|
||||
setUsers(function(state)
|
||||
state.user.name = "Jane"
|
||||
state.user.surname = "Smith"
|
||||
end)
|
||||
```
|
||||
|
||||
@param initialValue The value to make deeply reactive.
|
||||
@return A proxy table that tracks reads to nested properties.
|
||||
@return A function for updating the original table.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||
]]
|
||||
local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ())
|
||||
local track = signal(nil)
|
||||
|
||||
local function setValue(update: ((T) -> ()) | T?)
|
||||
if type(update) == "function" then
|
||||
update(initialValue)
|
||||
elseif type(update) == "table" then
|
||||
table.clear(initialValue)
|
||||
for key, value in update do
|
||||
(initialValue :: Target)[key] = value
|
||||
end
|
||||
end
|
||||
trigger(track)
|
||||
end
|
||||
|
||||
local proxyMeta = {
|
||||
initialValue = initialValue,
|
||||
track = track,
|
||||
setValue = setValue,
|
||||
}
|
||||
|
||||
local proxyMap = setmetatable({} :: { [Target]: any }, { __mode = "k" })
|
||||
|
||||
local function trackProperty(cache: { [any]: () -> any }, target: Target, key: any)
|
||||
if not cache[key] then
|
||||
cache[key] = untracked(computed, function()
|
||||
track()
|
||||
return target[key]
|
||||
end)
|
||||
end
|
||||
cache[key]()
|
||||
end
|
||||
|
||||
local function createProxy(target: Target): Proxy
|
||||
if not proxyMap[target] then
|
||||
proxyMap[target] = setmetatable({
|
||||
__target = target,
|
||||
__signals = setmetatable({}, { __mode = "v" }),
|
||||
}, proxyMeta)
|
||||
end
|
||||
return proxyMap[target]
|
||||
end
|
||||
|
||||
local function nextProxy(target: Target, index: any)
|
||||
local nextKey, nextValue = next(target, index)
|
||||
return nextKey, if type(nextValue) == "table" then createProxy(nextValue) else nextValue
|
||||
end
|
||||
|
||||
function proxyMeta.__index(self: Proxy, key: any): any
|
||||
if activeSub then
|
||||
trackProperty(self.__signals, self.__target, key)
|
||||
end
|
||||
local value = self.__target[key]
|
||||
return if type(value) == "table" then createProxy(value) else value
|
||||
end
|
||||
|
||||
function proxyMeta.__newindex(self: Proxy, key: any, value: any)
|
||||
if type(value) == "table" and getmetatable(value) == proxyMeta then
|
||||
value = value.__target
|
||||
end
|
||||
if self.__target[key] ~= value then
|
||||
self.__target[key] = value
|
||||
trigger(track)
|
||||
end
|
||||
end
|
||||
|
||||
function proxyMeta.__iter(self: Proxy)
|
||||
track()
|
||||
return nextProxy, self.__target
|
||||
end
|
||||
|
||||
function proxyMeta.__len(self: Proxy)
|
||||
track()
|
||||
return #self.__target
|
||||
end
|
||||
|
||||
return createProxy(initialValue) :: any, setValue
|
||||
end
|
||||
|
||||
local reactiveSystem = system.createReactiveSystem({
|
||||
update = function(node)
|
||||
if node.depsTail then
|
||||
|
|
@ -814,12 +931,10 @@ shallowPropagate = reactiveSystem.shallowPropagate
|
|||
return {
|
||||
ReactiveFlags = system.ReactiveFlags,
|
||||
flags = flags,
|
||||
|
||||
getActiveSub = getActiveSub,
|
||||
setActiveSub = setActiveSub,
|
||||
startBatch = startBatch,
|
||||
endBatch = endBatch,
|
||||
|
||||
signal = signal,
|
||||
computed = computed,
|
||||
effect = effect,
|
||||
|
|
@ -838,4 +953,5 @@ return {
|
|||
getter: () -> { [KI]: VI },
|
||||
mapper: (item: VI, key: KI) -> (VO, KO)
|
||||
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
|
||||
reactive = reactive,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
local Charm = require("./Charm")
|
||||
local React = require("./React")
|
||||
|
||||
--- Returns a React state that updates when the getter function updates.
|
||||
--- Re-renders the component when the value changes.
|
||||
---
|
||||
--- If the `dependencies` array is provided, a new effect is created for the
|
||||
--- current `getter` when the dependencies change. Otherwise, the effect is
|
||||
--- created only once, when the component is mounted.
|
||||
---
|
||||
--- @param getter The signal or selector to watch.
|
||||
--- @param dependencies An array of values used to memoize the getter.
|
||||
--- @return The current state.
|
||||
--[[
|
||||
Returns a React state that updates when the getter function updates.
|
||||
Re-renders the component when the value changes.
|
||||
|
||||
If the `dependencies` array is provided, a new effect is created for the
|
||||
current `getter` when the dependencies change. Otherwise, the effect is
|
||||
created only once, when the component is mounted.
|
||||
|
||||
@param getter The signal or selector to watch.
|
||||
@param dependencies An array of values used to memoize the getter.
|
||||
@return The current state.
|
||||
]]
|
||||
local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T
|
||||
local value, setValue = React.useState(getter)
|
||||
|
||||
|
|
@ -24,15 +26,17 @@ local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T
|
|||
return value
|
||||
end
|
||||
|
||||
--- Returns a React binding that updates when the getter function updates.
|
||||
---
|
||||
--- If the `dependencies` array is provided, a new effect is created for the
|
||||
--- current `getter` when the dependencies change. Otherwise, the effect is
|
||||
--- created only once, when the component is mounted.
|
||||
---
|
||||
--- @param getter The atom or selector to watch.
|
||||
--- @param dependencies An array of values used to memoize the getter.
|
||||
--- @return A binding that stores the current state.
|
||||
--[[
|
||||
Returns a React binding that updates when the getter function updates.
|
||||
|
||||
If the `dependencies` array is provided, a new effect is created for the
|
||||
current `getter` when the dependencies change. Otherwise, the effect is
|
||||
created only once, when the component is mounted.
|
||||
|
||||
@param getter The atom or selector to watch.
|
||||
@param dependencies An array of values used to memoize the getter.
|
||||
@return A binding that stores the current state.
|
||||
]]
|
||||
local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?)
|
||||
local initialValue = React.useMemo(getter, {})
|
||||
local binding, setBinding = React.useBinding(initialValue)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
local Charm = require("./Charm")
|
||||
local Vide = require("./Vide")
|
||||
|
||||
--- Returns a reactive source that subscribes to the current value of the given
|
||||
--- signal or getter function.
|
||||
---
|
||||
--- @param getter The getter function to subscribe to.
|
||||
--- @return The reactive source.
|
||||
--[[
|
||||
Returns a reactive source that subscribes to the current value of the given
|
||||
signal or getter function.
|
||||
|
||||
@param getter The getter function to subscribe to.
|
||||
@return The reactive source.
|
||||
]]
|
||||
local function useSignalState<State>(getter: () -> State): () -> State
|
||||
local state = Vide.source(getter())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue