diff --git a/README.md b/README.md index e6172b6..a550946 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,9 @@ - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`recursive()`](#recursive) - - [`trigger(callback)`](#triggercallback) - - [`flags`](#flags) + - [`trigger(callback)`](#triggercallback) + - [`reactive(initialValue)`](#reactiveinitialvalue) + - [`flags`](#flags) - [Client-Server Sync](#client-server-sync) - [Installation](#installation-1) - [Quick Start](#quick-start) @@ -601,6 +602,44 @@ print(total()) -- 2 --- +### `reactive(initialValue)` + +The `reactive()` function creates a deeply reactive value that tracks properties when they're accessed. It returns a setter function where you can make changes by mutating the original table. + +```luau +local users, setUsers = reactive({ + user = { name = "John", surname = "Doe" }, +}) + +-- Output: John Doe +effect(function() + print(`{users.user.name} {users.user.surname}`) +end) + +-- Output: Jane Smith +setUsers(function(state) + state.user.name = "Jane" + state.user.surname = "Smith" +end) +``` + +You can also set properties on the reactive value directly: + +```luau +local id = reactive({ name = "John", surname = "Doe" }) + +effect(function() + print(`{id.name} {id.surname}`) +end) + +id.name = "Jane" -- Jane Doe +id.surname = "Smith" -- Jane Smith +``` + +Be careful when mutating the reactive value: because it's a proxy table, functions like `table.insert` will not work. Table functions should be called on the original table provided by the setter function. + +--- + ### `flags` Charm exposes the following global flags to customize behavior: @@ -638,12 +677,14 @@ Start by specifying the signals that the server should sync to clients. For this -- nameStore local getName, setName = signal("John") local getSurname, setSurname = signal("Doe") +local ageAtom = atom(20) return { getName = getName, setName = setName, getSurname = getSurname, setSurname = setSurname, + ageAtom = ageAtom, } ``` @@ -653,10 +694,11 @@ Then, use `server.connect` to specify how state updates should be sent to each c ```luau local function onPlayerAdded(player: Player) - -- Add signal getters, computed signals, or atoms + -- Add signal getters, computed signals, atoms, or reactive objects server.addSignalsToClient(player, { name = nameStore.getName, surname = nameStore.getSurname, + age = nameStore.ageAtom, }) end @@ -684,10 +726,11 @@ To sync the client with the server's state, call `client.addSignals` with a tabl After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau --- Add signal setters or atoms +-- Add signal setters, atoms, or reactive objects client.addSignals({ name = nameStore.setName, surname = nameStore.setSurname, + age = nameStore.ageAtom, }) -- Update client signals when receiving updates from the server @@ -715,13 +758,14 @@ A configuration table that customizes the behavior of Charm Sync on the server. The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. -You can pass signal getter functions, computed signals, and atoms in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. +You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. ```luau Players.PlayerAdded:Connect(function(player) server.addSignalsToClient(player, { name = nameStore.getName, surname = nameStore.getSurname, + age = nameStore.ageAtom, }) end) ``` @@ -744,6 +788,7 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke ```luau server.addSignalsToClient(player, { + name = nameStore.getName, surname = nameStore.getSurname, }) @@ -804,12 +849,13 @@ server.flush() Subscribes the given writable signals to the states with the corresponding keys on the server. When the server sends updates, the functions associated with each key in the state will be called with the patched values. -You can pass either writable signals or atoms to this function: +You can pass either writable signals, atoms, or [reactive objects](#reactiveinitialvalue) to this function: ```luau client.addSignals({ name = nameStore.setName, surname = nameStore.setSurname, + age = nameStore.ageAtom, }) ``` @@ -822,6 +868,7 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta ```luau client.addSignals({ name = nameStore.setName, + surname = nameStore.setSurname, }) client.removeSignals("name") @@ -848,13 +895,29 @@ end) --- +### `signalToAtom(getter, setter)` + +If you have a lot of signals to sync between the server and clients, it might become difficult to keep track of many getters and setters. The `signalToAtom()` function unifies a signal's `get()` and `set()` functions, allowing you to reuse the same values for `client.addSignals` and `server.addSignalsToClient`. + +```luau +local sharedState = { + name = signalToAtom(nameStore.getName, nameStore.setName), + surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), +} + +client.addSignals(sharedState) +server.addSignalsToClient(client, sharedState) +``` + +--- + ### Sync Caveats -Charm Sync will only send clients the differences between the current state and the previously-synced state, which is a practice called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. +Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`. -This means nilable values can be represented as `None`, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. +This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. --- diff --git a/benches/mapped.luau b/benches/mapped.luau index 6b6d5dc..27ca6bd 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -58,7 +58,9 @@ for _, percent in { 0.1, 0.5, 1 } do bench.profilebegin("init") out() bench.profileend() + setSrc(updatePercentage(percent)) + bench.profilebegin("update") out() bench.profileend() @@ -71,7 +73,9 @@ for _, percent in { 0.1, 0.5, 1 } do bench.profilebegin("init") out() bench.profileend() + setSrc(updatePercentage(percent)) + bench.profilebegin("update") out() bench.profileend() diff --git a/benches/patch.bench.luau b/benches/patch.bench.luau deleted file mode 100644 index dc8f918..0000000 --- a/benches/patch.bench.luau +++ /dev/null @@ -1,54 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local CharmSync = require(ReplicatedStorage.packages["charm-sync"].src) - -local patch = CharmSync.patch - -local USERS = 1000 - -local function generate() - local users = {} - - for user = 1, USERS do - users[user] = { - coins = math.random(1, 2), - gems = math.random(1, 2), - } - end - - return { users = users } -end - -local function update(state) - state = table.clone(state) - state.users = table.clone(state.users) - - for user = 1, USERS do - local rng = math.random() - - if rng < 1 / 3 then - continue - elseif rng < 2 / 3 then - state.users[user] = nil - else - state.users[user] = { - coins = math.random(1, 2), - gems = math.random(1, 2), - } - end - end - - return state -end - -return { - ParameterGenerator = function() - local state = generate() - return state, update(state) - end, - Functions = { - diff = function(_, a, b) - patch.diff(a, b) - end, - }, -} diff --git a/benches/reactive.luau b/benches/reactive.luau new file mode 100644 index 0000000..2e499ca --- /dev/null +++ b/benches/reactive.luau @@ -0,0 +1,87 @@ +local Charm = require("../packages/charm/src") +local bench = require("./bench") + +local effect = Charm.effect +local reactive = Charm.reactive + +do + local value = reactive({ a = 1 }) + bench.run("track reactive property", function() + effect(function() + local _ = value.a + end) + end) +end + +do + local value = reactive({ a = 1 }) + bench.run("write reactive property", function() + value.a += 1 + end) +end + +for _, amount in { 10, 100, 1000 } do + print("---") + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + bench.run(`track for loop, {amount} elements`, function() + local sum = 0 + effect(function() + for i = 1, #array do + sum += array[i] + end + end) + end) + end + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + bench.run(`track iteration, {amount} elements`, function() + local sum = 0 + effect(function() + for _, x in array do + sum += x + end + end) + end) + end + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + effect(function() + local sum = 0 + for _, x in array do + sum += x + end + end) + + bench.run(`track index mutation (1st only), {amount} elements`, function() + array[1] += 1 + end) + end + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + effect(function() + local sum = 0 + for _, x in array do + sum += x + end + end) + + bench.run(`track index mutation (all), {amount} elements`, function() + for i = 1, #array do + array[i] += 1 + end + end) + end +end diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index 29a172e..10164a2 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -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 }) diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau index 09c036c..d8da3aa 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -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 {} diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index a62b456..65c0e45 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -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 {} diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau index f26b753..4a30449 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -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 = {} diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau index 5f8513c..8d07f04 100644 --- a/packages/charm-sync/src/__tests__/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -20,8 +20,4 @@ suite.describe("server", function() require("./server.spec") end) -suite.describe("validate", function() - require("./validate.spec") -end) - return {} diff --git a/packages/charm-sync/src/__tests__/validate.spec.luau b/packages/charm-sync/src/__tests__/validate.spec.luau deleted file mode 100644 index 1f107fd..0000000 --- a/packages/charm-sync/src/__tests__/validate.spec.luau +++ /dev/null @@ -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 {} diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 3427726..43103a7 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -4,64 +4,89 @@ local types = require("./types") type SyncPayload = types.SyncPayload -type Setter = (update: ((T) -> T) | T) -> () +type SignalSetter = (update: ((T) -> T) | T) -> () -local trackedSetters: { [string]: Setter } = {} +type ReactiveSetter = (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 = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter })) + +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 diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau index 9b528fc..a101f05 100644 --- a/packages/charm-sync/src/config.luau +++ b/packages/charm-sync/src/config.luau @@ -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, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 69381b6..6519951 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -11,8 +11,11 @@ export interface None { export type MaybeNone = undefined extends T ? None : never; -type GetterMap = Record any>; -type SetterMap = Record>; +type ReactiveObject = { [key: Key]: any }; + +type ServerSignalMap = Record any) | ReactiveObject>; + +type ClientSignalMap = Record | ReactiveObject>; /** * Infers the type of the return values produced by a map of functions. @@ -55,7 +58,7 @@ export type SyncPatch = MaybeNone = +export type SyncPayload = | { type: "init"; data: StateOfMap } | { type: "patch"; data: SyncPatch, 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: Setters): void; + export function addSignals(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(...keys: (keyof Setters)[]): void; + export function removeSignals( + ...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( + export function patch( payloads: SyncPayload[], ): 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(client: Player, getters: Getters): void; + export function addSignalsToClient( + 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( + export function removeSignalsFromClient( 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( + export function connect( onSync: (client: Player, payloads: SyncPayload[]) => 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(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom; diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index f56a118..15bfd2c 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -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(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, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 8979e0c..a43e3b9 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -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 = { - getter: () -> T, +type Reactive = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () })) + +type TrackedState = { + 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(key: string, getter: () -> T) - if trackedGetters[key] then +local function trackSignal(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(key: string, getter: () -> T) local value = getter() local trackedState = { - getter = getter, + target = getter, currentValue = value, lastSyncedValue = value, } @@ -61,14 +65,51 @@ local function startTracking(key: string, getter: () -> T) trackedState.currentValue = newValue end) - trackedGetters[key] = trackedState + trackedStates[key] = trackedState +end + +local function trackReactive(key: string, reactive: Reactive) + 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() diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index ac8ffb4..20af1f3 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -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 {} diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index 6a7f7b0..fbf9260 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -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(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom return function(...) return if select("#", ...) == 0 then getter() else setter((...)) end end +--[[ + Returns a deep copy of the given table. +]] +local function deepCopy(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, } diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index f350bb7..c108c73 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -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 diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau index 4189f87..34c8d4e 100644 --- a/packages/charm/src/__tests__/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -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(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() diff --git a/packages/charm/src/__tests__/reactive.spec.luau b/packages/charm/src/__tests__/reactive.spec.luau new file mode 100644 index 0000000..70237b9 --- /dev/null +++ b/packages/charm/src/__tests__/reactive.spec.luau @@ -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 {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index b95c070..17dd1f2 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -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) diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index cc27c44..973b458 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -280,6 +280,36 @@ export function observe( */ 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( + initialValue: T, +): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>; + export function getActiveSub(): ReactiveNode | undefined; export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index e1fc19f..4334a54 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -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) -> 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( initialValue: T, equals: ((current: T, incoming: T) -> boolean)? @@ -355,16 +341,18 @@ local function signal( 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(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom local getter, setter = signal(initialValue, equals) local function atomOper(...) @@ -373,12 +361,14 @@ local function atom(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(getter: (previousValue: T?) -> T): () -> T return computedOper({ flags = NONE, @@ -386,19 +376,21 @@ local function computed(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(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub then local prevSub = setActiveSub(nil) @@ -513,15 +513,17 @@ local function untracked(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(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub @@ -540,13 +542,15 @@ local function peek(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(fn: (Args...) -> Results..., ...: Args...): Results... startBatch() local results: { any } = { pcall(noYield(fn), ...) } @@ -557,28 +561,32 @@ local function batch(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(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(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () local tracked = toSignal(getter) local current: T @@ -589,17 +597,19 @@ local function listen(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(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () local tracked = toSignal(getter) local current: T @@ -615,16 +625,18 @@ local function subscribe(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(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> () local innerScopes: { [K]: () -> () } = {} local disposed = false @@ -659,31 +673,19 @@ local function observe(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(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(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(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(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 }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), + reactive = reactive, } diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 481275a..931a584 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -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(getter: () -> T, dependencies: { any }?): T local value, setValue = React.useState(getter) @@ -24,15 +26,17 @@ local function useSignalState(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(getter: () -> T, dependencies: { any }?) local initialValue = React.useMemo(getter, {}) local binding, setBinding = React.useBinding(initialValue) diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index 34b1c7b..073e759 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -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(getter: () -> State): () -> State local state = Vide.source(getter()) diff --git a/rokit.toml b/rokit.toml index 30840b6..17f523d 100644 --- a/rokit.toml +++ b/rokit.toml @@ -5,9 +5,9 @@ [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.6.0" -stylua = "johnnymorganz/stylua@2.3.0" +rojo = "rojo-rbx/rojo@7.6.1" +stylua = "johnnymorganz/stylua@2.3.1" selene = "Kampfkarren/selene@0.29.0" -luau-lsp = "johnnymorganz/luau-lsp@1.55.0" +luau-lsp = "johnnymorganz/luau-lsp@1.56.2" lune = "lune-org/lune@0.10.4" -lute = "luau-lang/lute@0.1.0-nightly.20250923" +lute = "luau-lang/lute@0.1.0-nightly.20251119"