From 8aef95e2ae066b29b478a9b9837c9b730fa494db Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 25 Sep 2024 22:10:04 -0700 Subject: [PATCH 01/68] build: update dev config --- dev.project.json | 9 ++++----- src/__tests__/sync/client.spec.luau | 22 ++++++++++++++++++++++ testez-companion.toml | 2 +- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/dev.project.json b/dev.project.json index ed0d1b9..6b6d807 100644 --- a/dev.project.json +++ b/dev.project.json @@ -4,12 +4,11 @@ "$className": "DataModel", "ReplicatedStorage": { "$className": "ReplicatedStorage", - "Packages": { - "$path": "Packages", - "ReactLua": { "$path": "Packages" }, - "charm": { "$path": "src" } + "DevPackages": { + "$path": "DevPackages", + "Charm": { "$path": "src" } }, - "DevPackages": { "$path": "DevPackages" } + "Packages": { "$path": "Packages" } }, "TestService": { "$className": "TestService", diff --git a/src/__tests__/sync/client.spec.luau b/src/__tests__/sync/client.spec.luau index afea44e..90c128a 100644 --- a/src/__tests__/sync/client.spec.luau +++ b/src/__tests__/sync/client.spec.luau @@ -20,6 +20,13 @@ return function() local previous = a() local client = sync.client({ atoms = { a = a } }) + client:sync({ + type = "init", + data = { a = a() }, + }) + + expect(a()).to.equal(previous) + client:sync({ type = "patch", data = { a = { b = 3 } }, @@ -34,6 +41,11 @@ return function() local a = atom({ 1 }) local client = sync.client({ atoms = { a = a } }) + client:sync({ + type = "init", + data = { a = a() }, + }) + -- This patch would be the result of a diff between { 1 } and { 1, 2 } that -- was JSON-serialized and deserialized. The client should be able to catch -- this and convert the string key back to a number. @@ -55,6 +67,11 @@ return function() table.insert(calls, value) end) + client:sync({ + type = "init", + data = { a = a() }, + }) + client:sync({ type = "patch", data = { a = 2 }, @@ -82,6 +99,11 @@ return function() states.a = a() end) + client:sync({ + type = "init", + data = { a = a(), b = b() }, + }) + client:sync({ type = "patch", data = { a = 1, b = 2 }, diff --git a/testez-companion.toml b/testez-companion.toml index bb50d4d..cc9ae64 100644 --- a/testez-companion.toml +++ b/testez-companion.toml @@ -1 +1 @@ -roots = ["ReplicatedStorage/Packages/charm/__tests__"] +roots = ["ReplicatedStorage/DevPackages/Charm/__tests__"] From e5f655482fd4cf896030c95e6f8c1b4d0e447cf0 Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 25 Sep 2024 22:10:19 -0700 Subject: [PATCH 02/68] refactor: separate code for preserving history --- src/sync/server.luau | 234 +++++++++++++++++++++++++++---------------- 1 file changed, 148 insertions(+), 86 deletions(-) diff --git a/src/sync/server.luau b/src/sync/server.luau index e13a4dc..a395abc 100644 --- a/src/sync/server.luau +++ b/src/sync/server.luau @@ -62,69 +62,103 @@ type ServerSyncer = { local function server(options: ServerOptions): ServerSyncer local self = {} :: ServerSyncer local sync: (player: Player, payload: SyncPayload) -> () - local snapshots: { { [string]: any } } = {} - local changed = false - -- Start the interval to send state patches over the network. - function self:connect(callback) - local cleanups = {} - sync = callback + local function createSnapshot() + local snapshot: { [string]: any } = {} - local function getSnapshot() - local snapshot: { [string]: any } = {} - for key, atom in next, options.atoms do - snapshot[key] = atom() - end - return snapshot - end - - local function pushSnapshot(key: string, current: any, previous: any) - if not options.preserveHistory then - if snapshots[2] then - snapshots[2][key] = current - else - snapshots[2] = getSnapshot() - end - return - end - - local lastSnapshot = snapshots[#snapshots] - local previousSnapshot = snapshots[#snapshots - 1] - - -- Optimize snapshots by updating the most recent snapshot if the - -- previous and current values are the same, since this allows us - -- to group multiple changes into a single snapshot. - if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then - lastSnapshot[key] = current - else - local nextSnapshot = table.clone(lastSnapshot) - nextSnapshot[key] = current - table.insert(snapshots, nextSnapshot) - end - end - - -- Populate the initial state and snapshot for each atom. - -- Subscribe to each atom and update the state when it changes. for key, atom in next, options.atoms do - cleanups[key] = subscribe(atom, function(current, previous) - pushSnapshot(key, current, previous) - changed = true - end) + snapshot[key] = atom() end - snapshots[1] = getSnapshot() + return snapshot + end + + if options.preserveHistory then + -- Preserve a history of every state change to allow for more granular + -- syncing with the client. + local snapshots: { { [string]: any } } = {} + local changed = false + + function self:connect(callback) + local subscriptions = {} + + sync = callback + + local function pushSnapshot(key: string, current: any, previous: any) + local lastSnapshot = snapshots[#snapshots] + local previousSnapshot = snapshots[#snapshots - 1] + + -- Optimize snapshots by updating the most recent snapshot if the + -- previous and current values are the same, since this allows us + -- to group multiple changes into a single snapshot. + if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then + lastSnapshot[key] = current + else + local nextSnapshot = table.clone(lastSnapshot) + nextSnapshot[key] = current + table.insert(snapshots, nextSnapshot) + end + end + + -- Populate the snapshot with the initial state of each atom. + -- Subscribe to each atom and update the state when it changes. + for key, atom in next, options.atoms do + subscriptions[key] = subscribe(atom, function(current, previous) + pushSnapshot(key, current, previous) + changed = true + end) + end + + snapshots[1] = createSnapshot() + + local disconnect = setInterval(function() + if not changed then + return + end + + local payloads: { SyncPayload } = {} + local lastSnapshot + + for index, snapshot in next, snapshots do + lastSnapshot = snapshot + + if index == 1 then + continue + end + + table.insert(payloads, { + type = "patch", + data = patch.diff(snapshots[index - 1], snapshot), + }) + end + + snapshots = { lastSnapshot } + changed = false + + for _, player in next, Players:GetPlayers() do + callback(player, unpack(payloads)) + end + end, options.interval or 0) + + return function() + disconnect() + + for _, unsubscribe in next, subscriptions do + unsubscribe() + end + end + end + + function self:_sendPatch(player: Player) + assert(sync, "connect() must be called before _sendPatch()") - local disconnect = setInterval(function() if not changed then return end local payloads: { SyncPayload } = {} - local lastSnapshot for index, snapshot in next, snapshots do - lastSnapshot = snapshot - if index == 1 then continue end @@ -135,19 +169,73 @@ local function server(options: ServerOptions): ServerSyncer }) end - snapshots = { lastSnapshot } + snapshots = { snapshots[#snapshots] } changed = false - for _, player in next, Players:GetPlayers() do - callback(player, unpack(payloads)) - end - end, options.interval or 0) + sync(player, unpack(payloads)) + end + else + -- Only send the most recent state to the client. + local previousSnapshot = createSnapshot() + local currentSnapshot = table.clone(previousSnapshot) + local changed = false - return function() - disconnect() - for _, cleanup in next, cleanups do - cleanup() + function self:connect(callback) + local subscriptions = {} + + sync = callback + + -- Subscribe to each atom and update the state when one changes. + for key, atom in next, options.atoms do + subscriptions[key] = subscribe(atom, function(state) + currentSnapshot[key] = state + changed = true + end) end + + local disconnect = setInterval(function() + if not changed then + return + end + + local payload: SyncPayload = { + type = "patch", + data = patch.diff(previousSnapshot, currentSnapshot), + } + + previousSnapshot = table.clone(currentSnapshot) + changed = false + + for _, player in next, Players:GetPlayers() do + callback(player, payload) + end + end, options.interval or 0) + + return function() + disconnect() + + for _, unsubscribe in next, subscriptions do + unsubscribe() + end + end + end + + function self:_sendPatch(player: Player) + assert(sync, "connect() must be called before _sendPatch()") + + if not changed then + return + end + + local payload: SyncPayload = { + type = "patch", + data = patch.diff(previousSnapshot, currentSnapshot), + } + + previousSnapshot = table.clone(currentSnapshot) + changed = false + + sync(player, payload) end end @@ -157,36 +245,10 @@ local function server(options: ServerOptions): ServerSyncer sync(player, { type = "init", - data = snapshots[#snapshots], + data = createSnapshot(), }) end - function self:_sendPatch(player: Player) - assert(sync, "connect() must be called before _sendPatch()") - - if not changed then - return - end - - local payloads: { SyncPayload } = {} - - for index, snapshot in next, snapshots do - if index == 1 then - continue - end - - table.insert(payloads, { - type = "patch", - data = patch.diff(snapshots[index - 1], snapshot), - }) - end - - snapshots = { snapshots[#snapshots] } - changed = false - - sync(player, unpack(payloads)) - end - return self end From 0b4dd2f53bbbea0a7143c6143be594708bd2dcb1 Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 25 Sep 2024 22:10:52 -0700 Subject: [PATCH 03/68] fix(sync): fill sparse arrays with `null` --- src/sync/patch.luau | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/sync/patch.luau b/src/sync/patch.luau index c59b166..388e115 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -10,6 +10,35 @@ local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" end +--[=[ + Some sparse arrays lose outlying values when serialized to JSON. For + example, `[1, 2, nil, nil, 5]` becomes `[1, 2]` in some cases. This loss of + data can cause hard-to-debug issues down the line. + + This function coerces the input object into being interpreted as a dense + array, so that the aforementioned `[1, 2]` result would instead be + `[1, 2, nil, nil, 5]`. +]=] +local function coerceSparseArray(object: any) + -- The object must be a table with a numeric key + if type(object) ~= "table" or type((next(object))) ~= "number" then + return object + end + + local maxn = table.maxn(object :: any) + + -- If the table is already dense, return it as-is + if maxn == #object then + return object + end + + local result = table.create(maxn, 0) + + table.move(object, 1, maxn, 1, result) + + return result +end + local function diff(prevState: { [any]: any }, nextState: { [any]: any }) local patches = table.clone(nextState) @@ -25,6 +54,9 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }) end end + -- Coerce the patches into being interpreted as dense arrays. + patches = coerceSparseArray(patches) + if _G.__DEV__ then for key, value in next, prevState do validate(value, key) From 640908ebb9a893b6f7bb859a9d3f546ab8b0e8d3 Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 25 Sep 2024 22:11:22 -0700 Subject: [PATCH 04/68] 0.7.1 --- package.json | 2 +- wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 32b3234..80caeaa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.7.0", + "version": "0.7.1", "description": "Atomic state management for Roblox", "scripts": { "lint": "sh bin/check.sh", diff --git a/wally.toml b/wally.toml index 5d5101e..8fcd7ec 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "littensy/charm" description = "Atomic state management for Roblox" -version = "0.7.0" +version = "0.7.1" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" From 651b91fbfbc27ecb4426a227140ef53a2eae34d9 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 26 Sep 2024 00:41:57 -0700 Subject: [PATCH 05/68] perf: optimize sparse array coercion --- src/sync/patch.luau | 67 +++++++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/src/sync/patch.luau b/src/sync/patch.luau index 388e115..1304f5b 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -1,8 +1,8 @@ local validate = require(script.Parent.validate) --[=[ - A special value that denotes the absence of a value. Used to represent - undefined values in patches. + A special symbol that denotes the absence of a value. Used to represent + deleted values in patches. ]=] local NONE = { __none = "__none" } @@ -11,40 +11,65 @@ local function isNone(value: any): boolean end --[=[ - Some sparse arrays lose outlying values when serialized to JSON. For - example, `[1, 2, nil, nil, 5]` becomes `[1, 2]` in some cases. This loss of - data can cause hard-to-debug issues down the line. + JSON serialization can drop the last values of a sparse array. For example, + `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. This loss of data can + cause hard-to-debug issues down the line, so we need to ensure that all + arrays are dense. This function coerces the input object into being interpreted as a dense - array, so that the aforementioned `[1, 2]` result would instead be - `[1, 2, nil, nil, 5]`. + array, so that the above result would instead be `[1, null, 3]`. ]=] -local function coerceSparseArray(object: any) - -- The object must be a table with a numeric key - if type(object) ~= "table" or type((next(object))) ~= "number" then - return object +local function coerceDenseArray(array: { any }) + local maxn = -1 + + for index in next, array do + if type(index) ~= "number" then + return + end + + if index > 2 ^ 16 then + -- The object is too large to be considered a dense array + if _G.__DEV__ then + warn( + "Charm encountered an array that may not be synced reliably. This can happen for any of the following reasons:", + "\n1. The array is too large (more than 2^16 elements).", + "\n2. The array has large gaps between elements (e.g. { [1] = foo, [100000] = bar }).", + "\n3. The array is keyed by large numbers (e.g. { [1234] = foo, [5678] = bar }).", + "\nTo resolve this issue, consider using string keys instead of an array.", + `\n\tat index {index} of array`, + array + ) + end + + return array + end + + maxn = math.max(maxn, index) end - local maxn = table.maxn(object :: any) - - -- If the table is already dense, return it as-is - if maxn == #object then - return object + if maxn == -1 or maxn == #array then + -- If the table is already dense, return it as-is + return array end - local result = table.create(maxn, 0) + local result = table.create(maxn) - table.move(object, 1, maxn, 1, result) + table.move(array, 1, maxn, 1, result) return result end local function diff(prevState: { [any]: any }, nextState: { [any]: any }) local patches = table.clone(nextState) + local numeric = true for key, previous in next, prevState do local next = nextState[key] + if numeric and type(key) ~= "number" then + numeric = false + end + if previous == next then patches[key] = nil elseif next == nil then @@ -54,8 +79,10 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }) end end - -- Coerce the patches into being interpreted as dense arrays. - patches = coerceSparseArray(patches) + if numeric then + -- Coerce sparse array patches into being interpreted as dense arrays + patches = coerceDenseArray(patches) + end if _G.__DEV__ then for key, value in next, prevState do From 6587db0167f144b88f31fa48b74712bd12b82848 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 26 Sep 2024 00:42:25 -0700 Subject: [PATCH 06/68] 0.7.2 --- package.json | 2 +- wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 80caeaa..d03da71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.7.1", + "version": "0.7.2", "description": "Atomic state management for Roblox", "scripts": { "lint": "sh bin/check.sh", diff --git a/wally.toml b/wally.toml index 8fcd7ec..517acec 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "littensy/charm" description = "Atomic state management for Roblox" -version = "0.7.1" +version = "0.7.2" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" From 495139995a33548ff7a41a4aa57cbaeea94a1637 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 26 Sep 2024 00:55:50 -0700 Subject: [PATCH 07/68] fix: return the object if not an array --- src/sync/patch.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sync/patch.luau b/src/sync/patch.luau index 1304f5b..917159c 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -24,7 +24,7 @@ local function coerceDenseArray(array: { any }) for index in next, array do if type(index) ~= "number" then - return + return array end if index > 2 ^ 16 then From 03847bc3f2609570ab5b80f383f3eb3b28e8f156 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 26 Sep 2024 00:56:12 -0700 Subject: [PATCH 08/68] 0.7.3 --- package.json | 2 +- wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d03da71..94798cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.7.2", + "version": "0.7.3", "description": "Atomic state management for Roblox", "scripts": { "lint": "sh bin/check.sh", diff --git a/wally.toml b/wally.toml index 517acec..c6f706e 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "littensy/charm" description = "Atomic state management for Roblox" -version = "0.7.2" +version = "0.7.3" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" From e32c62753e07174a2d1c578b1ba9073e0a9fd9fe Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 26 Sep 2024 11:20:27 -0700 Subject: [PATCH 09/68] perf: optimize sparse array preservation --- src/sync/patch.luau | 75 +++++++++++++++------------------------------ 1 file changed, 24 insertions(+), 51 deletions(-) diff --git a/src/sync/patch.luau b/src/sync/patch.luau index 917159c..dd192f1 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -12,76 +12,49 @@ end --[=[ JSON serialization can drop the last values of a sparse array. For example, - `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. This loss of data can - cause hard-to-debug issues down the line, so we need to ensure that all - arrays are dense. + `{ [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. - This function coerces the input object into being interpreted as a dense - array, so that the above result would instead be `[1, null, 3]`. + 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. ]=] -local function coerceDenseArray(array: { any }) - local maxn = -1 +local function stringifySparseArray(object: { any }): { [string | number]: any } + local maxn = table.maxn(object) - for index in next, array do - if type(index) ~= "number" then - return array - end - - if index > 2 ^ 16 then - -- The object is too large to be considered a dense array - if _G.__DEV__ then - warn( - "Charm encountered an array that may not be synced reliably. This can happen for any of the following reasons:", - "\n1. The array is too large (more than 2^16 elements).", - "\n2. The array has large gaps between elements (e.g. { [1] = foo, [100000] = bar }).", - "\n3. The array is keyed by large numbers (e.g. { [1234] = foo, [5678] = bar }).", - "\nTo resolve this issue, consider using string keys instead of an array.", - `\n\tat index {index} of array`, - array - ) - end - - return array - end - - maxn = math.max(maxn, index) + if maxn == 0 or maxn == #object then + -- The object is either not an array or a dense array + return object end - if maxn == -1 or maxn == #array then - -- If the table is already dense, return it as-is - return array + local dictionary = {} + + for index, value in next, object do + dictionary[tostring(index)] = value end - local result = table.create(maxn) - - table.move(array, 1, maxn, 1, result) - - return result + return dictionary end local function diff(prevState: { [any]: any }, nextState: { [any]: any }) local patches = table.clone(nextState) - local numeric = true for key, previous in next, prevState do - local next = nextState[key] + local current = nextState[key] - if numeric and type(key) ~= "number" then - numeric = false - end - - if previous == next then + if previous == current then patches[key] = nil - elseif next == nil then + elseif current == nil then patches[key] = NONE - elseif type(previous) == "table" and type(next) == "table" then - patches[key] = diff(previous, next) + elseif type(previous) == "table" and type(current) == "table" then + patches[key] = diff(previous, current) end end - if numeric then - -- Coerce sparse array patches into being interpreted as dense arrays - patches = coerceDenseArray(patches) + -- Coerce sparse array patches into dictionaries + if prevState[1] ~= nil or nextState[1] ~= nil then + patches = stringifySparseArray(patches) end if _G.__DEV__ then From 9e6d7ea9e4c633e74645eb11f38dfb152d07fd89 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 26 Sep 2024 11:20:33 -0700 Subject: [PATCH 10/68] test: add patch tests --- src/__tests__/sync/patch.spec.luau | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/__tests__/sync/patch.spec.luau diff --git a/src/__tests__/sync/patch.spec.luau b/src/__tests__/sync/patch.spec.luau new file mode 100644 index 0000000..813fdbc --- /dev/null +++ b/src/__tests__/sync/patch.spec.luau @@ -0,0 +1,99 @@ +local HttpService = game:GetService("HttpService") +local patch = require(script.Parent.Parent.Parent.sync.patch) + +return function() + local function serdes(value: { [any]: any }) + return HttpService:JSONDecode(HttpService:JSONEncode(value)) + end + + it("returns a patch", function() + local prevState = { foo = "bar", baz = "qux" } + local nextState = { foo = "bar", baz = "quux" } + local patches = patch.diff(prevState, nextState) + + expect(patches).to.be.ok() + expect(patches.baz).to.equal("quux") + expect(patches.foo).to.never.be.ok() + end) + + it("applies a patch", function() + local prevState = { foo = "bar", baz = "qux" } + local patches = { baz = "quux", qaz = "qux" } + local applied = patch.apply(prevState, patches) + + expect(applied).to.be.ok() + expect(applied.baz).to.equal("quux") + expect(applied.foo).to.equal("bar") + expect(applied.qaz).to.equal("qux") + end) + + it("allows nested patches", function() + local prevState = { foo = { bar = "baz" } } + local nextState = { foo = { bar = "qux" } } + local patches = patch.diff(prevState, nextState) + local applied = patch.apply(prevState, patches) + + expect(applied.foo.bar).to.equal("qux") + end) + + it("preserves array density when applying", function() + local prevState = { "a", "b", "c", "d" } + local nextState = { ["2"] = "B", ["4"] = "D" } + local applied = patch.apply(prevState, nextState) + + expect(applied[1]).to.equal("a") + expect(applied[2]).to.equal("B") + expect(applied[3]).to.equal("c") + expect(applied[4]).to.equal("D") + + expect(applied["2"]).to.never.be.ok() + expect(applied["4"]).to.never.be.ok() + end) + + it("preserves array density after serdes", function() + -- This particular change results in a patch that cannot be serialized + -- without losing information. Charm resolves this issue internally. + local prevState = { "a", "b", "c", "d", "e" } + local nextState = { "A", "b", "c", "D" } + + local patches = serdes(patch.diff(prevState, nextState)) + local applied = patch.apply(prevState, patches) + + for index in next, prevState do + expect(applied[index]).to.equal(nextState[index]) + end + + for index in next, applied do + expect(applied[index]).to.equal(nextState[index]) + end + end) + + it("preserves large gaps between keys", function() + local prevState = table.create(10000, "foo") + local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" }) + + local start = os.clock() + + local patches = serdes(patch.diff(prevState, nextState)) + local applied = patch.apply(prevState, patches) + + assert(os.clock() - start < 1e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`) + + for index in next, prevState do + expect(applied[index]).to.equal(nextState[index]) + end + end) + + it("cannot preserve sparse arrays", function() + local prevState = {} + local nextState = { [2] = "foo" } + + local patches = serdes(patch.diff(prevState, nextState)) + local applied = patch.apply(prevState, patches) + + -- Not enough information is available to determine whether 'patches' + -- should keep the string keys added during remote event serialization. + expect(applied["2"]).to.be.ok() + expect(applied[2]).to.never.be.ok() + end) +end From 4eb5a68ae876bfdeafd40b0c6e301665a82edb0f Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 26 Sep 2024 11:22:32 -0700 Subject: [PATCH 11/68] chore: bump deps --- package.json | 20 +- pnpm-lock.yaml | 518 ++++++++++++++++++++++--------------------------- 2 files changed, 240 insertions(+), 298 deletions(-) diff --git a/package.json b/package.json index 94798cf..437eebb 100644 --- a/package.json +++ b/package.json @@ -32,18 +32,18 @@ "access": "public" }, "devDependencies": { - "@rbxts/compiler-types": "2.3.0-types.1", - "@rbxts/react": "^0.3.6", - "@rbxts/types": "^1.0.788", - "@typescript-eslint/eslint-plugin": "^7.15.0", - "@typescript-eslint/parser": "^7.15.0", - "eslint": "^8.57.0", + "@rbxts/compiler-types": "3.0.0-types.0", + "@rbxts/react": "^0.4.0", + "@rbxts/types": "^1.0.809", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-roblox-ts": "^0.0.36", - "prettier": "^3.3.2", - "roblox-ts": "2.3.0-dev-ac7800f", - "typescript": "^5.5.3" + "prettier": "^3.3.3", + "roblox-ts": "3.0.0", + "typescript": "^5.6.2" }, "peerDependencies": { "@rbxts/react": "*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afec7dd..d5ddd4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,41 +9,41 @@ importers: .: devDependencies: '@rbxts/compiler-types': - specifier: 2.3.0-types.1 - version: 2.3.0-types.1 + specifier: 3.0.0-types.0 + version: 3.0.0-types.0 '@rbxts/react': - specifier: ^0.3.6 - version: 0.3.6 + specifier: ^0.4.0 + version: 0.4.0 '@rbxts/types': - specifier: ^1.0.788 - version: 1.0.788 + specifier: ^1.0.809 + version: 1.0.809 '@typescript-eslint/eslint-plugin': - specifier: ^7.15.0 - version: 7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3) + specifier: ^7.18.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2) '@typescript-eslint/parser': - specifier: ^7.15.0 - version: 7.15.0(eslint@8.57.0)(typescript@5.5.3) + specifier: ^7.18.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.6.2) eslint: - specifier: ^8.57.0 - version: 8.57.0 + specifier: ^8.57.1 + version: 8.57.1 eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.0(eslint@8.57.0) + version: 9.1.0(eslint@8.57.1) eslint-plugin-prettier: - specifier: ^5.1.3 - version: 5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.2) + specifier: ^5.2.1 + version: 5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3) eslint-plugin-roblox-ts: specifier: ^0.0.36 - version: 0.0.36(eslint@8.57.0) + version: 0.0.36(eslint@8.57.1) prettier: - specifier: ^3.3.2 - version: 3.3.2 + specifier: ^3.3.3 + version: 3.3.3 roblox-ts: - specifier: 2.3.0-dev-ac7800f - version: 2.3.0-dev-ac7800f + specifier: 3.0.0 + version: 3.0.0 typescript: - specifier: ^5.5.3 - version: 5.5.3 + specifier: ^5.6.2 + version: 5.6.2 packages: @@ -53,20 +53,20 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.11.0': - resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==} + '@eslint-community/regexpp@4.11.1': + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} '@eslint/eslintrc@2.1.4': resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@eslint/js@8.57.0': - resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@humanwhocodes/config-array@0.11.14': - resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} deprecated: Use @eslint/config-array instead @@ -94,59 +94,38 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@rbxts/compiler-types@2.3.0-types.1': - resolution: {integrity: sha512-NZWNo+fC4icfJte+NiDSDdWJo1KwzD0MDQ2iBi70YhNYlkA7+Xc+B1udbVqxLJuBur2JxG5bbKrpMAfHqfCtbw==} + '@rbxts/compiler-types@3.0.0-types.0': + resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} - '@rbxts/react-vendor@0.3.6': - resolution: {integrity: sha512-q3HilP6ZNHSgWW0lwka+Ft4Iq+fnPBppOeQhGua1XCtM2hEqhmUmME8DI1UlnaK/aGzdYW1bWC7YfHZhHGsIeA==} + '@rbxts/react-vendor@0.4.0': + resolution: {integrity: sha512-roWs9FmR7CQDK1N6gE/36tiCBQRl8v1S0pUJD2Ly3ZMVvfjAk2gixrkqGcKFGtXQQq6r4dTAtHduAeZJBd640g==} - '@rbxts/react@0.3.6': - resolution: {integrity: sha512-GeEqKjjyJT3zG85xEVCu3IMQdYRoXk4P9XCua4oriTIfuJaSTOWUukOmQ5ILpG/BpksLqXlpH8zqKKZcZaIs3g==} + '@rbxts/react@0.4.0': + resolution: {integrity: sha512-yhqXzfH5YpwfZyiWipQMfQsI//2sC86Jqn2rMkP+l1y4CfhCvoYxpaI5qS7Xum9F+DsBHiwJNeUxm2V2LzXW0A==} - '@rbxts/types@1.0.788': - resolution: {integrity: sha512-CvkAyNFuNoTYdZaopxDrtg+xCGmQap5O89TkzL7azMTmkCio4xjp4OYsCCxGvNl/cXHzYOa7nk7ceDGqR3KnvA==} + '@rbxts/types@1.0.809': + resolution: {integrity: sha512-COmNdE5VKq44tU1Sqbj0b70qDcUpBY1Uu5BhNb25v1yJATwcoVjsmYTyaxmRO4EVdqOATjOUBU2jbxZa4K83uw==} - '@roblox-ts/luau-ast@1.0.11': - resolution: {integrity: sha512-+maoLYpqY0HK8ugLFHS3qz0phMyDaN3i21jjW75T2ZaqJg84heKDUo98iXClvnx3mUDhW10IxqH+cYJ2iftYhQ==} + '@roblox-ts/luau-ast@2.0.0': + resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} - '@roblox-ts/path-translator@1.0.0': - resolution: {integrity: sha512-Lp6qVUqjmXIrICy2KPKRiX8IkJ+lNqn6RqoUplLiTr+4JehIN+mJv0tTnE72XRyIfcx0VWl5nKrRwUuqcOj1yg==} + '@roblox-ts/path-translator@1.1.0': + resolution: {integrity: sha512-D0akTmnNYqBw+ZIek5JxocT3BjmbgGOuOy0x1nIIxHBPNLGCpzseToY8jyYs/0mlvnN2xnSP/k8Tv+jvGOQSwQ==} - '@roblox-ts/rojo-resolver@1.0.6': - resolution: {integrity: sha512-+heTECMo6BdH3a3h4DCj+8kJvwKuxWqBevcW/m2BzQaVtmo1GtLa4V4bJCMvDuAMeEqYKQZUB7546nN2dcqqAA==} - - '@types/fs-extra@11.0.4': - resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@roblox-ts/rojo-resolver@1.1.0': + resolution: {integrity: sha512-QmvVryu1EeME+3QUoG5j/gHGJoJUaffCgZ92mhlG7cJSd1uyhgpY4CNWriZAwZJYkTlzd5Htkpn+18yDFbOFXA==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/jsonfile@6.1.4': - resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - - '@types/mocha@10.0.7': - resolution: {integrity: sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw==} - - '@types/node@20.14.10': - resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==} - - '@types/prompts@2.4.9': - resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} - - '@types/resolve@1.20.6': - resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + '@types/node@20.16.9': + resolution: {integrity: sha512-rkvIVJxsOfBejxK7I0FO5sa2WxFmJCzoDwcd88+fq/CUfynNywTo/1/T6hyFz22CyztsnLS9nVlHOnTI36RH5w==} '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.32': - resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} - - '@typescript-eslint/eslint-plugin@7.15.0': - resolution: {integrity: sha512-uiNHpyjZtFrLwLDpHnzaDlP3Tt6sGMqTCiqmxaN4n4RP0EfYZDODJyddiFDF44Hjwxr5xAcaYxVKm9QKQFJFLA==} + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: '@typescript-eslint/parser': ^7.0.0 @@ -162,8 +141,8 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/parser@7.15.0': - resolution: {integrity: sha512-k9fYuQNnypLFcqORNClRykkGOMOj+pV6V91R4GO/l1FDGwpqmSwoOQrOHo3cGaH63e+D3ZiCAOsuS/D2c99j/A==} + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -176,12 +155,12 @@ packages: resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/scope-manager@7.15.0': - resolution: {integrity: sha512-Q/1yrF/XbxOTvttNVPihxh1b9fxamjEoz2Os/Pe38OHwxC24CyCqXxGTOdpb4lt6HYtqw9HetA/Rf6gDGaMPlw==} + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/type-utils@7.15.0': - resolution: {integrity: sha512-SkgriaeV6PDvpA6253PDVep0qCqgbO1IOBiycjnXsszNTVQe5flN5wR5jiczoEoDEnAqYFSFFc9al9BSGVltkg==} + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -194,8 +173,8 @@ packages: resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/types@7.15.0': - resolution: {integrity: sha512-aV1+B1+ySXbQH0pLK0rx66I3IkiZNidYobyfn0WFsdGhSXw+P3YOqeTq5GED458SfB24tg+ux3S+9g118hjlTw==} + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} engines: {node: ^18.18.0 || >=20.0.0} '@typescript-eslint/typescript-estree@5.62.0': @@ -207,8 +186,8 @@ packages: typescript: optional: true - '@typescript-eslint/typescript-estree@7.15.0': - resolution: {integrity: sha512-gjyB/rHAopL/XxfmYThQbXbzRMGhZzGw6KpcMbfe8Q3nNQKStpxnUKeXb0KiN/fFDR42Z43szs6rY7eHk0zdGQ==} + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: typescript: '*' @@ -222,8 +201,8 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/utils@7.15.0': - resolution: {integrity: sha512-hfDMDqaqOqsUVGiEPSMLR/AjTSCsmJwjpKkYQRo1FNbmW4tBwBspYDwO9eh7sKSTwMQgBw9/T4DHudPaqshRWA==} + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -232,8 +211,8 @@ packages: resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/visitor-keys@7.15.0': - resolution: {integrity: sha512-Hqgy/ETgpt2L5xueA/zHHIl4fJI2O4XUE9l4+OIfbJIRSnTJb/QscncdqqZzofQegIJugRIF57OJea1khw2SDw==} + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} engines: {node: ^18.18.0 || >=20.0.0} '@ungap/structured-clone@1.2.0': @@ -252,8 +231,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.16.0: - resolution: {integrity: sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -321,8 +300,8 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} - debug@4.3.5: - resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -344,8 +323,8 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - escalade@3.1.2: - resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} escape-string-regexp@4.0.0: @@ -358,8 +337,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-prettier@5.1.3: - resolution: {integrity: sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==} + eslint-plugin-prettier@5.2.1: + resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' @@ -388,8 +367,8 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint@8.57.0: - resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true @@ -397,8 +376,8 @@ packages: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - esquery@1.5.0: - resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -433,6 +412,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.0.2: + resolution: {integrity: sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -508,8 +490,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - ignore@5.3.1: - resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} import-fresh@3.3.0: @@ -531,8 +513,8 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-core-module@2.14.0: - resolution: {integrity: sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==} + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} engines: {node: '>= 0.4'} is-extglob@2.1.1: @@ -580,10 +562,6 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -603,8 +581,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - micromatch@4.0.7: - resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} minimatch@3.1.2: @@ -614,8 +592,8 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -674,8 +652,8 @@ packages: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} - prettier@3.3.2: - resolution: {integrity: sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==} + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} engines: {node: '>=14'} hasBin: true @@ -715,15 +693,15 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - roblox-ts@2.3.0-dev-ac7800f: - resolution: {integrity: sha512-XZuBSgLoSMu11in/o5TUsaaHSj0LzTCrDyv6iFFXaoR3xY1AMl0XrUef7XPy+YN4LteX7VoDWV4dMitFP9KrrA==} + roblox-ts@3.0.0: + resolution: {integrity: sha512-hwAC2frIFlLJOtHd6F+5opMEhBgfAMK9z5l1mP+bykLBbMO5cn1q5lIwhhXbeh9Pq07rlhF8uGHlmeRLPd/3AA==} hasBin: true run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - semver@7.6.2: - resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} hasBin: true @@ -759,8 +737,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.8.8: - resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} + synckit@0.9.1: + resolution: {integrity: sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==} engines: {node: ^14.18.0 || >=16.0.0} text-table@0.2.0: @@ -776,14 +754,11 @@ packages: peerDependencies: typescript: '>=4.2.0' - ts-expose-internals@5.3.3: - resolution: {integrity: sha512-0lW96u0pa08dq9QLYUBRbgGkvsD+LvPAVGe94kr2tHaRZAxgckwJ+qqc1BSRoR1rpkTZf7AWAa6DKrLGFmctwg==} - tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.6.3: - resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} tsutils@3.21.0: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} @@ -799,18 +774,18 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} - typescript@5.3.3: - resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} - engines: {node: '>=14.17'} - hasBin: true - typescript@5.5.3: resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} engines: {node: '>=14.17'} hasBin: true - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + typescript@5.6.2: + resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} @@ -853,20 +828,20 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': dependencies: - eslint: 8.57.0 + eslint: 8.57.1 eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.11.0': {} + '@eslint-community/regexpp@4.11.1': {} '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.5 + debug: 4.3.7 espree: 9.6.1 globals: 13.24.0 - ignore: 5.3.1 + ignore: 5.3.2 import-fresh: 3.3.0 js-yaml: 4.1.0 minimatch: 3.1.2 @@ -874,12 +849,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@8.57.0': {} + '@eslint/js@8.57.1': {} - '@humanwhocodes/config-array@0.11.14': + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.5 + debug: 4.3.7 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -902,96 +877,72 @@ snapshots: '@pkgr/core@0.1.1': {} - '@rbxts/compiler-types@2.3.0-types.1': {} + '@rbxts/compiler-types@3.0.0-types.0': {} - '@rbxts/react-vendor@0.3.6': {} + '@rbxts/react-vendor@0.4.0': {} - '@rbxts/react@0.3.6': + '@rbxts/react@0.4.0': dependencies: - '@rbxts/react-vendor': 0.3.6 + '@rbxts/react-vendor': 0.4.0 - '@rbxts/types@1.0.788': {} + '@rbxts/types@1.0.809': {} - '@roblox-ts/luau-ast@1.0.11': {} + '@roblox-ts/luau-ast@2.0.0': {} - '@roblox-ts/path-translator@1.0.0': + '@roblox-ts/path-translator@1.1.0': dependencies: - ajv: 8.16.0 + ajv: 8.17.1 fs-extra: 11.2.0 - '@roblox-ts/rojo-resolver@1.0.6': + '@roblox-ts/rojo-resolver@1.1.0': dependencies: - ajv: 8.16.0 + ajv: 8.17.1 fs-extra: 11.2.0 - '@types/fs-extra@11.0.4': - dependencies: - '@types/jsonfile': 6.1.4 - '@types/node': 20.14.10 - '@types/json-schema@7.0.15': {} - '@types/jsonfile@6.1.4': + '@types/node@20.16.9': dependencies: - '@types/node': 20.14.10 - - '@types/mocha@10.0.7': {} - - '@types/node@20.14.10': - dependencies: - undici-types: 5.26.5 - - '@types/prompts@2.4.9': - dependencies: - '@types/node': 20.14.10 - kleur: 3.0.3 - - '@types/resolve@1.20.6': {} + undici-types: 6.19.8 '@types/semver@7.5.8': {} - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.32': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@types/yargs-parser': 21.0.3 - - '@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)': - dependencies: - '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 7.15.0(eslint@8.57.0)(typescript@5.5.3) - '@typescript-eslint/scope-manager': 7.15.0 - '@typescript-eslint/type-utils': 7.15.0(eslint@8.57.0)(typescript@5.5.3) - '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.5.3) - '@typescript-eslint/visitor-keys': 7.15.0 - eslint: 8.57.0 + '@eslint-community/regexpp': 4.11.1 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.1 graphemer: 1.4.0 - ignore: 5.3.1 + ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.5.3) + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.3 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.0)(typescript@5.5.3)': + '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.5.3) - eslint: 8.57.0 + '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.6.2) + eslint: 8.57.1 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.5.3)': + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@typescript-eslint/scope-manager': 7.15.0 - '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.5.3) - '@typescript-eslint/visitor-keys': 7.15.0 - debug: 4.3.5 - eslint: 8.57.0 + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7 + eslint: 8.57.1 optionalDependencies: - typescript: 5.5.3 + typescript: 5.6.2 transitivePeerDependencies: - supports-color @@ -1000,78 +951,78 @@ snapshots: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - '@typescript-eslint/scope-manager@7.15.0': + '@typescript-eslint/scope-manager@7.18.0': dependencies: - '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/visitor-keys': 7.15.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 - '@typescript-eslint/type-utils@7.15.0(eslint@8.57.0)(typescript@5.5.3)': + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.5.3) - '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.5.3) - debug: 4.3.5 - eslint: 8.57.0 - ts-api-utils: 1.3.0(typescript@5.5.3) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + debug: 4.3.7 + eslint: 8.57.1 + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.3 + typescript: 5.6.2 transitivePeerDependencies: - supports-color '@typescript-eslint/types@5.62.0': {} - '@typescript-eslint/types@7.15.0': {} + '@typescript-eslint/types@7.18.0': {} - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.5.3)': + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.6.2)': dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.5 + debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.6.2 - tsutils: 3.21.0(typescript@5.5.3) + semver: 7.6.3 + tsutils: 3.21.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.3 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@7.15.0(typescript@5.5.3)': + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.6.2)': dependencies: - '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/visitor-keys': 7.15.0 - debug: 4.3.5 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.6.2 - ts-api-utils: 1.3.0(typescript@5.5.3) + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.3 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.57.0)(typescript@5.5.3)': + '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.3) - eslint: 8.57.0 + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.2) + eslint: 8.57.1 eslint-scope: 5.1.1 - semver: 7.6.2 + semver: 7.6.3 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@7.15.0(eslint@8.57.0)(typescript@5.5.3)': + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@typescript-eslint/scope-manager': 7.15.0 - '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.5.3) - eslint: 8.57.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + eslint: 8.57.1 transitivePeerDependencies: - supports-color - typescript @@ -1081,9 +1032,9 @@ snapshots: '@typescript-eslint/types': 5.62.0 eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@7.15.0': + '@typescript-eslint/visitor-keys@7.18.0': dependencies: - '@typescript-eslint/types': 7.15.0 + '@typescript-eslint/types': 7.18.0 eslint-visitor-keys: 3.4.3 '@ungap/structured-clone@1.2.0': {} @@ -1101,12 +1052,12 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.16.0: + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 + fast-uri: 3.0.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - uri-js: 4.4.1 ansi-regex@5.0.1: {} @@ -1179,9 +1130,9 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - debug@4.3.5: + debug@4.3.7: dependencies: - ms: 2.1.2 + ms: 2.1.3 deep-is@0.1.4: {} @@ -1195,28 +1146,28 @@ snapshots: emoji-regex@8.0.0: {} - escalade@3.1.2: {} + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@9.1.0(eslint@8.57.0): + eslint-config-prettier@9.1.0(eslint@8.57.1): dependencies: - eslint: 8.57.0 + eslint: 8.57.1 - eslint-plugin-prettier@5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.2): + eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3): dependencies: - eslint: 8.57.0 - prettier: 3.3.2 + eslint: 8.57.1 + prettier: 3.3.3 prettier-linter-helpers: 1.0.0 - synckit: 0.8.8 + synckit: 0.9.1 optionalDependencies: - eslint-config-prettier: 9.1.0(eslint@8.57.0) + eslint-config-prettier: 9.1.0(eslint@8.57.1) - eslint-plugin-roblox-ts@0.0.36(eslint@8.57.0): + eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1): dependencies: - '@types/node': 20.14.10 - '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.0)(typescript@5.5.3) - typescript: 5.5.3 + '@types/node': 20.16.9 + '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.6.2) + typescript: 5.6.2 transitivePeerDependencies: - eslint - supports-color @@ -1233,26 +1184,26 @@ snapshots: eslint-visitor-keys@3.4.3: {} - eslint@8.57.0: + eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.11.1 '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.0 - '@humanwhocodes/config-array': 0.11.14 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.2.0 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.5 + debug: 4.3.7 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - esquery: 1.5.0 + esquery: 1.6.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 6.0.1 @@ -1260,7 +1211,7 @@ snapshots: glob-parent: 6.0.2 globals: 13.24.0 graphemer: 1.4.0 - ignore: 5.3.1 + ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 @@ -1282,7 +1233,7 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.12.1) eslint-visitor-keys: 3.4.3 - esquery@1.5.0: + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -1306,12 +1257,14 @@ snapshots: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.7 + micromatch: 4.0.8 fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fast-uri@3.0.2: {} + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -1378,7 +1331,7 @@ snapshots: array-union: 2.1.0 dir-glob: 3.0.1 fast-glob: 3.3.2 - ignore: 5.3.1 + ignore: 5.3.2 merge2: 1.4.1 slash: 3.0.0 @@ -1392,7 +1345,7 @@ snapshots: dependencies: function-bind: 1.1.2 - ignore@5.3.1: {} + ignore@5.3.2: {} import-fresh@3.3.0: dependencies: @@ -1412,7 +1365,7 @@ snapshots: dependencies: binary-extensions: 2.3.0 - is-core-module@2.14.0: + is-core-module@2.15.1: dependencies: hasown: 2.0.2 @@ -1452,8 +1405,6 @@ snapshots: dependencies: json-buffer: 3.0.1 - kleur@3.0.3: {} - kleur@4.1.5: {} levn@0.4.1: @@ -1469,7 +1420,7 @@ snapshots: merge2@1.4.1: {} - micromatch@4.0.7: + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 @@ -1482,7 +1433,7 @@ snapshots: dependencies: brace-expansion: 2.0.1 - ms@2.1.2: {} + ms@2.1.3: {} natural-compare@1.4.0: {} @@ -1531,7 +1482,7 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.3.2: {} + prettier@3.3.3: {} punycode@2.3.1: {} @@ -1549,7 +1500,7 @@ snapshots: resolve@1.22.8: dependencies: - is-core-module: 2.14.0 + is-core-module: 2.15.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -1559,30 +1510,23 @@ snapshots: dependencies: glob: 7.2.3 - roblox-ts@2.3.0-dev-ac7800f: + roblox-ts@3.0.0: dependencies: - '@roblox-ts/luau-ast': 1.0.11 - '@roblox-ts/path-translator': 1.0.0 - '@roblox-ts/rojo-resolver': 1.0.6 - '@types/fs-extra': 11.0.4 - '@types/mocha': 10.0.7 - '@types/node': 20.14.10 - '@types/prompts': 2.4.9 - '@types/resolve': 1.20.6 - '@types/ts-expose-internals': ts-expose-internals@5.3.3 - '@types/yargs': 17.0.32 + '@roblox-ts/luau-ast': 2.0.0 + '@roblox-ts/path-translator': 1.1.0 + '@roblox-ts/rojo-resolver': 1.1.0 chokidar: 3.6.0 fs-extra: 11.2.0 kleur: 4.1.5 resolve: 1.22.8 - typescript: 5.3.3 + typescript: 5.5.3 yargs: 17.7.2 run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - semver@7.6.2: {} + semver@7.6.3: {} shebang-command@2.0.0: dependencies: @@ -1610,10 +1554,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.8.8: + synckit@0.9.1: dependencies: '@pkgr/core': 0.1.1 - tslib: 2.6.3 + tslib: 2.7.0 text-table@0.2.0: {} @@ -1621,20 +1565,18 @@ snapshots: dependencies: is-number: 7.0.0 - ts-api-utils@1.3.0(typescript@5.5.3): + ts-api-utils@1.3.0(typescript@5.6.2): dependencies: - typescript: 5.5.3 - - ts-expose-internals@5.3.3: {} + typescript: 5.6.2 tslib@1.14.1: {} - tslib@2.6.3: {} + tslib@2.7.0: {} - tsutils@3.21.0(typescript@5.5.3): + tsutils@3.21.0(typescript@5.6.2): dependencies: tslib: 1.14.1 - typescript: 5.5.3 + typescript: 5.6.2 type-check@0.4.0: dependencies: @@ -1642,11 +1584,11 @@ snapshots: type-fest@0.20.2: {} - typescript@5.3.3: {} - typescript@5.5.3: {} - undici-types@5.26.5: {} + typescript@5.6.2: {} + + undici-types@6.19.8: {} universalify@2.0.1: {} @@ -1675,7 +1617,7 @@ snapshots: yargs@17.7.2: dependencies: cliui: 8.0.1 - escalade: 3.1.2 + escalade: 3.2.0 get-caller-file: 2.0.5 require-directory: 2.1.1 string-width: 4.2.3 From 169025983a6bbc692544f0415fb53f4f2433fc0e Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 26 Sep 2024 11:26:28 -0700 Subject: [PATCH 12/68] 0.7.4 --- package.json | 2 +- wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 437eebb..8fd80f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.7.3", + "version": "0.7.4", "description": "Atomic state management for Roblox", "scripts": { "lint": "sh bin/check.sh", diff --git a/wally.toml b/wally.toml index c6f706e..40fae75 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "littensy/charm" description = "Atomic state management for Roblox" -version = "0.7.3" +version = "0.7.4" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" From ecfb221fdf4f7e94b030544192e8469fc7ec0020 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 28 Sep 2024 07:12:52 -0700 Subject: [PATCH 13/68] fix: search for react in `_Index` --- src/modules/React.luau | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/React.luau b/src/modules/React.luau index d91d348..acb4d86 100644 --- a/src/modules/React.luau +++ b/src/modules/React.luau @@ -1,6 +1,8 @@ local Package = script.Parent.Parent +local Index = script:FindFirstAncestor("_Index") local Packages = if Package.Parent:FindFirstChild("React") then Package.Parent + elseif Index then Index.Parent else Package.Parent.Parent:FindFirstChild("ReactLua") type React = typeof(require(Package.Parent.React)) From 69e5f78463a744967e2c998033ed1a0a2e309126 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 28 Sep 2024 07:13:04 -0700 Subject: [PATCH 14/68] 0.7.5 --- package.json | 2 +- wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8fd80f8..4cff93c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.7.4", + "version": "0.7.5", "description": "Atomic state management for Roblox", "scripts": { "lint": "sh bin/check.sh", diff --git a/wally.toml b/wally.toml index 40fae75..01ac0a4 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "littensy/charm" description = "Atomic state management for Roblox" -version = "0.7.4" +version = "0.7.5" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" From 7c323e5739511e6859968a5ccf8a7d3aa603ad5a Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 28 Sep 2024 17:17:54 -0700 Subject: [PATCH 15/68] fix: skip nested captures (#22) * fix: skip nested captures * test: update atom bench --- src/__tests__/benchmarks/atom.bench.luau | 12 ++-- src/__tests__/capture.spec.luau | 17 +++++- src/__tests__/init.spec.luau | 2 + src/atom.luau | 6 +- src/store.luau | 78 +++++++++++++----------- 5 files changed, 71 insertions(+), 44 deletions(-) diff --git a/src/__tests__/benchmarks/atom.bench.luau b/src/__tests__/benchmarks/atom.bench.luau index 6663caa..f688841 100644 --- a/src/__tests__/benchmarks/atom.bench.luau +++ b/src/__tests__/benchmarks/atom.bench.luau @@ -1,7 +1,7 @@ -- This file is for use by Benchmarker -- https://boatbomber.itch.io/benchmarker -local Package = game:GetService("ReplicatedStorage").Packages.charm +local Package = game:GetService("ReplicatedStorage").DevPackages.Charm local atom = require(Package.atom) local computed = require(Package.computed) @@ -11,19 +11,19 @@ local function parameterGenerator() end local benchmarks = { - atom = function(_, source: any) + atom = function(_, state: any) for _ = 1, 500 do - source(math.random(1, 2)) + state(math.random(1, 2)) end end, - computed = function(_, source: any) + computed = function(_, state: any) computed(function() - return 2 * source() + return 2 * state() end) for _ = 1, 500 do - source(math.random(1, 2)) + state(math.random(1, 2)) end end, } diff --git a/src/__tests__/capture.spec.luau b/src/__tests__/capture.spec.luau index 60b6631..bb19c5e 100644 --- a/src/__tests__/capture.spec.luau +++ b/src/__tests__/capture.spec.luau @@ -1,5 +1,6 @@ local atom = require(script.Parent.Parent.atom) local store = require(script.Parent.Parent.store) +local subscribe = require(script.Parent.Parent.subscribe) return function() it("gracefully handles yielding", function() @@ -29,7 +30,7 @@ return function() expect(result).to.equal(1) end) - it("allows nested captures", function() + it("skips atoms in inner capture calls", function() local a = atom(1) local b = atom(1) local inner @@ -41,8 +42,20 @@ return function() end) assert(outer[a], "outer did not capture a") - assert(outer[b], "outer did not capture b") + assert(outer[b] == nil, "outer captured b") assert(inner[a] == nil, "inner captured a") assert(inner[b], "inner did not capture b") end) + + it("should not capture notified dependents", function() + local state = atom(1) + + subscribe(state, function() end) + + local deps = store.capture(function() + return state(2) + end) + + expect(deps[state]).to.never.be.ok() + end) end diff --git a/src/__tests__/init.spec.luau b/src/__tests__/init.spec.luau index a7878dc..5a4c1fc 100644 --- a/src/__tests__/init.spec.luau +++ b/src/__tests__/init.spec.luau @@ -13,5 +13,7 @@ return function() afterEach(function() table.clear(store.listeners :: any) + table.clear(store.capturing.stack) + store.capturing.index = 0 end) end diff --git a/src/atom.luau b/src/atom.luau index 07aeb59..f10dbd6 100644 --- a/src/atom.luau +++ b/src/atom.luau @@ -15,8 +15,10 @@ local function atom(state: T, options: AtomOptions?): Atom local function atom(...) if select("#", ...) == 0 then - for set in next, store.capturing do - set[atom] = true + local index = store.capturing.index + + if index > 0 then + store.capturing.stack[index][atom] = true end return state diff --git a/src/store.luau b/src/store.luau index e197048..68bea45 100644 --- a/src/store.luau +++ b/src/store.luau @@ -4,10 +4,15 @@ type Set = types.Set type WeakMap = types.WeakMap local listeners: WeakMap, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" }) -local capturing: Set>> = {} + local batched: Set<() -> ()> = {} local batching = false +local capturing = { + stack = {} :: { Set> }, + index = 0, +} + --[=[ Calls the given function and returns the result. If the function yields or throws an error, the thread is closed and an error is thrown. Regardless of @@ -29,7 +34,9 @@ local function try(callback: (U...) -> T | any, finally: (() -> ())?, . if coroutine.status(thread) == "suspended" then local source, line, name = debug.info(callback, "sln") + coroutine.close(thread) + error( "Yielding is not allowed in atom functions. Consider wrapping this code in a Promise or task.defer instead." .. `\nFunction defined at: {source}:{line}` @@ -37,6 +44,7 @@ local function try(callback: (U...) -> T | any, finally: (() -> ())?, . ) elseif not success then local source, line, name = debug.info(callback, "sln") + error( "An error occurred while running an atom function" .. `\nFunction defined at: {source}:{line}` @@ -53,6 +61,7 @@ local function try(callback: (U...) -> T | any, finally: (() -> ())?, . end local success, result = pcall(callback, ...) + finally() assert(success, result) @@ -87,6 +96,34 @@ local function notify(atom: Atom) end end +--[=[ + Returns the result of the function without subscribing to changes. If a + non-function value is provided, it is returned as is. + + @param molecule The atom or molecule to get the state of. + @param args Arguments to pass to the molecule. + @return The current state. +]=] +local function peek(callback: ((U...) -> T) | T, ...: U...): T + if type(callback) ~= "function" then + return callback + end + + if capturing.index == 0 then + return callback(...) + end + + capturing.index += 1 + capturing.stack[capturing.index] = {} + + local result = try(callback, function() + capturing.stack[capturing.index] = nil + capturing.index -= 1 + end, ...) + + return result +end + --[=[ Captures all atoms that are read during the function call and returns them along with the result of the function. Useful for tracking dependencies. @@ -96,14 +133,17 @@ end ]=] local function capture(molecule: () -> T): (Set>, T) if listeners[molecule] then - return { [molecule] = true }, molecule() + return { [molecule] = true }, peek(molecule) end local dependencies: Set> = {} - capturing[dependencies] = true + + capturing.index += 1 + capturing.stack[capturing.index] = dependencies local result = try(molecule, function() - capturing[dependencies] = nil + capturing.stack[capturing.index] = nil + capturing.index -= 1 end) return dependencies, result @@ -133,36 +173,6 @@ local function batch(callback: () -> ()) table.clear(batched) end ---[=[ - Returns the result of the function without subscribing to changes. If a - non-function value is provided, it is returned as is. - - @param molecule The atom or molecule to get the state of. - @param args Arguments to pass to the molecule. - @return The current state. -]=] -local function peek(callback: ((U...) -> T) | T, ...: U...): T - if type(callback) ~= "function" then - return callback - end - - if not next(capturing) then - return callback(...) - end - - local snapshot = table.clone(capturing) - - table.clear(capturing) - - local result = try(callback, function() - for set in next, snapshot do - capturing[set] = true - end - end, ...) - - return result -end - --[=[ Subscribes the listener to the changes of the given atoms. From b038bf1f7096765f435b39f34f9bb870775e5073 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 28 Sep 2024 17:18:34 -0700 Subject: [PATCH 16/68] 0.7.6 --- package.json | 2 +- wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4cff93c..aeaa219 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.7.5", + "version": "0.7.6", "description": "Atomic state management for Roblox", "scripts": { "lint": "sh bin/check.sh", diff --git a/wally.toml b/wally.toml index 01ac0a4..a9888e5 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "littensy/charm" description = "Atomic state management for Roblox" -version = "0.7.5" +version = "0.7.6" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" From 08c7e690d512fde2739fd8b2888ca300a9094bd0 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 12 Oct 2024 18:29:15 -0700 Subject: [PATCH 17/68] refactor!: migrate to monorepo (#23) * refactor!: create monorepo * chore: prepare release, set up workflows * refactor: rename `modules` to `src` * docs: add docs for all packages * refactor: rename root project file * chore: bump deps * build: rename `lint`, add `pack` script * revert: delete pack script This will not be useful for development. --- .github/workflows/ci.yml | 13 +- .github/workflows/release.yml | 15 +- .gitignore | 10 +- .vscode/settings.json | 4 +- README.md | 173 +++++++++++++---- bin/build.sh | 5 + bin/check.sh | 29 +-- bin/install.sh | 4 - bin/spec.server.luau | 10 - default.project.json | 28 ++- dev.project.json | 19 -- package.json | 49 +---- pnpm-lock.yaml | 181 ++++++++++++------ pnpm-workspace.yaml | 2 + src/__tests__/react/init.spec.luau | 11 -- .luaurc => src/charm-sync/.luaurc | 0 src/charm-sync/default.project.json | 6 + src/charm-sync/package.json | 16 ++ src/{sync => charm-sync/src}/client.luau | 11 +- src/charm-sync/src/index.d.ts | 163 ++++++++++++++++ src/{sync => charm-sync/src}/init.luau | 3 + .../src/interval.luau} | 4 +- src/{sync => charm-sync/src}/patch.luau | 6 +- src/{sync => charm-sync/src}/server.luau | 19 +- src/charm-sync/src/types.luau | 10 + src/{sync => charm-sync/src}/validate.luau | 4 +- src/charm-sync/wally.toml | 7 + src/charm/.luaurc | 3 + src/charm/default.project.json | 6 + src/charm/package.json | 13 ++ src/{ => charm/src}/atom.luau | 0 src/{ => charm/src}/computed.luau | 0 src/{ => charm/src}/effect.luau | 0 src/{ => charm/src}/index.d.ts | 172 ----------------- src/{ => charm/src}/init.luau | 8 +- src/{ => charm/src}/mapped.luau | 0 src/{ => charm/src}/observe.luau | 0 src/{ => charm/src}/store.luau | 0 src/{ => charm/src}/subscribe.luau | 0 src/{ => charm/src}/types.luau | 0 src/charm/wally.toml | 7 + src/modules/Promise.luau | 9 - src/modules/React.luau | 17 -- src/modules/ReactRoblox.luau | 9 - src/react/.luaurc | 3 + src/react/default.project.json | 6 + src/react/package.json | 19 ++ src/react/src/index.d.ts | 15 ++ src/react/{useAtom.luau => src/init.luau} | 16 +- src/react/wally.toml | 7 + src/vide/.luaurc | 3 + src/vide/default.project.json | 6 + src/vide/package.json | 19 ++ src/vide/src/index.d.ts | 9 + src/vide/src/init.luau | 23 +++ src/vide/wally.toml | 7 + testez-companion.toml | 2 +- .../benchmarks/atom.bench.luau | 10 +- .../charm-sync}/client.spec.luau | 20 +- .../charm-sync}/init.spec.luau | 8 +- .../sync => tests/charm-sync}/patch.spec.luau | 6 +- .../charm-sync}/server.spec.luau | 24 ++- .../charm-sync}/validate.spec.luau | 7 +- {src/__tests__ => tests/charm}/atom.spec.luau | 7 +- .../__tests__ => tests/charm}/batch.spec.luau | 13 +- .../charm}/capture.spec.luau | 9 +- .../charm}/computed.spec.luau | 9 +- .../charm}/effect.spec.luau | 7 +- tests/charm/init.spec.luau | 19 ++ .../charm}/mapped.spec.luau | 11 +- .../charm}/observe.spec.luau | 7 +- {src/__tests__ => tests/charm}/peek.spec.luau | 16 +- .../charm}/subscribe.spec.luau | 11 +- tests/init.server.luau | 5 + .../react/useAtom.spec.luau | 32 ++-- {src => tests}/utils/collect.luau | 0 {src => tests}/utils/count.luau | 0 .../helpers => tests/utils}/renderHook.luau | 8 +- tests/vide/useAtom.spec.luau | 36 ++++ wally.lock | 24 +-- wally.toml | 17 +- 81 files changed, 917 insertions(+), 570 deletions(-) create mode 100644 bin/build.sh delete mode 100644 bin/install.sh delete mode 100644 bin/spec.server.luau delete mode 100644 dev.project.json create mode 100644 pnpm-workspace.yaml delete mode 100644 src/__tests__/react/init.spec.luau rename .luaurc => src/charm-sync/.luaurc (100%) create mode 100644 src/charm-sync/default.project.json create mode 100644 src/charm-sync/package.json rename src/{sync => charm-sync/src}/client.luau (89%) create mode 100644 src/charm-sync/src/index.d.ts rename src/{sync => charm-sync/src}/init.luau (80%) rename src/{utils/setInterval.luau => charm-sync/src/interval.luau} (85%) rename src/{sync => charm-sync/src}/patch.luau (97%) rename src/{sync => charm-sync/src}/server.luau (93%) create mode 100644 src/charm-sync/src/types.luau rename src/{sync => charm-sync/src}/validate.luau (96%) create mode 100644 src/charm-sync/wally.toml create mode 100644 src/charm/.luaurc create mode 100644 src/charm/default.project.json create mode 100644 src/charm/package.json rename src/{ => charm/src}/atom.luau (100%) rename src/{ => charm/src}/computed.luau (100%) rename src/{ => charm/src}/effect.luau (100%) rename src/{ => charm/src}/index.d.ts (51%) rename src/{ => charm/src}/init.luau (81%) rename src/{ => charm/src}/mapped.luau (100%) rename src/{ => charm/src}/observe.luau (100%) rename src/{ => charm/src}/store.luau (100%) rename src/{ => charm/src}/subscribe.luau (100%) rename src/{ => charm/src}/types.luau (100%) create mode 100644 src/charm/wally.toml delete mode 100644 src/modules/Promise.luau delete mode 100644 src/modules/React.luau delete mode 100644 src/modules/ReactRoblox.luau create mode 100644 src/react/.luaurc create mode 100644 src/react/default.project.json create mode 100644 src/react/package.json create mode 100644 src/react/src/index.d.ts rename src/react/{useAtom.luau => src/init.luau} (62%) create mode 100644 src/react/wally.toml create mode 100644 src/vide/.luaurc create mode 100644 src/vide/default.project.json create mode 100644 src/vide/package.json create mode 100644 src/vide/src/index.d.ts create mode 100644 src/vide/src/init.luau create mode 100644 src/vide/wally.toml rename {src/__tests__ => tests}/benchmarks/atom.bench.luau (63%) rename {src/__tests__/sync => tests/charm-sync}/client.spec.luau (78%) rename {src/__tests__ => tests/charm-sync}/init.spec.luau (61%) rename {src/__tests__/sync => tests/charm-sync}/patch.spec.luau (92%) rename {src/__tests__/sync => tests/charm-sync}/server.spec.luau (73%) rename {src/__tests__/sync => tests/charm-sync}/validate.spec.luau (82%) rename {src/__tests__ => tests/charm}/atom.spec.luau (83%) rename {src/__tests__ => tests/charm}/batch.spec.luau (77%) rename {src/__tests__ => tests/charm}/capture.spec.luau (84%) rename {src/__tests__ => tests/charm}/computed.spec.luau (89%) rename {src/__tests__ => tests/charm}/effect.spec.luau (89%) create mode 100644 tests/charm/init.spec.luau rename {src/__tests__ => tests/charm}/mapped.spec.luau (88%) rename {src/__tests__ => tests/charm}/observe.spec.luau (75%) rename {src/__tests__ => tests/charm}/peek.spec.luau (50%) rename {src/__tests__ => tests/charm}/subscribe.spec.luau (88%) create mode 100644 tests/init.server.luau rename {src/__tests__ => tests}/react/useAtom.spec.luau (53%) rename {src => tests}/utils/collect.luau (100%) rename {src => tests}/utils/count.luau (100%) rename {src/__tests__/react/helpers => tests/utils}/renderHook.luau (79%) create mode 100644 tests/vide/useAtom.spec.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6cdec7..570c8ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,15 +22,10 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Install dependencies - run: | - pnpm install - pnpm wally + run: pnpm install - name: Check code quality - run: pnpm lint + run: pnpm check - - name: Run ESLint - run: pnpm eslint - - - name: Build model - run: rojo build -o charm.rbxm + - name: Build models + run: pnpm build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a179663..84e6eea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,10 +16,17 @@ jobs: - name: Install dependencies run: wally install - - name: Build model - run: rojo build -o charm.rbxm + - name: Build models + run: pnpm build - - uses: AButler/upload-release-assets@v3.0 + - name: Upload release assets + uses: AButler/upload-release-assets@v3.0 with: - files: "charm.rbxm" + files: "build/*" repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to NPM + run: pnpm --recursive publish --access public + + - name: Publish to Wally + run: pnpm --recursive publish:wally diff --git a/.gitignore b/.gitignore index f57862f..a5a20b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ /node_modules -/Packages -/DevPackages +/build +/devpackages +/packages +/src/**/node_modules +/src/**/wally.lock +/src/**/README.md +/src/**/LICENSE.md /sourcemap.json *.tsbuildinfo *.tgz +*.rbxm .DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json index e6aa02a..68885db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,3 @@ { - "luau-lsp.sourcemap.rojoProjectFile": "dev.project.json", - "luau-lsp.types.definitionFiles": ["bin/testez.d.luau"], - "luau-lsp.completion.imports.requireStyle": "alwaysRelative" + "luau-lsp.types.definitionFiles": ["bin/testez.d.luau"] } diff --git a/README.md b/README.md index 0ceccf5..4bda633 100644 --- a/README.md +++ b/README.md @@ -298,14 +298,31 @@ batch(() => { --- -## 📘 React +## 📦 React + +### Setup + +Install the React bindings for Charm using your package manager of choice. + +```sh +npm install @rbxts/react-charm +yarn add @rbxts/react-charm +pnpm add @rbxts/react-charm +``` + +```toml +[dependencies] +ReactCharm = "littensy/react-charm@VERSION" +``` + +--- ### `useAtom(atom, dependencies?)` Call `useAtom` at the top-level of a React component to read from an atom. ```tsx -import { useAtom } from "@rbxts/charm"; +import { useAtom } from "@rbxts/react-charm"; import { todosAtom } from "./todos-atom"; function TodosApp() { @@ -317,7 +334,7 @@ function TodosApp() { By default, the atom is subscribed to once when the component initially mounts. Optionally, you may pass an array of dependencies to `useAtom` if your atom should be memoized based on other values. ```tsx -const todos = useAtom(searchTodos(filter), [filter]); +const todos = useAtom(() => searchTodos(filter), [filter]); ``` #### Parameters @@ -332,27 +349,87 @@ const todos = useAtom(searchTodos(filter), [filter]); --- +## 📦 Vide + +### Setup + +Install the Vide bindings for Charm using your package manager of choice. + +```sh +npm install @rbxts/vide-charm +yarn add @rbxts/vide-charm +pnpm add @rbxts/vide-charm +``` + +```toml +[dependencies] +VideCharm = "littensy/vide-charm@VERSION" +``` + +--- + +### `useAtom(atom)` + +Call `useAtom` in a any scope to create a Vide source that reads from an atom. + +```tsx +import { useAtom } from "@rbxts/vide-charm"; +import { todosAtom } from "./todos-atom"; + +function TodosApp() { + const todos = useAtom(todosAtom); + // ... +} +``` + +#### Parameters + +- `atom`: An atom or molecule that you want to read from. This can be an atom, or a function that reads from one or more atoms. + +#### Returns + +`useAtom` returns a Vide source. + +--- + ## 📗 Charm Sync -### `sync.server(options)` +### Setup -Call `sync.server` to create a server sync object. The object handles sending state updates to clients at a specified interval, and hydrating clients with the initial state. +The Charm Sync package provides server-client synchronization for your Charm atoms. Install it using your package manager of choice. + +```sh +npm install @rbxts/charm-sync +yarn add @rbxts/charm-sync +pnpm add @rbxts/charm-sync +``` + +```toml +[dependencies] +CharmSync = "littensy/charm-sync@VERSION" +``` + +--- + +### `server(options)` + +Call `server` to create a server sync object. The syncer handles sending minimal state patches to clients at a specified interval, and hydrating clients with the initial state. ```ts -import { sync } from "@rbxts/charm"; +import { server } from "@rbxts/charm-sync"; -const server = sync.server({ +const syncer = server({ atoms: atomsToSync, interval: 0, preserveHistory: false, }); -server.connect((player, ...payloads) => { +syncer.connect((player, ...payloads) => { remotes.syncState.fire(player, ...payloads); }); remotes.requestState.connect((player) => { - server.hydrate(player); + syncer.hydrate(player); }); ``` @@ -368,11 +445,11 @@ remotes.requestState.connect((player) => { #### Returns -`sync.server` returns a server sync object. The sync object has the following methods: +`server` returns a server sync object. The sync object has the following methods: -- `server.connect(callback)` registers a callback to send state updates to clients. The callback will receive the player and the payload to send, and should send the payload to the client. The payload should not be mutated, so changes should be applied to a copy of the payload. +- `syncer.connect(callback)` registers a callback to send state updates to clients. The callback will receive the player and the payload to send, and should send the payload to the client. The payload should not be mutated, so changes should be applied to a copy of the payload. -- `server.hydrate(player)` sends the initial state to a player. This calls the function passed to `connect` with a payload containing the initial state. +- `syncer.hydrate(player)` sends the initial state to a player. This calls the function passed to `connect` with a payload containing the initial state. #### Caveats @@ -382,17 +459,17 @@ remotes.requestState.connect((player) => { --- -### `sync.client(options)` +### `client(options)` -Call `sync.client` to create a client sync object. The object will sync the client's copy of the state with the server's state. +Call `client` to create a client sync object. The syncer will hydrate the client's copy of the state with the server's state. ```ts -import { sync } from "@rbxts/charm"; +import { client } from "@rbxts/charm-sync"; -const client = sync.client({ atoms: atomsToSync }); +const syncer = client({ atoms: atomsToSync }); remotes.syncState.connect((...payloads) => { - client.sync(...payloads); + syncer.sync(...payloads); }); remotes.requestState.fire(); @@ -406,9 +483,9 @@ remotes.requestState.fire(); #### Returns -`sync.client` returns a client sync object. The sync object has the following methods: +`client` returns a client sync object. The sync object has the following methods: -- `client.sync(...payloads)` applies a state update from the server. +- `syncer.sync(...payloads)` applies a state update from the server. #### Caveats @@ -416,18 +493,18 @@ remotes.requestState.fire(); --- -### `sync.isNone(value)` - -State patches represent the _difference_ between the current state and next state, excluding unchanged values. However, this means both unchanged and removed values would be `nil` in the patch. In these cases, Charm uses the `None` marker to represent a removed value. +### `isNone(value)` Call `sync.isNone` to check if a value is `None`. +State patches represent the _difference_ between the current state and next state, excluding unchanged values. However, this means both unchanged and removed values would be `nil` in the patch. In these cases, Charm uses the `None` marker to represent a removed value. + ```ts -import { sync } from "@rbxts/charm"; +import { isNone, server } from "@rbxts/charm-sync"; -const server = sync.server({ atoms: atomsToSync }); +const syncer = server({ atoms: atomsToSync }); -server.connect((player, payload) => { +syncer.connect((player, payload) => { if (payload.type === "patch" && sync.isNone(payload.data.todosAtom?.eggs)) { // 'eggs' will be removed from the client's todos atom } @@ -437,11 +514,11 @@ server.connect((player, payload) => { #### Parameters -- `value`: Any value. If the value is `None`, `sync.isNone` will return `true`. +- `value`: Any value. If the value is `None`, `isNone` will return `true`. #### Returns -`sync.isNone` returns a boolean. +`isNone` returns a boolean. --- @@ -466,18 +543,18 @@ counterAtom(1); // 2 counterAtom((count) => count + 1); // 4 ``` -### Counter component +### React component ```tsx import React from "@rbxts/react"; -import { useAtom } from "@rbxts/charm"; +import { useAtom } from "@rbxts/react-charm"; import { counterAtom, incrementCounter } from "./counter-atom"; function Counter() { const count = useAtom(counterAtom); return ( - { + incrementCounter(); + }} + Text={() => `Count: ${count()}`} + Size={new UDim2(0, 100, 0, 50)} + /> + ); +} +``` + ### Server-client sync Charm provides client and server objects for synchronizing state between the server and clients. Start by defining a module (or creating an object) exporting the atoms you want to sync: @@ -502,35 +601,35 @@ Then, on the server, create a server sync object and pass in the atoms to sync. Note that if `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter. ```ts -import { sync } from "@rbxts/charm"; +import { server } from "@rbxts/charm-sync"; import { remotes } from "./remotes"; import * as atoms from "./atoms"; -const server = sync.server({ atoms }); +const syncer = server({ atoms }); // Broadcast a state update to a specific player -server.connect((player, ...payloads) => { +syncer.connect((player, ...payloads) => { remotes.syncState.fire(player, ...payloads); }); // Send initial state to a player upon request remotes.requestState.connect((player) => { - server.hydrate(player); + syncer.hydrate(player); }); ``` Finally, on the client, create a client sync object and apply incoming state changes. ```ts -import { sync } from "@rbxts/charm"; +import { client } from "@rbxts/charm-sync"; import { remotes } from "./remotes"; import * as atoms from "./atoms"; -const client = sync.client({ atoms }); +const syncer = sync.client({ atoms }); // Listen for incoming state changes from the server remotes.syncState.connect((...payloads) => { - client.sync(...payloads); + syncer.sync(...payloads); }); // Request initial state from the server diff --git a/bin/build.sh b/bin/build.sh new file mode 100644 index 0000000..b8a7ddc --- /dev/null +++ b/bin/build.sh @@ -0,0 +1,5 @@ +mkdir build +rojo build src/charm -o build/charm.rbxm +rojo build src/charm-sync -o build/charm-sync.rbxm +rojo build src/react -o build/react-charm.rbxm +rojo build src/vide -o build/vide-charm.rbxm diff --git a/bin/check.sh b/bin/check.sh index e388fe8..72d7bb8 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -1,18 +1,25 @@ curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua -rojo sourcemap dev.project.json -o sourcemap.json +rojo sourcemap -o sourcemap.json -luau-lsp analyze \ - --defs=bin/roblox.d.luau \ - --defs=bin/testez.d.luau \ - --flag:LuauFixIndexerSubtypingOrdering=true \ - --flag:LuauInstantiateInSubtyping=true \ - --flag:LuauTinyControlFlowAnalysis=true \ - --sourcemap=sourcemap.json \ - --ignore="**/_Index/**" src +check() { + echo "Checking $1" + + luau-lsp analyze \ + --defs=bin/roblox.d.luau \ + --defs=bin/testez.d.luau \ + --flag:LuauFixIndexerSubtypingOrdering=true \ + --flag:LuauInstantiateInSubtyping=true \ + --sourcemap=sourcemap.json \ + --ignore="**/_Index/**" \ + $1 + + selene $1 + stylua --check $1 +} -selene src -stylua --check src eslint src +check src/*/src +check tests rm bin/roblox.d.luau diff --git a/bin/install.sh b/bin/install.sh deleted file mode 100644 index 4091d75..0000000 --- a/bin/install.sh +++ /dev/null @@ -1,4 +0,0 @@ -wally install -rojo sourcemap dev.project.json --output sourcemap.json -wally-package-types --sourcemap sourcemap.json Packages -wally-package-types --sourcemap sourcemap.json DevPackages diff --git a/bin/spec.server.luau b/bin/spec.server.luau deleted file mode 100644 index 81d6907..0000000 --- a/bin/spec.server.luau +++ /dev/null @@ -1,10 +0,0 @@ ---!nocheck - -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Packages = ReplicatedStorage.Packages -local DevPackages = ReplicatedStorage.DevPackages - -local TestEZ = require(DevPackages.TestEZ) - -TestEZ.TestBootstrap:run({ Packages.charm.__tests__ }, TestEZ.Reporters.TextReporter) diff --git a/default.project.json b/default.project.json index a485e4d..8817adb 100644 --- a/default.project.json +++ b/default.project.json @@ -1,6 +1,30 @@ { - "name": "charm", + "name": "charm-test", "tree": { - "$path": "src" + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "DevPackages": { + "$path": "DevPackages", + "Charm": { + "$path": "src/charm" + }, + "CharmSync": { + "$path": "src/charm-sync" + }, + "ReactCharm": { + "$path": "src/react" + }, + "VideCharm": { + "$path": "src/vide" + } + } + }, + "ServerScriptService": { + "$className": "ServerScriptService", + "Tests": { + "$path": "Tests" + } + } } } diff --git a/dev.project.json b/dev.project.json deleted file mode 100644 index 6b6d807..0000000 --- a/dev.project.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "charm-dev", - "tree": { - "$className": "DataModel", - "ReplicatedStorage": { - "$className": "ReplicatedStorage", - "DevPackages": { - "$path": "DevPackages", - "Charm": { "$path": "src" } - }, - "Packages": { "$path": "Packages" } - }, - "TestService": { - "$className": "TestService", - "$properties": { "ExecuteWithStudioRun": true }, - "spec": { "$path": "bin/spec.server.luau" } - } - } -} diff --git a/package.json b/package.json index aeaa219..2cea8ea 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,13 @@ { - "name": "@rbxts/charm", - "version": "0.7.6", - "description": "Atomic state management for Roblox", + "private": true, "scripts": { - "lint": "sh bin/check.sh", - "wally": "sh bin/install.sh" - }, - "main": "src/init.lua", - "types": "src/index.d.ts", - "keywords": [ - "roblox", - "typescript", - "roblox-ts", - "state" - ], - "author": "littensy", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/littensy/charm" - }, - "bugs": { - "url": "https://github.com/littensy/charm/issues" - }, - "homepage": "https://github.com/littensy/charm", - "files": [ - "src", - "!src/__tests__" - ], - "publishConfig": { - "access": "public" + "build": "sh bin/build.sh", + "check": "sh bin/check.sh", + "wally": "wally install && rojo sourcemap --output sourcemap.json" }, "devDependencies": { "@rbxts/compiler-types": "3.0.0-types.0", - "@rbxts/react": "^0.4.0", - "@rbxts/types": "^1.0.809", + "@rbxts/types": "^1.0.811", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.1", @@ -42,15 +15,7 @@ "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-roblox-ts": "^0.0.36", "prettier": "^3.3.3", - "roblox-ts": "3.0.0", - "typescript": "^5.6.2" - }, - "peerDependencies": { - "@rbxts/react": "*" - }, - "peerDependenciesMeta": { - "@rbxts/react": { - "optional": true - } + "roblox-ts": "^3.0.0", + "typescript": "~5.5.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5ddd4d..3791d76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,18 +11,15 @@ importers: '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 - '@rbxts/react': - specifier: ^0.4.0 - version: 0.4.0 '@rbxts/types': - specifier: ^1.0.809 - version: 1.0.809 + specifier: ^1.0.811 + version: 1.0.811 '@typescript-eslint/eslint-plugin': specifier: ^7.18.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2) + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/parser': specifier: ^7.18.0 - version: 7.18.0(eslint@8.57.1)(typescript@5.6.2) + version: 7.18.0(eslint@8.57.1)(typescript@5.5.4) eslint: specifier: ^8.57.1 version: 8.57.1 @@ -39,11 +36,63 @@ importers: specifier: ^3.3.3 version: 3.3.3 roblox-ts: - specifier: 3.0.0 + specifier: ^3.0.0 version: 3.0.0 typescript: - specifier: ^5.6.2 - version: 5.6.2 + specifier: ~5.5.4 + version: 5.5.4 + + modules/charm: {} + + modules/charm-sync: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + + modules/react: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/react': + specifier: '*' + version: 17.2.1 + + modules/vide: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/vide': + specifier: '*' + version: 0.4.6 + + src/charm: {} + + src/charm-sync: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + + src/react: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/react': + specifier: '*' + version: 17.2.1 + + src/vide: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/vide': + specifier: '*' + version: 0.4.6 packages: @@ -97,14 +146,17 @@ packages: '@rbxts/compiler-types@3.0.0-types.0': resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} - '@rbxts/react-vendor@0.4.0': - resolution: {integrity: sha512-roWs9FmR7CQDK1N6gE/36tiCBQRl8v1S0pUJD2Ly3ZMVvfjAk2gixrkqGcKFGtXQQq6r4dTAtHduAeZJBd640g==} + '@rbxts/react-vendor@17.2.1': + resolution: {integrity: sha512-7DxFweCbhhso4M4tl1pO7Kil9SZrvOl1SoZ4lnwUfoxdHvSmWgeKgaUqd+ZS3/u1E8kRraqGQ56Q8zjUEEUzlA==} - '@rbxts/react@0.4.0': - resolution: {integrity: sha512-yhqXzfH5YpwfZyiWipQMfQsI//2sC86Jqn2rMkP+l1y4CfhCvoYxpaI5qS7Xum9F+DsBHiwJNeUxm2V2LzXW0A==} + '@rbxts/react@17.2.1': + resolution: {integrity: sha512-iu0RsVvfHroI7l+QXjwE0FiWV2rXoKMxuznDZ8vnx2BsFqrPnCqFjXuNeGYZwibibZC6jbyq7uKl+/8cz7XlsA==} - '@rbxts/types@1.0.809': - resolution: {integrity: sha512-COmNdE5VKq44tU1Sqbj0b70qDcUpBY1Uu5BhNb25v1yJATwcoVjsmYTyaxmRO4EVdqOATjOUBU2jbxZa4K83uw==} + '@rbxts/types@1.0.811': + resolution: {integrity: sha512-MPhs5lxzxvDqie34Y9FzJ1R+k+33jjrkj03agX2y2WzBKyGmO0rD/kPuUaKXQgEiACXuk36C5FxUTYgkKJPbvA==} + + '@rbxts/vide@0.4.6': + resolution: {integrity: sha512-xYtvCS05nGq1YU7fyQVmp4z1CcvuOR+VKpKj/Y7lFPvKobQ3oHTQEzZiJFEMZ+iSeHxwZhwpzVjrP+6+e2EoQw==} '@roblox-ts/luau-ast@2.0.0': resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} @@ -118,8 +170,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@20.16.9': - resolution: {integrity: sha512-rkvIVJxsOfBejxK7I0FO5sa2WxFmJCzoDwcd88+fq/CUfynNywTo/1/T6hyFz22CyztsnLS9nVlHOnTI36RH5w==} + '@types/node@20.16.11': + resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==} '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -370,6 +422,7 @@ packages: eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: @@ -737,8 +790,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.9.1: - resolution: {integrity: sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==} + synckit@0.9.2: + resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} engines: {node: ^14.18.0 || >=16.0.0} text-table@0.2.0: @@ -779,8 +832,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.6.2: - resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} + typescript@5.5.4: + resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} engines: {node: '>=14.17'} hasBin: true @@ -879,13 +932,15 @@ snapshots: '@rbxts/compiler-types@3.0.0-types.0': {} - '@rbxts/react-vendor@0.4.0': {} + '@rbxts/react-vendor@17.2.1': {} - '@rbxts/react@0.4.0': + '@rbxts/react@17.2.1': dependencies: - '@rbxts/react-vendor': 0.4.0 + '@rbxts/react-vendor': 17.2.1 - '@rbxts/types@1.0.809': {} + '@rbxts/types@1.0.811': {} + + '@rbxts/vide@0.4.6': {} '@roblox-ts/luau-ast@2.0.0': {} @@ -901,48 +956,48 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@20.16.9': + '@types/node@20.16.11': dependencies: undici-types: 6.19.8 '@types/semver@7.5.8': {} - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.11.1 - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.18.0 eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) eslint: 8.57.1 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.3.7 eslint: 8.57.1 optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color @@ -956,15 +1011,15 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) debug: 4.3.7 eslint: 8.57.1 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color @@ -972,7 +1027,7 @@ snapshots: '@typescript-eslint/types@7.18.0': {} - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.6.2)': + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.5.4)': dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 @@ -980,13 +1035,13 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.3 - tsutils: 3.21.0(typescript@5.6.2) + tsutils: 3.21.0(typescript@5.5.4) optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.6.2)': + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.5.4)': dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 @@ -995,20 +1050,20 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.4) eslint: 8.57.1 eslint-scope: 5.1.1 semver: 7.6.3 @@ -1016,12 +1071,12 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) eslint: 8.57.1 transitivePeerDependencies: - supports-color @@ -1159,15 +1214,15 @@ snapshots: eslint: 8.57.1 prettier: 3.3.3 prettier-linter-helpers: 1.0.0 - synckit: 0.9.1 + synckit: 0.9.2 optionalDependencies: eslint-config-prettier: 9.1.0(eslint@8.57.1) eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1): dependencies: - '@types/node': 20.16.9 - '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.6.2) - typescript: 5.6.2 + '@types/node': 20.16.11 + '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) + typescript: 5.5.4 transitivePeerDependencies: - eslint - supports-color @@ -1554,7 +1609,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.9.1: + synckit@0.9.2: dependencies: '@pkgr/core': 0.1.1 tslib: 2.7.0 @@ -1565,18 +1620,18 @@ snapshots: dependencies: is-number: 7.0.0 - ts-api-utils@1.3.0(typescript@5.6.2): + ts-api-utils@1.3.0(typescript@5.5.4): dependencies: - typescript: 5.6.2 + typescript: 5.5.4 tslib@1.14.1: {} tslib@2.7.0: {} - tsutils@3.21.0(typescript@5.6.2): + tsutils@3.21.0(typescript@5.5.4): dependencies: tslib: 1.14.1 - typescript: 5.6.2 + typescript: 5.5.4 type-check@0.4.0: dependencies: @@ -1586,7 +1641,7 @@ snapshots: typescript@5.5.3: {} - typescript@5.6.2: {} + typescript@5.5.4: {} undici-types@6.19.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..e79ce62 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - src/* diff --git a/src/__tests__/react/init.spec.luau b/src/__tests__/react/init.spec.luau deleted file mode 100644 index 05ca21e..0000000 --- a/src/__tests__/react/init.spec.luau +++ /dev/null @@ -1,11 +0,0 @@ -return function() - local previous = _G.__DEV__ - - beforeAll(function() - _G.__DEV__ = false -- prevent React errors - end) - - afterAll(function() - _G.__DEV__ = previous - end) -end diff --git a/.luaurc b/src/charm-sync/.luaurc similarity index 100% rename from .luaurc rename to src/charm-sync/.luaurc diff --git a/src/charm-sync/default.project.json b/src/charm-sync/default.project.json new file mode 100644 index 0000000..9c44c7c --- /dev/null +++ b/src/charm-sync/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "charm-sync", + "tree": { + "$path": "src" + } +} diff --git a/src/charm-sync/package.json b/src/charm-sync/package.json new file mode 100644 index 0000000..69bb096 --- /dev/null +++ b/src/charm-sync/package.json @@ -0,0 +1,16 @@ +{ + "name": "@rbxts/charm-sync", + "version": "0.1.0", + "description": "Sync server state with clients using Charm", + "repository": "littensy/charm", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:*" + } +} diff --git a/src/sync/client.luau b/src/charm-sync/src/client.luau similarity index 89% rename from src/sync/client.luau rename to src/charm-sync/src/client.luau index a5901d3..a2c2de9 100644 --- a/src/sync/client.luau +++ b/src/charm-sync/src/client.luau @@ -1,14 +1,13 @@ -local store = require(script.Parent.Parent.store) -local types = require(script.Parent.Parent.types) -type Atom = types.Atom -type SyncPayload = types.SyncPayload +local Charm = require(script.Parent.Parent.Charm) local patch = require(script.Parent.patch) +local types = require(script.Parent.types) +type SyncPayload = types.SyncPayload type ClientOptions = { --[=[ The atoms to synchronize with the server. ]=] - atoms: { [string]: Atom }, + atoms: { [string]: Charm.Atom }, --[=[ Whether to ignore patches sent before the client has been hydrated. Default is `true`. @@ -52,7 +51,7 @@ local function client(options: ClientOptions): ClientSyncer hydrated = true - store.batch(function() + Charm.batch(function() for key, state in next, payload.data do local atom = atoms[key] diff --git a/src/charm-sync/src/index.d.ts b/src/charm-sync/src/index.d.ts new file mode 100644 index 0000000..ef2b8d3 --- /dev/null +++ b/src/charm-sync/src/index.d.ts @@ -0,0 +1,163 @@ +import { Atom, Molecule } from "@rbxts/charm"; + +export = CharmSync; +export as namespace CharmSync; + +type Cleanup = () => void; + +declare namespace CharmSync { + type AtomMap = Record>; + + type MoleculeMap = Record>; + + /** + * Recursively infers the type of the state produced by a map of molecules. + */ + type StateOfMap = { + [P in keyof T]: T[P] extends Molecule ? State : never; + }; + + /** + * A special value that denotes the absence of a value. Used to represent + * removed values in patches. + */ + interface None { + readonly __none: "__none"; + } + + type MaybeNone = undefined extends T ? None : never; + + /** + * Creates a `ClientSyncer` object that receives patches from the server and + * applies them to the local state. + * + * @client + * @param options The atoms to synchronize with the server. + * @returns A `ClientSyncer` object. + */ + function client(options: ClientOptions): ClientSyncer; + + /** + * Creates a `ServerSyncer` object that sends patches to the client and + * hydrates the client's state. + * + * @server + * @param options The atoms to synchronize with the client. + * @returns A `ServerSyncer` object. + */ + function server(options: ServerOptions): ServerSyncer; + + /** + * 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. + * @returns `true` if the value is `None`, otherwise `false`. + */ + function isNone(value: unknown): value is None; + + type DataTypes = { + [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; + }; + + /** + * A type that should not be made partial in patches. + */ + type DataType = DataTypes[keyof DataTypes]; + + /** + * A partial patch that can be applied to the state to update it. Represents + * the difference between the current state and the next state. + * + * If a value was removed, it is replaced with `None`. This can be checked + * using the `sync.isNone` function. + */ + type SyncPatch = + | MaybeNone + | (State extends ReadonlyMap | Map + ? ReadonlyMap | None> + : State extends Set | ReadonlySet + ? ReadonlyMap + : State extends readonly (infer T)[] + ? readonly (SyncPatch | None | undefined)[] + : State extends DataType + ? State + : State extends object + ? { readonly [P in keyof State]?: SyncPatch } + : State); + + /** + * A payload that can be sent from the server to the client to synchronize + * state between the two. + */ + type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch> }; + + interface ClientOptions { + /** + * The atoms to synchronize with the server. + */ + atoms: Atoms; + /** + * Whether to ignore patches sent before the client has been hydrated. + * Defaults to `true`. + */ + ignoreUnhydrated?: boolean; + } + + interface ServerOptions { + /** + * The atoms to synchronize with the client. + */ + atoms: Molecules; + /** + * 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?: number; + /** + * 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`. + * + * If `true`, the broadcaster will send a list of payloads to the client + * instead of a single payload. The client will apply each payload in order + * to reconstruct the state's changes over time. + */ + preserveHistory?: boolean; + } + + interface ClientSyncer { + /** + * Applies a patch or initializes the state of the atoms with the given + * payload from the server. + * + * @param payloads The patches or hydration payloads to apply. + */ + sync(...payloads: SyncPayload[]): void; + } + + interface ServerSyncer { + /** + * Sets up a subscription to each atom that schedules a patch to be sent to + * the client whenever the state changes. When a change occurs, the `callback` + * is called with the player and the payloads to send. + * + * Note that a `payload` object should not be mutated. If you need to modify + * a payload, apply the changes to a copy of the object. + * + * @param callback The function to call when the state changes. + * @returns A cleanup function that unsubscribes all listeners. + */ + connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; + /** + * Hydrates the client's state with the server's state. This should be + * called when a player joins the game and requires the server's state. + * + * @param player The player to hydrate. + */ + hydrate(player: Player): void; + } +} diff --git a/src/sync/init.luau b/src/charm-sync/src/init.luau similarity index 80% rename from src/sync/init.luau rename to src/charm-sync/src/init.luau index 7a236f4..b57c18f 100644 --- a/src/sync/init.luau +++ b/src/charm-sync/src/init.luau @@ -1,6 +1,9 @@ local client = require(script.client) local patch = require(script.patch) local server = require(script.server) +local types = require(script.types) + +export type SyncPayload = types.SyncPayload --[=[ Synchronizes state between the client and server. The server sends patches diff --git a/src/utils/setInterval.luau b/src/charm-sync/src/interval.luau similarity index 85% rename from src/utils/setInterval.luau rename to src/charm-sync/src/interval.luau index e4650e7..31df067 100644 --- a/src/utils/setInterval.luau +++ b/src/charm-sync/src/interval.luau @@ -7,7 +7,7 @@ local RunService = game:GetService("RunService") @param seconds The interval in seconds. @return A function that cancels the interval. ]=] -local function setInterval(callback: () -> (), seconds: number): () -> () +local function interval(callback: () -> (), seconds: number): () -> () if seconds < 0 then return function() end end @@ -27,4 +27,4 @@ local function setInterval(callback: () -> (), seconds: number): () -> () end end -return setInterval +return interval diff --git a/src/sync/patch.luau b/src/charm-sync/src/patch.luau similarity index 97% rename from src/sync/patch.luau rename to src/charm-sync/src/patch.luau index dd192f1..6780d1f 100644 --- a/src/sync/patch.luau +++ b/src/charm-sync/src/patch.luau @@ -4,7 +4,7 @@ local validate = require(script.Parent.validate) A special symbol that denotes the absence of a value. Used to represent deleted values in patches. ]=] -local NONE = { __none = "__none" } +local None = { __none = "__none" } local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" @@ -46,7 +46,7 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }) if previous == current then patches[key] = nil elseif current == nil then - patches[key] = NONE + patches[key] = None elseif type(previous) == "table" and type(current) == "table" then patches[key] = diff(previous, current) end @@ -97,7 +97,7 @@ local function apply(state: any, patches: any): any end return { - NONE = NONE, + NONE = None, isNone = isNone, diff = diff, apply = apply, diff --git a/src/sync/server.luau b/src/charm-sync/src/server.luau similarity index 93% rename from src/sync/server.luau rename to src/charm-sync/src/server.luau index a395abc..53fe26e 100644 --- a/src/sync/server.luau +++ b/src/charm-sync/src/server.luau @@ -1,17 +1,16 @@ local Players = game:GetService("Players") -local subscribe = require(script.Parent.Parent.subscribe) -local types = require(script.Parent.Parent.types) -type Atom = types.Atom -type SyncPayload = types.SyncPayload -local setInterval = require(script.Parent.Parent.utils.setInterval) +local Charm = require(script.Parent.Parent.Charm) +local interval = require(script.Parent.interval) local patch = require(script.Parent.patch) +local types = require(script.Parent.types) +type SyncPayload = types.SyncPayload type ServerOptions = { --[=[ The atoms to synchronize with the client. ]=] - atoms: { [string]: Atom }, + atoms: { [string]: Charm.Atom }, --[=[ 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 @@ -103,7 +102,7 @@ local function server(options: ServerOptions): ServerSyncer -- Populate the snapshot with the initial state of each atom. -- Subscribe to each atom and update the state when it changes. for key, atom in next, options.atoms do - subscriptions[key] = subscribe(atom, function(current, previous) + subscriptions[key] = Charm.subscribe(atom, function(current, previous) pushSnapshot(key, current, previous) changed = true end) @@ -111,7 +110,7 @@ local function server(options: ServerOptions): ServerSyncer snapshots[1] = createSnapshot() - local disconnect = setInterval(function() + local disconnect = interval(function() if not changed then return end @@ -187,13 +186,13 @@ local function server(options: ServerOptions): ServerSyncer -- Subscribe to each atom and update the state when one changes. for key, atom in next, options.atoms do - subscriptions[key] = subscribe(atom, function(state) + subscriptions[key] = Charm.subscribe(atom, function(state) currentSnapshot[key] = state changed = true end) end - local disconnect = setInterval(function() + local disconnect = interval(function() if not changed then return end diff --git a/src/charm-sync/src/types.luau b/src/charm-sync/src/types.luau new file mode 100644 index 0000000..de30101 --- /dev/null +++ b/src/charm-sync/src/types.luau @@ -0,0 +1,10 @@ +--[=[ + A payload that can be sent from the server to the client to synchronize + state between the two. +]=] +export type SyncPayload = { + type: "patch" | "init", + data: { [any]: any }, +} + +return nil diff --git a/src/sync/validate.luau b/src/charm-sync/src/validate.luau similarity index 96% rename from src/sync/validate.luau rename to src/charm-sync/src/validate.luau index 26429ca..911816d 100644 --- a/src/sync/validate.luau +++ b/src/charm-sync/src/validate.luau @@ -1,4 +1,4 @@ -local store = require(script.Parent.Parent.store) +local Charm = require(script.Parent.Parent.Charm) local SAFE_KEYS = { string = true, number = true } local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true } @@ -47,7 +47,7 @@ local function validate(value: any, key: any) elseif typeOfValue == "table" then if getmetatable(value) ~= nil then error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) - elseif store.isAtom(value) then + elseif Charm.isAtom(value) then error(`Cannot sync nested atoms! Got atom at key '{key}'`) elseif isUnsafeTable(value) then error( diff --git a/src/charm-sync/wally.toml b/src/charm-sync/wally.toml new file mode 100644 index 0000000..346ac1a --- /dev/null +++ b/src/charm-sync/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "littensy/charm-sync" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] diff --git a/src/charm/.luaurc b/src/charm/.luaurc new file mode 100644 index 0000000..e2b625c --- /dev/null +++ b/src/charm/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} diff --git a/src/charm/default.project.json b/src/charm/default.project.json new file mode 100644 index 0000000..a485e4d --- /dev/null +++ b/src/charm/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "charm", + "tree": { + "$path": "src" + } +} diff --git a/src/charm/package.json b/src/charm/package.json new file mode 100644 index 0000000..772e860 --- /dev/null +++ b/src/charm/package.json @@ -0,0 +1,13 @@ +{ + "name": "@rbxts/charm", + "version": "0.8.0", + "description": "An atomic state management library for Roblox", + "repository": "littensy/charm", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + } +} diff --git a/src/atom.luau b/src/charm/src/atom.luau similarity index 100% rename from src/atom.luau rename to src/charm/src/atom.luau diff --git a/src/computed.luau b/src/charm/src/computed.luau similarity index 100% rename from src/computed.luau rename to src/charm/src/computed.luau diff --git a/src/effect.luau b/src/charm/src/effect.luau similarity index 100% rename from src/effect.luau rename to src/charm/src/effect.luau diff --git a/src/index.d.ts b/src/charm/src/index.d.ts similarity index 51% rename from src/index.d.ts rename to src/charm/src/index.d.ts index 95f9e99..b36d393 100644 --- a/src/index.d.ts +++ b/src/charm/src/index.d.ts @@ -42,16 +42,6 @@ declare namespace Charm { */ type StateOf = T extends Molecule ? State : never; - /** - * Recursively infers the type of the state produced by a map of molecules. - */ - type StateOfMap = { - [P in keyof T]: T[P] extends Molecule ? State : never; - }; - - type AtomMap = Record>; - type MoleculeMap = Record>; - interface AtomOptions { /** * A function that determines whether the state has changed. By default, @@ -189,166 +179,4 @@ declare namespace Charm { molecule: Molecule>, mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, ): Molecule>; - - /** - * A hook that subscribes to changes in the given atom or molecule. The - * component is re-rendered whenever the state changes. - * - * If the `dependencies` array is provided, the subscription to the atom or - * molecule is re-created whenever the dependencies change. Otherwise, the - * subscription is created once when the component is mounted. - * - * @param molecule The atom or molecule to subscribe to. - * @param dependencies An array of values that the subscription depends on. - * @returns The current state. - */ - function useAtom(molecule: Molecule, dependencies?: unknown[]): State; - - /** - * Synchronizes state between the client and server. The server sends patches - * to the client, which applies them to its local state. - */ - const sync: { - /** - * Creates a `ClientSyncer` object that receives patches from the server and - * applies them to the local state. - * - * @client - * @param options The atoms to synchronize with the server. - * @returns A `ClientSyncer` object. - */ - client: (options: ClientOptions) => ClientSyncer; - /** - * Creates a `ServerSyncer` object that sends patches to the client and - * hydrates the client's state. - * - * @server - * @param options The atoms to synchronize with the client. - * @returns A `ServerSyncer` object. - */ - server: (options: ServerOptions) => ServerSyncer; - /** - * 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. - * @returns `true` if the value is `None`, otherwise `false`. - */ - isNone: (value: unknown) => value is None; - }; - - /** - * A special value that denotes the absence of a value. Used to represent - * removed values in patches. - */ - interface None { - readonly __none: "__none"; - } - - type MaybeNone = undefined extends T ? None : never; - - type DataTypes = { - [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; - }; - - /** - * A type that should not be made partial in patches. - */ - type DataType = DataTypes[keyof DataTypes]; - - /** - * A partial patch that can be applied to the state to update it. Represents - * the difference between the current state and the next state. - * - * If a value was removed, it is replaced with `None`. This can be checked - * using the `sync.isNone` function. - */ - type SyncPatch = - | MaybeNone - | (State extends ReadonlyMap | Map - ? ReadonlyMap | None> - : State extends Set | ReadonlySet - ? ReadonlyMap - : State extends readonly (infer T)[] - ? readonly (SyncPatch | None | undefined)[] - : State extends DataType - ? State - : State extends object - ? { readonly [P in keyof State]?: SyncPatch } - : State); - - /** - * A payload that can be sent from the server to the client to synchronize - * state between the two. - */ - type SyncPayload = - | { type: "init"; data: StateOfMap } - | { type: "patch"; data: SyncPatch> }; - - interface ClientOptions { - /** - * The atoms to synchronize with the server. - */ - atoms: Atoms; - /** - * Whether to ignore patches sent before the client has been hydrated. - * Defaults to `true`. - */ - ignoreUnhydrated?: boolean; - } - - interface ServerOptions { - /** - * The atoms to synchronize with the client. - */ - atoms: Molecules; - /** - * 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?: number; - /** - * 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`. - * - * If `true`, the broadcaster will send a list of payloads to the client - * instead of a single payload. The client will apply each payload in order - * to reconstruct the state's changes over time. - */ - preserveHistory?: boolean; - } - - interface ClientSyncer { - /** - * Applies a patch or initializes the state of the atoms with the given - * payload from the server. - * - * @param payloads The patches or hydration payloads to apply. - */ - sync(...payloads: SyncPayload[]): void; - } - - interface ServerSyncer { - /** - * Sets up a subscription to each atom that schedules a patch to be sent to - * the client whenever the state changes. When a change occurs, the `callback` - * is called with the player and the payloads to send. - * - * Note that a `payload` object should not be mutated. If you need to modify - * a payload, apply the changes to a copy of the object. - * - * @param callback The function to call when the state changes. - * @returns A cleanup function that unsubscribes all listeners. - */ - connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; - /** - * Hydrates the client's state with the server's state. This should be - * called when a player joins the game and requires the server's state. - * - * @param player The player to hydrate. - */ - hydrate(player: Player): void; - } } diff --git a/src/init.luau b/src/charm/src/init.luau similarity index 81% rename from src/init.luau rename to src/charm/src/init.luau index 2e14c62..1cb6c02 100644 --- a/src/init.luau +++ b/src/charm/src/init.luau @@ -1,14 +1,10 @@ -_G[script.modules.Promise] = _G[script] - local atom = require(script.atom) local computed = require(script.computed) local effect = require(script.effect) local mapped = require(script.mapped) local observe = require(script.observe) -local useAtom = require(script.react.useAtom) local store = require(script.store) local subscribe = require(script.subscribe) -local sync = require(script.sync) local types = require(script.types) export type Atom = types.Atom @@ -21,12 +17,10 @@ return { effect = effect, mapped = mapped, observe = observe, - useAtom = useAtom, + subscribe = subscribe, batch = store.batch, capture = store.capture, isAtom = store.isAtom, notify = store.notify, peek = store.peek, - subscribe = subscribe, - sync = sync, } diff --git a/src/mapped.luau b/src/charm/src/mapped.luau similarity index 100% rename from src/mapped.luau rename to src/charm/src/mapped.luau diff --git a/src/observe.luau b/src/charm/src/observe.luau similarity index 100% rename from src/observe.luau rename to src/charm/src/observe.luau diff --git a/src/store.luau b/src/charm/src/store.luau similarity index 100% rename from src/store.luau rename to src/charm/src/store.luau diff --git a/src/subscribe.luau b/src/charm/src/subscribe.luau similarity index 100% rename from src/subscribe.luau rename to src/charm/src/subscribe.luau diff --git a/src/types.luau b/src/charm/src/types.luau similarity index 100% rename from src/types.luau rename to src/charm/src/types.luau diff --git a/src/charm/wally.toml b/src/charm/wally.toml new file mode 100644 index 0000000..d180c9e --- /dev/null +++ b/src/charm/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "littensy/charm" +version = "0.8.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] diff --git a/src/modules/Promise.luau b/src/modules/Promise.luau deleted file mode 100644 index c716f98..0000000 --- a/src/modules/Promise.luau +++ /dev/null @@ -1,9 +0,0 @@ -local Package = script.Parent.Parent - -if _G[script] then - return _G[script].Promise -end - -assert(Package.Parent:FindFirstChild("Promise"), `Could not find Promise in {Package.Parent:GetFullName()}`) - -return require(Package.Parent.Promise) diff --git a/src/modules/React.luau b/src/modules/React.luau deleted file mode 100644 index acb4d86..0000000 --- a/src/modules/React.luau +++ /dev/null @@ -1,17 +0,0 @@ -local Package = script.Parent.Parent -local Index = script:FindFirstAncestor("_Index") -local Packages = if Package.Parent:FindFirstChild("React") - then Package.Parent - elseif Index then Index.Parent - else Package.Parent.Parent:FindFirstChild("ReactLua") - -type React = typeof(require(Package.Parent.React)) - -local React: React - -return function() - if not React then - React = Packages and require(Packages.React) :: React - end - return React -end diff --git a/src/modules/ReactRoblox.luau b/src/modules/ReactRoblox.luau deleted file mode 100644 index 7e4560b..0000000 --- a/src/modules/ReactRoblox.luau +++ /dev/null @@ -1,9 +0,0 @@ -local Package = script.Parent.Parent -local Packages = if Package.Parent:FindFirstChild("ReactRoblox") - then Package.Parent - else Package.Parent.Parent:FindFirstChild("ReactLua") - -type ReactRoblox = typeof(require(Package.Parent.ReactRoblox)) - --- It's safe to require this module since it's only used in unit tests. -return (if Packages then require(Packages.ReactRoblox) else nil) :: ReactRoblox diff --git a/src/react/.luaurc b/src/react/.luaurc new file mode 100644 index 0000000..e2b625c --- /dev/null +++ b/src/react/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} diff --git a/src/react/default.project.json b/src/react/default.project.json new file mode 100644 index 0000000..baf768c --- /dev/null +++ b/src/react/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "react-charm", + "tree": { + "$path": "src" + } +} diff --git a/src/react/package.json b/src/react/package.json new file mode 100644 index 0000000..07a43b6 --- /dev/null +++ b/src/react/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rbxts/react-charm", + "version": "0.1.0", + "description": "React bindings for Charm", + "repository": "littensy/charm", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:*" + }, + "peerDependencies": { + "@rbxts/react": "*" + } +} diff --git a/src/react/src/index.d.ts b/src/react/src/index.d.ts new file mode 100644 index 0000000..abf0fb3 --- /dev/null +++ b/src/react/src/index.d.ts @@ -0,0 +1,15 @@ +import { Molecule } from "@rbxts/charm"; + +/** + * A hook that subscribes to changes in the given atom or molecule. The + * component is re-rendered whenever the state changes. + * + * If the `dependencies` array is provided, the subscription to the atom or + * molecule is re-created whenever the dependencies change. Otherwise, the + * subscription is created once when the component is mounted. + * + * @param molecule The atom or molecule to subscribe to. + * @param dependencies An array of values that the subscription depends on. + * @returns The current state. + */ +export function useAtom(molecule: Molecule, dependencies?: unknown[]): State; diff --git a/src/react/useAtom.luau b/src/react/src/init.luau similarity index 62% rename from src/react/useAtom.luau rename to src/react/src/init.luau index ef7ce3c..482b922 100644 --- a/src/react/useAtom.luau +++ b/src/react/src/init.luau @@ -1,7 +1,5 @@ -local requireReact = require(script.Parent.Parent.modules.React) -local subscribe = require(script.Parent.Parent.subscribe) -local types = require(script.Parent.Parent.types) -type Molecule = types.Molecule +local Charm = require(script.Parent.Charm) +local React = require(script.Parent.React) --[=[ A hook that subscribes to changes in the given atom or molecule. The @@ -15,17 +13,17 @@ type Molecule = types.Molecule @param dependencies An array of values that the subscription depends on. @return The current state. ]=] -local function useAtom(molecule: Molecule, dependencies: { any }?): State - local React = requireReact() - +local function useAtom(molecule: Charm.Molecule, dependencies: { any }?): State local state, setState = React.useState(molecule) React.useEffect(function() setState(molecule()) - return subscribe(molecule, setState) + return Charm.subscribe(molecule, setState) end, dependencies or {}) return state end -return useAtom +return { + useAtom = useAtom, +} diff --git a/src/react/wally.toml b/src/react/wally.toml new file mode 100644 index 0000000..e265be0 --- /dev/null +++ b/src/react/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "littensy/react-charm" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] diff --git a/src/vide/.luaurc b/src/vide/.luaurc new file mode 100644 index 0000000..e2b625c --- /dev/null +++ b/src/vide/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} diff --git a/src/vide/default.project.json b/src/vide/default.project.json new file mode 100644 index 0000000..e2a35ad --- /dev/null +++ b/src/vide/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "vide-charm", + "tree": { + "$path": "src" + } +} diff --git a/src/vide/package.json b/src/vide/package.json new file mode 100644 index 0000000..efb9c6c --- /dev/null +++ b/src/vide/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rbxts/vide-charm", + "version": "0.1.0", + "description": "Vide bindings for Charm", + "repository": "littensy/charm", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:*" + }, + "peerDependencies": { + "@rbxts/vide": "*" + } +} diff --git a/src/vide/src/index.d.ts b/src/vide/src/index.d.ts new file mode 100644 index 0000000..77e8612 --- /dev/null +++ b/src/vide/src/index.d.ts @@ -0,0 +1,9 @@ +import { Molecule } from "@rbxts/charm"; + +/** + * Subscribes to the state of an atom and returns a Vide source. + * + * @param molecule The atom or molecule to subscribe to. + * @returns The reactive source. + */ +export function useAtom(atom: Molecule): () => T; diff --git a/src/vide/src/init.luau b/src/vide/src/init.luau new file mode 100644 index 0000000..7146559 --- /dev/null +++ b/src/vide/src/init.luau @@ -0,0 +1,23 @@ +local Charm = require(script.Parent.Charm) +local Vide = require(script.Parent.Vide) + +--[=[ + Subscribes to the state of an atom and returns a Vide source. + + @param molecule The atom or molecule to subscribe to. + @return The reactive source. +]=] +local function useAtom(molecule: () -> State): State + local state = Vide.source(molecule()) + local unsubscribe = Charm.subscribe(molecule, function(value) + task.spawn(state, value) + end) + + Vide.cleanup(unsubscribe) + + return state +end + +return { + useAtom = useAtom, +} diff --git a/src/vide/wally.toml b/src/vide/wally.toml new file mode 100644 index 0000000..ca334cb --- /dev/null +++ b/src/vide/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "littensy/vide-charm" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] diff --git a/testez-companion.toml b/testez-companion.toml index cc9ae64..204e049 100644 --- a/testez-companion.toml +++ b/testez-companion.toml @@ -1 +1 @@ -roots = ["ReplicatedStorage/DevPackages/Charm/__tests__"] +roots = ["ServerScriptService/Tests"] diff --git a/src/__tests__/benchmarks/atom.bench.luau b/tests/benchmarks/atom.bench.luau similarity index 63% rename from src/__tests__/benchmarks/atom.bench.luau rename to tests/benchmarks/atom.bench.luau index f688841..af410ee 100644 --- a/src/__tests__/benchmarks/atom.bench.luau +++ b/tests/benchmarks/atom.bench.luau @@ -1,10 +1,8 @@ --- This file is for use by Benchmarker --- https://boatbomber.itch.io/benchmarker +local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Package = game:GetService("ReplicatedStorage").DevPackages.Charm - -local atom = require(Package.atom) -local computed = require(Package.computed) +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local computed = Charm.computed +local atom = Charm.atom local function parameterGenerator() return atom(0) diff --git a/src/__tests__/sync/client.spec.luau b/tests/charm-sync/client.spec.luau similarity index 78% rename from src/__tests__/sync/client.spec.luau rename to tests/charm-sync/client.spec.luau index 90c128a..2d2de15 100644 --- a/src/__tests__/sync/client.spec.luau +++ b/tests/charm-sync/client.spec.luau @@ -1,11 +1,15 @@ -local atom = require(script.Parent.Parent.Parent.atom) -local subscribe = require(script.Parent.Parent.Parent.subscribe) -local sync = require(script.Parent.Parent.Parent.sync) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local subscribe = Charm.subscribe +local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +local client = CharmSync.client return function() it("syncs state payload", function() local a = atom(1) - local client = sync.client({ atoms = { a = a } }) + local client = client({ atoms = { a = a } }) client:sync({ type = "init", @@ -18,7 +22,7 @@ return function() it("syncs state patch", function() local a = atom({ b = 1, c = 2 }) local previous = a() - local client = sync.client({ atoms = { a = a } }) + local client = client({ atoms = { a = a } }) client:sync({ type = "init", @@ -39,7 +43,7 @@ return function() it("fixes sparse array patches", function() local a = atom({ 1 }) - local client = sync.client({ atoms = { a = a } }) + local client = client({ atoms = { a = a } }) client:sync({ type = "init", @@ -60,7 +64,7 @@ return function() it("applies multiple patches", function() local a = atom(1) - local client = sync.client({ atoms = { a = a } }) + local client = client({ atoms = { a = a } }) local calls = {} subscribe(a, function(value) @@ -88,7 +92,7 @@ return function() it("batches patches", function() local a = atom(0) local b = atom(0) - local client = sync.client({ atoms = { a = a, b = b } }) + local client = client({ atoms = { a = a, b = b } }) local states = {} subscribe(a, function() diff --git a/src/__tests__/init.spec.luau b/tests/charm-sync/init.spec.luau similarity index 61% rename from src/__tests__/init.spec.luau rename to tests/charm-sync/init.spec.luau index 5a4c1fc..21a24c0 100644 --- a/src/__tests__/init.spec.luau +++ b/tests/charm-sync/init.spec.luau @@ -1,14 +1,14 @@ -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() - local previous = _G.__DEV__ - beforeAll(function() _G.__DEV__ = true end) afterAll(function() - _G.__DEV__ = previous + _G.__DEV__ = nil end) afterEach(function() diff --git a/src/__tests__/sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau similarity index 92% rename from src/__tests__/sync/patch.spec.luau rename to tests/charm-sync/patch.spec.luau index 813fdbc..5128954 100644 --- a/src/__tests__/sync/patch.spec.luau +++ b/tests/charm-sync/patch.spec.luau @@ -1,5 +1,7 @@ local HttpService = game:GetService("HttpService") -local patch = require(script.Parent.Parent.Parent.sync.patch) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) return function() local function serdes(value: { [any]: any }) @@ -77,7 +79,7 @@ return function() local patches = serdes(patch.diff(prevState, nextState)) local applied = patch.apply(prevState, patches) - assert(os.clock() - start < 1e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`) + assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`) for index in next, prevState do expect(applied[index]).to.equal(nextState[index]) diff --git a/src/__tests__/sync/server.spec.luau b/tests/charm-sync/server.spec.luau similarity index 73% rename from src/__tests__/sync/server.spec.luau rename to tests/charm-sync/server.spec.luau index da17b1e..7afd5dc 100644 --- a/src/__tests__/sync/server.spec.luau +++ b/tests/charm-sync/server.spec.luau @@ -1,15 +1,19 @@ -local atom = require(script.Parent.Parent.Parent.atom) -local sync = require(script.Parent.Parent.Parent.sync) -local patch = require(script.Parent.Parent.Parent.sync.patch) -local types = require(script.Parent.Parent.Parent.types) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +type SyncPayload = CharmSync.SyncPayload +local server = CharmSync.server +local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) return function() local target: Player = {} :: any it("sends state to new players", function() local a = atom(1) - local server = sync.server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: types.SyncPayload + local server = server({ atoms = { a = a }, interval = -1 }) + local player: Player, payload: SyncPayload local disconnect = server:connect(function(...) player, payload = ... @@ -26,8 +30,8 @@ return function() it("sends state patches", function() local a = atom({ b = 1, c = 2 } :: { [string]: number? }) - local server = sync.server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: types.SyncPayload + local server = server({ atoms = { a = a }, interval = -1 }) + local player: Player, payload: SyncPayload local disconnect = server:connect(function(...) player, payload = ... @@ -54,9 +58,9 @@ return function() it("preserves history", function() local a = atom(1) local b = atom(1) - local payloads: { types.SyncPayload } + local payloads: { SyncPayload } - local server = sync.server({ + local server = server({ atoms = { a = a, b = b }, interval = -1, preserveHistory = true, diff --git a/src/__tests__/sync/validate.spec.luau b/tests/charm-sync/validate.spec.luau similarity index 82% rename from src/__tests__/sync/validate.spec.luau rename to tests/charm-sync/validate.spec.luau index 68d9bfd..8065b9f 100644 --- a/src/__tests__/sync/validate.spec.luau +++ b/tests/charm-sync/validate.spec.luau @@ -1,5 +1,8 @@ -local atom = require(script.Parent.Parent.Parent.atom) -local validate = require(script.Parent.Parent.Parent.sync.validate) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate) return function() it("validates inputs", function() diff --git a/src/__tests__/atom.spec.luau b/tests/charm/atom.spec.luau similarity index 83% rename from src/__tests__/atom.spec.luau rename to tests/charm/atom.spec.luau index 09cfbb7..0872037 100644 --- a/src/__tests__/atom.spec.luau +++ b/tests/charm/atom.spec.luau @@ -1,5 +1,8 @@ -local atom = require(script.Parent.Parent.atom) -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("updates state", function() diff --git a/src/__tests__/batch.spec.luau b/tests/charm/batch.spec.luau similarity index 77% rename from src/__tests__/batch.spec.luau rename to tests/charm/batch.spec.luau index 319270b..375f515 100644 --- a/src/__tests__/batch.spec.luau +++ b/tests/charm/batch.spec.luau @@ -1,8 +1,11 @@ -local atom = require(script.Parent.Parent.atom) -local computed = require(script.Parent.Parent.computed) -local effect = require(script.Parent.Parent.effect) -local store = require(script.Parent.Parent.store) -local subscribe = require(script.Parent.Parent.subscribe) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local computed = Charm.computed +local subscribe = Charm.subscribe +local effect = Charm.effect +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("batches updates", function() diff --git a/src/__tests__/capture.spec.luau b/tests/charm/capture.spec.luau similarity index 84% rename from src/__tests__/capture.spec.luau rename to tests/charm/capture.spec.luau index bb19c5e..80669a5 100644 --- a/src/__tests__/capture.spec.luau +++ b/tests/charm/capture.spec.luau @@ -1,6 +1,9 @@ -local atom = require(script.Parent.Parent.atom) -local store = require(script.Parent.Parent.store) -local subscribe = require(script.Parent.Parent.subscribe) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local subscribe = Charm.subscribe +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("gracefully handles yielding", function() diff --git a/src/__tests__/computed.spec.luau b/tests/charm/computed.spec.luau similarity index 89% rename from src/__tests__/computed.spec.luau rename to tests/charm/computed.spec.luau index 87d0d6b..d71d46d 100644 --- a/src/__tests__/computed.spec.luau +++ b/tests/charm/computed.spec.luau @@ -1,6 +1,9 @@ -local atom = require(script.Parent.Parent.atom) -local computed = require(script.Parent.Parent.computed) -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local computed = Charm.computed +local store = require(ReplicatedStorage.DevPackages.Charm.store) local collect = require(script.Parent.Parent.utils.collect) local count = require(script.Parent.Parent.utils.count) diff --git a/src/__tests__/effect.spec.luau b/tests/charm/effect.spec.luau similarity index 89% rename from src/__tests__/effect.spec.luau rename to tests/charm/effect.spec.luau index 9e50c0b..faa95b3 100644 --- a/src/__tests__/effect.spec.luau +++ b/tests/charm/effect.spec.luau @@ -1,5 +1,8 @@ -local atom = require(script.Parent.Parent.atom) -local effect = require(script.Parent.Parent.effect) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local effect = Charm.effect return function() it("runs on state change", function() diff --git a/tests/charm/init.spec.luau b/tests/charm/init.spec.luau new file mode 100644 index 0000000..21a24c0 --- /dev/null +++ b/tests/charm/init.spec.luau @@ -0,0 +1,19 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local store = require(ReplicatedStorage.DevPackages.Charm.store) + +return function() + beforeAll(function() + _G.__DEV__ = true + end) + + afterAll(function() + _G.__DEV__ = nil + end) + + afterEach(function() + table.clear(store.listeners :: any) + table.clear(store.capturing.stack) + store.capturing.index = 0 + end) +end diff --git a/src/__tests__/mapped.spec.luau b/tests/charm/mapped.spec.luau similarity index 88% rename from src/__tests__/mapped.spec.luau rename to tests/charm/mapped.spec.luau index ac33a85..ea3d406 100644 --- a/src/__tests__/mapped.spec.luau +++ b/tests/charm/mapped.spec.luau @@ -1,7 +1,10 @@ -local atom = require(script.Parent.Parent.atom) -local mapped = require(script.Parent.Parent.mapped) -local observe = require(script.Parent.Parent.observe) -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local mapped = Charm.mapped +local observe = Charm.observe +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("returns mapped atoms", function() diff --git a/src/__tests__/observe.spec.luau b/tests/charm/observe.spec.luau similarity index 75% rename from src/__tests__/observe.spec.luau rename to tests/charm/observe.spec.luau index a4e6b63..314366c 100644 --- a/src/__tests__/observe.spec.luau +++ b/tests/charm/observe.spec.luau @@ -1,5 +1,8 @@ -local atom = require(script.Parent.Parent.atom) -local observe = require(script.Parent.Parent.observe) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local observe = Charm.observe return function() it("observes lifetime", function() diff --git a/src/__tests__/peek.spec.luau b/tests/charm/peek.spec.luau similarity index 50% rename from src/__tests__/peek.spec.luau rename to tests/charm/peek.spec.luau index a861517..7b72b86 100644 --- a/src/__tests__/peek.spec.luau +++ b/tests/charm/peek.spec.luau @@ -1,11 +1,15 @@ -local atom = require(script.Parent.Parent.atom) -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local capture = Charm.capture +local peek = Charm.peek return function() it("calls an atom without capturing it", function() local a, b, c = atom(1), atom(1), atom(1) - local deps, result = store.capture(function() - return a() + store.peek(b) + c() + local deps, result = capture(function() + return a() + peek(b) + c() end) expect(deps[a]).to.be.ok() expect(deps[b]).to.never.be.ok() @@ -14,8 +18,8 @@ return function() end) it("passes through arguments", function() - expect(store.peek(1)).to.equal(1) - expect(store.peek(function() + expect(peek(1)).to.equal(1) + expect(peek(function() return 1 end)).to.equal(1) end) diff --git a/src/__tests__/subscribe.spec.luau b/tests/charm/subscribe.spec.luau similarity index 88% rename from src/__tests__/subscribe.spec.luau rename to tests/charm/subscribe.spec.luau index d5256e5..a732be9 100644 --- a/src/__tests__/subscribe.spec.luau +++ b/tests/charm/subscribe.spec.luau @@ -1,7 +1,10 @@ -local atom = require(script.Parent.Parent.atom) -local computed = require(script.Parent.Parent.computed) -local store = require(script.Parent.Parent.store) -local subscribe = require(script.Parent.Parent.subscribe) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local computed = Charm.computed +local subscribe = Charm.subscribe +local store = require(ReplicatedStorage.DevPackages.Charm.store) local collect = require(script.Parent.Parent.utils.collect) local count = require(script.Parent.Parent.utils.count) diff --git a/tests/init.server.luau b/tests/init.server.luau new file mode 100644 index 0000000..f2d4789 --- /dev/null +++ b/tests/init.server.luau @@ -0,0 +1,5 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ) + +TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter) diff --git a/src/__tests__/react/useAtom.spec.luau b/tests/react/useAtom.spec.luau similarity index 53% rename from src/__tests__/react/useAtom.spec.luau rename to tests/react/useAtom.spec.luau index e1cf6a2..bb94715 100644 --- a/src/__tests__/react/useAtom.spec.luau +++ b/tests/react/useAtom.spec.luau @@ -1,42 +1,46 @@ -local atom = require(script.Parent.Parent.Parent.atom) -local useAtom = require(script.Parent.Parent.Parent.react.useAtom) -local renderHook = require(script.Parent.helpers.renderHook) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm) +local useAtom = ReactCharm.useAtom +local renderHook = require(script.Parent.Parent.utils.renderHook) return function() it("tracks atom state", function() - local source = atom(0) + local state = atom(0) local hook = renderHook(function() - return useAtom(source) + return useAtom(state) end) expect(hook.result).to.equal(0) - source(1) + state(1) expect(hook.result).to.equal(1) end) it("accepts a molecule", function() - local source = atom(0) + local state = atom(0) local hook = renderHook(function() return useAtom(function() - return 2 * source() + return 2 * state() end) end) expect(hook.result).to.equal(0) - source(1) + state(1) expect(hook.result).to.equal(2) end) it("tracks dependencies", function() - local source = atom(0) + local state = atom(0) local hook = renderHook(function() - return useAtom(source, { source }) + return useAtom(state, { state }) end) expect(hook.result).to.equal(0) - source(1) + state(1) expect(hook.result).to.equal(1) - source = atom(0) + state = atom(0) hook.rerender() expect(hook.result).to.equal(0) - source(1) + state(1) expect(hook.result).to.equal(1) end) end diff --git a/src/utils/collect.luau b/tests/utils/collect.luau similarity index 100% rename from src/utils/collect.luau rename to tests/utils/collect.luau diff --git a/src/utils/count.luau b/tests/utils/count.luau similarity index 100% rename from src/utils/count.luau rename to tests/utils/count.luau diff --git a/src/__tests__/react/helpers/renderHook.luau b/tests/utils/renderHook.luau similarity index 79% rename from src/__tests__/react/helpers/renderHook.luau rename to tests/utils/renderHook.luau index 020b2ae..0d2d19f 100644 --- a/src/__tests__/react/helpers/renderHook.luau +++ b/tests/utils/renderHook.luau @@ -1,9 +1,9 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + _G.__ROACT_17_MOCK_SCHEDULER__ = true -local Package = script:FindFirstAncestor("__tests__").Parent - -local React = require(Package.modules.React)() -local ReactRoblox = require(Package.modules.ReactRoblox) +local React = require(ReplicatedStorage.DevPackages.React) +local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox) type RenderedHook = { result: Result, diff --git a/tests/vide/useAtom.spec.luau b/tests/vide/useAtom.spec.luau new file mode 100644 index 0000000..6b8c66a --- /dev/null +++ b/tests/vide/useAtom.spec.luau @@ -0,0 +1,36 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local Vide = require(ReplicatedStorage.DevPackages.Vide) +local root = Vide.root +local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm) +local useAtom = VideCharm.useAtom + +return function() + it("tracks atom state", function() + local state = atom(0) + local source + local cleanup = root(function() + source = useAtom(state) + end) + expect(source()).to.equal(0) + state(1) + expect(source()).to.equal(1) + cleanup() + end) + + it("accepts a molecule", function() + local state = atom(0) + local source + local cleanup = root(function() + source = useAtom(function() + return 2 * state() + end) + end) + expect(source()).to.equal(0) + state(1) + expect(source()).to.equal(2) + cleanup() + end) +end diff --git a/wally.lock b/wally.lock index 09550f7..c7a5a63 100644 --- a/wally.lock +++ b/wally.lock @@ -3,8 +3,8 @@ registry = "test" [[package]] -name = "evaera/promise" -version = "4.0.0" +name = "centau/vide" +version = "0.3.1" dependencies = [] [[package]] @@ -54,27 +54,27 @@ dependencies = [] [[package]] name = "jsdotlua/react" -version = "17.1.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]] +version = "17.2.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.2.0"]] [[package]] name = "jsdotlua/react-reconciler" -version = "17.1.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.1.0"], ["scheduler", "jsdotlua/scheduler@17.1.0"], ["shared", "jsdotlua/shared@17.1.0"]] +version = "17.2.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.0"], ["scheduler", "jsdotlua/scheduler@17.2.0"], ["shared", "jsdotlua/shared@17.2.0"]] [[package]] name = "jsdotlua/react-roblox" -version = "17.1.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["react", "jsdotlua/react@17.1.0"], ["react-reconciler", "jsdotlua/react-reconciler@17.1.0"], ["scheduler", "jsdotlua/scheduler@17.1.0"], ["shared", "jsdotlua/shared@17.1.0"]] +version = "17.2.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["react", "jsdotlua/react@17.2.0"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.0"], ["scheduler", "jsdotlua/scheduler@17.2.0"], ["shared", "jsdotlua/shared@17.2.0"]] [[package]] name = "jsdotlua/scheduler" -version = "17.1.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]] +version = "17.2.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.2.0"]] [[package]] name = "jsdotlua/shared" -version = "17.1.0" +version = "17.2.0" dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"]] [[package]] @@ -95,7 +95,7 @@ dependencies = [["collections", "jsdotlua/collections@1.2.6"]] [[package]] name = "littensy/charm" version = "0.1.0" -dependencies = [["Promise", "evaera/promise@4.0.0"], ["React", "jsdotlua/react@17.1.0"], ["ReactRoblox", "jsdotlua/react-roblox@17.1.0"], ["TestEZ", "roblox/testez@0.4.1"]] +dependencies = [["React", "jsdotlua/react@17.2.0"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.0"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]] [[package]] name = "roblox/testez" diff --git a/wally.toml b/wally.toml index a9888e5..096ab3f 100644 --- a/wally.toml +++ b/wally.toml @@ -1,20 +1,11 @@ [package] name = "littensy/charm" -description = "Atomic state management for Roblox" -version = "0.7.6" -license = "MIT" +version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["**"] -include = ["src", "src/**", "default.project.json", "wally.toml", "LICENSE.md"] - -[place] -shared-packages = "game.ReplicatedStorage.Packages" - -[dependencies] -Promise = "evaera/promise@4.0.0" [dev-dependencies] -React = "jsdotlua/react@17.1.0" -ReactRoblox = "jsdotlua/react-roblox@17.1.0" +React = "jsdotlua/react@17.2.0" +ReactRoblox = "jsdotlua/react-roblox@17.2.0" TestEZ = "roblox/testez@0.4.1" +Vide = "centau/vide@0.3.1" From b6dbcea844ae0e4ed04edf7cc1fb8a0ad7ee41ed Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 18:44:00 -0700 Subject: [PATCH 18/68] chore: beta release --- src/charm-sync/package.json | 2 +- src/charm-sync/wally.toml | 3 ++- src/charm/package.json | 2 +- src/charm/wally.toml | 2 +- src/react/package.json | 2 +- src/react/wally.toml | 4 +++- src/vide/package.json | 2 +- src/vide/wally.toml | 4 +++- 8 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/charm-sync/package.json b/src/charm-sync/package.json index 69bb096..d390c2a 100644 --- a/src/charm-sync/package.json +++ b/src/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.1.0", + "version": "0.1.0-beta.1", "description": "Sync server state with clients using Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/charm-sync/wally.toml b/src/charm-sync/wally.toml index 346ac1a..23a693f 100644 --- a/src/charm-sync/wally.toml +++ b/src/charm-sync/wally.toml @@ -1,7 +1,8 @@ [package] name = "littensy/charm-sync" -version = "0.1.0" +version = "0.1.0-beta.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] +Charm = "littensy/charm@0.8.0-beta.1" diff --git a/src/charm/package.json b/src/charm/package.json index 772e860..aa1287b 100644 --- a/src/charm/package.json +++ b/src/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.8.0", + "version": "0.8.0-beta.1", "description": "An atomic state management library for Roblox", "repository": "littensy/charm", "license": "MIT", diff --git a/src/charm/wally.toml b/src/charm/wally.toml index d180c9e..0a4e55e 100644 --- a/src/charm/wally.toml +++ b/src/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.8.0" +version = "0.8.0-beta.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" diff --git a/src/react/package.json b/src/react/package.json index 07a43b6..e8d9b2d 100644 --- a/src/react/package.json +++ b/src/react/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.1.0", + "version": "0.1.0-beta.1", "description": "React bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/react/wally.toml b/src/react/wally.toml index e265be0..12efe99 100644 --- a/src/react/wally.toml +++ b/src/react/wally.toml @@ -1,7 +1,9 @@ [package] name = "littensy/react-charm" -version = "0.1.0" +version = "0.1.0-beta.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] +Charm = "littensy/charm@0.8.0-beta.1" +React = "jsdotlua/react@17.2.0" diff --git a/src/vide/package.json b/src/vide/package.json index efb9c6c..976babe 100644 --- a/src/vide/package.json +++ b/src/vide/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.1.0", + "version": "0.1.0-beta.1", "description": "Vide bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/vide/wally.toml b/src/vide/wally.toml index ca334cb..c54cd75 100644 --- a/src/vide/wally.toml +++ b/src/vide/wally.toml @@ -1,7 +1,9 @@ [package] name = "littensy/vide-charm" -version = "0.1.0" +version = "0.1.0-beta.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] +Charm = "littensy/charm@0.8.0-beta.1" +Vide = "centau/vide@0.3.1" From dcaa7ed1e64fa87b11c23039b2e2c16f21287382 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 18:51:07 -0700 Subject: [PATCH 19/68] fix: install pnpm in release workflow --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 570c8ea..6cbb8b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v3 with: - version: 8.15.4 + version: 9.12.1 - name: Install Rokit uses: CompeyDev/setup-rokit@v0.1.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84e6eea..cc22190 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 9.12.1 - name: Install Rokit uses: CompeyDev/setup-rokit@v0.1.2 From e94c542367f2ccb50bc7451b1aa2898576c73264 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 19:02:32 -0700 Subject: [PATCH 20/68] ci: change publish order --- .github/workflows/release.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc22190..f37bb93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,12 @@ jobs: with: version: 9.12.1 + - name: Publish to NPM + run: pnpm --recursive publish --access public + - name: Install Rokit uses: CompeyDev/setup-rokit@v0.1.2 - - name: Install dependencies - run: wally install - - name: Build models run: pnpm build @@ -28,8 +28,5 @@ jobs: files: "build/*" repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Publish to NPM - run: pnpm --recursive publish --access public - - name: Publish to Wally run: pnpm --recursive publish:wally From 5a32a24a47314fd2ba9c719c6e3af6e92cd4a565 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 19:06:24 -0700 Subject: [PATCH 21/68] ci: remove publishing from workflow --- .github/workflows/ci.yml | 4 +--- .github/workflows/release.yml | 10 +--------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cbb8b1..aef4707 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 - with: - version: 9.12.1 + - uses: pnpm/action-setup@v4 - name: Install Rokit uses: CompeyDev/setup-rokit@v0.1.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f37bb93..07b75ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,12 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 - with: - version: 9.12.1 - - - name: Publish to NPM - run: pnpm --recursive publish --access public + - uses: pnpm/action-setup@v4 - name: Install Rokit uses: CompeyDev/setup-rokit@v0.1.2 @@ -27,6 +22,3 @@ jobs: with: files: "build/*" repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish to Wally - run: pnpm --recursive publish:wally From a40e2ce6fa61ec3aa6dc4576683774aa11185f7b Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 19:09:20 -0700 Subject: [PATCH 22/68] build: add publish scripts --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2cea8ea..4c9a37a 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "scripts": { "build": "sh bin/build.sh", "check": "sh bin/check.sh", - "wally": "wally install && rojo sourcemap --output sourcemap.json" + "wally": "wally install && rojo sourcemap --output sourcemap.json", + "publish:npm": "pnpm publish -r --access public", + "publish:wally": "pnpm -r publish:wally" }, "devDependencies": { "@rbxts/compiler-types": "3.0.0-types.0", From 3b0fa2d5964b5ac7f269aeaad806098540a104c3 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 19:15:58 -0700 Subject: [PATCH 23/68] build: specify package manager --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c9a37a..ef757ec 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,6 @@ "prettier": "^3.3.3", "roblox-ts": "^3.0.0", "typescript": "~5.5.4" - } + }, + "packageManager": "pnpm@9.12.1" } From 175717f47eb125e13b63a67bf98ded17c4d47e7a Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 19:24:21 -0700 Subject: [PATCH 24/68] fix: support roblox-ts modules --- src/charm-sync/default.project.json | 2 +- src/charm/default.project.json | 2 +- src/react/default.project.json | 2 +- src/react/src/init.luau | 4 +++- src/vide/default.project.json | 2 +- src/vide/src/init.luau | 4 +++- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/charm-sync/default.project.json b/src/charm-sync/default.project.json index 9c44c7c..5115ff0 100644 --- a/src/charm-sync/default.project.json +++ b/src/charm-sync/default.project.json @@ -1,5 +1,5 @@ { - "name": "charm-sync", + "name": "CharmSync", "tree": { "$path": "src" } diff --git a/src/charm/default.project.json b/src/charm/default.project.json index a485e4d..89d3ada 100644 --- a/src/charm/default.project.json +++ b/src/charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "charm", + "name": "Charm", "tree": { "$path": "src" } diff --git a/src/react/default.project.json b/src/react/default.project.json index baf768c..c1ee26a 100644 --- a/src/react/default.project.json +++ b/src/react/default.project.json @@ -1,5 +1,5 @@ { - "name": "react-charm", + "name": "ReactCharm", "tree": { "$path": "src" } diff --git a/src/react/src/init.luau b/src/react/src/init.luau index 482b922..5bcafba 100644 --- a/src/react/src/init.luau +++ b/src/react/src/init.luau @@ -1,5 +1,7 @@ local Charm = require(script.Parent.Charm) -local React = require(script.Parent.React) +local React = if script.Parent:FindFirstChild("react") + then require(script.Parent.react) :: never + else require(script.Parent.React) --[=[ A hook that subscribes to changes in the given atom or molecule. The diff --git a/src/vide/default.project.json b/src/vide/default.project.json index e2a35ad..3a447c6 100644 --- a/src/vide/default.project.json +++ b/src/vide/default.project.json @@ -1,5 +1,5 @@ { - "name": "vide-charm", + "name": "VideCharm", "tree": { "$path": "src" } diff --git a/src/vide/src/init.luau b/src/vide/src/init.luau index 7146559..d311ce6 100644 --- a/src/vide/src/init.luau +++ b/src/vide/src/init.luau @@ -1,5 +1,7 @@ local Charm = require(script.Parent.Charm) -local Vide = require(script.Parent.Vide) +local Vide = if script.Parent:FindFirstChild("vide") + then require(script.Parent.vide.src) :: never + else require(script.Parent.Vide) --[=[ Subscribes to the state of an atom and returns a Vide source. From b0b2d34048663887eaef40190ad4cafa69208f26 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 19:26:21 -0700 Subject: [PATCH 25/68] chore: bump beta versions --- src/charm-sync/package.json | 2 +- src/charm-sync/wally.toml | 4 ++-- src/charm/package.json | 2 +- src/charm/wally.toml | 2 +- src/react/package.json | 2 +- src/react/wally.toml | 4 ++-- src/vide/package.json | 2 +- src/vide/wally.toml | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/charm-sync/package.json b/src/charm-sync/package.json index d390c2a..14ffd0c 100644 --- a/src/charm-sync/package.json +++ b/src/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "description": "Sync server state with clients using Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/charm-sync/wally.toml b/src/charm-sync/wally.toml index 23a693f..ff34702 100644 --- a/src/charm-sync/wally.toml +++ b/src/charm-sync/wally.toml @@ -1,8 +1,8 @@ [package] name = "littensy/charm-sync" -version = "0.1.0-beta.1" +version = "0.1.0-beta.2" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@0.8.0-beta.1" +Charm = "littensy/charm@0.8.0-beta.2" diff --git a/src/charm/package.json b/src/charm/package.json index aa1287b..7eb5960 100644 --- a/src/charm/package.json +++ b/src/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.8.0-beta.1", + "version": "0.8.0-beta.2", "description": "An atomic state management library for Roblox", "repository": "littensy/charm", "license": "MIT", diff --git a/src/charm/wally.toml b/src/charm/wally.toml index 0a4e55e..cc6d7b8 100644 --- a/src/charm/wally.toml +++ b/src/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.8.0-beta.1" +version = "0.8.0-beta.2" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" diff --git a/src/react/package.json b/src/react/package.json index e8d9b2d..32900d4 100644 --- a/src/react/package.json +++ b/src/react/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "description": "React bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/react/wally.toml b/src/react/wally.toml index 12efe99..2fb9add 100644 --- a/src/react/wally.toml +++ b/src/react/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/react-charm" -version = "0.1.0-beta.1" +version = "0.1.0-beta.2" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@0.8.0-beta.1" +Charm = "littensy/charm@0.8.0-beta.2" React = "jsdotlua/react@17.2.0" diff --git a/src/vide/package.json b/src/vide/package.json index 976babe..90838f2 100644 --- a/src/vide/package.json +++ b/src/vide/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "description": "Vide bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/vide/wally.toml b/src/vide/wally.toml index c54cd75..e8d473a 100644 --- a/src/vide/wally.toml +++ b/src/vide/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/vide-charm" -version = "0.1.0-beta.1" +version = "0.1.0-beta.2" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@0.8.0-beta.1" +Charm = "littensy/charm@0.8.0-beta.2" Vide = "centau/vide@0.3.1" From 50dd64ee186c0b7241314c3f072a14a830560ccd Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 19:40:51 -0700 Subject: [PATCH 26/68] fix: do not publish to wally concurrently --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef757ec..29d3129 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "check": "sh bin/check.sh", "wally": "wally install && rojo sourcemap --output sourcemap.json", "publish:npm": "pnpm publish -r --access public", - "publish:wally": "pnpm -r publish:wally" + "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, "devDependencies": { "@rbxts/compiler-types": "3.0.0-types.0", From 833d5f8e864505240e78a99f00d3a5fdddcffc8c Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 12 Oct 2024 22:22:04 -0700 Subject: [PATCH 27/68] chore: strip beta tag --- src/charm-sync/package.json | 2 +- src/charm-sync/wally.toml | 4 ++-- src/charm/package.json | 2 +- src/charm/wally.toml | 2 +- src/react/package.json | 2 +- src/react/wally.toml | 4 ++-- src/vide/package.json | 2 +- src/vide/wally.toml | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/charm-sync/package.json b/src/charm-sync/package.json index 14ffd0c..69bb096 100644 --- a/src/charm-sync/package.json +++ b/src/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.1.0-beta.2", + "version": "0.1.0", "description": "Sync server state with clients using Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/charm-sync/wally.toml b/src/charm-sync/wally.toml index ff34702..76fe3a5 100644 --- a/src/charm-sync/wally.toml +++ b/src/charm-sync/wally.toml @@ -1,8 +1,8 @@ [package] name = "littensy/charm-sync" -version = "0.1.0-beta.2" +version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@0.8.0-beta.2" +Charm = "littensy/charm@0.8.0" diff --git a/src/charm/package.json b/src/charm/package.json index 7eb5960..772e860 100644 --- a/src/charm/package.json +++ b/src/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.8.0-beta.2", + "version": "0.8.0", "description": "An atomic state management library for Roblox", "repository": "littensy/charm", "license": "MIT", diff --git a/src/charm/wally.toml b/src/charm/wally.toml index cc6d7b8..d180c9e 100644 --- a/src/charm/wally.toml +++ b/src/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.8.0-beta.2" +version = "0.8.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" diff --git a/src/react/package.json b/src/react/package.json index 32900d4..07a43b6 100644 --- a/src/react/package.json +++ b/src/react/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.1.0-beta.2", + "version": "0.1.0", "description": "React bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/react/wally.toml b/src/react/wally.toml index 2fb9add..daf700d 100644 --- a/src/react/wally.toml +++ b/src/react/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/react-charm" -version = "0.1.0-beta.2" +version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@0.8.0-beta.2" +Charm = "littensy/charm@0.8.0" React = "jsdotlua/react@17.2.0" diff --git a/src/vide/package.json b/src/vide/package.json index 90838f2..efb9c6c 100644 --- a/src/vide/package.json +++ b/src/vide/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.1.0-beta.2", + "version": "0.1.0", "description": "Vide bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/vide/wally.toml b/src/vide/wally.toml index e8d473a..24e8383 100644 --- a/src/vide/wally.toml +++ b/src/vide/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/vide-charm" -version = "0.1.0-beta.2" +version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@0.8.0-beta.2" +Charm = "littensy/charm@0.8.0" Vide = "centau/vide@0.3.1" From 73ae0e6de424cccf4e5b21cc62226f7117aff3af Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Fri, 25 Oct 2024 19:53:46 -0700 Subject: [PATCH 28/68] docs: rewrite documentation (#25) * docs: rewrite documentation * refactor: rename `Molecule` to `Selector` * refactor: replace all references to molecules * docs: rephrase intro * docs: remove italics * docs: fix typo * docs: update atom module names * docs: link example, add file names --- README.md | 527 ++++++++++++++++++---------------- src/charm-sync/src/index.d.ts | 29 +- src/charm/src/computed.luau | 15 +- src/charm/src/effect.luau | 2 +- src/charm/src/index.d.ts | 65 +++-- src/charm/src/init.luau | 3 +- src/charm/src/mapped.luau | 14 +- src/charm/src/observe.luau | 8 +- src/charm/src/store.luau | 14 +- src/charm/src/subscribe.luau | 26 +- src/charm/src/types.luau | 4 +- src/react/src/index.d.ts | 10 +- src/react/src/init.luau | 14 +- src/vide/src/index.d.ts | 6 +- src/vide/src/init.luau | 8 +- tests/react/useAtom.spec.luau | 2 +- tests/vide/useAtom.spec.luau | 2 +- 17 files changed, 399 insertions(+), 350 deletions(-) diff --git a/README.md b/README.md index 4bda633..ee9b816 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,19 @@ -**Charm** is an atomic state management library inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Designed to be a more composable alternative to Reflex, Charm aims to address common criticisms of Rodux-like state containers to better address certain use cases. +**Charm** is an atomic state management library inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Store your immutable state in atoms, and write intuitive functions that read, write, and subscribe to state. + +See an example of Charm's features in [this example repository](https://github.com/littensy/charm-example). ## 🍀 Features -- ⚛️ **Manage state with _atoms_.** Decompose state into small, distinct containers called _atoms_, as opposed to combining them into a single store. +- ⚛️ **Manage state with _atoms_.** Decompose state into tiny, composable containers called _atoms_, as opposed to combining them into a single store. - 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. - 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. -- 🦄 **Like magic.** Selector functions can be subscribed to as-is — their memoization and dependencies are resolved for you. +- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. --- @@ -57,10 +59,10 @@ Charm provides a debug mode to help you identify potential bugs in your project. Enabling `__DEV__` adds a few helpful features: -- Better error handling for molecules, listeners, and batched functions: +- Better error handling for selectors, subscriptions, and batched functions: - Errors provide the function's name and line number. - - Yielding in these functions will throw an error. + - Yielding in certain functions will throw an error. - Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. @@ -72,11 +74,13 @@ Enabling debug mode in unit tests, storybooks, and other development environment ### `atom(state, options?)` -Call `atom` to create a state container with the value `state`. +Atoms are the building blocks of Charm. They are functions that hold a single value, and calling them can read or write to that value. Atoms, or any function that reads from atoms, can also be [subscribed](#subscribecallback-listener) to. -```ts -const nameAtom = atom("John"); -const todosAtom = atom([]); +Call `atom` to create a state container initialized with the value `state`. + +```luau +local nameAtom = atom("John") +local todosAtom: Atom<{ string }> = atom({}) ``` #### Parameters @@ -85,7 +89,7 @@ const todosAtom = atom([]); - **optional** `options`: An object that configures the behavior of this atom. - - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`===`) is used. + - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. #### Returns @@ -94,43 +98,55 @@ The `atom` constructor returns an atom function with two possible operations: 1. **Read the state.** Call the atom without arguments to get the current state. 2. **Set the state.** Pass a new value or an updater function to change the state. -```ts -function newTodo() { - nameAtom("Jane"); - nameAtom(); // "Jane" - todosAtom((todos) => [...todos, "New todo"]); -} +```luau +local function newTodo() + nameAtom("Jane") + + todosAtom(function(todos) + todos = table.clone(todos) + table.insert(todos, "Buy milk") + return todos + end) + + print(nameAtom()) --> "Jane" +end ``` --- -### `subscribe(atom, listener)` +### `subscribe(callback, listener)` -Call `subscribe` to listen for changes to an atom. Changes to the atom will immediately notify all subscribers, passing the new state and the previous state as arguments. +Call `subscribe` to listen for changes in an atom or selector function. When the function's result changes, subscriptions are immediately called with the new state and the previous state. -```ts -const nameAtom = atom("John"); +```luau +local nameAtom = atom("John") -subscribe(nameAtom, (name, prevName) => { - print(name); -}); +subscribe(nameAtom, function(name, prevName) + print(name) +end) + +nameAtom("Jane") --> "Jane" ``` -You may also pass a _molecule_, or a function that derives a value from one or more atoms. The molecule will be memoized and only re-run when its dependencies change. +You may also pass a selector function that calls other atoms. The function will be memoized and only runs when its atom dependencies update. -```ts -const getUppercase = () => nameAtom().upper(); +```luau +local function getUppercase() + return string.upper(nameAtom()) +end -subscribe(getUppercase, (name) => { - print(name); -}); +subscribe(getUppercase, function(name) + print(name) +end) + +nameAtom("Jane") --> "JANE" ``` #### Parameters -- `atom`: An atom or molecule that you want to subscribe to. This can be an atom, or a function that reads from one or more atoms. +- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. -- `listener`: A function that will be called whenever the atom changes. The listener will receive the new state and the previous state as arguments. +- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. #### Returns @@ -140,22 +156,24 @@ subscribe(getUppercase, (name) => { ### `effect(callback)` -Call `effect` to declare a side effect that runs when any atom that it depends on changes. The effect will run immediately and whenever its dependencies change. +Call `effect` to track state changes in all atoms read within the callback. The callback will run once to retrieve its dependencies, and then again whenever they change. Your callback may return a cleanup function to run when the effect is removed or about to re-run. -```ts -const nameAtom = atom("John"); +```luau +local nameAtom = atom("John") -effect(() => { - print(nameAtom()); - return () => { - print("Changing name!"); - }; -}); +effect(function() + print(nameAtom()) + return function() + print("Cleanup function called!") + end +end) ``` +Because `effect` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. + #### Parameters -- `callback`: The function that runs your effect. The function is called once to retrieve its dependencies, and then again whenever they change. Your callback may return a cleanup function to run when the effect is removed or about to re-run. +- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. #### Returns @@ -163,22 +181,28 @@ effect(() => { --- -### `computed(molecule, options?)` +### `computed(callback, options?)` -Call `computed` when you want to derive an expensive value from one or more atoms. The derived value will be memoized and only re-run when its dependencies change. +Call `computed` when you want to derive a new atom from one or more atoms. The callback will be memoized, meaning that subsequent calls to the atom return a cached value that is only re-calculated when the dependencies change. -```ts -const todosAtom = atom([]); -const mapToUppercase = computed(() => { - return todosAtom().map((todo) => todo.upper()); -}); +```luau +local todosAtom: Atom<{ string }> = atom({}) +local mapToUppercase = computed(function() + local result = table.clone(todosAtom()) + for key, todo in result do + result[key] = string.upper(todo) + end + return result +end) ``` -`computed` is useful when you have multiple subscribers that depend on the same derived value. By memoizing the value, you can avoid re-calculating it for each subscriber. +Because `computed` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. + +This function is also useful for optimizing `effect` calls that depend on multiple atoms. For instance, if an effect derives some value from two atoms, it will run twice if both atoms change at the same time. Using `computed` can group these dependencies together and avoid re-running side effects. #### Parameters -- `molecule`: A function that reads from one or more atoms and returns a derived value. +- `callback`: A function that returns a new value depending on one or more atoms. - **optional** [`options`](#parameters): An object that configures the behavior of this atom. @@ -188,59 +212,56 @@ const mapToUppercase = computed(() => { --- -### `observe(atom, factory)` +### `observe(callback, factory)` -Call `observe` to run the factory when a key is added to the atom's state. Your factory can return a cleanup function to run when the key is removed or the observer is disposed. +Call `observe` to create an instance of `factory` for each key present in a dictionary or array. Your factory can return a cleanup function to run when the key is removed or the observer is cleaned up. -```ts -const todosAtom = atom<{ [Id in string]?: Todo }>({}); +> [!NOTE] +> Because `observe` tracks the lifetime of each key in your data, your keys must be unique and unchanging. If your data is not keyed by unique and stable identifiers, consider using [`mapped`](#mappedcallback-mapper) to transform it into a keyed object before passing it to `observe`. -observe(todosAtom, (todo, key) => { - print(todo); - return () => { - print("Removing todo!"); - }; -}); +```luau +local todosAtom: Atom<{ [string]: Todo }> = atom({}) + +observe(todosAtom, function(todo, key) + print(`Added {key}: {todo.name}`) + return function() + print(`Removed {key}`) + end +end) ``` #### Parameters -- `atom`: An atom or molecule that you want to observe. This can be a primitive atom, or a function that reads from one or more atoms. The atom should return a dictionary or an array of objects. +- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. -- `factory`: A function called for each key in the atom's state. The factory will receive the entry as an argument and should return a cleanup function. +- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. #### Returns `observe` returns a cleanup function. -#### Caveats - -- The factory will only run when a key is added or removed, not when the value at that key changes. If your data is not keyed by a unique and stable identifier, consider using `mapped` to transform it into a keyed object before passing it to `observe`. - --- -### `mapped(atom, mapper)` +### `mapped(callback, mapper)` -Call `mapped` to transform the key-value pairs of an atom's state. The mapper function will be called for each key-value pair in the atom's state, and the result will be stored in a new atom. +Call `mapped` to transform the keys and values of your state. The `mapper` function will be called for each key-value pair in the atom's state, and the new keys and atoms will be stored in a new atom. -```ts -const todosAtom = atom([]); -const todosById = mapped(todosAtom, (todo, index) => { - return $tuple(todo, todo.id); -}); +```luau +local todosAtom: Atom<{ Todo }> = atom({}) +local todosById = mapped(todosAtom, function(todo, index) + return todo, todo.id +end) ``` #### Parameters -- `atom`: An atom or molecule that you want to map. This can be a primitive atom, or a function that reads from one or more atoms. The atom should return a dictionary or an array of objects. +- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. -- `mapper`: A function called for each key-value pair in the atom's state. The mapper will receive the entry as an argument and should return a new key-value pair. The value you return determines the type of the mapped atom: +- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: - - If you return a tuple, the mapped atom returns a dictionary with the first element as the value and the second element as the key. - - - If you return a value without a key, the mapped atom returns an array of the given values in the order they were mapped. - - - If the first element is `undefined`, the entry will be omitted from the mapped atom. + 1. Return a single value to map the table's original key to a new value. + 2. Return two values, the first being the value and the second being the key, to update both keys and values. + 3. Return `nil` for the value to remove the key from the resulting table. #### Returns @@ -248,49 +269,49 @@ const todosById = mapped(todosAtom, (todo, index) => { --- -### `peek(value)` +### `peek(value, ...)` -Call `peek` to get the current state of an atom without tracking it as a dependency in functions like `effect` and `subscribe`. +Call `peek` to call a function without tracking it as the dependency of an effect or a selector function. -```ts -const nameAtom = atom("John"); -const ageAtom = atom(25); +```luau +local nameAtom = atom("John") +local ageAtom = atom(25) -effect(() => { - const name = nameAtom(); - const age = peek(ageAtom); -}); +effect(function() + local name = nameAtom() + local age = peek(ageAtom) +end) ``` #### Parameters -- `value`: Any value. If the value is an atom, `peek` will return the current state of the atom without tracking it. Otherwise, it will return the value as-is. +- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. - **optional** `...args`: Additional arguments to pass to the value if it is a function. #### Returns -`peek` returns the current state of the atom. If the value is not a function, it will return the value as-is. +`peek` returns the result of the given function. If the value is not a function, it will return the value as-is. --- ### `batch(callback)` -Call `batch` to group multiple state changes into a single update. The callback will run immediately and listeners will only be notified once all changes have been applied. +Call `batch` to defer state changes until after the callback has run. This is useful when you need to make multiple changes to the state and only want listeners to be notified once. -```ts -const nameAtom = atom("John"); -const ageAtom = atom(25); +```luau +local nameAtom = atom("John") +local ageAtom = atom(25) -batch(() => { - nameAtom("Jane"); - ageAtom(26); -}); +batch(function() + nameAtom("Jane") + ageAtom(26) +end) ``` #### Parameters -- `callback`: A function that makes multiple state changes. The changes will be batched together and listeners will only be notified once all changes have been applied. +- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. #### Returns @@ -317,31 +338,32 @@ ReactCharm = "littensy/react-charm@VERSION" --- -### `useAtom(atom, dependencies?)` +### `useAtom(callback, dependencies?)` -Call `useAtom` at the top-level of a React component to read from an atom. +Call `useAtom` at the top-level of a React component to read from an atom or selector. The component will re-render when the value changes. -```tsx -import { useAtom } from "@rbxts/react-charm"; -import { todosAtom } from "./todos-atom"; +```luau +local todosAtom = require(script.Parent.todosAtom) -function TodosApp() { - const todos = useAtom(todosAtom); - // ... -} +local function Todos() + local todos = useAtom(todosAtom) + -- ... +end ``` -By default, the atom is subscribed to once when the component initially mounts. Optionally, you may pass an array of dependencies to `useAtom` if your atom should be memoized based on other values. +If your selector depends on the component's state or props, remember to pass them in a dependency array. This prevents skipped updates when an untracked parameter of the selector changes. -```tsx -const todos = useAtom(() => searchTodos(filter), [filter]); +```luau +local todos = useAtom(function() + return searchTodos(props.filter) +end, { props.filter }) ``` #### Parameters -- `atom`: An atom or molecule that you want to read from. This can be an atom, or a function that reads from one or more atoms. +- `callback`: An atom or selector function that depends on an atom. -- **optional** `dependencies`: An array of values that the atom depends on. If the dependencies change, the atom will be re-subscribed to. +- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. #### Returns @@ -368,23 +390,22 @@ VideCharm = "littensy/vide-charm@VERSION" --- -### `useAtom(atom)` +### `useAtom(callback)` -Call `useAtom` in a any scope to create a Vide source that reads from an atom. +Call `useAtom` in any scope to create a Vide source that returns the current state of an atom or selector. -```tsx -import { useAtom } from "@rbxts/vide-charm"; -import { todosAtom } from "./todos-atom"; +```luau +local todosAtom = require(script.Parent.todosAtom) -function TodosApp() { - const todos = useAtom(todosAtom); - // ... -} +local function Todos() + local todos = useAtom(todosAtom) + -- ... +end ``` #### Parameters -- `atom`: An atom or molecule that you want to read from. This can be an atom, or a function that reads from one or more atoms. +- `callback`: An atom or selector function that depends on an atom. #### Returns @@ -413,79 +434,85 @@ CharmSync = "littensy/charm-sync@VERSION" ### `server(options)` -Call `server` to create a server sync object. The syncer handles sending minimal state patches to clients at a specified interval, and hydrating clients with the initial state. +Call `server` to create a server sync object. This synchronizes every client's atoms with the server's state by sending partial patches that the client merges into its state. -```ts -import { server } from "@rbxts/charm-sync"; +```luau +local syncer = CharmSync.server({ + atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the client's + interval = 0, -- The minimum interval between state updates + preserveHistory = false, -- Whether to send a full history of changes made to the atoms (slower) +}) -const syncer = server({ - atoms: atomsToSync, - interval: 0, - preserveHistory: false, -}); +-- Sends state updates to clients when a synced atom changes. +-- Omitting sensitive information and data serialization can be done here. +syncer:connect(function(player, ...) + remotes.syncState:fire(player, ...) +end) -syncer.connect((player, ...payloads) => { - remotes.syncState.fire(player, ...payloads); -}); - -remotes.requestState.connect((player) => { - syncer.hydrate(player); -}); +-- Sends the initial state to a player upon request. This should fire when a +-- player joins the game. +remotes.requestState:connect(function(player) + syncer:hydrate(player) +end) ``` #### Parameters -- `options`: An object to configure the server syncer. +- `options`: An object to configure sync behavior. - - `atoms`: An object containing the atoms to sync. The keys should match the keys on the client. + - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. - - **optional** `interval`: The interval at which to send state updates to clients. Defaults to `0`, meaning updates are sent on the next frame. + - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. - - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance reasons. + - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. #### Returns -`server` returns a server sync object. The sync object has the following methods: +`server` returns an object with the following methods: -- `syncer.connect(callback)` registers a callback to send state updates to clients. The callback will receive the player and the payload to send, and should send the payload to the client. The payload should not be mutated, so changes should be applied to a copy of the payload. +- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. -- `syncer.hydrate(player)` sends the initial state to a player. This calls the function passed to `connect` with a payload containing the initial state. +- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. #### Caveats - By default, Charm omits the individual changes made to atoms between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. -- The server sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes sending the initial state, which is implemented via `requestState` in the example above. +- Charm does not handle network communication. You must implement your own network layer to send and receive state updates. This is implemented via the `remotes` namespace in the example above. --- ### `client(options)` -Call `client` to create a client sync object. The syncer will hydrate the client's copy of the state with the server's state. +Call `client` to create a client sync object. This synchronizes the client's atoms with the server's state by merging partial patches sent by the server into each atom. -```ts -import { client } from "@rbxts/charm-sync"; +```luau +local syncer = CharmSync.client({ + atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's +}) -const syncer = client({ atoms: atomsToSync }); +-- Applies state updates from the server to the client's atoms. +-- Data deserialization can be done here. +remotes.syncState:connect(function(...) + syncer:sync(...) +end) -remotes.syncState.connect((...payloads) => { - syncer.sync(...payloads); -}); - -remotes.requestState.fire(); +-- Requests the initial state from the server when the client joins the game. +-- Before this runs, the client uses the atoms' default values. +remotes.requestState:fire() ``` #### Parameters -- `options`: An object to configure the client syncer. +- `options`: An object to configure sync behavior. - - `atoms`: An object containing the atoms to sync. The keys should match the keys on the server. + - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. #### Returns -`client` returns a client sync object. The sync object has the following methods: +`client` returns an object with the following methods: -- `syncer.sync(...payloads)` applies a state update from the server. +- `syncer:sync(...payloads)` applies a state update from the server. #### Caveats @@ -495,21 +522,23 @@ remotes.requestState.fire(); ### `isNone(value)` -Call `sync.isNone` to check if a value is `None`. +Call `isNone` to check if a value is `None`. Charm's partial state patches omit values that did not change between sync events, so to mark keys for deletion, Charm uses the `None` marker. -State patches represent the _difference_ between the current state and next state, excluding unchanged values. However, this means both unchanged and removed values would be `nil` in the patch. In these cases, Charm uses the `None` marker to represent a removed value. +This function can be used to check whether a value is about to be removed from an atom. -```ts -import { isNone, server } from "@rbxts/charm-sync"; +```luau +local syncer = CharmSync.server({ atoms = atomsToSync }) -const syncer = server({ atoms: atomsToSync }); - -syncer.connect((player, payload) => { - if (payload.type === "patch" && sync.isNone(payload.data.todosAtom?.eggs)) { - // 'eggs' will be removed from the client's todos atom - } - remotes.syncState.fire(player, payload); -}); +syncer:connect(function(player, payload) + if + payload.type === "patch" + and payload.data.todosAtom + and CharmSync.isNone(payload.data.todosAtom.eggs) + then + -- 'eggs' will be removed from the client's todo list + end + remotes.syncState.fire(player, payload) +end) ``` #### Parameters @@ -526,114 +555,120 @@ syncer.connect((player, payload) => { ### Counter atom -```ts -import { atom, subscribe } from "@rbxts/charm"; +```luau +local counterAtom = atom(0) -const counterAtom = atom(0); +-- Create a selector that returns double the counter value +local function doubleCounter() + return counterAtom() * 2 +end -// Create a derived atom that returns double the counter value -const doubleCounterAtom = () => counterAtom() * 2; +-- Runs after counterAtom is updated and prints double the new value +subscribe(doubleCounter, function(value) + print(value) +end) -// Runs after counterAtom is updated and prints double the new value -subscribe(doubleCounterAtom, (value) => { - print(value); -}); - -counterAtom(1); // 2 -counterAtom((count) => count + 1); // 4 +counterAtom(1) --> 2 +counterAtom(function(count) + return count + 1 +end) --> 4 ``` ### React component -```tsx -import React from "@rbxts/react"; -import { useAtom } from "@rbxts/react-charm"; -import { counterAtom, incrementCounter } from "./counter-atom"; +```luau +local counter = require(script.Parent.counter) +local counterAtom = counter.counterAtom +local incrementCounter = counter.incrementCounter -function Counter() { - const count = useAtom(counterAtom); +local function Counter() + local count = useAtom(counterAtom) - return ( - incrementCounter(), - }} - /> - ); -} + return React.createElement("TextButton", { + [React.Event.Activated] = incrementCounter, + Text = `Count: {count}`, + Size = UDim2.new(0, 100, 0, 50), + }) +end ``` ### Vide component -```tsx -import Vide from "@rbxts/vide"; -import { useAtom } from "@rbxts/vide-charm"; -import { counterAtom, incrementCounter } from "./counter-atom"; +```luau +local counter = require(script.Parent.counter) +local counterAtom = counter.counterAtom +local incrementCounter = counter.incrementCounter -function Counter() { - const count = useAtom(counterAtom); +local function Counter() + local count = useAtom(counterAtom) - return ( - { - incrementCounter(); - }} - Text={() => `Count: ${count()}`} - Size={new UDim2(0, 100, 0, 50)} - /> - ); -} + return create "TextButton" { + Activated = incrementCounter, + Text = function() + return `Count: {count()}` + end, + Size = UDim2.new(0, 100, 0, 50), + } +end ``` ### Server-client sync -Charm provides client and server objects for synchronizing state between the server and clients. Start by defining a module (or creating an object) exporting the atoms you want to sync: +Charm is designed for both client and server use, but there are often cases where the client needs to reference state that lives on the server. The CharmSync package provides a way to synchronize atoms between the server and its clients using remote events. -```ts -export { counterAtom } from "./counter-atom"; -export { writerAtom } from "./writer-atom"; +Start by creating a set of atoms to sync between the server and clients. Export these atoms from a module to be shared between the server and client: + +```luau +-- atoms.luau +local counter = require(script.Parent.counter) +local todos = require(script.Parent.todos) + +return { + counterAtom = counter.counterAtom, + todosAtom = todos.todosAtom, +} ``` Then, on the server, create a server sync object and pass in the atoms to sync. Use remote events to broadcast state updates and send initial state to clients upon request. -Note that if `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter. +> [!NOTE] +> If `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a variadic `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter. -```ts -import { server } from "@rbxts/charm-sync"; -import { remotes } from "./remotes"; -import * as atoms from "./atoms"; +```luau +-- sync.server.luau +local atoms = require(script.Parent.atoms) -const syncer = server({ atoms }); +local syncer = CharmSync.server({ atoms = atoms }) -// Broadcast a state update to a specific player -syncer.connect((player, ...payloads) => { - remotes.syncState.fire(player, ...payloads); -}); +-- Sends state updates to clients when a synced atom changes. +-- Omitting sensitive information and data serialization can be done here. +syncer:connect(function(player, payload) + remotes.syncState.fire(player, payload) +end) -// Send initial state to a player upon request -remotes.requestState.connect((player) => { - syncer.hydrate(player); -}); +-- Sends the initial state to a player upon request. This should fire when a +-- player joins the game. +remotes.requestState:connect(function(player) + syncer:hydrate(player) +end) ``` -Finally, on the client, create a client sync object and apply incoming state changes. +Finally, on the client, create a client sync object and use it to apply incoming state changes. -```ts -import { client } from "@rbxts/charm-sync"; -import { remotes } from "./remotes"; -import * as atoms from "./atoms"; +```luau +-- sync.client.luau +local atoms = require(script.Parent.atoms) -const syncer = sync.client({ atoms }); +local syncer = CharmSync.client({ atoms = atoms }) -// Listen for incoming state changes from the server -remotes.syncState.connect((...payloads) => { - syncer.sync(...payloads); -}); +-- Applies state updates from the server to the client's atoms. +remotes.syncState:connect(function(payload) + syncer:sync(payload) +end) -// Request initial state from the server -remotes.requestState.fire(); +-- Requests the initial state from the server when the client joins the game. +-- Before this runs, the client uses the atoms' default values. +remotes.requestState:fire() ``` --- diff --git a/src/charm-sync/src/index.d.ts b/src/charm-sync/src/index.d.ts index ef2b8d3..33168dd 100644 --- a/src/charm-sync/src/index.d.ts +++ b/src/charm-sync/src/index.d.ts @@ -1,4 +1,4 @@ -import { Atom, Molecule } from "@rbxts/charm"; +import { Atom, Selector } from "@rbxts/charm"; export = CharmSync; export as namespace CharmSync; @@ -8,13 +8,18 @@ type Cleanup = () => void; declare namespace CharmSync { type AtomMap = Record>; - type MoleculeMap = Record>; + /** + * @deprecated Use `SelectorMap` instead. + */ + type MoleculeMap = Record>; + + type SelectorMap = Record>; /** - * Recursively infers the type of the state produced by a map of molecules. + * Infers the type of the return values produced by a map of functions. */ type StateOfMap = { - [P in keyof T]: T[P] extends Molecule ? State : never; + [P in keyof T]: T[P] extends Selector ? State : never; }; /** @@ -45,7 +50,7 @@ declare namespace CharmSync { * @param options The atoms to synchronize with the client. * @returns A `ServerSyncer` object. */ - function server(options: ServerOptions): ServerSyncer; + function server(options: ServerOptions): ServerSyncer; /** * Checks whether a value is `None`. If `true`, the value is scheduled to be @@ -90,9 +95,9 @@ declare namespace CharmSync { * A payload that can be sent from the server to the client to synchronize * state between the two. */ - type SyncPayload = - | { type: "init"; data: StateOfMap } - | { type: "patch"; data: SyncPatch> }; + type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch> }; interface ClientOptions { /** @@ -106,11 +111,11 @@ declare namespace CharmSync { ignoreUnhydrated?: boolean; } - interface ServerOptions { + interface ServerOptions { /** * The atoms to synchronize with the client. */ - atoms: Molecules; + atoms: Selectors; /** * 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 @@ -139,7 +144,7 @@ declare namespace CharmSync { sync(...payloads: SyncPayload[]): void; } - interface ServerSyncer { + interface ServerSyncer { /** * Sets up a subscription to each atom that schedules a patch to be sent to * the client whenever the state changes. When a change occurs, the `callback` @@ -151,7 +156,7 @@ declare namespace CharmSync { * @param callback The function to call when the state changes. * @returns A cleanup function that unsubscribes all listeners. */ - connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; + connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; /** * Hydrates the client's state with the server's state. This should be * called when a player joins the game and requires the server's state. diff --git a/src/charm/src/computed.luau b/src/charm/src/computed.luau index 7fed8f7..e786c45 100644 --- a/src/charm/src/computed.luau +++ b/src/charm/src/computed.luau @@ -1,21 +1,20 @@ local atom = require(script.Parent.atom) local store = require(script.Parent.store) local types = require(script.Parent.types) -type Atom = types.Atom type AtomOptions = types.AtomOptions -type Molecule = types.Molecule +type Selector = types.Selector --[=[ Creates a read-only atom that derives its state from one or more atoms. Used to avoid unnecessary recomputations if multiple listeners depend on - the same molecule. - - @param molecule The function that produces the state. + the same atoms. + + @param callback The function that produces the state. @param options Optional configuration. @return A new read-only atom. ]=] -local function computed(molecule: Molecule, options: AtomOptions?): Molecule - local dependencies, state = store.capture(molecule) +local function computed(callback: Selector, options: AtomOptions?): Selector + local dependencies, state = store.capture(callback) local computedAtom = atom(state, options) local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" }) @@ -24,7 +23,7 @@ local function computed(molecule: Molecule, options: AtomOptions?): Mol if computedAtom then store.disconnect(dependencies, listener) - dependencies, state = store.capture(molecule) + dependencies, state = store.capture(callback) store.connect(dependencies, listener, computedAtom) computedAtom(state) end diff --git a/src/charm/src/effect.luau b/src/charm/src/effect.luau index ae36188..1b822b7 100644 --- a/src/charm/src/effect.luau +++ b/src/charm/src/effect.luau @@ -9,7 +9,7 @@ type Cleanup = () -> () @param callback The function to run. @return A function that unsubscribes the callback. ]=] -local function effect(callback: () -> (() -> ())?): () -> () +local function effect(callback: () -> Cleanup?): Cleanup local dependencies, cleanup = store.capture(callback) local disconnected = false diff --git a/src/charm/src/index.d.ts b/src/charm/src/index.d.ts index b36d393..69a9ca6 100644 --- a/src/charm/src/index.d.ts +++ b/src/charm/src/index.d.ts @@ -17,7 +17,7 @@ declare namespace Charm { * @param state The next state or a function that produces the next state. * @returns The current state, if no arguments are provided. */ - interface Atom extends Molecule { + interface Atom extends Selector { /** * @deprecated This property is not meant to be accessed directly. */ @@ -26,21 +26,29 @@ declare namespace Charm { * @param state The next state or a function that produces the next state. * @returns The current state, if no arguments are provided. */ - (state: State | ((prev: State) => State)): void; + (state?: State | ((prev: State) => State)): State; } /** - * A function that depends on one or more atoms and produces a state. Can be + * A function that depends on one or more atoms and produces a value. Can be * used to derive state from atoms. * * @returns The current state. */ - type Molecule = () => State; + type Selector = () => State; /** - * Infers the type of the state produced by the given molecule. + * A function that depends on one or more atoms and produces a value. Can be + * used to derive state from atoms. + * + * @returns The current state. */ - type StateOf = T extends Molecule ? State : never; + type Molecule = Selector; + + /** + * Infers the type of the state produced by the given function. + */ + type StateOf = T extends Selector ? State : never; interface AtomOptions { /** @@ -66,24 +74,24 @@ declare namespace Charm { /** * Creates a read-only atom that derives its state from one or more atoms. * Used to avoid unnecessary recomputations if multiple listeners depend on - * the same molecule. + * the same atoms. * - * @param molecule The function that produces the state. + * @param callback The function that produces the state. * @param options Optional configuration. * @returns A new read-only atom. */ - function computed(molecule: Molecule, options?: AtomOptions): Molecule; + function computed(callback: Selector, options?: AtomOptions): Selector; /** - * Subscribes to changes in the given atom or molecule. The callback is + * Subscribes to changes in the given atom or selector. The callback is * called with the current state and the previous state immediately after a * change occurs. * - * @param molecule The atom or molecule to subscribe to. - * @param callback The function to call when the state changes. + * @param callback The atom or selector to subscribe to. + * @param listener The function to call when the state changes. * @returns A function that unsubscribes the callback. */ - function subscribe(molecule: Molecule, callback: (state: State, prev: State) => void): Cleanup; + function subscribe(callback: Selector, listener: (state: State, prev: State) => void): Cleanup; /** * Runs the given callback immediately and whenever any atom it depends on @@ -98,11 +106,11 @@ declare namespace Charm { * Returns the result of the function without subscribing to changes. If a * non-function value is provided, it is returned as is. * - * @param molecule The atom or molecule to get the state of. - * @param args Arguments to pass to the molecule. + * @param callback The atom or selector to get the state of. + * @param args Arguments to pass to the function. * @returns The current state. */ - function peek(molecule: State | ((...args: Args) => State), ...args: Args): State; + function peek(callback: State | ((...args: Args) => State), ...args: Args): State; /** * Returns whether the given value is an atom. @@ -124,13 +132,14 @@ declare namespace Charm { * Captures all atoms that are read during the function call and returns them * along with the result of the function. Useful for tracking dependencies. * - * @param molecule The function to run. + * @param callback The function to run. * @returns A tuple containing the captured atoms and the result of the function. */ - function capture(molecule: Molecule): LuaTuple<[dependencies: Set>, state: State]>; + function capture(callback: Selector): LuaTuple<[dependencies: Set>, state: State]>; /** * Notifies all subscribers of the given atom that the state has changed. + * Mainly for running effects, since subscriptions check for equality. * * @param atom The atom to notify. */ @@ -141,17 +150,17 @@ declare namespace Charm { * cleans up the instance when the item is removed. Returns a cleanup function * that unsubscribes all instances. * - * @param molecule The atom or molecule to observe. + * @param callback The atom or selector to observe. * @param factory The function that tracks the lifecycle of each item. * @returns A function that unsubscribes all instances. */ function observe( - molecule: Molecule, + callback: Selector, factory: (item: Item, index: number) => Cleanup | void, ): Cleanup; function observe( - molecule: Molecule>, + callback: Selector>, factory: (item: Item, key: Key) => Cleanup | void, ): Cleanup; @@ -161,22 +170,22 @@ declare namespace Charm { * When the atom changes, the `mapper` is called for each entry in the state * to compute the new state. * - * @param molecule The atom or molecule to map. + * @param callback The atom or selector to map. * @param mapper The function that maps each entry. * @returns A new atom with the mapped state. */ function mapped( - molecule: Molecule, + callback: Selector, mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>, - ): Molecule>; + ): Selector>; function mapped( - molecule: Molecule, + callback: Selector, mapper: (value: V0, index: number) => V1, - ): Molecule; + ): Selector; function mapped( - molecule: Molecule>, + callback: Selector>, mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, - ): Molecule>; + ): Selector>; } diff --git a/src/charm/src/init.luau b/src/charm/src/init.luau index 1cb6c02..4bc1a8c 100644 --- a/src/charm/src/init.luau +++ b/src/charm/src/init.luau @@ -8,7 +8,8 @@ local subscribe = require(script.subscribe) local types = require(script.types) export type Atom = types.Atom -export type Molecule = types.Molecule +export type Selector = types.Selector +export type Molecule = types.Selector export type SyncPayload = types.SyncPayload return { diff --git a/src/charm/src/mapped.luau b/src/charm/src/mapped.luau index c7a2496..1f1e977 100644 --- a/src/charm/src/mapped.luau +++ b/src/charm/src/mapped.luau @@ -3,12 +3,12 @@ local effect = require(script.Parent.effect) local store = require(script.Parent.store) local types = require(script.Parent.types) type Atom = types.Atom -type Molecule = types.Molecule +type Selector = types.Selector type Map = - ((fn: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Molecule<{ [K1]: V1 }>) - & ((fn: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Molecule<{ [K0]: V1 }>) - & ((fn: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Molecule<{ [K1]: V1 }>) + ((fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Selector<{ [K1]: V1 }>) + & ((fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Selector<{ [K0]: V1 }>) + & ((fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Selector<{ [K1]: V1 }>) --[=[ Maps each entry in the atom's state to a new key-value pair. If the `mapper` @@ -16,11 +16,11 @@ type Map = When the atom changes, the `mapper` is called for each entry in the state to compute the new state. - @param molecule The atom or molecule to map. + @param callback The atom or selector to map. @param mapper The function that maps each entry. @return A new atom with the mapped state. ]=] -local function mapped(molecule: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Molecule<{ [K1]: V1 }> +local function mapped(callback: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Selector<{ [K1]: V1 }> local mappedAtom = atom({}) local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" }) local prevMappedItems: { [K1]: V1 } = {} @@ -31,7 +31,7 @@ local function mapped(molecule: Molecule<{ [K0]: V0 }>, mapper: return unsubscribe() end - local items = molecule() + local items = callback() local mappedItems = table.clone(store.peek(mappedAtomRef.current)) local mappedKeys = {} diff --git a/src/charm/src/observe.luau b/src/charm/src/observe.luau index ea326ef..5bb0296 100644 --- a/src/charm/src/observe.luau +++ b/src/charm/src/observe.luau @@ -1,6 +1,6 @@ local effect = require(script.Parent.effect) local types = require(script.Parent.types) -type Molecule = types.Molecule +type Selector = types.Selector local function noop() end @@ -9,15 +9,15 @@ local function noop() end cleans up the instance when the item is removed. Returns a cleanup function that unsubscribes all instances. - @param molecule The atom or molecule to observe. + @param callback The atom or selector to observe. @param factory The function that tracks the lifecycle of each item. @return A function that unsubscribes all instances. ]=] -local function observe(molecule: Molecule<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> () +local function observe(callback: Selector<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> () local connections: { [K]: () -> () } = {} local unsubscribe = effect(function() - local state = molecule() + local state = callback() for key, disconnect in next, connections do if state[key] == nil then diff --git a/src/charm/src/store.luau b/src/charm/src/store.luau index 68bea45..488ae11 100644 --- a/src/charm/src/store.luau +++ b/src/charm/src/store.luau @@ -100,8 +100,8 @@ end Returns the result of the function without subscribing to changes. If a non-function value is provided, it is returned as is. - @param molecule The atom or molecule to get the state of. - @param args Arguments to pass to the molecule. + @param callback The atom or selector to get the state of. + @param args Arguments to pass to the function. @return The current state. ]=] local function peek(callback: ((U...) -> T) | T, ...: U...): T @@ -128,12 +128,12 @@ end Captures all atoms that are read during the function call and returns them along with the result of the function. Useful for tracking dependencies. - @param molecule The function to run. + @param callback The function to run. @return A tuple containing the captured atoms and the result of the function. ]=] -local function capture(molecule: () -> T): (Set>, T) - if listeners[molecule] then - return { [molecule] = true }, peek(molecule) +local function capture(callback: () -> T): (Set>, T) + if listeners[callback] then + return { [callback] = true }, peek(callback) end local dependencies: Set> = {} @@ -141,7 +141,7 @@ local function capture(molecule: () -> T): (Set>, T) capturing.index += 1 capturing.stack[capturing.index] = dependencies - local result = try(molecule, function() + local result = try(callback, function() capturing.stack[capturing.index] = nil capturing.index -= 1 end) diff --git a/src/charm/src/subscribe.luau b/src/charm/src/subscribe.luau index 8f13ddd..0286d68 100644 --- a/src/charm/src/subscribe.luau +++ b/src/charm/src/subscribe.luau @@ -1,41 +1,41 @@ local store = require(script.Parent.store) local types = require(script.Parent.types) -type Molecule = types.Molecule +type Selector = types.Selector --[=[ - Subscribes to changes in the given atom or molecule. The callback is + Subscribes to changes in the given atom or selector. The callback is called with the current state and the previous state immediately after a change occurs. - @param molecule The atom or molecule to subscribe to. - @param callback The function to call when the state changes. + @param callback The atom or selector to subscribe to. + @param listener The function to call when the state changes. @return A function that unsubscribes the callback. ]=] -local function subscribe(molecule: Molecule, callback: (state: T, prev: T) -> ()): () -> () - local dependencies, state = store.capture(molecule) +local function subscribe(callback: Selector, listener: (state: T, prev: T) -> ()): () -> () + local dependencies, state = store.capture(callback) local disconnected = false - local function listener() + local function handler() local prevState = state - store.disconnect(dependencies, listener) - dependencies, state = store.capture(molecule) + store.disconnect(dependencies, handler) + dependencies, state = store.capture(callback) if not disconnected then - store.connect(dependencies, listener) + store.connect(dependencies, handler) end if state ~= prevState then - callback(state, prevState) + listener(state, prevState) end end - store.connect(dependencies, listener) + store.connect(dependencies, handler) return function() if not disconnected then disconnected = true - store.disconnect(dependencies, listener) + store.disconnect(dependencies, handler) end end end diff --git a/src/charm/src/types.luau b/src/charm/src/types.luau index 16d19f2..fd3ba6b 100644 --- a/src/charm/src/types.luau +++ b/src/charm/src/types.luau @@ -9,7 +9,7 @@ export type WeakMap = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" @param state The next state or a function that produces the next state. @return The current state, if no arguments are provided. ]=] -export type Atom = (() -> T) & (state: T | (T) -> T) -> T +export type Atom = (state: (T | (T) -> T)?) -> T --[=[ A function that depends on one or more atoms and produces a state. Can be @@ -18,7 +18,7 @@ export type Atom = (() -> T) & (state: T | (T) -> T) -> T @template State The type of the state. @return The current state. ]=] -export type Molecule = () -> T +export type Selector = () -> T --[=[ Optional configuration for creating an atom. diff --git a/src/react/src/index.d.ts b/src/react/src/index.d.ts index abf0fb3..e3eb917 100644 --- a/src/react/src/index.d.ts +++ b/src/react/src/index.d.ts @@ -1,15 +1,15 @@ -import { Molecule } from "@rbxts/charm"; +import { Selector } from "@rbxts/charm"; /** - * A hook that subscribes to changes in the given atom or molecule. The + * A hook that subscribes to changes in the given atom or selector. The * component is re-rendered whenever the state changes. * * If the `dependencies` array is provided, the subscription to the atom or - * molecule is re-created whenever the dependencies change. Otherwise, the + * selector is re-created whenever the dependencies change. Otherwise, the * subscription is created once when the component is mounted. * - * @param molecule The atom or molecule to subscribe to. + * @param callback The atom or selector to subscribe to. * @param dependencies An array of values that the subscription depends on. * @returns The current state. */ -export function useAtom(molecule: Molecule, dependencies?: unknown[]): State; +export function useAtom(callback: Selector, dependencies?: unknown[]): State; diff --git a/src/react/src/init.luau b/src/react/src/init.luau index 5bcafba..e200551 100644 --- a/src/react/src/init.luau +++ b/src/react/src/init.luau @@ -4,23 +4,23 @@ local React = if script.Parent:FindFirstChild("react") else require(script.Parent.React) --[=[ - A hook that subscribes to changes in the given atom or molecule. The + A hook that subscribes to changes in the given atom or selector. The component is re-rendered whenever the state changes. If the `dependencies` array is provided, the subscription to the atom or - molecule is re-created whenever the dependencies change. Otherwise, the + selector is re-created whenever the dependencies change. Otherwise, the subscription is created once when the component is mounted. - @param molecule The atom or molecule to subscribe to. + @param callback The atom or selector to subscribe to. @param dependencies An array of values that the subscription depends on. @return The current state. ]=] -local function useAtom(molecule: Charm.Molecule, dependencies: { any }?): State - local state, setState = React.useState(molecule) +local function useAtom(callback: Charm.Selector, dependencies: { any }?): State + local state, setState = React.useState(callback) React.useEffect(function() - setState(molecule()) - return Charm.subscribe(molecule, setState) + setState(callback()) + return Charm.subscribe(callback, setState) end, dependencies or {}) return state diff --git a/src/vide/src/index.d.ts b/src/vide/src/index.d.ts index 77e8612..21ace65 100644 --- a/src/vide/src/index.d.ts +++ b/src/vide/src/index.d.ts @@ -1,9 +1,9 @@ -import { Molecule } from "@rbxts/charm"; +import { Selector } from "@rbxts/charm"; /** * Subscribes to the state of an atom and returns a Vide source. * - * @param molecule The atom or molecule to subscribe to. + * @param callback The atom or selector to subscribe to. * @returns The reactive source. */ -export function useAtom(atom: Molecule): () => T; +export function useAtom(callback: Selector): () => T; diff --git a/src/vide/src/init.luau b/src/vide/src/init.luau index d311ce6..605fdd8 100644 --- a/src/vide/src/init.luau +++ b/src/vide/src/init.luau @@ -6,12 +6,12 @@ local Vide = if script.Parent:FindFirstChild("vide") --[=[ Subscribes to the state of an atom and returns a Vide source. - @param molecule The atom or molecule to subscribe to. + @param callback The atom or selector to subscribe to. @return The reactive source. ]=] -local function useAtom(molecule: () -> State): State - local state = Vide.source(molecule()) - local unsubscribe = Charm.subscribe(molecule, function(value) +local function useAtom(callback: () -> State): State + local state = Vide.source(callback()) + local unsubscribe = Charm.subscribe(callback, function(value) task.spawn(state, value) end) diff --git a/tests/react/useAtom.spec.luau b/tests/react/useAtom.spec.luau index bb94715..5476f28 100644 --- a/tests/react/useAtom.spec.luau +++ b/tests/react/useAtom.spec.luau @@ -17,7 +17,7 @@ return function() expect(hook.result).to.equal(1) end) - it("accepts a molecule", function() + it("accepts a selector", function() local state = atom(0) local hook = renderHook(function() return useAtom(function() diff --git a/tests/vide/useAtom.spec.luau b/tests/vide/useAtom.spec.luau index 6b8c66a..cc4dd40 100644 --- a/tests/vide/useAtom.spec.luau +++ b/tests/vide/useAtom.spec.luau @@ -20,7 +20,7 @@ return function() cleanup() end) - it("accepts a molecule", function() + it("accepts a selector", function() local state = atom(0) local source local cleanup = root(function() From 0358adc9c2720e7422e04d0becf56ca0157e62bd Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 26 Oct 2024 19:16:06 -0700 Subject: [PATCH 29/68] fix: function generics default to `any` for `AnyMap` --- src/charm/src/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/charm/src/index.d.ts b/src/charm/src/index.d.ts index 69a9ca6..385b7e7 100644 --- a/src/charm/src/index.d.ts +++ b/src/charm/src/index.d.ts @@ -159,7 +159,7 @@ declare namespace Charm { factory: (item: Item, index: number) => Cleanup | void, ): Cleanup; - function observe( + function observe( callback: Selector>, factory: (item: Item, key: Key) => Cleanup | void, ): Cleanup; @@ -184,7 +184,7 @@ declare namespace Charm { mapper: (value: V0, index: number) => V1, ): Selector; - function mapped( + function mapped( callback: Selector>, mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, ): Selector>; From 210266f1488d8d2831cd48acd443d23ab8ec1153 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 26 Oct 2024 19:17:20 -0700 Subject: [PATCH 30/68] chore: bump versions --- src/charm/package.json | 2 +- src/charm/wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/charm/package.json b/src/charm/package.json index 772e860..879473f 100644 --- a/src/charm/package.json +++ b/src/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.8.0", + "version": "0.8.1", "description": "An atomic state management library for Roblox", "repository": "littensy/charm", "license": "MIT", diff --git a/src/charm/wally.toml b/src/charm/wally.toml index d180c9e..858cd88 100644 --- a/src/charm/wally.toml +++ b/src/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.8.0" +version = "0.8.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" From e4019c049cbeeaef31cb48c2360d2fa3a4c23499 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 26 Oct 2024 19:23:21 -0700 Subject: [PATCH 31/68] chore: bump versions --- src/charm-sync/package.json | 2 +- src/charm-sync/wally.toml | 4 ++-- src/react/package.json | 2 +- src/react/wally.toml | 6 +++--- src/vide/package.json | 2 +- src/vide/wally.toml | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/charm-sync/package.json b/src/charm-sync/package.json index 69bb096..ff737cd 100644 --- a/src/charm-sync/package.json +++ b/src/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.1.0", + "version": "0.1.1", "description": "Sync server state with clients using Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/charm-sync/wally.toml b/src/charm-sync/wally.toml index 76fe3a5..e2e5135 100644 --- a/src/charm-sync/wally.toml +++ b/src/charm-sync/wally.toml @@ -1,8 +1,8 @@ [package] name = "littensy/charm-sync" -version = "0.1.0" +version = "0.1.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@0.8.0" +Charm = "littensy/charm@^0.8" diff --git a/src/react/package.json b/src/react/package.json index 07a43b6..d04d15a 100644 --- a/src/react/package.json +++ b/src/react/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.1.0", + "version": "0.1.1", "description": "React bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/react/wally.toml b/src/react/wally.toml index daf700d..e9234ed 100644 --- a/src/react/wally.toml +++ b/src/react/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/react-charm" -version = "0.1.0" +version = "0.1.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@0.8.0" -React = "jsdotlua/react@17.2.0" +Charm = "littensy/charm@^0.8" +React = "jsdotlua/react@^17" diff --git a/src/vide/package.json b/src/vide/package.json index efb9c6c..fb0602c 100644 --- a/src/vide/package.json +++ b/src/vide/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.1.0", + "version": "0.1.1", "description": "Vide bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/src/vide/wally.toml b/src/vide/wally.toml index 24e8383..5a9e092 100644 --- a/src/vide/wally.toml +++ b/src/vide/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/vide-charm" -version = "0.1.0" +version = "0.1.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@0.8.0" -Vide = "centau/vide@0.3.1" +Charm = "littensy/charm@^0.8" +Vide = "centau/vide@^0.3" From aae769c981d2bd7fcea9ade44db20defa2ed5d02 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 17 Nov 2024 01:22:07 -0800 Subject: [PATCH 32/68] feat: add `serializeArrays` option (#29) * feat: add optional `convertArrays` * perf: sync atomic changes to keys * feat: add `serializeArrays` option * docs: rephrase debug mode flag --- src/charm-sync/.luaurc => .luaurc | 0 .prettierrc | 8 +- README.md | 94 +++++++++++---------- default.project.json | 2 +- src/charm-sync/src/client.luau | 49 ++++++----- src/charm-sync/src/index.d.ts | 24 ++++-- src/charm-sync/src/patch.luau | 27 +++++-- src/charm-sync/src/server.luau | 130 ++++++++++++++++-------------- src/charm-sync/src/types.luau | 7 +- src/charm-sync/src/validate.luau | 12 +-- src/charm/.luaurc | 3 - src/charm/src/index.d.ts | 17 +--- src/charm/src/store.luau | 4 +- src/react/.luaurc | 3 - src/vide/.luaurc | 3 - src/vide/src/init.luau | 6 +- tests/charm-sync/init.spec.luau | 19 ----- tests/charm-sync/patch.spec.luau | 10 +++ tests/charm-sync/server.spec.luau | 2 +- tests/init.server.luau | 7 ++ tests/{charm => }/init.spec.luau | 8 -- 21 files changed, 230 insertions(+), 205 deletions(-) rename src/charm-sync/.luaurc => .luaurc (100%) delete mode 100644 src/charm/.luaurc delete mode 100644 src/react/.luaurc delete mode 100644 src/vide/.luaurc delete mode 100644 tests/charm-sync/init.spec.luau rename tests/{charm => }/init.spec.luau (74%) diff --git a/src/charm-sync/.luaurc b/.luaurc similarity index 100% rename from src/charm-sync/.luaurc rename to .luaurc diff --git a/.prettierrc b/.prettierrc index 701a273..537ac33 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { - "printWidth": 120, - "tabWidth": 2, - "trailingComma": "all", - "useTabs": true + "printWidth": 120, + "tabWidth": 4, + "trailingComma": "all", + "useTabs": true } diff --git a/README.md b/README.md index ee9b816..ae188f8 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,13 @@ See an example of Charm's features in [this example repository](https://github.c ## 🍀 Features -- ⚛️ **Manage state with _atoms_.** Decompose state into tiny, composable containers called _atoms_, as opposed to combining them into a single store. +- ⚛️ **Manage state with _atoms_.** Decompose state into tiny, composable containers called _atoms_, as opposed to combining them into a single store. -- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. +- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. -- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. +- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. -- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. +- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. --- @@ -55,16 +55,16 @@ Charm = "littensy/charm@VERSION" ## 🐛 Debugging -Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` at the entry point of your project. +Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` before importing Charm. Enabling `__DEV__` adds a few helpful features: -- Better error handling for selectors, subscriptions, and batched functions: +- Better error handling for selectors, subscriptions, and batched functions: - - Errors provide the function's name and line number. - - Yielding in certain functions will throw an error. + - Errors provide the function's name and line number. + - Yielding in certain functions will throw an error. -- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. +- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead. @@ -85,11 +85,11 @@ local todosAtom: Atom<{ string }> = atom({}) #### Parameters -- `state`: The value to assign to the atom initially. +- `state`: The value to assign to the atom initially. -- **optional** `options`: An object that configures the behavior of this atom. +- **optional** `options`: An object that configures the behavior of this atom. - - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. + - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. #### Returns @@ -144,9 +144,9 @@ nameAtom("Jane") --> "JANE" #### Parameters -- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. +- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. -- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. +- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. #### Returns @@ -173,7 +173,7 @@ Because `effect` implicitly tracks all atoms read within the callback, it might #### Parameters -- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. +- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. #### Returns @@ -202,9 +202,9 @@ This function is also useful for optimizing `effect` calls that depend on multip #### Parameters -- `callback`: A function that returns a new value depending on one or more atoms. +- `callback`: A function that returns a new value depending on one or more atoms. -- **optional** [`options`](#parameters): An object that configures the behavior of this atom. +- **optional** [`options`](#parameters): An object that configures the behavior of this atom. #### Returns @@ -232,9 +232,9 @@ end) #### Parameters -- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. +- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. -- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. +- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. #### Returns @@ -255,13 +255,13 @@ end) #### Parameters -- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. +- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. -- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: +- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: - 1. Return a single value to map the table's original key to a new value. - 2. Return two values, the first being the value and the second being the key, to update both keys and values. - 3. Return `nil` for the value to remove the key from the resulting table. + 1. Return a single value to map the table's original key to a new value. + 2. Return two values, the first being the value and the second being the key, to update both keys and values. + 3. Return `nil` for the value to remove the key from the resulting table. #### Returns @@ -285,9 +285,9 @@ end) #### Parameters -- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. +- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. -- **optional** `...args`: Additional arguments to pass to the value if it is a function. +- **optional** `...args`: Additional arguments to pass to the value if it is a function. #### Returns @@ -311,7 +311,7 @@ end) #### Parameters -- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. +- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. #### Returns @@ -361,9 +361,9 @@ end, { props.filter }) #### Parameters -- `callback`: An atom or selector function that depends on an atom. +- `callback`: An atom or selector function that depends on an atom. -- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. +- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. #### Returns @@ -405,7 +405,7 @@ end #### Parameters -- `callback`: An atom or selector function that depends on an atom. +- `callback`: An atom or selector function that depends on an atom. #### Returns @@ -441,6 +441,7 @@ local syncer = CharmSync.server({ atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the client's interval = 0, -- The minimum interval between state updates preserveHistory = false, -- Whether to send a full history of changes made to the atoms (slower) + serializeArrays = true, -- Safety measures for arrays, should be false when using ByteNet or Zap }) -- Sends state updates to clients when a synced atom changes. @@ -458,27 +459,31 @@ end) #### Parameters -- `options`: An object to configure sync behavior. +- `options`: An object to configure sync behavior. - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. + - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. - - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. + - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. - - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. + - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. + + - **optional** `serializeArrays`: Whether to convert problematic arrays to dictionaries before firing remote events. Because arrays in state patches can have holes, extra work is required to send them over a remote event intact. Defaults to `true`, but should be `false` if payloads are serialized (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). #### Returns `server` returns an object with the following methods: -- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. +- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. -- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. +- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. #### Caveats -- By default, Charm omits the individual changes made to atoms between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. +- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. -- Charm does not handle network communication. You must implement your own network layer to send and receive state updates. This is implemented via the `remotes` namespace in the example above. +- **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. + +- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `serializeArrays` accordingly! --- @@ -489,6 +494,7 @@ Call `client` to create a client sync object. This synchronizes the client's ato ```luau local syncer = CharmSync.client({ atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's + ignoreUnhydrated = true, -- Whether to ignore state updates before the initial update }) -- Applies state updates from the server to the client's atoms. @@ -504,19 +510,21 @@ remotes.requestState:fire() #### Parameters -- `options`: An object to configure sync behavior. +- `options`: An object to configure sync behavior. - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. + - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. + + - **optional** `ignoreUnhydrated`: Whether to ignore state updates before setting the initial state. Defaults to `true`. #### Returns `client` returns an object with the following methods: -- `syncer:sync(...payloads)` applies a state update from the server. +- `syncer:sync(...payloads)` applies a state update from the server. #### Caveats -- The client sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes requesting the initial state, which is implemented via `requestState` in the example above. +- **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above. --- @@ -543,7 +551,7 @@ end) #### Parameters -- `value`: Any value. If the value is `None`, `isNone` will return `true`. +- `value`: Any value. If the value is `None`, `isNone` will return `true`. #### Returns diff --git a/default.project.json b/default.project.json index 8817adb..880d017 100644 --- a/default.project.json +++ b/default.project.json @@ -23,7 +23,7 @@ "ServerScriptService": { "$className": "ServerScriptService", "Tests": { - "$path": "Tests" + "$path": "tests" } } } diff --git a/src/charm-sync/src/client.luau b/src/charm-sync/src/client.luau index a2c2de9..724aba5 100644 --- a/src/charm-sync/src/client.luau +++ b/src/charm-sync/src/client.luau @@ -7,7 +7,7 @@ type ClientOptions = { --[=[ The atoms to synchronize with the server. ]=] - atoms: { [string]: Charm.Atom }, + atoms: { [string | number]: Charm.Atom }, --[=[ Whether to ignore patches sent before the client has been hydrated. Default is `true`. @@ -33,33 +33,44 @@ type ClientSyncer = { @return A `ClientSyncer` object. ]=] local function client(options: ClientOptions): ClientSyncer + local self = {} :: ClientSyncer + local atoms = options.atoms local ignoreUnhydrated = options.ignoreUnhydrated ~= false - local self = {} :: ClientSyncer local hydrated = false - -- Apply the state changes sent by the server. + local function hydrate(state: { [string | number]: any }) + hydrated = true + + for key, value in next, state do + local atom = atoms[key] + atom(value) + end + end + + local function apply(data: { [string | number]: any }) + local target = {} + + for key, atom in next, atoms do + target[key] = atom() + end + + target = patch.apply(target, data) + + for key, atom in next, atoms do + atom(target[key]) + end + end + function self:sync(...) for index = 1, select("#", ...) do local payload: SyncPayload = select(index, ...) - if ignoreUnhydrated and payload.type == "patch" and not hydrated then - -- If the client is not initialized, ignore patches. This is - -- to prevent patching incomplete state. - continue - end - - hydrated = true - Charm.batch(function() - for key, state in next, payload.data do - local atom = atoms[key] - - if payload.type == "patch" then - atom(patch.apply(atom(), state)) - else - atom(state) - end + if payload.type == "init" then + hydrate(payload.data) + elseif not ignoreUnhydrated or hydrated then + apply(payload.data) end end) end diff --git a/src/charm-sync/src/index.d.ts b/src/charm-sync/src/index.d.ts index 33168dd..1c59675 100644 --- a/src/charm-sync/src/index.d.ts +++ b/src/charm-sync/src/index.d.ts @@ -8,23 +8,22 @@ type Cleanup = () => void; declare namespace CharmSync { type AtomMap = Record>; + type SelectorMap = Record>; + /** * @deprecated Use `SelectorMap` instead. */ - type MoleculeMap = Record>; - - type SelectorMap = Record>; + type MoleculeMap = SelectorMap; /** * Infers the type of the return values produced by a map of functions. */ type StateOfMap = { - [P in keyof T]: T[P] extends Selector ? State : never; + readonly [P in keyof T]: T[P] extends Selector ? State : never; }; /** - * A special value that denotes the absence of a value. Used to represent - * removed values in patches. + * Represents the removal of a value from the state. */ interface None { readonly __none: "__none"; @@ -96,8 +95,8 @@ declare namespace CharmSync { * state between the two. */ type SyncPayload = - | { type: "init"; data: StateOfMap } - | { type: "patch"; data: SyncPatch> }; + | { readonly type: "init"; readonly data: StateOfMap } + | { readonly type: "patch"; readonly data: SyncPatch> }; interface ClientOptions { /** @@ -132,6 +131,15 @@ declare namespace CharmSync { * to reconstruct the state's changes over time. */ preserveHistory?: boolean; + /** + * When `true`, convert problematic sparse arrays into dictionaries + * with string keys. Defaults to `true`. + * + * While this is mandatory for safely syncing arrays over vanilla Roblox + * remotes, it can be disabled if your network library uses a custom + * serialization method (i.e. Zap, ByteNet). + */ + serializeArrays?: boolean; } interface ClientSyncer { diff --git a/src/charm-sync/src/patch.luau b/src/charm-sync/src/patch.luau index 6780d1f..7ad5554 100644 --- a/src/charm-sync/src/patch.luau +++ b/src/charm-sync/src/patch.luau @@ -1,5 +1,7 @@ local validate = require(script.Parent.validate) +local __DEV__ = _G.__DEV__ + --[=[ A special symbol that denotes the absence of a value. Used to represent deleted values in patches. @@ -37,7 +39,16 @@ local function stringifySparseArray(object: { any }): { [string | number]: any } return dictionary end -local function diff(prevState: { [any]: any }, nextState: { [any]: any }) +--[=[ + Compares two states and returns a patch that can be applied to the previous + state to produce the next state. + + @param prevState The previous state. + @param nextState The next state. + @param serializeArrays Whether to convert sparse arrays into dictionaries. Defaults to `true`. + @returns A patch that can be applied to the previous state. +]=] +local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serializeArrays: boolean?) local patches = table.clone(nextState) for key, previous in next, prevState do @@ -48,16 +59,16 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }) elseif current == nil then patches[key] = None elseif type(previous) == "table" and type(current) == "table" then - patches[key] = diff(previous, current) + patches[key] = diff(previous, current, serializeArrays) end end -- Coerce sparse array patches into dictionaries - if prevState[1] ~= nil or nextState[1] ~= nil then + if serializeArrays ~= false and (prevState[1] ~= nil or nextState[1] ~= nil) then patches = stringifySparseArray(patches) end - if _G.__DEV__ then + if __DEV__ then for key, value in next, prevState do validate(value, key) end @@ -72,6 +83,13 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }) return patches end +--[=[ + Applies a patch to a state and returns the next state. + + @param state The current state. + @param patches The patches to apply. + @returns The next state. +]=] local function apply(state: any, patches: any): any if type(patches) == "table" and patches.__none == "__none" then return nil @@ -97,7 +115,6 @@ local function apply(state: any, patches: any): any end return { - NONE = None, isNone = isNone, diff = diff, apply = apply, diff --git a/src/charm-sync/src/server.luau b/src/charm-sync/src/server.luau index 53fe26e..3327b87 100644 --- a/src/charm-sync/src/server.luau +++ b/src/charm-sync/src/server.luau @@ -10,7 +10,7 @@ type ServerOptions = { --[=[ The atoms to synchronize with the client. ]=] - atoms: { [string]: Charm.Atom }, + atoms: { [string | number]: Charm.Atom }, --[=[ 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 @@ -23,6 +23,15 @@ type ServerOptions = { per frame, where each individual change is important. Defaults to `false`. ]=] preserveHistory: boolean?, + --[=[ + Whether to convert problematic sparse arrays into dictionaries with + string keys. Defaults to `true`. + + While this is mandatory for safely syncing arrays over vanilla Roblox + remotes, it can be disabled if your network library uses a custom + serialization method (i.e. Zap, ByteNet). + ]=] + serializeArrays: boolean?, } type ServerSyncer = { @@ -63,7 +72,7 @@ local function server(options: ServerOptions): ServerSyncer local sync: (player: Player, payload: SyncPayload) -> () local function createSnapshot() - local snapshot: { [string]: any } = {} + local snapshot = {} for key, atom in next, options.atoms do snapshot[key] = atom() @@ -72,10 +81,20 @@ local function server(options: ServerOptions): ServerSyncer return snapshot end + -- Send the initial state to a player when they join the server. + function self:hydrate(player) + assert(sync, "connect() must be called before hydrate()") + + sync(player, { + type = "init", + data = createSnapshot(), + }) + end + + -- If the history of state changes should be preserved, keep a list of + -- previous snapshots and diff-check each one from the last. if options.preserveHistory then - -- Preserve a history of every state change to allow for more granular - -- syncing with the client. - local snapshots: { { [string]: any } } = {} + local snapshots = { createSnapshot() } local changed = false function self:connect(callback) @@ -83,7 +102,7 @@ local function server(options: ServerOptions): ServerSyncer sync = callback - local function pushSnapshot(key: string, current: any, previous: any) + local function pushSnapshot(key: string | number, current: any, previous: any) local lastSnapshot = snapshots[#snapshots] local previousSnapshot = snapshots[#snapshots - 1] @@ -108,8 +127,6 @@ local function server(options: ServerOptions): ServerSyncer end) end - snapshots[1] = createSnapshot() - local disconnect = interval(function() if not changed then return @@ -127,7 +144,7 @@ local function server(options: ServerOptions): ServerSyncer table.insert(payloads, { type = "patch", - data = patch.diff(snapshots[index - 1], snapshot), + data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays), }) end @@ -164,7 +181,7 @@ local function server(options: ServerOptions): ServerSyncer table.insert(payloads, { type = "patch", - data = patch.diff(snapshots[index - 1], snapshot), + data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays), }) end @@ -173,79 +190,70 @@ local function server(options: ServerOptions): ServerSyncer sync(player, unpack(payloads)) end - else - -- Only send the most recent state to the client. - local previousSnapshot = createSnapshot() - local currentSnapshot = table.clone(previousSnapshot) - local changed = false - function self:connect(callback) - local subscriptions = {} + return self + end - sync = callback + local previousSnapshot = createSnapshot() + local currentSnapshot = table.clone(previousSnapshot) + local changed = false - -- Subscribe to each atom and update the state when one changes. - for key, atom in next, options.atoms do - subscriptions[key] = Charm.subscribe(atom, function(state) - currentSnapshot[key] = state - changed = true - end) - end + function self:connect(callback) + local subscriptions = {} - local disconnect = interval(function() - if not changed then - return - end + sync = callback - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot), - } - - previousSnapshot = table.clone(currentSnapshot) - changed = false - - for _, player in next, Players:GetPlayers() do - callback(player, payload) - end - end, options.interval or 0) - - return function() - disconnect() - - for _, unsubscribe in next, subscriptions do - unsubscribe() - end - end + -- Subscribe to each atom and update the state when one changes. + for key, atom in next, options.atoms do + subscriptions[key] = Charm.subscribe(atom, function(state) + currentSnapshot[key] = state + changed = true + end) end - function self:_sendPatch(player: Player) - assert(sync, "connect() must be called before _sendPatch()") - + local disconnect = interval(function() if not changed then return end local payload: SyncPayload = { type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot), + data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays), } previousSnapshot = table.clone(currentSnapshot) changed = false - sync(player, payload) + for _, player in next, Players:GetPlayers() do + callback(player, payload) + end + end, options.interval or 0) + + return function() + disconnect() + + for _, unsubscribe in next, subscriptions do + unsubscribe() + end end end - -- Send the initial state to a player when they join the server. - function self:hydrate(player) - assert(sync, "connect() must be called before hydrate()") + function self:_sendPatch(player) + assert(sync, "connect() must be called before _sendPatch()") - sync(player, { - type = "init", - data = createSnapshot(), - }) + if not changed then + return + end + + local payload: SyncPayload = { + type = "patch", + data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays), + } + + previousSnapshot = table.clone(currentSnapshot) + changed = false + + sync(player, payload) end return self diff --git a/src/charm-sync/src/types.luau b/src/charm-sync/src/types.luau index de30101..f6deb17 100644 --- a/src/charm-sync/src/types.luau +++ b/src/charm-sync/src/types.luau @@ -3,8 +3,11 @@ state between the two. ]=] export type SyncPayload = { - type: "patch" | "init", - data: { [any]: any }, + type: "init", + data: { [string | number]: any }, +} | { + type: "patch", + data: { [string | number]: any }, } return nil diff --git a/src/charm-sync/src/validate.luau b/src/charm-sync/src/validate.luau index 911816d..833f4b6 100644 --- a/src/charm-sync/src/validate.luau +++ b/src/charm-sync/src/validate.luau @@ -1,9 +1,7 @@ -local Charm = require(script.Parent.Parent.Charm) - local SAFE_KEYS = { string = true, number = true } -local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true } +local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true, vector = true } -local function isUnsafeTable(object: { [any]: any }) +local function isTableUnsafe(object: { [any]: any }) local keyType = nil local objectSize = 0 @@ -47,9 +45,7 @@ local function validate(value: any, key: any) elseif typeOfValue == "table" then if getmetatable(value) ~= nil then error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) - elseif Charm.isAtom(value) then - error(`Cannot sync nested atoms! Got atom at key '{key}'`) - elseif isUnsafeTable(value) then + elseif isTableUnsafe(value) then error( `Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n` .. "This can be for the following reasons:\n" @@ -69,7 +65,7 @@ local function validate(value: any, key: any) elseif typeOfKey == "number" then if key == math.huge or key == -math.huge then error("Cannot sync infinity as key") - elseif math.floor(key) ~= key then + elseif key ~= math.floor(key) then error("Cannot sync non-integer number as key") end end diff --git a/src/charm/.luaurc b/src/charm/.luaurc deleted file mode 100644 index e2b625c..0000000 --- a/src/charm/.luaurc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "languageMode": "strict" -} diff --git a/src/charm/src/index.d.ts b/src/charm/src/index.d.ts index 385b7e7..e31bf06 100644 --- a/src/charm/src/index.d.ts +++ b/src/charm/src/index.d.ts @@ -10,24 +10,13 @@ type AnyMap = declare namespace Charm { /** - * A primitive state container that can be read from and written to. When the - * state changes, all subscribers are notified. + * A primitive state container that can be read from or written to. When + * the state changes, all subscribers are notified. * - * @template State The type of the state. * @param state The next state or a function that produces the next state. * @returns The current state, if no arguments are provided. */ - interface Atom extends Selector { - /** - * @deprecated This property is not meant to be accessed directly. - */ - readonly __nominal: unique symbol; - /** - * @param state The next state or a function that produces the next state. - * @returns The current state, if no arguments are provided. - */ - (state?: State | ((prev: State) => State)): State; - } + type Atom = (state?: State | ((prev: State) => State)) => State; /** * A function that depends on one or more atoms and produces a value. Can be diff --git a/src/charm/src/store.luau b/src/charm/src/store.luau index 488ae11..957b5a0 100644 --- a/src/charm/src/store.luau +++ b/src/charm/src/store.luau @@ -3,6 +3,8 @@ type Atom = types.Atom type Set = types.Set type WeakMap = types.WeakMap +local __DEV__ = _G.__DEV__ + local listeners: WeakMap, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" }) local batched: Set<() -> ()> = {} @@ -24,7 +26,7 @@ local capturing = { @return The result of the callback. ]=] local function try(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T - if _G.__DEV__ then + if __DEV__ then local thread = coroutine.create(callback) local success, result = coroutine.resume(thread, ...) diff --git a/src/react/.luaurc b/src/react/.luaurc deleted file mode 100644 index e2b625c..0000000 --- a/src/react/.luaurc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "languageMode": "strict" -} diff --git a/src/vide/.luaurc b/src/vide/.luaurc deleted file mode 100644 index e2b625c..0000000 --- a/src/vide/.luaurc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "languageMode": "strict" -} diff --git a/src/vide/src/init.luau b/src/vide/src/init.luau index 605fdd8..766fb35 100644 --- a/src/vide/src/init.luau +++ b/src/vide/src/init.luau @@ -9,10 +9,12 @@ local Vide = if script.Parent:FindFirstChild("vide") @param callback The atom or selector to subscribe to. @return The reactive source. ]=] -local function useAtom(callback: () -> State): State +local function useAtom(callback: () -> State): () -> State local state = Vide.source(callback()) local unsubscribe = Charm.subscribe(callback, function(value) - task.spawn(state, value) + task.spawn(function() + state(value) + end) end) Vide.cleanup(unsubscribe) diff --git a/tests/charm-sync/init.spec.luau b/tests/charm-sync/init.spec.luau deleted file mode 100644 index 21a24c0..0000000 --- a/tests/charm-sync/init.spec.luau +++ /dev/null @@ -1,19 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local store = require(ReplicatedStorage.DevPackages.Charm.store) - -return function() - beforeAll(function() - _G.__DEV__ = true - end) - - afterAll(function() - _G.__DEV__ = nil - end) - - afterEach(function() - table.clear(store.listeners :: any) - table.clear(store.capturing.stack) - store.capturing.index = 0 - end) -end diff --git a/tests/charm-sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau index 5128954..e76becb 100644 --- a/tests/charm-sync/patch.spec.luau +++ b/tests/charm-sync/patch.spec.luau @@ -98,4 +98,14 @@ return function() expect(applied["2"]).to.be.ok() expect(applied[2]).to.never.be.ok() end) + + it("optionally fixes sparse arrays", function() + for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do + expect(index).to.be.a("string") + end + + for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do + expect(index).to.be.a("number") + end + end) end diff --git a/tests/charm-sync/server.spec.luau b/tests/charm-sync/server.spec.luau index 7afd5dc..5fc33f9 100644 --- a/tests/charm-sync/server.spec.luau +++ b/tests/charm-sync/server.spec.luau @@ -49,7 +49,7 @@ return function() server:_sendPatch(target) expect(payload.data.a.b).to.never.be.ok() - expect(payload.data.a.c).to.equal(patch.NONE) + expect(patch.isNone(payload.data.a.c)).to.equal(true) expect(payload.data.a.d).to.equal(1) disconnect() diff --git a/tests/init.server.luau b/tests/init.server.luau index f2d4789..4b3505c 100644 --- a/tests/init.server.luau +++ b/tests/init.server.luau @@ -1,5 +1,12 @@ +--!nocheck + local ReplicatedStorage = game:GetService("ReplicatedStorage") local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ) +-- Start Charm in debug mode +_G.__DEV__ = true +require(ReplicatedStorage.DevPackages.Charm) +_G.__DEV__ = false + TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter) diff --git a/tests/charm/init.spec.luau b/tests/init.spec.luau similarity index 74% rename from tests/charm/init.spec.luau rename to tests/init.spec.luau index 21a24c0..aed8099 100644 --- a/tests/charm/init.spec.luau +++ b/tests/init.spec.luau @@ -3,14 +3,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() - beforeAll(function() - _G.__DEV__ = true - end) - - afterAll(function() - _G.__DEV__ = nil - end) - afterEach(function() table.clear(store.listeners :: any) table.clear(store.capturing.stack) From 88567e17c5f125728375c6b334c16c7c67744224 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 11:05:11 -0800 Subject: [PATCH 33/68] ci: check all packages in src --- bin/check.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/check.sh b/bin/check.sh index 72d7bb8..ac1b63e 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -19,7 +19,11 @@ check() { } eslint src -check src/*/src + +for i in src/*/src; do + check $i +done + check tests rm bin/roblox.d.luau From d15c23bce15d4173212f381cc52b0b6baa90512a Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 11:07:30 -0800 Subject: [PATCH 34/68] fix: exclude `undefined` from non-nullable atom --- src/charm/src/index.d.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/charm/src/index.d.ts b/src/charm/src/index.d.ts index e31bf06..676662f 100644 --- a/src/charm/src/index.d.ts +++ b/src/charm/src/index.d.ts @@ -16,7 +16,10 @@ declare namespace Charm { * @param state The next state or a function that produces the next state. * @returns The current state, if no arguments are provided. */ - type Atom = (state?: State | ((prev: State) => State)) => State; + interface Atom { + (): State; + (nextState: State | ((prevState: State) => State)): void; + } /** * A function that depends on one or more atoms and produces a value. Can be From f44e3e135f33579ddf63bda17aeb72499e1b4d35 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 25 Jan 2025 11:10:23 -0800 Subject: [PATCH 35/68] fix: add object overloads for `observe()` and `mapped()` (#38) --- src/charm/src/index.d.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/charm/src/index.d.ts b/src/charm/src/index.d.ts index 676662f..c7a6700 100644 --- a/src/charm/src/index.d.ts +++ b/src/charm/src/index.d.ts @@ -3,11 +3,6 @@ export as namespace Charm; type Cleanup = () => void; -type AnyMap = - | Map - | ReadonlyMap - | (K extends string | number | symbol ? { readonly [Key in K]: V } : never); - declare namespace Charm { /** * A primitive state container that can be read from or written to. When @@ -53,7 +48,6 @@ declare namespace Charm { /** * Creates a new atom with the given state. * - * @template State The type of the state. * @param state The initial state. * @param options Optional configuration. * @returns A new atom. @@ -151,8 +145,13 @@ declare namespace Charm { factory: (item: Item, index: number) => Cleanup | void, ): Cleanup; - function observe( - callback: Selector>, + function observe( + callback: Selector | ReadonlyMap>, + factory: (item: Item, key: Key) => Cleanup | void, + ): Cleanup; + + function observe( + callback: Selector<{ readonly [P in Key]: Item }>, factory: (item: Item, key: Key) => Cleanup | void, ): Cleanup; @@ -176,8 +175,13 @@ declare namespace Charm { mapper: (value: V0, index: number) => V1, ): Selector; - function mapped( - callback: Selector>, + function mapped( + callback: Selector | ReadonlyMap>, + mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, + ): Selector>; + + function mapped( + callback: Selector<{ readonly [P in K0]: V0 }>, mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, ): Selector>; } From a430cf63e568a1421a2f54017408dbd7fb65d953 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 11:21:56 -0800 Subject: [PATCH 36/68] fix: `Atom` type extends `Selector` Also deprecates `Molecule` --- src/charm/src/index.d.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/charm/src/index.d.ts b/src/charm/src/index.d.ts index c7a6700..da1c5fa 100644 --- a/src/charm/src/index.d.ts +++ b/src/charm/src/index.d.ts @@ -9,11 +9,10 @@ declare namespace Charm { * the state changes, all subscribers are notified. * * @param state The next state or a function that produces the next state. - * @returns The current state, if no arguments are provided. + * @returns The current state. */ - interface Atom { - (): State; - (nextState: State | ((prevState: State) => State)): void; + interface Atom extends Selector { + (nextState: State | ((prevState: State) => State)): State; } /** @@ -28,6 +27,7 @@ declare namespace Charm { * A function that depends on one or more atoms and produces a value. Can be * used to derive state from atoms. * + * @deprecated Use `Selector` instead. * @returns The current state. */ type Molecule = Selector; From 6be2fbb746c14d4a76916e7f5e6e14dd2348d0bc Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 25 Jan 2025 11:51:17 -0800 Subject: [PATCH 37/68] fix: do not track implicit dependencies in `observe()` and `mapped()` (#39) * fix: use `subscribe` for explicit dependencies * fix: call listeners initially once * fix: wrap initial run in `peek()` --- src/charm/src/mapped.luau | 17 ++++++++++++----- src/charm/src/observe.luau | 17 ++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/charm/src/mapped.luau b/src/charm/src/mapped.luau index 1f1e977..555aa3f 100644 --- a/src/charm/src/mapped.luau +++ b/src/charm/src/mapped.luau @@ -1,6 +1,6 @@ local atom = require(script.Parent.atom) -local effect = require(script.Parent.effect) local store = require(script.Parent.store) +local subscribe = require(script.Parent.subscribe) local types = require(script.Parent.types) type Atom = types.Atom type Selector = types.Selector @@ -26,13 +26,14 @@ local function mapped(callback: Selector<{ [K0]: V0 }>, mapper: local prevMappedItems: { [K1]: V1 } = {} local unsubscribe - unsubscribe = effect(function() - if not mappedAtomRef.current then + local function listener(items: { [K0]: V0 }) + local mappedAtom = mappedAtomRef.current + + if not mappedAtom then return unsubscribe() end - local items = callback() - local mappedItems = table.clone(store.peek(mappedAtomRef.current)) + local mappedItems = table.clone(mappedAtom()) local mappedKeys = {} -- TODO: Only call mapper if the item has changed. @@ -56,6 +57,12 @@ local function mapped(callback: Selector<{ [K0]: V0 }>, mapper: prevMappedItems = mappedItems mappedAtom(mappedItems) + end + + unsubscribe = subscribe(callback, listener) + + store.peek(function(): () + listener(callback()) end) return mappedAtom diff --git a/src/charm/src/observe.luau b/src/charm/src/observe.luau index 5bb0296..120ae3b 100644 --- a/src/charm/src/observe.luau +++ b/src/charm/src/observe.luau @@ -1,4 +1,5 @@ -local effect = require(script.Parent.effect) +local store = require(script.Parent.store) +local subscribe = require(script.Parent.subscribe) local types = require(script.Parent.types) type Selector = types.Selector @@ -16,9 +17,7 @@ local function noop() end local function observe(callback: Selector<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> () local connections: { [K]: () -> () } = {} - local unsubscribe = effect(function() - local state = callback() - + local function listener(state: { [K]: V }) for key, disconnect in next, connections do if state[key] == nil then connections[key] = nil @@ -31,17 +30,21 @@ local function observe(callback: Selector<{ [K]: V }>, factory: (value: V, connections[key] = factory(value, key) or noop end end + end + + local unsubscribe = subscribe(callback, listener) + + store.peek(function(): () + listener(callback()) end) - local function cleanup() + return function() unsubscribe() for _, disconnect in next, connections do disconnect() end table.clear(connections) end - - return cleanup end return observe From 89f1dfeaa5d3956c7b79a7e3c1b8d653ab727bc3 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 25 Jan 2025 12:14:21 -0800 Subject: [PATCH 38/68] feat: pass cleanup function to effect (#40) --- src/charm/src/effect.luau | 22 +++++++++++++++------- src/charm/src/store.luau | 9 +++++---- tests/charm/effect.spec.luau | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/charm/src/effect.luau b/src/charm/src/effect.luau index 1b822b7..23f3294 100644 --- a/src/charm/src/effect.luau +++ b/src/charm/src/effect.luau @@ -9,9 +9,11 @@ type Cleanup = () -> () @param callback The function to run. @return A function that unsubscribes the callback. ]=] -local function effect(callback: () -> Cleanup?): Cleanup - local dependencies, cleanup = store.capture(callback) +local function effect(callback: (cleanup: Cleanup) -> Cleanup?): Cleanup + local dependencies = {} + local cleanup: Cleanup? local disconnected = false + local disconnect local function listener() if cleanup then @@ -19,16 +21,14 @@ local function effect(callback: () -> Cleanup?): Cleanup end store.disconnect(dependencies, listener) - dependencies, cleanup = store.capture(callback) + dependencies, cleanup = store.capture(callback, disconnect) if not disconnected then store.connect(dependencies, listener) end end - store.connect(dependencies, listener) - - return function() + function disconnect() if disconnected then return end @@ -40,6 +40,14 @@ local function effect(callback: () -> Cleanup?): Cleanup cleanup() end end + + dependencies, cleanup = store.capture(callback, disconnect) + + if not disconnected then + store.connect(dependencies, listener) + end + + return disconnect end -return effect :: (callback: (() -> ()) | (() -> Cleanup)) -> Cleanup +return effect :: (callback: ((cleanup: Cleanup) -> ()) | ((cleanup: Cleanup) -> Cleanup?)) -> Cleanup diff --git a/src/charm/src/store.luau b/src/charm/src/store.luau index 957b5a0..fbffa90 100644 --- a/src/charm/src/store.luau +++ b/src/charm/src/store.luau @@ -133,9 +133,10 @@ end @param callback The function to run. @return A tuple containing the captured atoms and the result of the function. ]=] -local function capture(callback: () -> T): (Set>, T) - if listeners[callback] then - return { [callback] = true }, peek(callback) +local function capture(callback: (U...) -> T, ...: U...): (Set>, T) + -- If the callback is an atom, return it immediately + if listeners[callback :: any] then + return { [callback :: any] = true }, peek(callback) end local dependencies: Set> = {} @@ -146,7 +147,7 @@ local function capture(callback: () -> T): (Set>, T) local result = try(callback, function() capturing.stack[capturing.index] = nil capturing.index -= 1 - end) + end, ...) return dependencies, result end diff --git a/tests/charm/effect.spec.luau b/tests/charm/effect.spec.luau index faa95b3..1e834d0 100644 --- a/tests/charm/effect.spec.luau +++ b/tests/charm/effect.spec.luau @@ -93,4 +93,19 @@ return function() a(2) a(3) end) + + it("allows immediate self-cleanup", function() + local a = atom(1) + local cleanedUp = false + + effect(function(cleanup) + assert(not cleanedUp, "cleanup ran twice") + cleanup() + cleanedUp = true + a() + end) + + a(2) + a(3) + end) end From 8eddd182d37f9d339ccca76e43201c1846b7a5a9 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 25 Jan 2025 13:12:50 -0800 Subject: [PATCH 39/68] refactor: move modules to `packages` (#41) * refactor: move modules to `packages` * chore: remove .vscode --- .github/workflows/ci.yml | 6 +- .gitignore | 12 +- .vscode/settings.json | 3 - bin/build.sh | 8 +- bin/check.sh | 4 +- bin/install.sh | 4 + default.project.json | 10 +- modules/wally.lock | 103 ++++++++++++++++++ wally.toml => modules/wally.toml | 0 package.json | 4 +- .../charm-sync/default.project.json | 0 {src => packages}/charm-sync/package.json | 0 {src => packages}/charm-sync/src/client.luau | 0 {src => packages}/charm-sync/src/index.d.ts | 0 {src => packages}/charm-sync/src/init.luau | 0 .../charm-sync/src/interval.luau | 0 {src => packages}/charm-sync/src/patch.luau | 0 {src => packages}/charm-sync/src/server.luau | 0 {src => packages}/charm-sync/src/types.luau | 0 .../charm-sync/src/validate.luau | 0 {src => packages}/charm-sync/wally.toml | 0 {src => packages}/charm/default.project.json | 0 {src => packages}/charm/package.json | 0 {src => packages}/charm/src/atom.luau | 0 {src => packages}/charm/src/computed.luau | 0 {src => packages}/charm/src/effect.luau | 0 {src => packages}/charm/src/index.d.ts | 0 {src => packages}/charm/src/init.luau | 0 {src => packages}/charm/src/mapped.luau | 0 {src => packages}/charm/src/observe.luau | 0 {src => packages}/charm/src/store.luau | 0 {src => packages}/charm/src/subscribe.luau | 0 {src => packages}/charm/src/types.luau | 0 {src => packages}/charm/wally.toml | 0 {src => packages}/react/default.project.json | 0 {src => packages}/react/package.json | 0 {src => packages}/react/src/index.d.ts | 0 {src => packages}/react/src/init.luau | 0 {src => packages}/react/wally.toml | 0 {src => packages}/vide/default.project.json | 0 {src => packages}/vide/package.json | 0 {src => packages}/vide/src/index.d.ts | 0 {src => packages}/vide/src/init.luau | 0 {src => packages}/vide/wally.toml | 0 pnpm-lock.yaml | 58 +++------- pnpm-workspace.yaml | 2 +- rokit.toml | 8 +- stylua.toml | 1 + wally.lock | 103 ------------------ 49 files changed, 152 insertions(+), 174 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 bin/install.sh create mode 100644 modules/wally.lock rename wally.toml => modules/wally.toml (100%) rename {src => packages}/charm-sync/default.project.json (100%) rename {src => packages}/charm-sync/package.json (100%) rename {src => packages}/charm-sync/src/client.luau (100%) rename {src => packages}/charm-sync/src/index.d.ts (100%) rename {src => packages}/charm-sync/src/init.luau (100%) rename {src => packages}/charm-sync/src/interval.luau (100%) rename {src => packages}/charm-sync/src/patch.luau (100%) rename {src => packages}/charm-sync/src/server.luau (100%) rename {src => packages}/charm-sync/src/types.luau (100%) rename {src => packages}/charm-sync/src/validate.luau (100%) rename {src => packages}/charm-sync/wally.toml (100%) rename {src => packages}/charm/default.project.json (100%) rename {src => packages}/charm/package.json (100%) rename {src => packages}/charm/src/atom.luau (100%) rename {src => packages}/charm/src/computed.luau (100%) rename {src => packages}/charm/src/effect.luau (100%) rename {src => packages}/charm/src/index.d.ts (100%) rename {src => packages}/charm/src/init.luau (100%) rename {src => packages}/charm/src/mapped.luau (100%) rename {src => packages}/charm/src/observe.luau (100%) rename {src => packages}/charm/src/store.luau (100%) rename {src => packages}/charm/src/subscribe.luau (100%) rename {src => packages}/charm/src/types.luau (100%) rename {src => packages}/charm/wally.toml (100%) rename {src => packages}/react/default.project.json (100%) rename {src => packages}/react/package.json (100%) rename {src => packages}/react/src/index.d.ts (100%) rename {src => packages}/react/src/init.luau (100%) rename {src => packages}/react/wally.toml (100%) rename {src => packages}/vide/default.project.json (100%) rename {src => packages}/vide/package.json (100%) rename {src => packages}/vide/src/index.d.ts (100%) rename {src => packages}/vide/src/init.luau (100%) rename {src => packages}/vide/wally.toml (100%) create mode 100644 stylua.toml delete mode 100644 wally.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aef4707..4369391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,12 +15,14 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 + with: + run_install: true - name: Install Rokit uses: CompeyDev/setup-rokit@v0.1.2 - - name: Install dependencies - run: pnpm install + - name: Install Wally dependencies + run: pnpm wally - name: Check code quality run: pnpm check diff --git a/.gitignore b/.gitignore index a5a20b4..f3a27c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ +/.vscode /node_modules /build -/devpackages -/packages -/src/**/node_modules -/src/**/wally.lock -/src/**/README.md -/src/**/LICENSE.md +/modules/DevPackages +/packages/**/node_modules +/packages/**/wally.lock +/packages/**/README.md +/packages/**/LICENSE.md /sourcemap.json *.tsbuildinfo *.tgz diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 68885db..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "luau-lsp.types.definitionFiles": ["bin/testez.d.luau"] -} diff --git a/bin/build.sh b/bin/build.sh index b8a7ddc..547e4e7 100644 --- a/bin/build.sh +++ b/bin/build.sh @@ -1,5 +1,5 @@ mkdir build -rojo build src/charm -o build/charm.rbxm -rojo build src/charm-sync -o build/charm-sync.rbxm -rojo build src/react -o build/react-charm.rbxm -rojo build src/vide -o build/vide-charm.rbxm +rojo build packages/charm -o build/charm.rbxm +rojo build packages/charm-sync -o build/charm-sync.rbxm +rojo build packages/react -o build/react-charm.rbxm +rojo build packages/vide -o build/vide-charm.rbxm diff --git a/bin/check.sh b/bin/check.sh index ac1b63e..0e948f8 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -18,9 +18,9 @@ check() { stylua --check $1 } -eslint src +eslint packages -for i in src/*/src; do +for i in packages/*/src; do check $i done diff --git a/bin/install.sh b/bin/install.sh new file mode 100644 index 0000000..53ccca2 --- /dev/null +++ b/bin/install.sh @@ -0,0 +1,4 @@ +cd modules +wally install +cd .. +rojo sourcemap --output sourcemap.json diff --git a/default.project.json b/default.project.json index 880d017..a43197e 100644 --- a/default.project.json +++ b/default.project.json @@ -5,18 +5,18 @@ "ReplicatedStorage": { "$className": "ReplicatedStorage", "DevPackages": { - "$path": "DevPackages", + "$path": "modules/DevPackages", "Charm": { - "$path": "src/charm" + "$path": "packages/charm" }, "CharmSync": { - "$path": "src/charm-sync" + "$path": "packages/charm-sync" }, "ReactCharm": { - "$path": "src/react" + "$path": "packages/react" }, "VideCharm": { - "$path": "src/vide" + "$path": "packages/vide" } } }, diff --git a/modules/wally.lock b/modules/wally.lock new file mode 100644 index 0000000..7294d77 --- /dev/null +++ b/modules/wally.lock @@ -0,0 +1,103 @@ +# This file is automatically @generated by Wally. +# It is not intended for manual editing. +registry = "test" + +[[package]] +name = "centau/vide" +version = "0.3.1" +dependencies = [] + +[[package]] +name = "jsdotlua/boolean" +version = "1.2.7" +dependencies = [["number", "jsdotlua/number@1.2.7"]] + +[[package]] +name = "jsdotlua/collections" +version = "1.2.7" +dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"]] + +[[package]] +name = "jsdotlua/console" +version = "1.2.7" +dependencies = [["collections", "jsdotlua/collections@1.2.7"]] + +[[package]] +name = "jsdotlua/es7-types" +version = "1.2.7" +dependencies = [] + +[[package]] +name = "jsdotlua/instance-of" +version = "1.2.7" +dependencies = [] + +[[package]] +name = "jsdotlua/luau-polyfill" +version = "1.2.7" +dependencies = [["boolean", "jsdotlua/boolean@1.2.7"], ["collections", "jsdotlua/collections@1.2.7"], ["console", "jsdotlua/console@1.2.7"], ["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"], ["math", "jsdotlua/math@1.2.7"], ["number", "jsdotlua/number@1.2.7"], ["string", "jsdotlua/string@1.2.7"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.7"]] + +[[package]] +name = "jsdotlua/math" +version = "1.2.7" +dependencies = [] + +[[package]] +name = "jsdotlua/number" +version = "1.2.7" +dependencies = [] + +[[package]] +name = "jsdotlua/promise" +version = "3.5.2" +dependencies = [] + +[[package]] +name = "jsdotlua/react" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] + +[[package]] +name = "jsdotlua/react-reconciler" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] + +[[package]] +name = "jsdotlua/react-roblox" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["react", "jsdotlua/react@17.2.1"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] + +[[package]] +name = "jsdotlua/scheduler" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] + +[[package]] +name = "jsdotlua/shared" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"]] + +[[package]] +name = "jsdotlua/string" +version = "1.2.7" +dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["number", "jsdotlua/number@1.2.7"]] + +[[package]] +name = "jsdotlua/symbol-luau" +version = "1.0.1" +dependencies = [] + +[[package]] +name = "jsdotlua/timers" +version = "1.2.7" +dependencies = [["collections", "jsdotlua/collections@1.2.7"]] + +[[package]] +name = "littensy/charm" +version = "0.1.0" +dependencies = [["React", "jsdotlua/react@17.2.1"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.1"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]] + +[[package]] +name = "roblox/testez" +version = "0.4.1" +dependencies = [] diff --git a/wally.toml b/modules/wally.toml similarity index 100% rename from wally.toml rename to modules/wally.toml diff --git a/package.json b/package.json index 29d3129..73e2375 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "scripts": { "build": "sh bin/build.sh", "check": "sh bin/check.sh", - "wally": "wally install && rojo sourcemap --output sourcemap.json", + "wally": "sh bin/install.sh", "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, @@ -20,5 +20,5 @@ "roblox-ts": "^3.0.0", "typescript": "~5.5.4" }, - "packageManager": "pnpm@9.12.1" + "packageManager": "pnpm@9.15.4" } diff --git a/src/charm-sync/default.project.json b/packages/charm-sync/default.project.json similarity index 100% rename from src/charm-sync/default.project.json rename to packages/charm-sync/default.project.json diff --git a/src/charm-sync/package.json b/packages/charm-sync/package.json similarity index 100% rename from src/charm-sync/package.json rename to packages/charm-sync/package.json diff --git a/src/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau similarity index 100% rename from src/charm-sync/src/client.luau rename to packages/charm-sync/src/client.luau diff --git a/src/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts similarity index 100% rename from src/charm-sync/src/index.d.ts rename to packages/charm-sync/src/index.d.ts diff --git a/src/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau similarity index 100% rename from src/charm-sync/src/init.luau rename to packages/charm-sync/src/init.luau diff --git a/src/charm-sync/src/interval.luau b/packages/charm-sync/src/interval.luau similarity index 100% rename from src/charm-sync/src/interval.luau rename to packages/charm-sync/src/interval.luau diff --git a/src/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau similarity index 100% rename from src/charm-sync/src/patch.luau rename to packages/charm-sync/src/patch.luau diff --git a/src/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau similarity index 100% rename from src/charm-sync/src/server.luau rename to packages/charm-sync/src/server.luau diff --git a/src/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau similarity index 100% rename from src/charm-sync/src/types.luau rename to packages/charm-sync/src/types.luau diff --git a/src/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau similarity index 100% rename from src/charm-sync/src/validate.luau rename to packages/charm-sync/src/validate.luau diff --git a/src/charm-sync/wally.toml b/packages/charm-sync/wally.toml similarity index 100% rename from src/charm-sync/wally.toml rename to packages/charm-sync/wally.toml diff --git a/src/charm/default.project.json b/packages/charm/default.project.json similarity index 100% rename from src/charm/default.project.json rename to packages/charm/default.project.json diff --git a/src/charm/package.json b/packages/charm/package.json similarity index 100% rename from src/charm/package.json rename to packages/charm/package.json diff --git a/src/charm/src/atom.luau b/packages/charm/src/atom.luau similarity index 100% rename from src/charm/src/atom.luau rename to packages/charm/src/atom.luau diff --git a/src/charm/src/computed.luau b/packages/charm/src/computed.luau similarity index 100% rename from src/charm/src/computed.luau rename to packages/charm/src/computed.luau diff --git a/src/charm/src/effect.luau b/packages/charm/src/effect.luau similarity index 100% rename from src/charm/src/effect.luau rename to packages/charm/src/effect.luau diff --git a/src/charm/src/index.d.ts b/packages/charm/src/index.d.ts similarity index 100% rename from src/charm/src/index.d.ts rename to packages/charm/src/index.d.ts diff --git a/src/charm/src/init.luau b/packages/charm/src/init.luau similarity index 100% rename from src/charm/src/init.luau rename to packages/charm/src/init.luau diff --git a/src/charm/src/mapped.luau b/packages/charm/src/mapped.luau similarity index 100% rename from src/charm/src/mapped.luau rename to packages/charm/src/mapped.luau diff --git a/src/charm/src/observe.luau b/packages/charm/src/observe.luau similarity index 100% rename from src/charm/src/observe.luau rename to packages/charm/src/observe.luau diff --git a/src/charm/src/store.luau b/packages/charm/src/store.luau similarity index 100% rename from src/charm/src/store.luau rename to packages/charm/src/store.luau diff --git a/src/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau similarity index 100% rename from src/charm/src/subscribe.luau rename to packages/charm/src/subscribe.luau diff --git a/src/charm/src/types.luau b/packages/charm/src/types.luau similarity index 100% rename from src/charm/src/types.luau rename to packages/charm/src/types.luau diff --git a/src/charm/wally.toml b/packages/charm/wally.toml similarity index 100% rename from src/charm/wally.toml rename to packages/charm/wally.toml diff --git a/src/react/default.project.json b/packages/react/default.project.json similarity index 100% rename from src/react/default.project.json rename to packages/react/default.project.json diff --git a/src/react/package.json b/packages/react/package.json similarity index 100% rename from src/react/package.json rename to packages/react/package.json diff --git a/src/react/src/index.d.ts b/packages/react/src/index.d.ts similarity index 100% rename from src/react/src/index.d.ts rename to packages/react/src/index.d.ts diff --git a/src/react/src/init.luau b/packages/react/src/init.luau similarity index 100% rename from src/react/src/init.luau rename to packages/react/src/init.luau diff --git a/src/react/wally.toml b/packages/react/wally.toml similarity index 100% rename from src/react/wally.toml rename to packages/react/wally.toml diff --git a/src/vide/default.project.json b/packages/vide/default.project.json similarity index 100% rename from src/vide/default.project.json rename to packages/vide/default.project.json diff --git a/src/vide/package.json b/packages/vide/package.json similarity index 100% rename from src/vide/package.json rename to packages/vide/package.json diff --git a/src/vide/src/index.d.ts b/packages/vide/src/index.d.ts similarity index 100% rename from src/vide/src/index.d.ts rename to packages/vide/src/index.d.ts diff --git a/src/vide/src/init.luau b/packages/vide/src/init.luau similarity index 100% rename from src/vide/src/init.luau rename to packages/vide/src/init.luau diff --git a/src/vide/wally.toml b/packages/vide/wally.toml similarity index 100% rename from src/vide/wally.toml rename to packages/vide/wally.toml diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3791d76..90e0e73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,57 +42,31 @@ importers: specifier: ~5.5.4 version: 5.5.4 - modules/charm: {} + packages/charm: {} - modules/charm-sync: + packages/charm-sync: dependencies: '@rbxts/charm': specifier: workspace:* version: link:../charm - modules/react: + packages/react: dependencies: '@rbxts/charm': specifier: workspace:* version: link:../charm '@rbxts/react': specifier: '*' - version: 17.2.1 + version: 17.2.3 - modules/vide: + packages/vide: dependencies: '@rbxts/charm': specifier: workspace:* version: link:../charm '@rbxts/vide': specifier: '*' - version: 0.4.6 - - src/charm: {} - - src/charm-sync: - dependencies: - '@rbxts/charm': - specifier: workspace:* - version: link:../charm - - src/react: - dependencies: - '@rbxts/charm': - specifier: workspace:* - version: link:../charm - '@rbxts/react': - specifier: '*' - version: 17.2.1 - - src/vide: - dependencies: - '@rbxts/charm': - specifier: workspace:* - version: link:../charm - '@rbxts/vide': - specifier: '*' - version: 0.4.6 + version: 0.5.3 packages: @@ -146,17 +120,17 @@ packages: '@rbxts/compiler-types@3.0.0-types.0': resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} - '@rbxts/react-vendor@17.2.1': - resolution: {integrity: sha512-7DxFweCbhhso4M4tl1pO7Kil9SZrvOl1SoZ4lnwUfoxdHvSmWgeKgaUqd+ZS3/u1E8kRraqGQ56Q8zjUEEUzlA==} + '@rbxts/react-vendor@17.2.3': + resolution: {integrity: sha512-dWCiVelvTK6h920geaFH/OQ5zoWHyhyqyfH51hKx2/iVZ6OiiyNVJ5UqEIkJMG4HsJ8JnkTORizV3+MtQ41BtA==} - '@rbxts/react@17.2.1': - resolution: {integrity: sha512-iu0RsVvfHroI7l+QXjwE0FiWV2rXoKMxuznDZ8vnx2BsFqrPnCqFjXuNeGYZwibibZC6jbyq7uKl+/8cz7XlsA==} + '@rbxts/react@17.2.3': + resolution: {integrity: sha512-IoOB8e2W8MxT0MKWFf0Ls0vT61ImYL6ctHQgz5+oazma6z8bqFtJjgPxOuy6jmMSuyfBe7rYp/muou0kIjZT+Q==} '@rbxts/types@1.0.811': resolution: {integrity: sha512-MPhs5lxzxvDqie34Y9FzJ1R+k+33jjrkj03agX2y2WzBKyGmO0rD/kPuUaKXQgEiACXuk36C5FxUTYgkKJPbvA==} - '@rbxts/vide@0.4.6': - resolution: {integrity: sha512-xYtvCS05nGq1YU7fyQVmp4z1CcvuOR+VKpKj/Y7lFPvKobQ3oHTQEzZiJFEMZ+iSeHxwZhwpzVjrP+6+e2EoQw==} + '@rbxts/vide@0.5.3': + resolution: {integrity: sha512-evaZbBRZzaqU8ibddTMaomb30pmW5OhWTT7ba1PLSA/iGBCid0eOF6BX1o0ArKPyMFnNj6pMM2yMTQzL5b9cIA==} '@roblox-ts/luau-ast@2.0.0': resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} @@ -932,15 +906,15 @@ snapshots: '@rbxts/compiler-types@3.0.0-types.0': {} - '@rbxts/react-vendor@17.2.1': {} + '@rbxts/react-vendor@17.2.3': {} - '@rbxts/react@17.2.1': + '@rbxts/react@17.2.3': dependencies: - '@rbxts/react-vendor': 17.2.1 + '@rbxts/react-vendor': 17.2.3 '@rbxts/types@1.0.811': {} - '@rbxts/vide@0.4.6': {} + '@rbxts/vide@0.5.3': {} '@roblox-ts/luau-ast@2.0.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e79ce62..924b55f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,2 @@ packages: - - src/* + - packages/* diff --git a/rokit.toml b/rokit.toml index 0df6c48..02a00e8 100644 --- a/rokit.toml +++ b/rokit.toml @@ -6,7 +6,7 @@ [tools] wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.4.4" -wally-package-types = "JohnnyMorganz/wally-package-types@1.3.2" -stylua = "johnnymorganz/stylua@0.20.0" -selene = "Kampfkarren/selene@0.27.1" -luau-lsp = "johnnymorganz/luau-lsp@1.32.4" +wally-package-types = "JohnnyMorganz/wally-package-types@1.4.2" +stylua = "johnnymorganz/stylua@2.0.2" +selene = "Kampfkarren/selene@0.28.0" +luau-lsp = "johnnymorganz/luau-lsp@1.38.1" diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..caaa48d --- /dev/null +++ b/stylua.toml @@ -0,0 +1 @@ +syntax = "Luau" diff --git a/wally.lock b/wally.lock deleted file mode 100644 index c7a5a63..0000000 --- a/wally.lock +++ /dev/null @@ -1,103 +0,0 @@ -# This file is automatically @generated by Wally. -# It is not intended for manual editing. -registry = "test" - -[[package]] -name = "centau/vide" -version = "0.3.1" -dependencies = [] - -[[package]] -name = "jsdotlua/boolean" -version = "1.2.6" -dependencies = [["number", "jsdotlua/number@1.2.6"]] - -[[package]] -name = "jsdotlua/collections" -version = "1.2.6" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.6"], ["instance-of", "jsdotlua/instance-of@1.2.6"]] - -[[package]] -name = "jsdotlua/console" -version = "1.2.6" -dependencies = [["collections", "jsdotlua/collections@1.2.6"]] - -[[package]] -name = "jsdotlua/es7-types" -version = "1.2.6" -dependencies = [] - -[[package]] -name = "jsdotlua/instance-of" -version = "1.2.6" -dependencies = [] - -[[package]] -name = "jsdotlua/luau-polyfill" -version = "1.2.6" -dependencies = [["boolean", "jsdotlua/boolean@1.2.6"], ["collections", "jsdotlua/collections@1.2.6"], ["console", "jsdotlua/console@1.2.6"], ["es7-types", "jsdotlua/es7-types@1.2.6"], ["instance-of", "jsdotlua/instance-of@1.2.6"], ["math", "jsdotlua/math@1.2.6"], ["number", "jsdotlua/number@1.2.6"], ["string", "jsdotlua/string@1.2.6"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.6"]] - -[[package]] -name = "jsdotlua/math" -version = "1.2.6" -dependencies = [] - -[[package]] -name = "jsdotlua/number" -version = "1.2.6" -dependencies = [] - -[[package]] -name = "jsdotlua/promise" -version = "3.5.2" -dependencies = [] - -[[package]] -name = "jsdotlua/react" -version = "17.2.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.2.0"]] - -[[package]] -name = "jsdotlua/react-reconciler" -version = "17.2.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.0"], ["scheduler", "jsdotlua/scheduler@17.2.0"], ["shared", "jsdotlua/shared@17.2.0"]] - -[[package]] -name = "jsdotlua/react-roblox" -version = "17.2.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["react", "jsdotlua/react@17.2.0"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.0"], ["scheduler", "jsdotlua/scheduler@17.2.0"], ["shared", "jsdotlua/shared@17.2.0"]] - -[[package]] -name = "jsdotlua/scheduler" -version = "17.2.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.2.0"]] - -[[package]] -name = "jsdotlua/shared" -version = "17.2.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"]] - -[[package]] -name = "jsdotlua/string" -version = "1.2.6" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.6"], ["number", "jsdotlua/number@1.2.6"]] - -[[package]] -name = "jsdotlua/symbol-luau" -version = "1.0.1" -dependencies = [] - -[[package]] -name = "jsdotlua/timers" -version = "1.2.6" -dependencies = [["collections", "jsdotlua/collections@1.2.6"]] - -[[package]] -name = "littensy/charm" -version = "0.1.0" -dependencies = [["React", "jsdotlua/react@17.2.0"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.0"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]] - -[[package]] -name = "roblox/testez" -version = "0.4.1" -dependencies = [] From b8bb1233795c9380c46b4bfb9515cde73d14c504 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 25 Jan 2025 14:07:14 -0800 Subject: [PATCH 40/68] feat(sync): add `autoSerialize` option (#42) * feat(sync): add `autoSerialize` option * docs: fix `autoSerialize` note * docs: fix typo --- README.md | 24 +++++++++++++++++------- packages/charm-sync/src/index.d.ts | 11 +++++------ packages/charm-sync/src/patch.luau | 20 ++++++++------------ packages/charm-sync/src/server.luau | 19 +++++++++---------- packages/charm-sync/src/validate.luau | 12 +++--------- tests/charm-sync/patch.spec.luau | 12 +++++++++++- tests/charm-sync/validate.spec.luau | 5 +---- tests/init.server.luau | 1 + 8 files changed, 55 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index ae188f8..7806828 100644 --- a/README.md +++ b/README.md @@ -438,10 +438,15 @@ Call `server` to create a server sync object. This synchronizes every client's a ```luau local syncer = CharmSync.server({ - atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the client's - interval = 0, -- The minimum interval between state updates - preserveHistory = false, -- Whether to send a full history of changes made to the atoms (slower) - serializeArrays = true, -- Safety measures for arrays, should be false when using ByteNet or Zap + -- A dictionary of the atoms to sync, matching the client's + atoms = atomsToSync, + -- The minimum interval between state updates + interval = 0, + -- Whether to send a full history of changes made to the atoms (slower) + preserveHistory = false, + -- Whether to apply fixes for remote event limitations. Disable this option + -- when using a network library with custom ser/des, like ByteNep or Zap. + autoSerialize = true, }) -- Sends state updates to clients when a synced atom changes. @@ -467,7 +472,12 @@ end) - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. - - **optional** `serializeArrays`: Whether to convert problematic arrays to dictionaries before firing remote events. Because arrays in state patches can have holes, extra work is required to send them over a remote event intact. Defaults to `true`, but should be `false` if payloads are serialized (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). + - **optional** `autoSerialize`: Whether to apply validation and workarounds to certain [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#table-indexing). Defaults to `true`, but you should set it to `false` if you serialize remote arguments (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). + +> [!NOTE] +> Charm sends table updates in the form of partial tables, so arrays will contain `nil` values, which has undefined behavior in remotes without serialization. +> +> Charm's default `autoSerialize` behavior fixes this, but it can interfere with custom serialization. Disable this option if you use a network library that serializes remote event arguments. #### Returns @@ -479,11 +489,11 @@ end) #### Caveats -- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. +- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. [Read more about argument limitations in remotes.](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) - **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. -- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `serializeArrays` accordingly! +- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `autoSerialize` accordingly! --- diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 1c59675..41e88a5 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -132,14 +132,13 @@ declare namespace CharmSync { */ preserveHistory?: boolean; /** - * When `true`, convert problematic sparse arrays into dictionaries - * with string keys. Defaults to `true`. + * When `true`, Charm will apply validation and serialize unsafe arrays + * to address remote event argument limitations. Defaults to `true`. * - * While this is mandatory for safely syncing arrays over vanilla Roblox - * remotes, it can be disabled if your network library uses a custom - * serialization method (i.e. Zap, ByteNet). + * This option should be disabled if your network library uses a custom + * serialization method (i.e. Zap, ByteNet) to prevent interference. */ - serializeArrays?: boolean; + autoSerialize?: boolean; } interface ClientSyncer { diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 7ad5554..3d021b5 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -45,10 +45,12 @@ end @param prevState The previous state. @param nextState The next state. - @param serializeArrays Whether to convert sparse arrays into dictionaries. Defaults to `true`. + @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`. @returns A patch that can be applied to the previous state. ]=] -local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serializeArrays: boolean?) +local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?) + serialize = serialize ~= false + local patches = table.clone(nextState) for key, previous in next, prevState do @@ -59,25 +61,19 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial elseif current == nil then patches[key] = None elseif type(previous) == "table" and type(current) == "table" then - patches[key] = diff(previous, current, serializeArrays) + patches[key] = diff(previous, current, serialize) end end -- Coerce sparse array patches into dictionaries - if serializeArrays ~= false and (prevState[1] ~= nil or nextState[1] ~= nil) then + if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then patches = stringifySparseArray(patches) end - if __DEV__ then - for key, value in next, prevState do + if serialize and __DEV__ then + for key, value in next, patches do validate(value, key) end - - for key, value in nextState do - if prevState[key] ~= value then - validate(value, key) - end - end end return patches diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 3327b87..a2a8de7 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -24,14 +24,13 @@ type ServerOptions = { ]=] preserveHistory: boolean?, --[=[ - Whether to convert problematic sparse arrays into dictionaries with - string keys. Defaults to `true`. + When `true`, Charm will apply validation and serialize unsafe arrays + to address remote event argument limitations. Defaults to `true`. - While this is mandatory for safely syncing arrays over vanilla Roblox - remotes, it can be disabled if your network library uses a custom - serialization method (i.e. Zap, ByteNet). + This option should be disabled if your network library uses a custom + serialization method (i.e. Zap, ByteNet) to prevent interference. ]=] - serializeArrays: boolean?, + autoSerialize: boolean?, } type ServerSyncer = { @@ -144,7 +143,7 @@ local function server(options: ServerOptions): ServerSyncer table.insert(payloads, { type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays), + data = patch.diff(snapshots[index - 1], snapshot, options.autoSerialize), }) end @@ -181,7 +180,7 @@ local function server(options: ServerOptions): ServerSyncer table.insert(payloads, { type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays), + data = patch.diff(snapshots[index - 1], snapshot, options.autoSerialize), }) end @@ -218,7 +217,7 @@ local function server(options: ServerOptions): ServerSyncer local payload: SyncPayload = { type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays), + data = patch.diff(previousSnapshot, currentSnapshot, options.autoSerialize), } previousSnapshot = table.clone(currentSnapshot) @@ -247,7 +246,7 @@ local function server(options: ServerOptions): ServerSyncer local payload: SyncPayload = { type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays), + data = patch.diff(previousSnapshot, currentSnapshot, options.autoSerialize), } previousSnapshot = table.clone(currentSnapshot) diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index 833f4b6..c832542 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,5 +1,5 @@ local SAFE_KEYS = { string = true, number = true } -local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true, vector = true } +local UNSAFE_VALUES = { ["function"] = true, thread = true } local function isTableUnsafe(object: { [any]: any }) local keyType = nil @@ -40,7 +40,7 @@ local function validate(value: any, key: any) if not SAFE_KEYS[typeOfKey] then error(`Invalid key type '{typeOfKey}' at key '{key}'`) - elseif not SAFE_VALUES[typeOfValue] then + elseif UNSAFE_VALUES[typeOfValue] then error(`Invalid value type '{typeOfValue}' at key '{key}'`) elseif typeOfValue == "table" then if getmetatable(value) ~= nil then @@ -56,13 +56,7 @@ local function validate(value: any, key: any) end end - if typeOfValue == "number" then - if value ~= value then - error(`Cannot sync NaN at key '{key}'`) - elseif value == math.huge or value == -math.huge then - error(`Cannot sync infinity at key '{key}'`) - end - elseif typeOfKey == "number" then + if typeOfKey == "number" then if key == math.huge or key == -math.huge then error("Cannot sync infinity as key") elseif key ~= math.floor(key) then diff --git a/tests/charm-sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau index e76becb..6e850b2 100644 --- a/tests/charm-sync/patch.spec.luau +++ b/tests/charm-sync/patch.spec.luau @@ -100,7 +100,7 @@ return function() end) it("optionally fixes sparse arrays", function() - for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do + for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do expect(index).to.be.a("string") end @@ -108,4 +108,14 @@ return function() expect(index).to.be.a("number") end end) + + it("optionally validates state", function() + expect(function() + patch.diff({}, { [0.1] = true }, true) + end).to.throw() + + expect(function() + patch.diff({}, { [0.1] = true }, false) + end).to.never.throw() + end) end diff --git a/tests/charm-sync/validate.spec.luau b/tests/charm-sync/validate.spec.luau index 8065b9f..0a378d8 100644 --- a/tests/charm-sync/validate.spec.luau +++ b/tests/charm-sync/validate.spec.luau @@ -12,12 +12,9 @@ return function() array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false }, map_decimal_key = { value = { [1.5] = 0 }, expected = false }, + map_infinity_key = { value = { [math.huge] = true }, expected = false }, map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false }, - number = { value = 1, expected = true }, - number_infinity = { value = math.huge, expected = false }, - number_nan = { value = 0 / 0, expected = false }, - nested_atom = { value = atom(0), expected = false }, } diff --git a/tests/init.server.luau b/tests/init.server.luau index 4b3505c..518d08b 100644 --- a/tests/init.server.luau +++ b/tests/init.server.luau @@ -7,6 +7,7 @@ local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ) -- Start Charm in debug mode _G.__DEV__ = true require(ReplicatedStorage.DevPackages.Charm) +require(ReplicatedStorage.DevPackages.CharmSync) _G.__DEV__ = false TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter) From c7c5601e7cb635cf06bd75e18a9056486fb18d57 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 14:14:05 -0800 Subject: [PATCH 41/68] chore: update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7806828..df2ccfc 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ -**Charm** is an atomic state management library inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Store your immutable state in atoms, and write intuitive functions that read, write, and subscribe to state. +**Charm** is an atomic and immutable state management library, inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Store your state in atoms, and write your own functions to read, write, and observe state. See an example of Charm's features in [this example repository](https://github.com/littensy/charm-example). ## 🍀 Features -- ⚛️ **Manage state with _atoms_.** Decompose state into tiny, composable containers called _atoms_, as opposed to combining them into a single store. +- ⚛️ **Manage state with _atoms_.** Decompose state into independent containers called _atoms_, as opposed to combining them into a single store. - 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. From d703990e5b4edd07bd9844ed420344fb14eaf608 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 14:19:26 -0800 Subject: [PATCH 42/68] refactor(sync): destructure `options` argument --- packages/charm-sync/src/client.luau | 4 ++-- packages/charm-sync/src/server.luau | 25 +++++++++++++++---------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 724aba5..801a432 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -33,10 +33,10 @@ type ClientSyncer = { @return A `ClientSyncer` object. ]=] local function client(options: ClientOptions): ClientSyncer - local self = {} :: ClientSyncer - local atoms = options.atoms local ignoreUnhydrated = options.ignoreUnhydrated ~= false + + local self = {} :: ClientSyncer local hydrated = false local function hydrate(state: { [string | number]: any }) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index a2a8de7..6eac9c3 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -67,13 +67,18 @@ type ServerSyncer = { @return A `ServerSyncer` object. ]=] local function server(options: ServerOptions): ServerSyncer + local atoms = options.atoms + local autoSerialize = options.autoSerialize ~= false + local preserveHistory = options.preserveHistory + local syncRate = options.interval or 0 + local self = {} :: ServerSyncer local sync: (player: Player, payload: SyncPayload) -> () local function createSnapshot() local snapshot = {} - for key, atom in next, options.atoms do + for key, atom in next, atoms do snapshot[key] = atom() end @@ -92,7 +97,7 @@ local function server(options: ServerOptions): ServerSyncer -- If the history of state changes should be preserved, keep a list of -- previous snapshots and diff-check each one from the last. - if options.preserveHistory then + if preserveHistory then local snapshots = { createSnapshot() } local changed = false @@ -119,7 +124,7 @@ local function server(options: ServerOptions): ServerSyncer -- Populate the snapshot with the initial state of each atom. -- Subscribe to each atom and update the state when it changes. - for key, atom in next, options.atoms do + for key, atom in next, atoms do subscriptions[key] = Charm.subscribe(atom, function(current, previous) pushSnapshot(key, current, previous) changed = true @@ -143,7 +148,7 @@ local function server(options: ServerOptions): ServerSyncer table.insert(payloads, { type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, options.autoSerialize), + data = patch.diff(snapshots[index - 1], snapshot, autoSerialize), }) end @@ -153,7 +158,7 @@ local function server(options: ServerOptions): ServerSyncer for _, player in next, Players:GetPlayers() do callback(player, unpack(payloads)) end - end, options.interval or 0) + end, syncRate) return function() disconnect() @@ -180,7 +185,7 @@ local function server(options: ServerOptions): ServerSyncer table.insert(payloads, { type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, options.autoSerialize), + data = patch.diff(snapshots[index - 1], snapshot, autoSerialize), }) end @@ -203,7 +208,7 @@ local function server(options: ServerOptions): ServerSyncer sync = callback -- Subscribe to each atom and update the state when one changes. - for key, atom in next, options.atoms do + for key, atom in next, atoms do subscriptions[key] = Charm.subscribe(atom, function(state) currentSnapshot[key] = state changed = true @@ -217,7 +222,7 @@ local function server(options: ServerOptions): ServerSyncer local payload: SyncPayload = { type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, options.autoSerialize), + data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), } previousSnapshot = table.clone(currentSnapshot) @@ -226,7 +231,7 @@ local function server(options: ServerOptions): ServerSyncer for _, player in next, Players:GetPlayers() do callback(player, payload) end - end, options.interval or 0) + end, syncRate) return function() disconnect() @@ -246,7 +251,7 @@ local function server(options: ServerOptions): ServerSyncer local payload: SyncPayload = { type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, options.autoSerialize), + data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), } previousSnapshot = table.clone(currentSnapshot) From 884e6c0e8cd8b58a35b2cd3d357fcb3122a7e898 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 15:05:55 -0800 Subject: [PATCH 43/68] test: add patch benchmark --- tests/benchmarks/atom.bench.luau | 46 ++++++++++++++--------------- tests/benchmarks/patch.bench.luau | 48 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 tests/benchmarks/patch.bench.luau diff --git a/tests/benchmarks/atom.bench.luau b/tests/benchmarks/atom.bench.luau index af410ee..3bae3ca 100644 --- a/tests/benchmarks/atom.bench.luau +++ b/tests/benchmarks/atom.bench.luau @@ -4,29 +4,27 @@ local Charm = require(ReplicatedStorage.DevPackages.Charm) local computed = Charm.computed local atom = Charm.atom -local function parameterGenerator() - return atom(0) -end - -local benchmarks = { - atom = function(_, state: any) - for _ = 1, 500 do - state(math.random(1, 2)) - end - end, - - computed = function(_, state: any) - computed(function() - return 2 * state() - end) - - for _ = 1, 500 do - state(math.random(1, 2)) - end - end, -} - return { - ParameterGenerator = parameterGenerator, - Functions = benchmarks, + ParameterGenerator = function() end, + Functions = { + atom = function() + local state = atom(0) + + for _ = 1, 500 do + state(math.random(1, 2)) + end + end, + + computed = function() + local state = atom(0) + + local _double = computed(function() + return 2 * state() + end) + + for _ = 1, 500 do + state(math.random(1, 2)) + end + end, + }, } diff --git a/tests/benchmarks/patch.bench.luau b/tests/benchmarks/patch.bench.luau new file mode 100644 index 0000000..6ea53f6 --- /dev/null +++ b/tests/benchmarks/patch.bench.luau @@ -0,0 +1,48 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local patch = require(ReplicatedStorage.DevPackages.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 + if math.random() > 0.5 then + continue + end + + state.users[user] = { + coins = math.random(1, 2), + gems = math.random(1, 2), + } + 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, + }, +} From 63388761f8138729f166f0502815898dbecca96e Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 16:07:28 -0800 Subject: [PATCH 44/68] refactor(sync): create `AtomMap` type --- packages/charm-sync/src/client.luau | 3 ++- packages/charm-sync/src/server.luau | 3 ++- packages/charm-sync/src/types.luau | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 801a432..8f3cfb1 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -1,13 +1,14 @@ local Charm = require(script.Parent.Parent.Charm) local patch = require(script.Parent.patch) local types = require(script.Parent.types) +type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload type ClientOptions = { --[=[ The atoms to synchronize with the server. ]=] - atoms: { [string | number]: Charm.Atom }, + atoms: AtomMap, --[=[ Whether to ignore patches sent before the client has been hydrated. Default is `true`. diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 6eac9c3..f2c336e 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -4,13 +4,14 @@ local Charm = require(script.Parent.Parent.Charm) local interval = require(script.Parent.interval) local patch = require(script.Parent.patch) local types = require(script.Parent.types) +type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload type ServerOptions = { --[=[ The atoms to synchronize with the client. ]=] - atoms: { [string | number]: Charm.Atom }, + atoms: AtomMap, --[=[ 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 diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index f6deb17..641f744 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -1,3 +1,6 @@ +local Charm = require(script.Parent.Parent.Charm) +type Atom = Charm.Atom + --[=[ A payload that can be sent from the server to the client to synchronize state between the two. @@ -10,4 +13,8 @@ export type SyncPayload = { data: { [string | number]: any }, } +export type AtomMap = { + [string | number]: Atom, +} + return nil From 8063ea3142a7a95fd04747817ae61412a1243199 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 25 Jan 2025 16:11:34 -0800 Subject: [PATCH 45/68] feat(sync): add `flatten()` function (#43) --- packages/charm-sync/src/flatten.luau | 28 ++++++++++++++++++++++ packages/charm-sync/src/index.d.ts | 35 ++++++++++++++++++++++++++++ packages/charm-sync/src/init.luau | 2 ++ tests/charm-sync/flatten.spec.luau | 24 +++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 packages/charm-sync/src/flatten.luau create mode 100644 tests/charm-sync/flatten.spec.luau diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau new file mode 100644 index 0000000..9c8d012 --- /dev/null +++ b/packages/charm-sync/src/flatten.luau @@ -0,0 +1,28 @@ +local types = require(script.Parent.types) +type AtomMap = types.AtomMap + +type NestedAtomMap = { + [string]: NestedAtomMap | () -> (), +} + +local function flatten(atoms: NestedAtomMap): AtomMap + local result: AtomMap = {} + + local function visit(node: NestedAtomMap, path: string) + for key, value in node do + local location = if path == "" then key else path .. "/" .. key + + if type(value) == "table" then + visit(value, location) + else + result[location] = value + end + end + end + + visit(atoms, "") + + return result +end + +return flatten diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 41e88a5..dfd8abf 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -5,6 +5,8 @@ export as namespace CharmSync; type Cleanup = () => void; +type Key = string | number | symbol; + declare namespace CharmSync { type AtomMap = Record>; @@ -60,6 +62,39 @@ declare namespace CharmSync { */ function isNone(value: unknown): value is None; + interface NestedAtomMap { + readonly [key: string]: NestedAtomMap | Atom; + } + + type AppendPath = undefined extends Path + ? Name + : Name extends string + ? Path extends string + ? `${Path}/${Name}` + : never + : never; + + type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; + + type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; + + type FlattenNestedAtoms = IntersectValues<{ + readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom + ? { readonly [K in AppendPath]: Atoms[Name] } + : Atoms[Name] extends NestedAtomMap + ? FlattenNestedAtoms> + : never; + }>; + + /** + * Flattens a nested atom map into a single object with slash-separated + * keys. Useful for recursively collecting atoms returned by modules. + * + * @param atoms The nested atom map to flatten. + * @return A flattened atom map. + */ + function flatten(atoms: Atoms): FlattenNestedAtoms; + type DataTypes = { [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; }; diff --git a/packages/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau index b57c18f..5fc9171 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -1,4 +1,5 @@ local client = require(script.client) +local flatten = require(script.flatten) local patch = require(script.patch) local server = require(script.server) local types = require(script.types) @@ -12,5 +13,6 @@ export type SyncPayload = types.SyncPayload return { client = client, server = server, + flatten = flatten, isNone = patch.isNone, } diff --git a/tests/charm-sync/flatten.spec.luau b/tests/charm-sync/flatten.spec.luau new file mode 100644 index 0000000..c274d05 --- /dev/null +++ b/tests/charm-sync/flatten.spec.luau @@ -0,0 +1,24 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +local flatten = CharmSync.flatten + +return function() + it("flattens atoms", function() + local input = { + a = atom(0), + b = { c = atom(0) }, + d = { e = { f = atom(0) } }, + } + local result = flatten(input) + + expect(result.a).to.equal(input.a) + expect(result["b/c"]).to.equal(input.b.c) + expect(result["d/e/f"]).to.equal(input.d.e.f) + + expect(result.b).to.never.be.ok() + expect(result.d).to.never.be.ok() + end) +end From fa2fce090b74ff87e741b92fdc5721f5f7f4d092 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 16:17:08 -0800 Subject: [PATCH 46/68] docs: document cleanup behavior --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index df2ccfc..0c16762 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Call `subscribe` to listen for changes in an atom or selector function. When the ```luau local nameAtom = atom("John") -subscribe(nameAtom, function(name, prevName) +local cleanup = subscribe(nameAtom, function(name, prevName) print(name) end) @@ -135,7 +135,7 @@ local function getUppercase() return string.upper(nameAtom()) end -subscribe(getUppercase, function(name) +local cleanup = subscribe(getUppercase, function(name) print(name) end) @@ -161,7 +161,7 @@ Call `effect` to track state changes in all atoms read within the callback. The ```luau local nameAtom = atom("John") -effect(function() +local cleanup = effect(function() print(nameAtom()) return function() print("Cleanup function called!") @@ -179,6 +179,17 @@ Because `effect` implicitly tracks all atoms read within the callback, it might `effect` returns a cleanup function. +#### Caveats + +- **If your effect calls its own cleanup function, use the `cleanup` argument.** Because effects run immediately, your effect will run before a `cleanup` function is returned. To use `cleanup` from with in the effect, use the argument passed to your effect instead: + ```lua + effect(function(cleanup) + if condition() then + cleanup() + end + end) + ``` + --- ### `computed(callback, options?)` @@ -222,7 +233,7 @@ Call `observe` to create an instance of `factory` for each key present in a dict ```luau local todosAtom: Atom<{ [string]: Todo }> = atom({}) -observe(todosAtom, function(todo, key) +local cleanup = observe(todosAtom, function(todo, key) print(`Added {key}: {todo.name}`) return function() print(`Removed {key}`) From 39a5b67d9c43db55d2ed288ebc5ff2b521091be7 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 16:19:23 -0800 Subject: [PATCH 47/68] docs: fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c16762..0bcc5a2 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ Because `effect` implicitly tracks all atoms read within the callback, it might #### Caveats -- **If your effect calls its own cleanup function, use the `cleanup` argument.** Because effects run immediately, your effect will run before a `cleanup` function is returned. To use `cleanup` from with in the effect, use the argument passed to your effect instead: +- **If your effect should disconnect itself, use the `cleanup` argument.** Because effects run immediately, your effect may run before a `cleanup` function is returned. To disconnect an effect from the inside, use the argument passed to your effect instead: ```lua effect(function(cleanup) if condition() then From c9755d31fd11b920aaa6d13dd5a4bd3cd352f0cc Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 16:41:05 -0800 Subject: [PATCH 48/68] refactor(vide): simplify `task.spawn` call --- packages/vide/src/init.luau | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/vide/src/init.luau b/packages/vide/src/init.luau index 766fb35..df962b2 100644 --- a/packages/vide/src/init.luau +++ b/packages/vide/src/init.luau @@ -12,9 +12,7 @@ local Vide = if script.Parent:FindFirstChild("vide") local function useAtom(callback: () -> State): () -> State local state = Vide.source(callback()) local unsubscribe = Charm.subscribe(callback, function(value) - task.spawn(function() - state(value) - end) + task.spawn(state :: (State) -> State, value) end) Vide.cleanup(unsubscribe) From 5d25f9ff20a9f4fa3dab2553c099f2f80ac7bad4 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 16:52:05 -0800 Subject: [PATCH 49/68] build: move charm to peerDependencies --- packages/charm-sync/package.json | 2 +- packages/react/package.json | 4 +--- packages/vide/package.json | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index ff737cd..eadbf2b 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -10,7 +10,7 @@ "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" }, - "dependencies": { + "peerDependencies": { "@rbxts/charm": "workspace:*" } } diff --git a/packages/react/package.json b/packages/react/package.json index d04d15a..657e40f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -10,10 +10,8 @@ "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" }, - "dependencies": { - "@rbxts/charm": "workspace:*" - }, "peerDependencies": { + "@rbxts/charm": "workspace:*", "@rbxts/react": "*" } } diff --git a/packages/vide/package.json b/packages/vide/package.json index fb0602c..085d73f 100644 --- a/packages/vide/package.json +++ b/packages/vide/package.json @@ -10,10 +10,8 @@ "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" }, - "dependencies": { - "@rbxts/charm": "workspace:*" - }, "peerDependencies": { + "@rbxts/charm": "workspace:*", "@rbxts/vide": "*" } } From fae2635ba9824e3d244505a003daa0839cb1e987 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 25 Jan 2025 16:53:14 -0800 Subject: [PATCH 50/68] chore: bump versions --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 4 ++-- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/react/package.json | 2 +- packages/react/wally.toml | 4 ++-- packages/vide/package.json | 2 +- packages/vide/wally.toml | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index eadbf2b..642f01f 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.1.1", + "version": "0.2.0", "description": "Sync server state with clients using Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index e2e5135..dffe326 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,8 +1,8 @@ [package] name = "littensy/charm-sync" -version = "0.1.1" +version = "0.2.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@^0.8" +Charm = "littensy/charm@^0.9" diff --git a/packages/charm/package.json b/packages/charm/package.json index 879473f..71a30dc 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.8.1", + "version": "0.9.0", "description": "An atomic state management library for Roblox", "repository": "littensy/charm", "license": "MIT", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 858cd88..dde85b5 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.8.1" +version = "0.9.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" diff --git a/packages/react/package.json b/packages/react/package.json index 657e40f..04c8c1f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.1.1", + "version": "0.2.0", "description": "React bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/packages/react/wally.toml b/packages/react/wally.toml index e9234ed..a26d426 100644 --- a/packages/react/wally.toml +++ b/packages/react/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/react-charm" -version = "0.1.1" +version = "0.2.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@^0.8" +Charm = "littensy/charm@^0.9" React = "jsdotlua/react@^17" diff --git a/packages/vide/package.json b/packages/vide/package.json index 085d73f..bf0b559 100644 --- a/packages/vide/package.json +++ b/packages/vide/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.1.1", + "version": "0.2.0", "description": "Vide bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/packages/vide/wally.toml b/packages/vide/wally.toml index 5a9e092..fdb1b0e 100644 --- a/packages/vide/wally.toml +++ b/packages/vide/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/vide-charm" -version = "0.1.1" +version = "0.2.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -Charm = "littensy/charm@^0.8" +Charm = "littensy/charm@^0.9" Vide = "centau/vide@^0.3" From e7fe400fa8f3e82ebd38f8e40bc4141eb0e091c7 Mon Sep 17 00:00:00 2001 From: safazi Date: Sun, 26 Jan 2025 17:21:57 -0600 Subject: [PATCH 51/68] docs: fix typo in README.md (#44) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0bcc5a2..8ea132b 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,7 @@ local syncer = CharmSync.server({ -- Whether to send a full history of changes made to the atoms (slower) preserveHistory = false, -- Whether to apply fixes for remote event limitations. Disable this option - -- when using a network library with custom ser/des, like ByteNep or Zap. + -- when using a network library with custom ser/des, like ByteNet or Zap. autoSerialize = true, }) From e9d8dffb02a90e794ca95d15b640636030457a9a Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 9 Mar 2025 15:33:04 -0700 Subject: [PATCH 52/68] build: remove project json files (#46) * build: remove project json files Including Rojo project files in packages interferes with certain Wally packages that depend on roblox-ts packages * build: create project files for release --- bin/build.sh | 17 +++++++++++++---- default.project.json | 10 ++++------ packages/charm-sync/default.project.json | 6 ------ packages/charm-sync/{src => }/index.d.ts | 0 packages/charm-sync/init.luau | 5 +++++ packages/charm-sync/package.json | 4 ++-- packages/charm-sync/src/client.luau | 2 +- packages/charm-sync/src/server.luau | 2 +- packages/charm-sync/src/types.luau | 2 +- packages/charm-sync/wally.toml | 4 +++- packages/charm/default.project.json | 6 ------ packages/charm/{src => }/index.d.ts | 0 packages/charm/init.luau | 7 +++++++ packages/charm/package.json | 4 ++-- packages/charm/src/init.luau | 1 - packages/charm/src/types.luau | 9 --------- packages/charm/wally.toml | 2 ++ packages/react/default.project.json | 6 ------ packages/react/{src => }/index.d.ts | 0 packages/react/init.luau | 1 + packages/react/package.json | 4 ++-- packages/react/src/init.luau | 8 ++++---- packages/react/wally.toml | 4 +++- packages/vide/default.project.json | 6 ------ packages/vide/{src => }/index.d.ts | 0 packages/vide/init.luau | 1 + packages/vide/package.json | 4 ++-- packages/vide/src/init.luau | 8 ++++---- packages/vide/wally.toml | 4 +++- tests/benchmarks/atom.bench.luau | 2 +- tests/benchmarks/patch.bench.luau | 2 +- tests/charm-sync/client.spec.luau | 4 ++-- tests/charm-sync/flatten.spec.luau | 4 ++-- tests/charm-sync/patch.spec.luau | 2 +- tests/charm-sync/server.spec.luau | 6 +++--- tests/charm-sync/validate.spec.luau | 4 ++-- tests/charm/atom.spec.luau | 4 ++-- tests/charm/batch.spec.luau | 4 ++-- tests/charm/capture.spec.luau | 4 ++-- tests/charm/computed.spec.luau | 4 ++-- tests/charm/effect.spec.luau | 2 +- tests/charm/mapped.spec.luau | 4 ++-- tests/charm/observe.spec.luau | 2 +- tests/charm/peek.spec.luau | 2 +- tests/charm/subscribe.spec.luau | 4 ++-- tests/init.server.luau | 4 ++-- tests/init.spec.luau | 2 +- tests/react/useAtom.spec.luau | 4 ++-- tests/vide/useAtom.spec.luau | 4 ++-- 49 files changed, 95 insertions(+), 100 deletions(-) delete mode 100644 packages/charm-sync/default.project.json rename packages/charm-sync/{src => }/index.d.ts (100%) create mode 100644 packages/charm-sync/init.luau delete mode 100644 packages/charm/default.project.json rename packages/charm/{src => }/index.d.ts (100%) create mode 100644 packages/charm/init.luau delete mode 100644 packages/react/default.project.json rename packages/react/{src => }/index.d.ts (100%) create mode 100644 packages/react/init.luau delete mode 100644 packages/vide/default.project.json rename packages/vide/{src => }/index.d.ts (100%) create mode 100644 packages/vide/init.luau diff --git a/bin/build.sh b/bin/build.sh index 547e4e7..6db8152 100644 --- a/bin/build.sh +++ b/bin/build.sh @@ -1,5 +1,14 @@ mkdir build -rojo build packages/charm -o build/charm.rbxm -rojo build packages/charm-sync -o build/charm-sync.rbxm -rojo build packages/react -o build/react-charm.rbxm -rojo build packages/vide -o build/vide-charm.rbxm + +build() { + cat > packages/default.project.json < = Charm.Atom --[=[ diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index dffe326..80468d8 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -3,6 +3,8 @@ name = "littensy/charm-sync" version = "0.2.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["package.json", "node_modules"] +include = ["LICENSE.md"] [dependencies] -Charm = "littensy/charm@^0.9" +charm = "littensy/charm@^0.9" diff --git a/packages/charm/default.project.json b/packages/charm/default.project.json deleted file mode 100644 index 89d3ada..0000000 --- a/packages/charm/default.project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Charm", - "tree": { - "$path": "src" - } -} diff --git a/packages/charm/src/index.d.ts b/packages/charm/index.d.ts similarity index 100% rename from packages/charm/src/index.d.ts rename to packages/charm/index.d.ts diff --git a/packages/charm/init.luau b/packages/charm/init.luau new file mode 100644 index 0000000..c98e594 --- /dev/null +++ b/packages/charm/init.luau @@ -0,0 +1,7 @@ +local src = require(script.src) + +export type Atom = src.Atom +export type Selector = src.Selector +export type Molecule = src.Selector + +return src diff --git a/packages/charm/package.json b/packages/charm/package.json index 71a30dc..9580314 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -4,8 +4,8 @@ "description": "An atomic state management library for Roblox", "repository": "littensy/charm", "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", + "main": "init.luau", + "types": "index.d.ts", "scripts": { "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 4bc1a8c..d81908f 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -10,7 +10,6 @@ local types = require(script.types) export type Atom = types.Atom export type Selector = types.Selector export type Molecule = types.Selector -export type SyncPayload = types.SyncPayload return { atom = atom, diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau index fd3ba6b..6aca257 100644 --- a/packages/charm/src/types.luau +++ b/packages/charm/src/types.luau @@ -31,13 +31,4 @@ export type AtomOptions = { equals: (prev: T, next: T) -> boolean, } ---[=[ - A payload that can be sent from the server to the client to synchronize - state between the two. -]=] -export type SyncPayload = { - type: "patch" | "init", - data: { [any]: any }, -} - return nil diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index dde85b5..d3ac7df 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -3,5 +3,7 @@ name = "littensy/charm" version = "0.9.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["package.json", "node_modules"] +include = ["LICENSE.md"] [dependencies] diff --git a/packages/react/default.project.json b/packages/react/default.project.json deleted file mode 100644 index c1ee26a..0000000 --- a/packages/react/default.project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "ReactCharm", - "tree": { - "$path": "src" - } -} diff --git a/packages/react/src/index.d.ts b/packages/react/index.d.ts similarity index 100% rename from packages/react/src/index.d.ts rename to packages/react/index.d.ts diff --git a/packages/react/init.luau b/packages/react/init.luau new file mode 100644 index 0000000..0aab139 --- /dev/null +++ b/packages/react/init.luau @@ -0,0 +1 @@ +return require(script.src) diff --git a/packages/react/package.json b/packages/react/package.json index 04c8c1f..41a83f7 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -4,8 +4,8 @@ "description": "React bindings for Charm", "repository": "littensy/charm", "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", + "main": "init.luau", + "types": "index.d.ts", "scripts": { "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" diff --git a/packages/react/src/init.luau b/packages/react/src/init.luau index e200551..fe15dee 100644 --- a/packages/react/src/init.luau +++ b/packages/react/src/init.luau @@ -1,7 +1,7 @@ -local Charm = require(script.Parent.Charm) -local React = if script.Parent:FindFirstChild("react") - then require(script.Parent.react) :: never - else require(script.Parent.React) +local Charm = require(script.Parent.Parent.charm) +local React = if script.Parent.Parent:FindFirstChild("React") + then require(script.Parent.Parent.React) + else require((script.Parent.Parent :: any).react) :: never --[=[ A hook that subscribes to changes in the given atom or selector. The diff --git a/packages/react/wally.toml b/packages/react/wally.toml index a26d426..bd379d8 100644 --- a/packages/react/wally.toml +++ b/packages/react/wally.toml @@ -3,7 +3,9 @@ name = "littensy/react-charm" version = "0.2.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["package.json", "node_modules"] +include = ["LICENSE.md"] [dependencies] -Charm = "littensy/charm@^0.9" +charm = "littensy/charm@^0.9" React = "jsdotlua/react@^17" diff --git a/packages/vide/default.project.json b/packages/vide/default.project.json deleted file mode 100644 index 3a447c6..0000000 --- a/packages/vide/default.project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "VideCharm", - "tree": { - "$path": "src" - } -} diff --git a/packages/vide/src/index.d.ts b/packages/vide/index.d.ts similarity index 100% rename from packages/vide/src/index.d.ts rename to packages/vide/index.d.ts diff --git a/packages/vide/init.luau b/packages/vide/init.luau new file mode 100644 index 0000000..0aab139 --- /dev/null +++ b/packages/vide/init.luau @@ -0,0 +1 @@ +return require(script.src) diff --git a/packages/vide/package.json b/packages/vide/package.json index bf0b559..7ffe63c 100644 --- a/packages/vide/package.json +++ b/packages/vide/package.json @@ -4,8 +4,8 @@ "description": "Vide bindings for Charm", "repository": "littensy/charm", "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", + "main": "init.luau", + "types": "index.d.ts", "scripts": { "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" diff --git a/packages/vide/src/init.luau b/packages/vide/src/init.luau index df962b2..5f2df7b 100644 --- a/packages/vide/src/init.luau +++ b/packages/vide/src/init.luau @@ -1,7 +1,7 @@ -local Charm = require(script.Parent.Charm) -local Vide = if script.Parent:FindFirstChild("vide") - then require(script.Parent.vide.src) :: never - else require(script.Parent.Vide) +local Charm = require(script.Parent.Parent.charm) +local Vide = if script.Parent.Parent:FindFirstChild("Vide") + then require(script.Parent.Parent.Vide) + else require((script.Parent.Parent :: any).vide.src) :: never --[=[ Subscribes to the state of an atom and returns a Vide source. diff --git a/packages/vide/wally.toml b/packages/vide/wally.toml index fdb1b0e..642ab3c 100644 --- a/packages/vide/wally.toml +++ b/packages/vide/wally.toml @@ -3,7 +3,9 @@ name = "littensy/vide-charm" version = "0.2.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["package.json", "node_modules"] +include = ["LICENSE.md"] [dependencies] -Charm = "littensy/charm@^0.9" +charm = "littensy/charm@^0.9" Vide = "centau/vide@^0.3" diff --git a/tests/benchmarks/atom.bench.luau b/tests/benchmarks/atom.bench.luau index 3bae3ca..48d64b7 100644 --- a/tests/benchmarks/atom.bench.luau +++ b/tests/benchmarks/atom.bench.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local computed = Charm.computed local atom = Charm.atom diff --git a/tests/benchmarks/patch.bench.luau b/tests/benchmarks/patch.bench.luau index 6ea53f6..09119c5 100644 --- a/tests/benchmarks/patch.bench.luau +++ b/tests/benchmarks/patch.bench.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) +local patch = require(ReplicatedStorage.DevPackages["charm-sync"].src.patch) local USERS = 1000 diff --git a/tests/charm-sync/client.spec.luau b/tests/charm-sync/client.spec.luau index 2d2de15..4923a4d 100644 --- a/tests/charm-sync/client.spec.luau +++ b/tests/charm-sync/client.spec.luau @@ -1,9 +1,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local subscribe = Charm.subscribe -local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +local CharmSync = require(ReplicatedStorage.DevPackages["charm-sync"]) local client = CharmSync.client return function() diff --git a/tests/charm-sync/flatten.spec.luau b/tests/charm-sync/flatten.spec.luau index c274d05..0ca4ad4 100644 --- a/tests/charm-sync/flatten.spec.luau +++ b/tests/charm-sync/flatten.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom -local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +local CharmSync = require(ReplicatedStorage.DevPackages["charm-sync"]) local flatten = CharmSync.flatten return function() diff --git a/tests/charm-sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau index 6e850b2..fe9906b 100644 --- a/tests/charm-sync/patch.spec.luau +++ b/tests/charm-sync/patch.spec.luau @@ -1,7 +1,7 @@ local HttpService = game:GetService("HttpService") local ReplicatedStorage = game:GetService("ReplicatedStorage") -local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) +local patch = require(ReplicatedStorage.DevPackages["charm-sync"].src.patch) return function() local function serdes(value: { [any]: any }) diff --git a/tests/charm-sync/server.spec.luau b/tests/charm-sync/server.spec.luau index 5fc33f9..75d4c54 100644 --- a/tests/charm-sync/server.spec.luau +++ b/tests/charm-sync/server.spec.luau @@ -1,11 +1,11 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom -local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +local CharmSync = require(ReplicatedStorage.DevPackages["charm-sync"]) type SyncPayload = CharmSync.SyncPayload local server = CharmSync.server -local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) +local patch = require(ReplicatedStorage.DevPackages["charm-sync"].src.patch) return function() local target: Player = {} :: any diff --git a/tests/charm-sync/validate.spec.luau b/tests/charm-sync/validate.spec.luau index 0a378d8..548ddb1 100644 --- a/tests/charm-sync/validate.spec.luau +++ b/tests/charm-sync/validate.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom -local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate) +local validate = require(ReplicatedStorage.DevPackages["charm-sync"].src.validate) return function() it("validates inputs", function() diff --git a/tests/charm/atom.spec.luau b/tests/charm/atom.spec.luau index 0872037..1c74776 100644 --- a/tests/charm/atom.spec.luau +++ b/tests/charm/atom.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom -local store = require(ReplicatedStorage.DevPackages.Charm.store) +local store = require(ReplicatedStorage.DevPackages.charm.src.store) return function() it("updates state", function() diff --git a/tests/charm/batch.spec.luau b/tests/charm/batch.spec.luau index 375f515..2726a56 100644 --- a/tests/charm/batch.spec.luau +++ b/tests/charm/batch.spec.luau @@ -1,11 +1,11 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local computed = Charm.computed local subscribe = Charm.subscribe local effect = Charm.effect -local store = require(ReplicatedStorage.DevPackages.Charm.store) +local store = require(ReplicatedStorage.DevPackages.charm.src.store) return function() it("batches updates", function() diff --git a/tests/charm/capture.spec.luau b/tests/charm/capture.spec.luau index 80669a5..d74bd4d 100644 --- a/tests/charm/capture.spec.luau +++ b/tests/charm/capture.spec.luau @@ -1,9 +1,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local subscribe = Charm.subscribe -local store = require(ReplicatedStorage.DevPackages.Charm.store) +local store = require(ReplicatedStorage.DevPackages.charm.src.store) return function() it("gracefully handles yielding", function() diff --git a/tests/charm/computed.spec.luau b/tests/charm/computed.spec.luau index d71d46d..08ff46a 100644 --- a/tests/charm/computed.spec.luau +++ b/tests/charm/computed.spec.luau @@ -1,9 +1,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local computed = Charm.computed -local store = require(ReplicatedStorage.DevPackages.Charm.store) +local store = require(ReplicatedStorage.DevPackages.charm.src.store) local collect = require(script.Parent.Parent.utils.collect) local count = require(script.Parent.Parent.utils.count) diff --git a/tests/charm/effect.spec.luau b/tests/charm/effect.spec.luau index 1e834d0..7947726 100644 --- a/tests/charm/effect.spec.luau +++ b/tests/charm/effect.spec.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local effect = Charm.effect diff --git a/tests/charm/mapped.spec.luau b/tests/charm/mapped.spec.luau index ea3d406..720ff3a 100644 --- a/tests/charm/mapped.spec.luau +++ b/tests/charm/mapped.spec.luau @@ -1,10 +1,10 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local mapped = Charm.mapped local observe = Charm.observe -local store = require(ReplicatedStorage.DevPackages.Charm.store) +local store = require(ReplicatedStorage.DevPackages.charm.src.store) return function() it("returns mapped atoms", function() diff --git a/tests/charm/observe.spec.luau b/tests/charm/observe.spec.luau index 314366c..7937562 100644 --- a/tests/charm/observe.spec.luau +++ b/tests/charm/observe.spec.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local observe = Charm.observe diff --git a/tests/charm/peek.spec.luau b/tests/charm/peek.spec.luau index 7b72b86..0e1a3ce 100644 --- a/tests/charm/peek.spec.luau +++ b/tests/charm/peek.spec.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local capture = Charm.capture local peek = Charm.peek diff --git a/tests/charm/subscribe.spec.luau b/tests/charm/subscribe.spec.luau index a732be9..04d2e79 100644 --- a/tests/charm/subscribe.spec.luau +++ b/tests/charm/subscribe.spec.luau @@ -1,10 +1,10 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local computed = Charm.computed local subscribe = Charm.subscribe -local store = require(ReplicatedStorage.DevPackages.Charm.store) +local store = require(ReplicatedStorage.DevPackages.charm.src.store) local collect = require(script.Parent.Parent.utils.collect) local count = require(script.Parent.Parent.utils.count) diff --git a/tests/init.server.luau b/tests/init.server.luau index 518d08b..adcb138 100644 --- a/tests/init.server.luau +++ b/tests/init.server.luau @@ -6,8 +6,8 @@ local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ) -- Start Charm in debug mode _G.__DEV__ = true -require(ReplicatedStorage.DevPackages.Charm) -require(ReplicatedStorage.DevPackages.CharmSync) +require(ReplicatedStorage.DevPackages.charm) +require(ReplicatedStorage.DevPackages["charm-sync"]) _G.__DEV__ = false TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter) diff --git a/tests/init.spec.luau b/tests/init.spec.luau index aed8099..742d200 100644 --- a/tests/init.spec.luau +++ b/tests/init.spec.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local store = require(ReplicatedStorage.DevPackages.Charm.store) +local store = require(ReplicatedStorage.DevPackages.charm.src.store) return function() afterEach(function() diff --git a/tests/react/useAtom.spec.luau b/tests/react/useAtom.spec.luau index 5476f28..79e5640 100644 --- a/tests/react/useAtom.spec.luau +++ b/tests/react/useAtom.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom -local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm) +local ReactCharm = require(ReplicatedStorage.DevPackages["react-charm"]) local useAtom = ReactCharm.useAtom local renderHook = require(script.Parent.Parent.utils.renderHook) diff --git a/tests/vide/useAtom.spec.luau b/tests/vide/useAtom.spec.luau index cc4dd40..ceaebc0 100644 --- a/tests/vide/useAtom.spec.luau +++ b/tests/vide/useAtom.spec.luau @@ -1,10 +1,10 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.DevPackages.charm) local atom = Charm.atom local Vide = require(ReplicatedStorage.DevPackages.Vide) local root = Vide.root -local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm) +local VideCharm = require(ReplicatedStorage.DevPackages["vide-charm"]) local useAtom = VideCharm.useAtom return function() From 53ce1e8774e7243667c9744de2c91d6189d24b25 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 9 Mar 2025 16:09:00 -0700 Subject: [PATCH 53/68] feat: refine patch type for `autoSerialize` (#47) --- packages/charm-sync/index.d.ts | 46 ++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/charm-sync/index.d.ts b/packages/charm-sync/index.d.ts index dfd8abf..52ff658 100644 --- a/packages/charm-sync/index.d.ts +++ b/packages/charm-sync/index.d.ts @@ -51,7 +51,9 @@ declare namespace CharmSync { * @param options The atoms to synchronize with the client. * @returns A `ServerSyncer` object. */ - function server(options: ServerOptions): ServerSyncer; + function server( + options: ServerOptions, + ): ServerSyncer; /** * Checks whether a value is `None`. If `true`, the value is scheduled to be @@ -111,27 +113,29 @@ declare namespace CharmSync { * If a value was removed, it is replaced with `None`. This can be checked * using the `sync.isNone` function. */ - type SyncPatch = - | MaybeNone - | (State extends ReadonlyMap | Map - ? ReadonlyMap | None> - : State extends Set | ReadonlySet - ? ReadonlyMap - : State extends readonly (infer T)[] - ? readonly (SyncPatch | None | undefined)[] - : State extends DataType - ? State - : State extends object - ? { readonly [P in keyof State]?: SyncPatch } - : State); + type SyncPatch = MaybeNone | State extends + | ReadonlyMap + | Map + ? ReadonlyMap | None> + : State extends Set | ReadonlySet + ? ReadonlyMap + : State extends readonly (infer T)[] + ? Serialize extends true + ? ReadonlyMap | None> + : readonly (SyncPatch | None | undefined)[] + : State extends DataType + ? State + : State extends object + ? { readonly [P in keyof State]?: SyncPatch } + : State; /** * A payload that can be sent from the server to the client to synchronize * state between the two. */ - type SyncPayload = + type SyncPayload = | { readonly type: "init"; readonly data: StateOfMap } - | { readonly type: "patch"; readonly data: SyncPatch> }; + | { readonly type: "patch"; readonly data: SyncPatch, Serialize> }; interface ClientOptions { /** @@ -145,7 +149,7 @@ declare namespace CharmSync { ignoreUnhydrated?: boolean; } - interface ServerOptions { + interface ServerOptions { /** * The atoms to synchronize with the client. */ @@ -173,7 +177,7 @@ declare namespace CharmSync { * This option should be disabled if your network library uses a custom * serialization method (i.e. Zap, ByteNet) to prevent interference. */ - autoSerialize?: boolean; + autoSerialize?: Serialize; } interface ClientSyncer { @@ -183,10 +187,10 @@ declare namespace CharmSync { * * @param payloads The patches or hydration payloads to apply. */ - sync(...payloads: SyncPayload[]): void; + sync(...payloads: SyncPayload[]): void; } - interface ServerSyncer { + interface ServerSyncer { /** * Sets up a subscription to each atom that schedules a patch to be sent to * the client whenever the state changes. When a change occurs, the `callback` @@ -198,7 +202,7 @@ declare namespace CharmSync { * @param callback The function to call when the state changes. * @returns A cleanup function that unsubscribes all listeners. */ - connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; + connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; /** * Hydrates the client's state with the server's state. This should be * called when a player joins the game and requires the server's state. From 990d71cd5b76d98439acaefd022d429b0dda8fbc Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 9 Mar 2025 16:24:23 -0700 Subject: [PATCH 54/68] chore: bump versions --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 4 ++-- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/react/package.json | 2 +- packages/react/wally.toml | 4 ++-- packages/vide/package.json | 2 +- packages/vide/wally.toml | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 38f8a2a..d37cdb4 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.2.0", + "version": "0.3.0", "description": "Sync server state with clients using Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 80468d8..a852895 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,10 +1,10 @@ [package] name = "littensy/charm-sync" -version = "0.2.0" +version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["package.json", "node_modules"] include = ["LICENSE.md"] [dependencies] -charm = "littensy/charm@^0.9" +charm = "littensy/charm@^0.10" diff --git a/packages/charm/package.json b/packages/charm/package.json index 9580314..8217cfb 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.9.0", + "version": "0.10.0", "description": "An atomic state management library for Roblox", "repository": "littensy/charm", "license": "MIT", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index d3ac7df..c200b5e 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.9.0" +version = "0.10.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["package.json", "node_modules"] diff --git a/packages/react/package.json b/packages/react/package.json index 41a83f7..c73159f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.2.0", + "version": "0.3.0", "description": "React bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/packages/react/wally.toml b/packages/react/wally.toml index bd379d8..08e4b2b 100644 --- a/packages/react/wally.toml +++ b/packages/react/wally.toml @@ -1,11 +1,11 @@ [package] name = "littensy/react-charm" -version = "0.2.0" +version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["package.json", "node_modules"] include = ["LICENSE.md"] [dependencies] -charm = "littensy/charm@^0.9" +charm = "littensy/charm@^0.10" React = "jsdotlua/react@^17" diff --git a/packages/vide/package.json b/packages/vide/package.json index 7ffe63c..51e0ab8 100644 --- a/packages/vide/package.json +++ b/packages/vide/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.2.0", + "version": "0.3.0", "description": "Vide bindings for Charm", "repository": "littensy/charm", "license": "MIT", diff --git a/packages/vide/wally.toml b/packages/vide/wally.toml index 642ab3c..a0156ab 100644 --- a/packages/vide/wally.toml +++ b/packages/vide/wally.toml @@ -1,11 +1,11 @@ [package] name = "littensy/vide-charm" -version = "0.2.0" +version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["package.json", "node_modules"] include = ["LICENSE.md"] [dependencies] -charm = "littensy/charm@^0.9" +charm = "littensy/charm@^0.10" Vide = "centau/vide@^0.3" From d7a1f62c3cf7b2bcd71813b3fa798a54c72c8cae Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:03:54 -0700 Subject: [PATCH 55/68] revert: build: remove project json files (#46) --- bin/build.sh | 17 ++++------------- default.project.json | 10 ++++++---- packages/charm-sync/default.project.json | 6 ++++++ packages/charm-sync/init.luau | 5 ----- packages/charm-sync/package.json | 4 ++-- packages/charm-sync/src/client.luau | 2 +- packages/charm-sync/{ => src}/index.d.ts | 0 packages/charm-sync/src/server.luau | 2 +- packages/charm-sync/src/types.luau | 2 +- packages/charm-sync/wally.toml | 2 -- packages/charm/default.project.json | 6 ++++++ packages/charm/init.luau | 7 ------- packages/charm/package.json | 4 ++-- packages/charm/{ => src}/index.d.ts | 0 packages/charm/src/init.luau | 1 + packages/charm/src/types.luau | 9 +++++++++ packages/charm/wally.toml | 2 -- packages/react/default.project.json | 6 ++++++ packages/react/init.luau | 1 - packages/react/package.json | 4 ++-- packages/react/{ => src}/index.d.ts | 0 packages/react/src/init.luau | 8 ++++---- packages/react/wally.toml | 2 -- packages/vide/default.project.json | 6 ++++++ packages/vide/init.luau | 1 - packages/vide/package.json | 4 ++-- packages/vide/{ => src}/index.d.ts | 0 packages/vide/src/init.luau | 8 ++++---- packages/vide/wally.toml | 2 -- tests/benchmarks/atom.bench.luau | 2 +- tests/benchmarks/patch.bench.luau | 2 +- tests/charm-sync/client.spec.luau | 4 ++-- tests/charm-sync/flatten.spec.luau | 4 ++-- tests/charm-sync/patch.spec.luau | 2 +- tests/charm-sync/server.spec.luau | 6 +++--- tests/charm-sync/validate.spec.luau | 4 ++-- tests/charm/atom.spec.luau | 4 ++-- tests/charm/batch.spec.luau | 4 ++-- tests/charm/capture.spec.luau | 4 ++-- tests/charm/computed.spec.luau | 4 ++-- tests/charm/effect.spec.luau | 2 +- tests/charm/mapped.spec.luau | 4 ++-- tests/charm/observe.spec.luau | 2 +- tests/charm/peek.spec.luau | 2 +- tests/charm/subscribe.spec.luau | 4 ++-- tests/init.server.luau | 4 ++-- tests/init.spec.luau | 2 +- tests/react/useAtom.spec.luau | 4 ++-- tests/vide/useAtom.spec.luau | 4 ++-- 49 files changed, 97 insertions(+), 92 deletions(-) create mode 100644 packages/charm-sync/default.project.json delete mode 100644 packages/charm-sync/init.luau rename packages/charm-sync/{ => src}/index.d.ts (100%) create mode 100644 packages/charm/default.project.json delete mode 100644 packages/charm/init.luau rename packages/charm/{ => src}/index.d.ts (100%) create mode 100644 packages/react/default.project.json delete mode 100644 packages/react/init.luau rename packages/react/{ => src}/index.d.ts (100%) create mode 100644 packages/vide/default.project.json delete mode 100644 packages/vide/init.luau rename packages/vide/{ => src}/index.d.ts (100%) diff --git a/bin/build.sh b/bin/build.sh index 6db8152..547e4e7 100644 --- a/bin/build.sh +++ b/bin/build.sh @@ -1,14 +1,5 @@ mkdir build - -build() { - cat > packages/default.project.json < = Charm.Atom --[=[ diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index a852895..f1a78f0 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -3,8 +3,6 @@ name = "littensy/charm-sync" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["package.json", "node_modules"] -include = ["LICENSE.md"] [dependencies] charm = "littensy/charm@^0.10" diff --git a/packages/charm/default.project.json b/packages/charm/default.project.json new file mode 100644 index 0000000..89d3ada --- /dev/null +++ b/packages/charm/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "Charm", + "tree": { + "$path": "src" + } +} diff --git a/packages/charm/init.luau b/packages/charm/init.luau deleted file mode 100644 index c98e594..0000000 --- a/packages/charm/init.luau +++ /dev/null @@ -1,7 +0,0 @@ -local src = require(script.src) - -export type Atom = src.Atom -export type Selector = src.Selector -export type Molecule = src.Selector - -return src diff --git a/packages/charm/package.json b/packages/charm/package.json index 8217cfb..ea40fa6 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -4,8 +4,8 @@ "description": "An atomic state management library for Roblox", "repository": "littensy/charm", "license": "MIT", - "main": "init.luau", - "types": "index.d.ts", + "main": "src/init.luau", + "types": "src/index.d.ts", "scripts": { "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" diff --git a/packages/charm/index.d.ts b/packages/charm/src/index.d.ts similarity index 100% rename from packages/charm/index.d.ts rename to packages/charm/src/index.d.ts diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index d81908f..4bc1a8c 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -10,6 +10,7 @@ local types = require(script.types) export type Atom = types.Atom export type Selector = types.Selector export type Molecule = types.Selector +export type SyncPayload = types.SyncPayload return { atom = atom, diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau index 6aca257..fd3ba6b 100644 --- a/packages/charm/src/types.luau +++ b/packages/charm/src/types.luau @@ -31,4 +31,13 @@ export type AtomOptions = { equals: (prev: T, next: T) -> boolean, } +--[=[ + A payload that can be sent from the server to the client to synchronize + state between the two. +]=] +export type SyncPayload = { + type: "patch" | "init", + data: { [any]: any }, +} + return nil diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index c200b5e..4497d3a 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -3,7 +3,5 @@ name = "littensy/charm" version = "0.10.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["package.json", "node_modules"] -include = ["LICENSE.md"] [dependencies] diff --git a/packages/react/default.project.json b/packages/react/default.project.json new file mode 100644 index 0000000..c1ee26a --- /dev/null +++ b/packages/react/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactCharm", + "tree": { + "$path": "src" + } +} diff --git a/packages/react/init.luau b/packages/react/init.luau deleted file mode 100644 index 0aab139..0000000 --- a/packages/react/init.luau +++ /dev/null @@ -1 +0,0 @@ -return require(script.src) diff --git a/packages/react/package.json b/packages/react/package.json index c73159f..df178aa 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -4,8 +4,8 @@ "description": "React bindings for Charm", "repository": "littensy/charm", "license": "MIT", - "main": "init.luau", - "types": "index.d.ts", + "main": "src/init.luau", + "types": "src/index.d.ts", "scripts": { "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" diff --git a/packages/react/index.d.ts b/packages/react/src/index.d.ts similarity index 100% rename from packages/react/index.d.ts rename to packages/react/src/index.d.ts diff --git a/packages/react/src/init.luau b/packages/react/src/init.luau index fe15dee..e200551 100644 --- a/packages/react/src/init.luau +++ b/packages/react/src/init.luau @@ -1,7 +1,7 @@ -local Charm = require(script.Parent.Parent.charm) -local React = if script.Parent.Parent:FindFirstChild("React") - then require(script.Parent.Parent.React) - else require((script.Parent.Parent :: any).react) :: never +local Charm = require(script.Parent.Charm) +local React = if script.Parent:FindFirstChild("react") + then require(script.Parent.react) :: never + else require(script.Parent.React) --[=[ A hook that subscribes to changes in the given atom or selector. The diff --git a/packages/react/wally.toml b/packages/react/wally.toml index 08e4b2b..91073ad 100644 --- a/packages/react/wally.toml +++ b/packages/react/wally.toml @@ -3,8 +3,6 @@ name = "littensy/react-charm" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["package.json", "node_modules"] -include = ["LICENSE.md"] [dependencies] charm = "littensy/charm@^0.10" diff --git a/packages/vide/default.project.json b/packages/vide/default.project.json new file mode 100644 index 0000000..3a447c6 --- /dev/null +++ b/packages/vide/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "VideCharm", + "tree": { + "$path": "src" + } +} diff --git a/packages/vide/init.luau b/packages/vide/init.luau deleted file mode 100644 index 0aab139..0000000 --- a/packages/vide/init.luau +++ /dev/null @@ -1 +0,0 @@ -return require(script.src) diff --git a/packages/vide/package.json b/packages/vide/package.json index 51e0ab8..adfcef4 100644 --- a/packages/vide/package.json +++ b/packages/vide/package.json @@ -4,8 +4,8 @@ "description": "Vide bindings for Charm", "repository": "littensy/charm", "license": "MIT", - "main": "init.luau", - "types": "index.d.ts", + "main": "src/init.luau", + "types": "src/index.d.ts", "scripts": { "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" diff --git a/packages/vide/index.d.ts b/packages/vide/src/index.d.ts similarity index 100% rename from packages/vide/index.d.ts rename to packages/vide/src/index.d.ts diff --git a/packages/vide/src/init.luau b/packages/vide/src/init.luau index 5f2df7b..df962b2 100644 --- a/packages/vide/src/init.luau +++ b/packages/vide/src/init.luau @@ -1,7 +1,7 @@ -local Charm = require(script.Parent.Parent.charm) -local Vide = if script.Parent.Parent:FindFirstChild("Vide") - then require(script.Parent.Parent.Vide) - else require((script.Parent.Parent :: any).vide.src) :: never +local Charm = require(script.Parent.Charm) +local Vide = if script.Parent:FindFirstChild("vide") + then require(script.Parent.vide.src) :: never + else require(script.Parent.Vide) --[=[ Subscribes to the state of an atom and returns a Vide source. diff --git a/packages/vide/wally.toml b/packages/vide/wally.toml index a0156ab..de0f253 100644 --- a/packages/vide/wally.toml +++ b/packages/vide/wally.toml @@ -3,8 +3,6 @@ name = "littensy/vide-charm" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["package.json", "node_modules"] -include = ["LICENSE.md"] [dependencies] charm = "littensy/charm@^0.10" diff --git a/tests/benchmarks/atom.bench.luau b/tests/benchmarks/atom.bench.luau index 48d64b7..3bae3ca 100644 --- a/tests/benchmarks/atom.bench.luau +++ b/tests/benchmarks/atom.bench.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local computed = Charm.computed local atom = Charm.atom diff --git a/tests/benchmarks/patch.bench.luau b/tests/benchmarks/patch.bench.luau index 09119c5..6ea53f6 100644 --- a/tests/benchmarks/patch.bench.luau +++ b/tests/benchmarks/patch.bench.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local patch = require(ReplicatedStorage.DevPackages["charm-sync"].src.patch) +local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) local USERS = 1000 diff --git a/tests/charm-sync/client.spec.luau b/tests/charm-sync/client.spec.luau index 4923a4d..2d2de15 100644 --- a/tests/charm-sync/client.spec.luau +++ b/tests/charm-sync/client.spec.luau @@ -1,9 +1,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local subscribe = Charm.subscribe -local CharmSync = require(ReplicatedStorage.DevPackages["charm-sync"]) +local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) local client = CharmSync.client return function() diff --git a/tests/charm-sync/flatten.spec.luau b/tests/charm-sync/flatten.spec.luau index 0ca4ad4..c274d05 100644 --- a/tests/charm-sync/flatten.spec.luau +++ b/tests/charm-sync/flatten.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom -local CharmSync = require(ReplicatedStorage.DevPackages["charm-sync"]) +local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) local flatten = CharmSync.flatten return function() diff --git a/tests/charm-sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau index fe9906b..6e850b2 100644 --- a/tests/charm-sync/patch.spec.luau +++ b/tests/charm-sync/patch.spec.luau @@ -1,7 +1,7 @@ local HttpService = game:GetService("HttpService") local ReplicatedStorage = game:GetService("ReplicatedStorage") -local patch = require(ReplicatedStorage.DevPackages["charm-sync"].src.patch) +local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) return function() local function serdes(value: { [any]: any }) diff --git a/tests/charm-sync/server.spec.luau b/tests/charm-sync/server.spec.luau index 75d4c54..5fc33f9 100644 --- a/tests/charm-sync/server.spec.luau +++ b/tests/charm-sync/server.spec.luau @@ -1,11 +1,11 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom -local CharmSync = require(ReplicatedStorage.DevPackages["charm-sync"]) +local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) type SyncPayload = CharmSync.SyncPayload local server = CharmSync.server -local patch = require(ReplicatedStorage.DevPackages["charm-sync"].src.patch) +local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) return function() local target: Player = {} :: any diff --git a/tests/charm-sync/validate.spec.luau b/tests/charm-sync/validate.spec.luau index 548ddb1..0a378d8 100644 --- a/tests/charm-sync/validate.spec.luau +++ b/tests/charm-sync/validate.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom -local validate = require(ReplicatedStorage.DevPackages["charm-sync"].src.validate) +local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate) return function() it("validates inputs", function() diff --git a/tests/charm/atom.spec.luau b/tests/charm/atom.spec.luau index 1c74776..0872037 100644 --- a/tests/charm/atom.spec.luau +++ b/tests/charm/atom.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom -local store = require(ReplicatedStorage.DevPackages.charm.src.store) +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("updates state", function() diff --git a/tests/charm/batch.spec.luau b/tests/charm/batch.spec.luau index 2726a56..375f515 100644 --- a/tests/charm/batch.spec.luau +++ b/tests/charm/batch.spec.luau @@ -1,11 +1,11 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local computed = Charm.computed local subscribe = Charm.subscribe local effect = Charm.effect -local store = require(ReplicatedStorage.DevPackages.charm.src.store) +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("batches updates", function() diff --git a/tests/charm/capture.spec.luau b/tests/charm/capture.spec.luau index d74bd4d..80669a5 100644 --- a/tests/charm/capture.spec.luau +++ b/tests/charm/capture.spec.luau @@ -1,9 +1,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local subscribe = Charm.subscribe -local store = require(ReplicatedStorage.DevPackages.charm.src.store) +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("gracefully handles yielding", function() diff --git a/tests/charm/computed.spec.luau b/tests/charm/computed.spec.luau index 08ff46a..d71d46d 100644 --- a/tests/charm/computed.spec.luau +++ b/tests/charm/computed.spec.luau @@ -1,9 +1,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local computed = Charm.computed -local store = require(ReplicatedStorage.DevPackages.charm.src.store) +local store = require(ReplicatedStorage.DevPackages.Charm.store) local collect = require(script.Parent.Parent.utils.collect) local count = require(script.Parent.Parent.utils.count) diff --git a/tests/charm/effect.spec.luau b/tests/charm/effect.spec.luau index 7947726..1e834d0 100644 --- a/tests/charm/effect.spec.luau +++ b/tests/charm/effect.spec.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local effect = Charm.effect diff --git a/tests/charm/mapped.spec.luau b/tests/charm/mapped.spec.luau index 720ff3a..ea3d406 100644 --- a/tests/charm/mapped.spec.luau +++ b/tests/charm/mapped.spec.luau @@ -1,10 +1,10 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local mapped = Charm.mapped local observe = Charm.observe -local store = require(ReplicatedStorage.DevPackages.charm.src.store) +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("returns mapped atoms", function() diff --git a/tests/charm/observe.spec.luau b/tests/charm/observe.spec.luau index 7937562..314366c 100644 --- a/tests/charm/observe.spec.luau +++ b/tests/charm/observe.spec.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local observe = Charm.observe diff --git a/tests/charm/peek.spec.luau b/tests/charm/peek.spec.luau index 0e1a3ce..7b72b86 100644 --- a/tests/charm/peek.spec.luau +++ b/tests/charm/peek.spec.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local capture = Charm.capture local peek = Charm.peek diff --git a/tests/charm/subscribe.spec.luau b/tests/charm/subscribe.spec.luau index 04d2e79..a732be9 100644 --- a/tests/charm/subscribe.spec.luau +++ b/tests/charm/subscribe.spec.luau @@ -1,10 +1,10 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local computed = Charm.computed local subscribe = Charm.subscribe -local store = require(ReplicatedStorage.DevPackages.charm.src.store) +local store = require(ReplicatedStorage.DevPackages.Charm.store) local collect = require(script.Parent.Parent.utils.collect) local count = require(script.Parent.Parent.utils.count) diff --git a/tests/init.server.luau b/tests/init.server.luau index adcb138..518d08b 100644 --- a/tests/init.server.luau +++ b/tests/init.server.luau @@ -6,8 +6,8 @@ local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ) -- Start Charm in debug mode _G.__DEV__ = true -require(ReplicatedStorage.DevPackages.charm) -require(ReplicatedStorage.DevPackages["charm-sync"]) +require(ReplicatedStorage.DevPackages.Charm) +require(ReplicatedStorage.DevPackages.CharmSync) _G.__DEV__ = false TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter) diff --git a/tests/init.spec.luau b/tests/init.spec.luau index 742d200..aed8099 100644 --- a/tests/init.spec.luau +++ b/tests/init.spec.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local store = require(ReplicatedStorage.DevPackages.charm.src.store) +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() afterEach(function() diff --git a/tests/react/useAtom.spec.luau b/tests/react/useAtom.spec.luau index 79e5640..5476f28 100644 --- a/tests/react/useAtom.spec.luau +++ b/tests/react/useAtom.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom -local ReactCharm = require(ReplicatedStorage.DevPackages["react-charm"]) +local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm) local useAtom = ReactCharm.useAtom local renderHook = require(script.Parent.Parent.utils.renderHook) diff --git a/tests/vide/useAtom.spec.luau b/tests/vide/useAtom.spec.luau index ceaebc0..cc4dd40 100644 --- a/tests/vide/useAtom.spec.luau +++ b/tests/vide/useAtom.spec.luau @@ -1,10 +1,10 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.charm) +local Charm = require(ReplicatedStorage.DevPackages.Charm) local atom = Charm.atom local Vide = require(ReplicatedStorage.DevPackages.Vide) local root = Vide.root -local VideCharm = require(ReplicatedStorage.DevPackages["vide-charm"]) +local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm) local useAtom = VideCharm.useAtom return function() From d0b7cff9436bf8c45c86f585a34775213b78de8e Mon Sep 17 00:00:00 2001 From: alihsaas <40891677+alihsaas@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:35:39 +0300 Subject: [PATCH 56/68] fix: add cleanup parameter to effect type (#52) --- packages/charm/src/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index da1c5fa..8336177 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -86,7 +86,7 @@ declare namespace Charm { * @param callback The function to run. * @returns A function that unsubscribes the callback. */ - function effect(callback: () => Cleanup | void): Cleanup; + function effect(callback: (cleanup: Cleanup) => Cleanup | void): Cleanup; /** * Returns the result of the function without subscribing to changes. If a From 573533058f831a0b033ef31ec7cc76050c335228 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:51:17 -0700 Subject: [PATCH 57/68] feat!: use alien-signals for reactivity (#50) * feat: use alien-signals * refactor: remove pnpm, fix tests * fix: `effectScope` should set scope * fix: check previous value before calling listener * refactor: improve imports * refactor: exclude re-exports from wally * refactor: embrace scope cleanup, restructure code * fix: update pkgs dir references * ci: simplify pesde install * build: install dev deps with wally * feat: add detached, add more tests * test: simplify mapped test update * chore: rephrase doc comment * perf: optimize subscribe and mapped * refactor: avoid ambiguous optional function type * refactor: add assertion error messages * perf: optimize mapped, add benches * chore: delete redundant benchmarks * perf: simplify mapped logic * test: prettify test library * test: try different gray * test: color brackets * refactor: sync 2.0.4 * refactor: use setCurrentSub instead of pauseTracking * test: improve test suite * test: separate parent from root * refactor: iterate over previous data in mapped * refactor: improve logic * style: use `---` doc comments * refactor: improve logic * chore: housekeeping * ci: add pnpm step * ci: init submodules * test: use lune * refactor: improve structure and alien-signals integration * ci: update test file path * feat: add peek and roblox-ts types * feat: add deep freeze flag, misc refactors * refactor: reduce type casting, housekeeping * refactor: rewrite untracked, update alien-signals * chore: update eslint, housekeeping * refactor: start charm-sync rewrite * fix: send init payload even if atom did not update * test: add all sync tests * feat: prepare for alpha release * feat: add peek, make untracked escape scope * fix: wrap hooks in untracked, update types * feat: add flag for inner effect tracking * refactor: add docs, remove some modules * refactor: align with alien-signals 3.0 * feat: add signal api * refactor: update charm sync api * feat: update alien-signals * refactor: remove unnecessary features, update docs * feat: finalize sync docs * docs: update sync section * docs: simplify examples * docs: capitalize headers * docs: add migration tips * docs: fix whitespace * docs: rephrase computed arg change * refactor: rename batch, misc fixes & perf updates * refactor: simplify charm sync validation * feat: update alien-signals, add recursion flag * feat: add `recursive()` * fix: allow dispose in observers * feat: add `trigger()`, refactor source * docs: update readme * chore: prepare release candidate * test: make some tests more reliable * docs: fix typo * feat: add `reactive()` * chore: bump versions * docs: reword reactive docs * docs: fix typo * feat: add `toRaw()` * fix: check for nil diffs * chore: bump versions * perf: do not create proxies for iteration * fix: make `toRaw` track all calls * fix: check none before patching mutable tables * chore: bump charm-sync version * docs: add fishing game example project * feat: sync signals that return reactive proxy * fix: refine reactive proxy logic * fix: prevent early reactive gc, alien-signals 3.1.1 * fix: wrap iterated values in `reactive()` * fix: loosen client signal types * chore: bump release candidate * fix: remove unsynced signals immediately * test: use lute for tests * chore: bump versions * ci: set up lute * refactor: improve source readability * ci: upgrade lute * build: loosen workspace version requirements * chore: bump deps * fix: update signal overload for setter (#58) * fix: add missing recursed check * test: add important vue reactivity tests * chore: fix react tests, run stylua * chore: update vide * docs: improve intro and explain getters * docs: adjust ordering by importance * docs: move listen nesting note * build: fix react peer dep for ci * chore: update lockfile * ci: use javascript eslint config * ci: ignore eslint config file * refactor: move test directories * feat: allow recursion in effects by default * release: charm v0.11.0-rc.4 * refactor: remove recursive() * refactor: reorganize repo, create deep-charm * docs: rephrase some sections * feat: export getter and setter types * docs: rephrase intro again * feat!: run listen and subscribe callback in untracked * refactor!: remove peek * docs: note peek in migration guide * release: charm v0.11.0-rc.5 * release: charm-sync v0.4.0-rc.7 * release: react-charm v0.4.0-rc.4 * fix: remove old keys when updating key in `mapped()` * refactor: shorten error traceback, housekeeping * refactor: keep error level in some cases * fix: check if cleanup functions disposed effect * refactor!: run effects in batched * ci: check eslint and prettier * perf: run observe fn in untracked * perf: call mapper as-is in `mapped()` * refactor: error for effect cleanup errors * refactor: use new type solver * ci: disable new solver flag until 0.714 * chore: housekeeping * refactor: improve errors in nonstrict, optimize noyield * chore: housekeeping * refactor: satisfy new solver * refactor: improve error traceback * docs: link roblox signals * chore: housekeeping * perf: remove `clearPending` inline operation * refactor: check type of cleanup * ci: remove selene * fix: always return pending value in signal setter * test: add multi benchmark * release: charm v0.11.0-rc.6 * test: add cleanup order tests * chore: bump deps * docs: add warning for deep-charm and note for `observe()` --------- Co-authored-by: safazi --- .eslintrc | 26 - .gitattributes | 1 - .github/workflows/ci.yml | 31 +- .github/workflows/release.yml | 8 +- .gitignore | 10 +- .gitmodules | 3 + .luaurc | 6 +- .lute/build.luau | 6 + .lute/check.luau | 33 + .lute/test.luau | 1 + .npmrc | 1 + LICENSE | 21 + LICENSE.md | 7 - README.md | 1346 ++++++++++------ {images => assets}/logo.png | Bin benches/bench.luau | 48 + benches/mapped.luau | 84 + benches/multi.luau | 188 +++ benches/propagate.luau | 47 + benches/reactive.luau | 88 + bin/build.sh | 5 - bin/check.sh | 29 - bin/install.sh | 4 - bin/testez.d.luau | 24 - build.project.json | 11 + default.project.json | 61 +- eslint.config.js | 20 + modules/wally.lock | 103 -- modules/wally.toml | 11 - package.json | 33 +- packages/charm-sync/Charm.luau | 9 + packages/charm-sync/default.project.json | 2 +- packages/charm-sync/package.json | 23 +- packages/charm-sync/src/client.luau | 179 ++- packages/charm-sync/src/config.luau | 31 + packages/charm-sync/src/flatten.luau | 28 - packages/charm-sync/src/index.d.ts | 462 +++--- packages/charm-sync/src/init.luau | 21 +- packages/charm-sync/src/interval.luau | 30 - packages/charm-sync/src/patch.luau | 142 +- packages/charm-sync/src/server.luau | 635 +++++--- packages/charm-sync/src/types.luau | 44 +- packages/charm-sync/src/utils.luau | 65 + packages/charm-sync/src/validate.luau | 85 +- packages/charm-sync/test/client.test.luau | 137 ++ packages/charm-sync/test/e2e.test.luau | 97 ++ packages/charm-sync/test/init.luau | 23 + packages/charm-sync/test/patch.test.luau | 133 ++ packages/charm-sync/test/server.test.luau | 408 +++++ packages/charm-sync/test/utils.test.luau | 22 + packages/charm-sync/wally.toml | 11 +- packages/charm/default.project.json | 2 +- packages/charm/package.json | 19 +- packages/charm/src/atom.luau | 42 - packages/charm/src/computed.luau | 37 - packages/charm/src/effect.luau | 53 - packages/charm/src/index.d.ts | 459 +++--- packages/charm/src/init.luau | 1011 +++++++++++- packages/charm/src/mapped.luau | 71 - packages/charm/src/observe.luau | 50 - packages/charm/src/store.luau | 214 --- packages/charm/src/subscribe.luau | 43 - packages/charm/src/system.luau | 237 +++ packages/charm/src/types.luau | 43 - packages/charm/test/computed.test.luau | 220 +++ packages/charm/test/effect.test.luau | 576 +++++++ packages/charm/test/effectScope.test.luau | 95 ++ packages/charm/test/errors.test.luau | 200 +++ packages/charm/test/gc.test.luau | 65 + packages/charm/test/init.luau | 55 + packages/charm/test/listen.test.luau | 77 + packages/charm/test/mapped.test.luau | 128 ++ packages/charm/test/observe.test.luau | 198 +++ packages/charm/test/signal.test.luau | 67 + packages/charm/test/subscribe.test.luau | 91 ++ packages/charm/test/topology.test.luau | 484 ++++++ packages/charm/test/trigger.test.luau | 111 ++ packages/charm/test/untracked.test.luau | 176 ++ packages/charm/wally.toml | 3 +- packages/deep-charm/Charm.luau | 5 + .../default.project.json | 2 +- packages/deep-charm/package.json | 27 + packages/deep-charm/src/index.d.ts | 53 + packages/deep-charm/src/init.luau | 165 ++ packages/deep-charm/test/gc.test.luau | 79 + packages/deep-charm/test/init.luau | 11 + packages/deep-charm/test/reactive.test.luau | 232 +++ packages/deep-charm/wally.toml | 9 + packages/react-charm/Charm.luau | 1 + packages/react-charm/React.luau | 1 + packages/react-charm/ReactRoblox.luau | 1 + packages/react-charm/default.project.json | 6 + packages/react-charm/package.json | 30 + packages/react-charm/src/index.d.ts | 31 + packages/react-charm/src/init.luau | 52 + .../react-charm/test/helpers}/renderHook.luau | 15 +- packages/react-charm/test/init.luau | 13 + .../test/useSignalBinding.test.luau | 44 + .../react-charm/test/useSignalState.test.luau | 44 + packages/react-charm/wally.toml | 17 + packages/react/package.json | 17 - packages/react/src/index.d.ts | 15 - packages/react/src/init.luau | 31 - packages/react/wally.toml | 9 - packages/vide-charm/Charm.luau | 1 + packages/vide-charm/Vide.luau | 1 + .../{vide => vide-charm}/default.project.json | 2 +- packages/vide-charm/package.json | 30 + packages/vide-charm/src/index.d.ts | 11 + packages/vide-charm/src/init.luau | 22 + packages/vide-charm/test/init.luau | 7 + .../vide-charm/test/useSignalState.test.luau | 59 + packages/vide-charm/wally.toml | 17 + packages/vide/package.json | 17 - packages/vide/src/index.d.ts | 9 - packages/vide/src/init.luau | 25 - packages/vide/wally.toml | 9 - pnpm-lock.yaml | 1425 +++++++++-------- rokit.toml | 14 +- selene.toml | 5 - stylua.toml | 3 + testez-companion.toml | 1 - testez.yml | 53 - tests/benchmarks/atom.bench.luau | 30 - tests/benchmarks/patch.bench.luau | 48 - tests/charm-sync/client.spec.luau | 121 -- tests/charm-sync/flatten.spec.luau | 24 - tests/charm-sync/patch.spec.luau | 121 -- tests/charm-sync/server.spec.luau | 97 -- tests/charm-sync/validate.spec.luau | 33 - tests/charm/atom.spec.luau | 62 - tests/charm/batch.spec.luau | 64 - tests/charm/capture.spec.luau | 64 - tests/charm/computed.spec.luau | 105 -- tests/charm/effect.spec.luau | 111 -- tests/charm/mapped.spec.luau | 97 -- tests/charm/observe.spec.luau | 35 - tests/charm/peek.spec.luau | 26 - tests/charm/subscribe.spec.luau | 101 -- tests/init.server.luau | 13 - tests/init.spec.luau | 11 - tests/react/useAtom.spec.luau | 46 - tests/run.server.luau | 33 + tests/test | 1 + tests/utils/collect.luau | 12 - tests/utils/count.luau | 15 - tests/vide/useAtom.spec.luau | 36 - tsconfig.json | 9 +- 148 files changed, 9222 insertions(+), 4384 deletions(-) delete mode 100644 .eslintrc delete mode 100644 .gitattributes create mode 100644 .gitmodules create mode 100644 .lute/build.luau create mode 100644 .lute/check.luau create mode 100644 .lute/test.luau create mode 100644 .npmrc create mode 100644 LICENSE delete mode 100644 LICENSE.md rename {images => assets}/logo.png (100%) create mode 100644 benches/bench.luau create mode 100644 benches/mapped.luau create mode 100644 benches/multi.luau create mode 100644 benches/propagate.luau create mode 100644 benches/reactive.luau delete mode 100644 bin/build.sh delete mode 100644 bin/check.sh delete mode 100644 bin/install.sh delete mode 100644 bin/testez.d.luau create mode 100644 build.project.json create mode 100644 eslint.config.js delete mode 100644 modules/wally.lock delete mode 100644 modules/wally.toml create mode 100644 packages/charm-sync/Charm.luau create mode 100644 packages/charm-sync/src/config.luau delete mode 100644 packages/charm-sync/src/flatten.luau delete mode 100644 packages/charm-sync/src/interval.luau create mode 100644 packages/charm-sync/src/utils.luau create mode 100644 packages/charm-sync/test/client.test.luau create mode 100644 packages/charm-sync/test/e2e.test.luau create mode 100644 packages/charm-sync/test/init.luau create mode 100644 packages/charm-sync/test/patch.test.luau create mode 100644 packages/charm-sync/test/server.test.luau create mode 100644 packages/charm-sync/test/utils.test.luau delete mode 100644 packages/charm/src/atom.luau delete mode 100644 packages/charm/src/computed.luau delete mode 100644 packages/charm/src/effect.luau delete mode 100644 packages/charm/src/mapped.luau delete mode 100644 packages/charm/src/observe.luau delete mode 100644 packages/charm/src/store.luau delete mode 100644 packages/charm/src/subscribe.luau create mode 100644 packages/charm/src/system.luau delete mode 100644 packages/charm/src/types.luau create mode 100644 packages/charm/test/computed.test.luau create mode 100644 packages/charm/test/effect.test.luau create mode 100644 packages/charm/test/effectScope.test.luau create mode 100644 packages/charm/test/errors.test.luau create mode 100644 packages/charm/test/gc.test.luau create mode 100644 packages/charm/test/init.luau create mode 100644 packages/charm/test/listen.test.luau create mode 100644 packages/charm/test/mapped.test.luau create mode 100644 packages/charm/test/observe.test.luau create mode 100644 packages/charm/test/signal.test.luau create mode 100644 packages/charm/test/subscribe.test.luau create mode 100644 packages/charm/test/topology.test.luau create mode 100644 packages/charm/test/trigger.test.luau create mode 100644 packages/charm/test/untracked.test.luau create mode 100644 packages/deep-charm/Charm.luau rename packages/{react => deep-charm}/default.project.json (60%) create mode 100644 packages/deep-charm/package.json create mode 100644 packages/deep-charm/src/index.d.ts create mode 100644 packages/deep-charm/src/init.luau create mode 100644 packages/deep-charm/test/gc.test.luau create mode 100644 packages/deep-charm/test/init.luau create mode 100644 packages/deep-charm/test/reactive.test.luau create mode 100644 packages/deep-charm/wally.toml create mode 100644 packages/react-charm/Charm.luau create mode 100644 packages/react-charm/React.luau create mode 100644 packages/react-charm/ReactRoblox.luau create mode 100644 packages/react-charm/default.project.json create mode 100644 packages/react-charm/package.json create mode 100644 packages/react-charm/src/index.d.ts create mode 100644 packages/react-charm/src/init.luau rename {tests/utils => packages/react-charm/test/helpers}/renderHook.luau (62%) create mode 100644 packages/react-charm/test/init.luau create mode 100644 packages/react-charm/test/useSignalBinding.test.luau create mode 100644 packages/react-charm/test/useSignalState.test.luau create mode 100644 packages/react-charm/wally.toml delete mode 100644 packages/react/package.json delete mode 100644 packages/react/src/index.d.ts delete mode 100644 packages/react/src/init.luau delete mode 100644 packages/react/wally.toml create mode 100644 packages/vide-charm/Charm.luau create mode 100644 packages/vide-charm/Vide.luau rename packages/{vide => vide-charm}/default.project.json (60%) create mode 100644 packages/vide-charm/package.json create mode 100644 packages/vide-charm/src/index.d.ts create mode 100644 packages/vide-charm/src/init.luau create mode 100644 packages/vide-charm/test/init.luau create mode 100644 packages/vide-charm/test/useSignalState.test.luau create mode 100644 packages/vide-charm/wally.toml delete mode 100644 packages/vide/package.json delete mode 100644 packages/vide/src/index.d.ts delete mode 100644 packages/vide/src/init.luau delete mode 100644 packages/vide/wally.toml delete mode 100644 selene.toml delete mode 100644 testez-companion.toml delete mode 100644 testez.yml delete mode 100644 tests/benchmarks/atom.bench.luau delete mode 100644 tests/benchmarks/patch.bench.luau delete mode 100644 tests/charm-sync/client.spec.luau delete mode 100644 tests/charm-sync/flatten.spec.luau delete mode 100644 tests/charm-sync/patch.spec.luau delete mode 100644 tests/charm-sync/server.spec.luau delete mode 100644 tests/charm-sync/validate.spec.luau delete mode 100644 tests/charm/atom.spec.luau delete mode 100644 tests/charm/batch.spec.luau delete mode 100644 tests/charm/capture.spec.luau delete mode 100644 tests/charm/computed.spec.luau delete mode 100644 tests/charm/effect.spec.luau delete mode 100644 tests/charm/mapped.spec.luau delete mode 100644 tests/charm/observe.spec.luau delete mode 100644 tests/charm/peek.spec.luau delete mode 100644 tests/charm/subscribe.spec.luau delete mode 100644 tests/init.server.luau delete mode 100644 tests/init.spec.luau delete mode 100644 tests/react/useAtom.spec.luau create mode 100644 tests/run.server.luau create mode 160000 tests/test delete mode 100644 tests/utils/collect.luau delete mode 100644 tests/utils/count.luau delete mode 100644 tests/vide/useAtom.spec.luau diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 8f628fe..0000000 --- a/.eslintrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "jsx": true, - "useJSXTextNode": true, - "ecmaVersion": 2018, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "plugins": [ - "@typescript-eslint", - "roblox-ts", - "prettier" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:roblox-ts/recommended", - "plugin:prettier/recommended" - ], - "rules": { - "prettier/prettier": "warn", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unsafe-declaration-merging": "off" - } -} diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 6313b56..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4369391..912f2ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,31 +1,32 @@ name: CI -on: - push: - branches: - - main - - pull_request: - branches: - - main +on: [push, pull_request] jobs: analyze: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: pnpm/action-setup@v4 with: run_install: true + - uses: CompeyDev/setup-rokit@v0.1.2 - - name: Install Rokit - uses: CompeyDev/setup-rokit@v0.1.2 - - - name: Install Wally dependencies - run: pnpm wally + - name: Setup Lute + run: lute setup --with-luaurc - name: Check code quality - run: pnpm check + run: lute run check + + - name: Run ESLint and Prettier + run: | + pnpm eslint packages + pnpm prettier --check packages + + - name: Run tests + run: lute run test - name: Build models - run: pnpm build + run: lute run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07b75ad..3bd512d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,13 +9,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: CompeyDev/setup-rokit@v0.1.2 - - name: Install Rokit - uses: CompeyDev/setup-rokit@v0.1.2 + - name: Setup Lute + run: lute setup --with-luaurc - name: Build models - run: pnpm build + run: lute run build - name: Upload release assets uses: AButler/upload-release-assets@v3.0 diff --git a/.gitignore b/.gitignore index f3a27c4..d217e96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,13 @@ /.vscode -/node_modules +/.pesde /build -/modules/DevPackages -/packages/**/node_modules /packages/**/wally.lock +/packages/**/LICENSE* /packages/**/README.md -/packages/**/LICENSE.md /sourcemap.json +node_modules *.tsbuildinfo -*.tgz *.rbxm +*.tgz .DS_Store +roblox.d.luau diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..409278b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tests/test"] + path = tests/test + url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4 diff --git a/.luaurc b/.luaurc index e2b625c..c48b0f2 100644 --- a/.luaurc +++ b/.luaurc @@ -1,3 +1,7 @@ { - "languageMode": "strict" + "languageMode": "strict", + "aliases": { + "lute": "~/.lute/typedefs/1.0.0/lute", + "std": "~/.lute/typedefs/1.0.0/std" + } } diff --git a/.lute/build.luau b/.lute/build.luau new file mode 100644 index 0000000..b708003 --- /dev/null +++ b/.lute/build.luau @@ -0,0 +1,6 @@ +local fs = require("@std/fs") +local process = require("@lute/process") + +fs.createDirectory("build") + +process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" }) diff --git a/.lute/check.luau b/.lute/check.luau new file mode 100644 index 0000000..b5ad31b --- /dev/null +++ b/.lute/check.luau @@ -0,0 +1,33 @@ +local fs = require("@std/fs") +local net = require("@std/net") +local process = require("@lute/process") + +process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) + +fs.writeStringToFile( + "roblox.d.luau", + net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body +) + +local analyze = process.run({ + "luau-lsp", + "analyze", + "--sourcemap=sourcemap.json", + "--ignore=**/node_modules/**", + "--base-luaurc=.luaurc", + "--definitions=roblox.d.luau", + "--flag:LuauSolverV2=true", + "packages", + "tests", + "benches", +}, { stdio = "inherit" }) + +fs.remove("roblox.d.luau") + +local stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" }) + +local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" }) + +if not (analyze.ok and stylua.ok and eslint.ok) then + process.exit(1) +end diff --git a/.lute/test.luau b/.lute/test.luau new file mode 100644 index 0000000..4abbbb9 --- /dev/null +++ b/.lute/test.luau @@ -0,0 +1 @@ +(require)("../tests/run.server") diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b644580 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=*@rbxts* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d10b611 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright 2024-present Littensy + +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. diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index ae1bb3d..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,7 +0,0 @@ -Copyright 2024 Littensy - -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. diff --git a/README.md b/README.md index 8ea132b..35895ac 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

- Logo + Logo

Charm

- Atomic state management for Roblox. + Fine-grained reactivity for Roblox
npm package →

@@ -14,29 +14,139 @@ ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=main&logo=github) [![NPM Version](https://img.shields.io/npm/v/@rbxts/charm.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/charm) -[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md) +[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE) -**Charm** is an atomic and immutable state management library, inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Store your state in atoms, and write your own functions to read, write, and observe state. +Charm is a reactive state management library designed for storing data in discrete containers called _signals_. Connect behavior to data with signals, ensuring systems stay up-to-date with their underlying data while reactivity eliminates the need for manual updates. -See an example of Charm's features in [this example repository](https://github.com/littensy/charm-example). +**Build game state from simple building blocks:** -## 🍀 Features +- Store data in [signals](#signalinitialvalue-equals): reactive state containers that hold a value +- React to state changes with [effects](#effectcallback): re-run code when signals update +- Derive new values from data with [computed signals](#computedgetter): memoize functions that access signals -- ⚛️ **Manage state with _atoms_.** Decompose state into independent containers called _atoms_, as opposed to combining them into a single store. +**Want to learn more about reactivity?** -- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. +- https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf +- https://preactjs.com/blog/introducing-signals +- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity +- https://github.com/roblox/signals -- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. +[Migrating from an older version of Charm?](#migration) -- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. +
+Table of Contents ---- +- [Installation](#installation) +- [Reference](#reference) + - [`signal(initialValue, equals?)`](#signalinitialvalue-equals) + - [`computed(getter)`](#computedgetter) + - [`effect(callback)`](#effectcallback) + - [Nested effects](#nested-effects) + - [`effectScope(callback)`](#effectscopecallback) + - [`listen(getter, callback)`](#listengetter-callback) + - [Getter functions](#getter-functions) + - [`observe(getter, callback)`](#observegetter-callback) + - [`subscribe(getter, callback)`](#subscribegetter-callback) + - [`untracked(callback)`](#untrackedcallback) + - [`batch(callback)`](#batchcallback) + - [`mapped(getter, transform)`](#mappedgetter-transform) + - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) + - [`atom(initialValue, equals?)`](#atominitialvalue-equals) + - [`trigger(callback)`](#triggercallback) + - [`flags`](#flags) +- [Client-Server Sync](#client-server-sync) + - [Installation](#installation-1) + - [Quick Start](#quick-start) + - [Server API](#config) + - [Client API](#clientaddsignalssetters) + - [Sync Caveats](#sync-caveats) +- [Deep Reactivity](#deep-reactivity) + - [Installation](#installation-2) + - [`reactive(initialValue)`](#reactiveinitialvalue) + - [Mutation vs. update function](#mutation-vs-update-function) + - [`toRaw(value)`](#torawvalue) + - [`isReactive(value)`](#isreactivevalue) +- [Migration](#migration) +- [Examples](#examples) -## 📦 Setup +
-Install Charm for roblox-ts using your package manager of choice. +## At a Glance + +```luau +local getTodos, setTodos = signal({} :: { string }) +local getQuery, setQuery = signal("") + +observe(getTodos, function(todo: string, index: number) + local instance = Instance.new("TextLabel") + + local getText = computed(function() + return getTodos()[index] or "" + end) + + effect(function() + instance.Text = getText() + instance.Visible = string.match(getText(), getQuery()) ~= nil + end) + + instance.LayoutOrder = index + instance.Size = UDim2.new(1, 0, 0, 40) + instance.Parent = screenGui + + return function() + instance:Destroy() + end +end) + +setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) +setQuery("Buy") +``` + +
+Explain code + +```luau +-- Declare state for a todo list and a search query +local getTodos, setTodos = signal({} :: { string }) +local getQuery, setQuery = signal("") + +-- Create a text label when an item is added to the list +observe(getTodos, function(todo, index) + local instance = Instance.new("TextLabel") + + -- Create a memoized function that only re-runs effects when the todo list + -- updates or the function returns new text + local getText = computed(function() + return getTodos()[index] or "" + end) + + -- Update instance properties when the text or query updates + effect(function() + instance.Text = getText() + instance.Visible = string.match(getText(), getQuery()) ~= nil + end) + + instance.LayoutOrder = index + instance.Size = UDim2.new(1, 0, 0, 40) + instance.Parent = screenGui + + -- Destroy the instance when this item is removed + return function() + instance:Destroy() + end +end) + +-- Add items to the todo list +setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) +-- Filter for items containing "Buy" +setQuery("Buy") +``` + +
+ +## Installation ```sh npm install @rbxts/charm @@ -44,391 +154,461 @@ yarn add @rbxts/charm pnpm add @rbxts/charm ``` -Alternatively, add `littensy/charm` to your `wally.toml` file. - ```toml +# wally.toml [dependencies] Charm = "littensy/charm@VERSION" ``` --- -## 🐛 Debugging +## Reference -Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` before importing Charm. +### `signal(initialValue, equals?)` -Enabling `__DEV__` adds a few helpful features: - -- Better error handling for selectors, subscriptions, and batched functions: - - - Errors provide the function's name and line number. - - Yielding in certain functions will throw an error. - -- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. - -Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead. - ---- - -## 📚 Reference - -### `atom(state, options?)` - -Atoms are the building blocks of Charm. They are functions that hold a single value, and calling them can read or write to that value. Atoms, or any function that reads from atoms, can also be [subscribed](#subscribecallback-listener) to. - -Call `atom` to create a state container initialized with the value `state`. +Signals are the core of reactivity in Charm. The `signal` function creates a reactive signal that acts as a container for a value. It returns a function to access the value, and another to update the value. ```luau -local nameAtom = atom("John") -local todosAtom: Atom<{ string }> = atom({}) +local getCounter, setCounter = signal(0) + +setCounter(1) +setCounter(function(count) + return count + 1 +end) +print(getCounter()) -- 2 ``` -#### Parameters +Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately notify every effect and computed signal that depends on the signal, ensuring all of your state is correct and up-to-date. -- `state`: The value to assign to the atom initially. - -- **optional** `options`: An object that configures the behavior of this atom. - - - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. - -#### Returns - -The `atom` constructor returns an atom function with two possible operations: - -1. **Read the state.** Call the atom without arguments to get the current state. -2. **Set the state.** Pass a new value or an updater function to change the state. +You can also pass a custom equality function to only update the signal if the equality function returns `false`: ```luau -local function newTodo() - nameAtom("Jane") - - todosAtom(function(todos) - todos = table.clone(todos) - table.insert(todos, "Buy milk") - return todos - end) - - print(nameAtom()) --> "Jane" -end -``` - ---- - -### `subscribe(callback, listener)` - -Call `subscribe` to listen for changes in an atom or selector function. When the function's result changes, subscriptions are immediately called with the new state and the previous state. - -```luau -local nameAtom = atom("John") - -local cleanup = subscribe(nameAtom, function(name, prevName) - print(name) +local getMax, setMax = signal(0, function(current, incoming) + return incoming <= current end) -nameAtom("Jane") --> "Jane" +setMax(1) -- 1 +setMax(2) -- 2 +setMax(-1) -- 2 ``` -You may also pass a selector function that calls other atoms. The function will be memoized and only runs when its atom dependencies update. - -```luau -local function getUppercase() - return string.upper(nameAtom()) -end - -local cleanup = subscribe(getUppercase, function(name) - print(name) -end) - -nameAtom("Jane") --> "JANE" -``` - -#### Parameters - -- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. - -- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. - -#### Returns - -`subscribe` returns a cleanup function. +> [!NOTE] +> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a unified getter and setter. --- ### `effect(callback)` -Call `effect` to track state changes in all atoms read within the callback. The callback will run once to retrieve its dependencies, and then again whenever they change. Your callback may return a cleanup function to run when the effect is removed or about to re-run. +Effects are fundamental to reactivity, allowing you to react to signal updates. The `effect` function subscribes to signals accessed by the effect callback, and when a dependency updates, the callback will re-execute. ```luau -local nameAtom = atom("John") +local getCounter, setCounter = signal(0) -local cleanup = effect(function() - print(nameAtom()) +effect(function() + print(`Count is {getCounter()}`) +end) -- Count is 0 + +setCounter(1) -- Count is 1 +``` + +You can also return a cleanup function that will run once, either before the effect re-runs or when the effect is disposed: + +```luau +local getCounter, setCounter = signal(0) + +local dispose = effect(function() + local count = getCounter() return function() - print("Cleanup function called!") + print(`Cleanup {count}`) end end) + +setCounter(1) -- Cleanup 0 +dispose() -- Cleanup 1 ``` -Because `effect` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. +### Nested Effects -#### Parameters - -- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. - -#### Returns - -`effect` returns a cleanup function. - -#### Caveats - -- **If your effect should disconnect itself, use the `cleanup` argument.** Because effects run immediately, your effect may run before a `cleanup` function is returned. To disconnect an effect from the inside, use the argument passed to your effect instead: - ```lua - effect(function(cleanup) - if condition() then - cleanup() - end - end) - ``` - ---- - -### `computed(callback, options?)` - -Call `computed` when you want to derive a new atom from one or more atoms. The callback will be memoized, meaning that subsequent calls to the atom return a cached value that is only re-calculated when the dependencies change. +An effect is _nested_ if it was created during the execution of another effect. In Charm, when an effect with nested effects re-runs or gets cleaned up, the nested effects from the previous run are automatically cleaned up and re-created if needed. This prevents memory leaks and ensures that outer effects always run before their inner effects: ```luau -local todosAtom: Atom<{ string }> = atom({}) -local mapToUppercase = computed(function() - local result = table.clone(todosAtom()) - for key, todo in result do - result[key] = string.upper(todo) +local getPrintCount, setPrintCount = signal(true) +local getCount, setCount = signal(1) + +effect(function() + if getPrintCount() then + -- This inner effect is created when getPrintCount() is true + effect(function() + print(`Count is {getCount()}`) + end) end - return result -end) +end) -- Count is 1 + +setCount(2) -- Count is 2 + +-- This re-runs the outer effect and cleans up old inner effects +setPrintCount(false) + +setCount(3) -- No output ``` -Because `computed` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. - -This function is also useful for optimizing `effect` calls that depend on multiple atoms. For instance, if an effect derives some value from two atoms, it will run twice if both atoms change at the same time. Using `computed` can group these dependencies together and avoid re-running side effects. - -#### Parameters - -- `callback`: A function that returns a new value depending on one or more atoms. - -- **optional** [`options`](#parameters): An object that configures the behavior of this atom. - -#### Returns - -`computed` returns a read-only atom. - ---- - -### `observe(callback, factory)` - -Call `observe` to create an instance of `factory` for each key present in a dictionary or array. Your factory can return a cleanup function to run when the key is removed or the observer is cleaned up. - > [!NOTE] -> Because `observe` tracks the lifetime of each key in your data, your keys must be unique and unchanging. If your data is not keyed by unique and stable identifiers, consider using [`mapped`](#mappedcallback-mapper) to transform it into a keyed object before passing it to `observe`. +> To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `flags.trackInnerEffects` flag to assist with debugging. + +--- + +### `computed(getter)` + +The `computed` function creates a read-only signal whose value is derived from other signals. The computed signal caches the getter function's last result, and the value is only re-computed if a dependency has updated since the last computation. ```luau -local todosAtom: Atom<{ [string]: Todo }> = atom({}) +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local getFullName = computed(function() + return `{getName()} {getSurname()}` +end) -local cleanup = observe(todosAtom, function(todo, key) - print(`Added {key}: {todo.name}`) +print(getFullName()) -- "John Doe" +setName("Jane") +print(getFullName()) -- "Jane Doe" +``` + +The getter function also receives the previous result (or `nil` during the initial run). You can use this for computed signals that depend on the previous result: + +```luau +local getCounter, setCounter = signal(10) +local getMax = computed(function(prevMax) + return math.max(getCounter(), prevMax or 0) +end) + +print(getMax()) -- 10 +setCounter(5) +print(getMax()) -- 10 +``` + +--- + +### `effectScope(callback)` + +Scopes allow you to dispose multiple effects at once. The `effectScope` function creates a scope that tracks inner effects, so effects created during the execution of the callback will clean up when the scope disposes. + +```luau +local getCounter, setCounter = signal(0) + +local dispose = effectScope(function() + effect(function() + print(`Count 1 is {getCounter()}`) + end) + effect(function() + print(`Count 2 is {getCounter()}`) + end) +end) + +setCounter(1) -- Count 1 is 1, Count 2 is 1 +dispose() +setCounter(2) -- No output; effects got disposed +``` + +Similar to `effect()`, the callback can return a cleanup function that runs when the effect scope is disposed. + +--- + +### `listen(getter, callback)` + +The `listen` function creates an effect that only subscribes to the signals accessed by `getter`. Signals accessed by the callback will not be subscribed to, avoiding accidental subscriptions when you want to run side effects. + +The callback also receives the previous value, or `nil` when running for the first time. + +```luau +local getCounter, setCounter = signal(0) + +listen(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) +end) -- Count is 0 (was nil) + +setCounter(1) -- Count is 1 (was 0) +``` + +Note that the listener callback runs in `untracked()`, so nested effects are not cleaned up. + +### Getter functions + +In most Charm APIs, you can also subscribe to getter functions that call one or more signals, and they will automatically be tracked: + +```luau +local getCounter, setCounter = signal(0) + +local function floorCounter() + return math.floor(getCounter()) +end + +listen(floorCounter, function(count, prevCount) + print(`Floor of count is {count} (was {prevCount})`) +end) -- Floor of count is 0 (was nil) + +setCounter(0.5) -- Doesn't print anything, floor is still 0 +setCounter(1) -- Floor of count is 1 (was 0) +``` + +--- + +### `observe(getter, callback)` + +[Observers](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) allow you to track the lifetime of a given state. The `observe` function executes the callback for every unique key added to a table, and disposes the callback when that key is removed. + +```luau +local getItems, setItems = signal({ a = 0, b = 0 }) + +observe(getItems, function(value, key) + print(`Added {key}`) return function() print(`Removed {key}`) end -end) +end) -- Added a, Added b + +setItems({ a = 0, c = 0 }) -- Removed b, Added c ``` -#### Parameters +The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: -- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. +```luau +local getItems, setItems = signal({}) -- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. +local dispose = observe(getItems, function(value, key) + local getValue = computed(function(prevValue) + return getItems()[key] or prevValue + end) -#### Returns + effect(function() + local value = getValue() + print(`Set {key} = {value}`) + return function() + print(`Cleanup {key} = {value}`) + end + end) +end) -`observe` returns a cleanup function. +setItems({ a = 0, b = 0 }) -- Set a = 0, Set b = 0 +setItems({ a = 1, b = 0 }) -- Cleanup a = 0, Set a = 1 +setItems({ a = 1 }) -- Cleanup b = 0 +dispose() -- Cleanup a = 1 +``` --- -### `mapped(callback, mapper)` +### `subscribe(getter, callback)` -Call `mapped` to transform the keys and values of your state. The `mapper` function will be called for each key-value pair in the atom's state, and the new keys and atoms will be stored in a new atom. +The `subscribe` function is identical to `listen()`, but the callback does not run initially. The callback only runs when the value returned by the getter function changes. ```luau -local todosAtom: Atom<{ Todo }> = atom({}) -local todosById = mapped(todosAtom, function(todo, index) - return todo, todo.id +local getCounter, setCounter = signal(0) + +-- Does not output anything initially +subscribe(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) end) + +setCounter(1) -- Count is 1 (was 0) ``` -#### Parameters - -- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. - -- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: - - 1. Return a single value to map the table's original key to a new value. - 2. Return two values, the first being the value and the second being the key, to update both keys and values. - 3. Return `nil` for the value to remove the key from the resulting table. - -#### Returns - -`mapped` returns a read-only atom. - --- -### `peek(value, ...)` +### `untracked(callback)` -Call `peek` to call a function without tracking it as the dependency of an effect or a selector function. +In case you want to opt-out of dependency tracking in an effect, you can use `untracked()` to call a function _outside_ the current scope, preventing signals and effects in the function from being tracked. ```luau -local nameAtom = atom("John") -local ageAtom = atom(25) +local getTracked, setTracked = signal(0) +local getUntracked, setUntracked = signal(0) effect(function() - local name = nameAtom() - local age = peek(ageAtom) -end) + print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`) +end) -- Tracked: 0, Untracked: 0 + +setTracked(1) -- Tracked: 1, Untracked: 0 +setUntracked(1) -- No output +setTracked(2) -- Tracked: 2, Untracked: 1 ``` -#### Parameters +Because `untracked()` executes the callback outside the current effect, nested effects created during the execution of the callback will not be tracked by the parent effect: -- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. +```luau +local stopEffect +local stopScope = effectScope(function() + untracked(function() + stopEffect = effect(function() + return function() + print("Cleaned up untracked effect") + end + end) + end) +end) -- **optional** `...args`: Additional arguments to pass to the value if it is a function. - -#### Returns - -`peek` returns the result of the given function. If the value is not a function, it will return the value as-is. +stopScope() -- No output, the scope did not track the effect +stopEffect() -- Cleaned up untracked effect +``` --- ### `batch(callback)` -Call `batch` to defer state changes until after the callback has run. This is useful when you need to make multiple changes to the state and only want listeners to be notified once. +Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. ```luau -local nameAtom = atom("John") -local ageAtom = atom(25) +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +effect(function() + print(`Full name: {getName()} {getSurname()}`) +end) + +-- Combines both writes into a single update. +-- Once the callback completes, outputs "Full name: Foo Bar" batch(function() - nameAtom("Jane") - ageAtom(26) + setName("Foo") + setSurname("Bar") end) ``` -#### Parameters - -- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. - -#### Returns - -`batch` does not return anything. - --- -## 📦 React +### `mapped(getter, transform)` -### Setup +The `mapped` function iterates over every key in a table and uses the transform function to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, `transform` is called for that key and its current value. -Install the React bindings for Charm using your package manager of choice. - -```sh -npm install @rbxts/react-charm -yarn add @rbxts/react-charm -pnpm add @rbxts/react-charm -``` - -```toml -[dependencies] -ReactCharm = "littensy/react-charm@VERSION" -``` - ---- - -### `useAtom(callback, dependencies?)` - -Call `useAtom` at the top-level of a React component to read from an atom or selector. The component will re-render when the value changes. +The first value returned by the transform function is used as the new value: ```luau -local todosAtom = require(script.Parent.todosAtom) +local getList, setList = signal({ "a", "b", "c" }) -local function Todos() - local todos = useAtom(todosAtom) - -- ... -end +local getUppercase = mapped(getList, function(value) + return string.upper(value) +end) + +print(getUppercase()) -- { "A", "B", "C" } ``` -If your selector depends on the component's state or props, remember to pass them in a dependency array. This prevents skipped updates when an untracked parameter of the selector changes. +If the transform function returns two values, the second value is used as the new key: ```luau -local todos = useAtom(function() - return searchTodos(props.filter) -end, { props.filter }) -``` +local getList, setList = signal({ "a", "b", "c" }) -#### Parameters +local getSwapped = mapped(getList, function(value, key) + return key, value +end) -- `callback`: An atom or selector function that depends on an atom. - -- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. - -#### Returns - -`useAtom` returns the current state of the atom. - ---- - -## 📦 Vide - -### Setup - -Install the Vide bindings for Charm using your package manager of choice. - -```sh -npm install @rbxts/vide-charm -yarn add @rbxts/vide-charm -pnpm add @rbxts/vide-charm -``` - -```toml -[dependencies] -VideCharm = "littensy/vide-charm@VERSION" +print(getSwapped()) -- { a = 1, b = 2, c = 3 } ``` --- -### `useAtom(callback)` +### `onCleanup(callback, failSilently?)` -Call `useAtom` in any scope to create a Vide source that returns the current state of an atom or selector. +The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect. + +Unless `failSilently` is set to `true`, this function will emit a warning if there is no active effect or scope. ```luau -local todosAtom = require(script.Parent.todosAtom) +local dispose = effectScope(function() + onCleanup(function() + print("Cleaned up") + end) +end) -local function Todos() - local todos = useAtom(todosAtom) - -- ... -end +dispose() -- Cleaned up ``` -#### Parameters - -- `callback`: An atom or selector function that depends on an atom. - -#### Returns - -`useAtom` returns a Vide source. - --- -## 📗 Charm Sync +### `atom(initialValue, equals?)` -### Setup +The `atom` function creates a new reactive signal and returns a single function that acts as both a getter and setter. -The Charm Sync package provides server-client synchronization for your Charm atoms. Install it using your package manager of choice. +If the atom is called with 0 arguments, the atom returns the current value and subscribes to the signal. Otherwise, when called with 1 or more arguments, the atom will update the signal's value. + +```luau +local counter = atom(0) + +print(counter()) -- 0 +counter(1) +counter(function(count) + return count + 1 +end) +``` + +You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: + +```luau +local max = atom(0, function(current, incoming) + return incoming <= current +end) + +max(1) -- 1 +max(-1) -- 1 +``` + +--- + +### `trigger(callback)` + +The `trigger()` function allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter: + +```luau +local array = signal({} :: { number }) +local length = computed(function() + return #array() +end) + +print(length()) -- 0 + +-- Direct mutation doesn't automatically trigger updates +table.insert(array(), 1) +print(length()) -- Still 0 + +-- Manually trigger updates +trigger(array) +print(length()) -- 1 +``` + +You can also trigger multiple signals at once: + +```luau +local src1 = signal({} :: { number }) +local src2 = signal({} :: { number }) +local total = computed(function() + return #src1() + #src2() +end) + +table.insert(src1(), 1) +table.insert(src2(), 2) + +trigger(function() + src1() + src2() +end) + +print(total()) -- 2 +``` + +--- + +### `flags` + +Charm exposes the following global flags to customize behavior: + +| Flag | Default | Description | +| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| strict | `true`\* | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | +| frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | + +The `strict` and `frozen` flags are automatically enabled in Roblox Studio and other development environments where the Luau optimization level is `O1` or lower. + +--- + +## Client-Server Sync + +### Installation ```sh npm install @rbxts/charm-sync @@ -441,273 +621,459 @@ pnpm add @rbxts/charm-sync CharmSync = "littensy/charm-sync@VERSION" ``` ---- +### Quick Start -### `server(options)` - -Call `server` to create a server sync object. This synchronizes every client's atoms with the server's state by sending partial patches that the client merges into its state. +Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: ```luau -local syncer = CharmSync.server({ - -- A dictionary of the atoms to sync, matching the client's - atoms = atomsToSync, - -- The minimum interval between state updates - interval = 0, - -- Whether to send a full history of changes made to the atoms (slower) - preserveHistory = false, - -- Whether to apply fixes for remote event limitations. Disable this option - -- when using a network library with custom ser/des, like ByteNet or Zap. - autoSerialize = true, -}) +-- nameStore +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local ageAtom = atom(20) --- Sends state updates to clients when a synced atom changes. --- Omitting sensitive information and data serialization can be done here. -syncer:connect(function(player, ...) - remotes.syncState:fire(player, ...) -end) - --- Sends the initial state to a player upon request. This should fire when a --- player joins the game. -remotes.requestState:connect(function(player) - syncer:hydrate(player) -end) +return { + getName = getName, + setName = setName, + getSurname = getSurname, + setSurname = setSurname, + ageAtom = ageAtom, +} ``` -#### Parameters +When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. -- `options`: An object to configure sync behavior. +Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to: - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. +```luau +playerReadyEvent.OnServerEvent:Connect(function(player) + -- Sync signal getters, computed signals, atoms, or reactive proxies + server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, + age = nameStore.ageAtom, + }) +end) - - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. +Players.PlayerRemoving:Connect(function(player) + server.removeClient(player) +end) - - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. - - - **optional** `autoSerialize`: Whether to apply validation and workarounds to certain [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#table-indexing). Defaults to `true`, but you should set it to `false` if you serialize remote arguments (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). +server.connect(function(player, updates) + -- Customize how you send state updates to clients + syncEvent:FireClient(player, updates) +end) +``` > [!NOTE] -> Charm sends table updates in the form of partial tables, so arrays will contain `nil` values, which has undefined behavior in remotes without serialization. -> -> Charm's default `autoSerialize` behavior fixes this, but it can interfere with custom serialization. Disable this option if you use a network library that serializes remote event arguments. +> On the server, make sure each key corresponds to the same data across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. -#### Returns +To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. -`server` returns an object with the following methods: - -- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. - -- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. - -#### Caveats - -- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. [Read more about argument limitations in remotes.](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) - -- **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. - -- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `autoSerialize` accordingly! - ---- - -### `client(options)` - -Call `client` to create a client sync object. This synchronizes the client's atoms with the server's state by merging partial patches sent by the server into each atom. +After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau -local syncer = CharmSync.client({ - atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's - ignoreUnhydrated = true, -- Whether to ignore state updates before the initial update +-- Add signal setters, atoms, or reactive proxies +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, + age = nameStore.ageAtom, }) --- Applies state updates from the server to the client's atoms. --- Data deserialization can be done here. -remotes.syncState:connect(function(...) - syncer:sync(...) +-- Update client signals when receiving updates from the server +syncEvent.OnClientEvent:Connect(function(updates) + client.patch(updates) end) - --- Requests the initial state from the server when the client joins the game. --- Before this runs, the client uses the atoms' default values. -remotes.requestState:fire() ``` -#### Parameters - -- `options`: An object to configure sync behavior. - - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. - - - **optional** `ignoreUnhydrated`: Whether to ignore state updates before setting the initial state. Defaults to `true`. - -#### Returns - -`client` returns an object with the following methods: - -- `syncer:sync(...payloads)` applies a state update from the server. - -#### Caveats - -- **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above. - --- -### `isNone(value)` +### `config` -Call `isNone` to check if a value is `None`. Charm's partial state patches omit values that did not change between sync events, so to mark keys for deletion, Charm uses the `None` marker. +A configuration table that customizes the behavior of Charm Sync on the server. -This function can be used to check whether a value is about to be removed from an atom. - -```luau -local syncer = CharmSync.server({ atoms = atomsToSync }) - -syncer:connect(function(player, payload) - if - payload.type === "patch" - and payload.data.todosAtom - and CharmSync.isNone(payload.data.todosAtom.eggs) - then - -- 'eggs' will be removed from the client's todo list - end - remotes.syncState.fire(player, payload) -end) -``` - -#### Parameters - -- `value`: Any value. If the value is `None`, `isNone` will return `true`. - -#### Returns - -`isNone` returns a boolean. +| Option | Default | Description | +| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. | +| preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. | +| fixArrays | `true` | When `true`, Charm will attempt to work around remote event limitations that cause data loss in array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | +| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will emit a warning. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | --- -## 🚀 Examples +### `server.addSignalsToClient(client, getters)` -### Counter atom +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, 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 -local counterAtom = atom(0) - --- Create a selector that returns double the counter value -local function doubleCounter() - return counterAtom() * 2 -end - --- Runs after counterAtom is updated and prints double the new value -subscribe(doubleCounter, function(value) - print(value) +playerReadyEvent.OnServerEvent:Connect(function(player) + server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, + age = nameStore.ageAtom, + }) end) - -counterAtom(1) --> 2 -counterAtom(function(count) - return count + 1 -end) --> 4 ``` -### React component +You're also allowed to create new signals to sync to specific players, as long as the key is unique to that data: ```luau -local counter = require(script.Parent.counter) -local counterAtom = counter.counterAtom -local incrementCounter = counter.incrementCounter +playerReadyEvent.OnServerEvent:Connect(function(player) + server.addSignalsToClient(player, { + -- 🟢 Good: Player-specific data is synced with a unique key + [`data-{player.UserId}`] = computed(function() + return getPlayerData(player.UserId) + end), + + -- 🔴 Bad: Syncing different data with the same keys does not work + playerData = computed(function() + return getPlayerData(player.UserId) + end), + }) +end) +``` + +--- + +### `server.removeSignalsFromClient(client, ...keys)` + +The `removeSignalsFromClient` function unsubscribes the client from a list of keys that were previously subscribed to via `addSignalsToClient`. + +```luau +server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, +}) + +server.removeSignalsFromClient(player, "surname") +``` + +--- + +### `server.removeClient(client)` + +The `removeClient` function unsubscribes a client from receiving all state updates from the server. You should call this function when a player leaves the game. + +```luau +playerReadyEvent.OnServerEvent:Connect(function(player) + server.addSignalsToClient(player, signals) +end) + +Players.PlayerRemoving:Connect(function(player) + server.removeClient(player) +end) +``` + +--- + +### `server.connect(onSync)` + +Binds a callback to run when sending state updates a client. Scheduled updates will be sent periodically at the interval specified in [`config.interval`](#config). + +When a synced signal updates, the `onSync` function is scheduled to run for each client subscribed to that signal on the next sync event. + +```luau +server.connect(function(player, updates) + syncEvent:FireServer(player, updates) +end) +``` + +--- + +### `server.disconnect()` + +Stops syncing state updates to clients at the automatic interval. This doesn't unbind the last callback, so you can still manually trigger sync events after calling this function by calling `server.flush()`. + +```luau +server.connect(function(player, updates) + syncEvent:FireServer(player, updates) +end) + +-- Stops calling the function at the automatic interval +server.disconnect() + +-- Flushing still sends pending updates +server.flush() +``` + +--- + +### `client.addSignals(setters)` + +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, atoms, or [reactive objects](#reactiveinitialvalue) to this function: + +```luau +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, + age = nameStore.ageAtom, +}) +``` + +--- + +### `client.removeSignals(...keys)` + +Unsubscribes from each signal with the corresponding keys. The signals will retain their current values, but will no longer receive updates from the server. + +```luau +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, +}) + +client.removeSignals("name") +``` + +--- + +### `client.patch(updates)` + +The `patch` function patches the client's state with the updates sent from the server. The initial update sent by the server will be the full state, and later updates will only include the values that changed. + +You should call `patch` when receiving updates from the server from a remote event: + +```luau +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, +}) + +syncEvent.OnClientEvent:Connect(function(updates) + client.patch(updates) +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 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 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. + +--- + +## Deep Reactivity + +Charm's reactivity system is _shallow_ by default: only the top-level value is reactive, so table properties are not checked when determining whether a signal has updated. As a result, tables in Charm should be immutable (copied before writing) in order to signal that a table's properties have changed. + +Deep reactivity, on the other hand, uses proxy tables to perform dependency tracking on properties. You can subscribe to a property by indexing the proxy table, and setting a property will notify its subscribers. This approach to reactivity lets you work with mutable data, making state management more intuitive at the cost of added overhead. + +You can opt-in to deep reactivity with the Deep Charm library: + +### Installation + +> [!WARNING] +> Deep Charm is an experimental library that is not fully documented, and the API is subject to breaking changes. + +```sh +npm install @rbxts/deep-charm +yarn add @rbxts/deep-charm +pnpm add @rbxts/deep-charm +``` + +```toml +[dependencies] +DeepCharm = "littensy/deep-charm@VERSION" +``` + +--- + +### `reactive(initialValue)` + +The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and nested tables will also be wrapped in a reactive proxy. + +```luau +local users, updateUsers = reactive({ + { name = "John", surname = "Doe" }, +}) + +effect(function() + print(`{users[1].name} {users[1].surname}`) +end) -- Output: John Doe + +updateUsers(function(raw) + raw[1].name = "Jane" + raw[1].surname = "Smith" + table.insert(raw, { name = "Steve", surname = "Doe" }) +end) -- Output: Jane Smith + +-- You can also mutate the reactive proxy directly: +users[1].name = "John" -- John Smith +``` + +### Mutation vs. update function + +Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value, which you can access through the update function returned by `reactive()`. + +This update function (`updateUsers()` in the example above) passes the raw table for you to mutate. Once your callback is done executing, the updater will manually notify subscriptions to the reactive proxy and its nested properties. This process does not use metatables, so you should use this for table operations or batching updates. + +--- + +### `toRaw(value)` + +If you need to access the raw table through the reactive proxy, use the `toRaw()` function: + +```luau +local raw = {} +local proxy = reactive(raw) + +print(toRaw(proxy) == raw) -- true +``` + +Calling `toRaw()` on a proxy also subscribes to its changes. This allows you to use proxies with `observe()` and other Charm APIs that accept a getter function: + +```luau +local proxy, updateProxy = reactive({}) + +observe(function() + return toRaw(proxy) +end, function(value, key) + print(`Value {value} added at key {key}`) +end) + +updateProxy(function(state) + table.insert(state, "foo") +end) -- Value foo added at key 1 +``` + +--- + +### `isReactive(value)` + +The `isReactive()` function returns `true` if the given value is a reactive proxy by checking its metatable. + +```lua +local raw = {} +local proxy = reactive(raw) + +print(isReactive(proxy)) -- true +print(isReactive(raw)) -- false +``` + +--- + +## Migration + +Charm v0.11 introduces several breaking changes, so this section should help you migrate from an older version. + +For reference, a signal is a state container with a separated getter and setter function. Atoms are a signal with a single function for getting and setting the state. + +**What to look out for:** + +1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: + - `peek()` was changed to `untracked()` for parity with other state managers + - The second arguments of `atom()` changed from an `options` table to an equality function + - Removed the second argument of `computed()` (you can do your own equality checks since the computed callback now receives the previous value) + - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`) + +2. If you use Charm Sync, you'll have to rewrite a lot of your sync code. Fortunately, most of the changes should make your code _less_ complicated: + - You can now sync signals per-client, including computed signals. You shouldn't have to modify sync payloads to filter data anymore. + - Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`. + - [Read the updated docs for syncing state →](#client-server-sync) + +3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors. The flags have the following behaviors: + - `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error + - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations + +4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent. + - This feature applies to the observer function in `observe()`, which also automatically cleans up inner effects to prevent memory leaks. However, this does not apply to `subscribe()` or `listen()`. + - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). + - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can temporarily disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`. + +5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! + - [`signal()`](#signalinitialvalue-equals): make atom reads and writes more explicit + - [`listen()`](#listengetter-callback): create a subscription that also runs once immediately + - [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once + - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect or scope + - [`trigger()`](#triggercallback): trigger updates for table mutations + +--- + +## Examples + +- https://github.com/littensy/fishing-minigame: Fisch clone using Charm for server and client state + +### React Counter + +```luau +local Charm = require("@packages/charm") +local ReactCharm = require("@packages/react-charm") +local React = require("@packages/react") + +local getCounter, setCounter = Charm.signal(0) local function Counter() - local count = useAtom(counterAtom) + local count = ReactCharm.useSignalState(getCounter) return React.createElement("TextButton", { - [React.Event.Activated] = incrementCounter, + [React.Event.Activated] = function() + setCounter(count + 1) + end, Text = `Count: {count}`, - Size = UDim2.new(0, 100, 0, 50), + Size = UDim2.fromOffset(100, 50), }) end ``` -### Vide component +### Vide Counter ```luau -local counter = require(script.Parent.counter) -local counterAtom = counter.counterAtom -local incrementCounter = counter.incrementCounter +local Charm = require("@packages/charm") +local VideCharm = require("@packages/vide-charm") +local Vide = require("@packages/vide") + +local create = Vide.create + +local getCounter, setCounter = Charm.signal(0) local function Counter() - local count = useAtom(counterAtom) + local count = VideCharm.useSignalState(getCounter) return create "TextButton" { - Activated = incrementCounter, + Activated = function() + setCounter(function(count) + return count + 1 + end) + end, Text = function() return `Count: {count()}` end, - Size = UDim2.new(0, 100, 0, 50), + Size = UDim2.fromOffset(100, 50), } end ``` -### Server-client sync - -Charm is designed for both client and server use, but there are often cases where the client needs to reference state that lives on the server. The CharmSync package provides a way to synchronize atoms between the server and its clients using remote events. - -Start by creating a set of atoms to sync between the server and clients. Export these atoms from a module to be shared between the server and client: - -```luau --- atoms.luau -local counter = require(script.Parent.counter) -local todos = require(script.Parent.todos) - -return { - counterAtom = counter.counterAtom, - todosAtom = todos.todosAtom, -} -``` - -Then, on the server, create a server sync object and pass in the atoms to sync. Use remote events to broadcast state updates and send initial state to clients upon request. - -> [!NOTE] -> If `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a variadic `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter. - -```luau --- sync.server.luau -local atoms = require(script.Parent.atoms) - -local syncer = CharmSync.server({ atoms = atoms }) - --- Sends state updates to clients when a synced atom changes. --- Omitting sensitive information and data serialization can be done here. -syncer:connect(function(player, payload) - remotes.syncState.fire(player, payload) -end) - --- Sends the initial state to a player upon request. This should fire when a --- player joins the game. -remotes.requestState:connect(function(player) - syncer:hydrate(player) -end) -``` - -Finally, on the client, create a client sync object and use it to apply incoming state changes. - -```luau --- sync.client.luau -local atoms = require(script.Parent.atoms) - -local syncer = CharmSync.client({ atoms = atoms }) - --- Applies state updates from the server to the client's atoms. -remotes.syncState:connect(function(payload) - syncer:sync(payload) -end) - --- Requests the initial state from the server when the client joins the game. --- Before this runs, the client uses the atoms' default values. -remotes.requestState:fire() -``` - ---

-Charm is released under the MIT License. +Charm is released under the MIT License.

-[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md) +[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE)
diff --git a/images/logo.png b/assets/logo.png similarity index 100% rename from images/logo.png rename to assets/logo.png diff --git a/benches/bench.luau b/benches/bench.luau new file mode 100644 index 0000000..ffb839d --- /dev/null +++ b/benches/bench.luau @@ -0,0 +1,48 @@ +local profiles: { [string]: number } = {} +local times: { [string]: number } = {} +local stack: { string } = {} + +local function run(name: string, test: () -> ()) + local start = os.clock() + local iter = 0 + + table.clear(profiles) + table.clear(times) + table.clear(stack) + + while os.clock() - start < 2 do + iter += 1 + test() + end + + local elapsed = os.clock() - start + local results = {} + + table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) + + for name, duration in times do + table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`) + end + + print(table.concat(results, " | ")) +end + +local function profilebegin(name: string) + profiles[name] = os.clock() + table.insert(stack, name) +end + +local function profileend() + local name = table.remove(stack) + + if name then + times[name] = (times[name] or 0) + os.clock() - profiles[name] + profiles[name] = nil + end +end + +return { + run = run, + profilebegin = profilebegin, + profileend = profileend, +} diff --git a/benches/mapped.luau b/benches/mapped.luau new file mode 100644 index 0000000..695b7f5 --- /dev/null +++ b/benches/mapped.luau @@ -0,0 +1,84 @@ +local Charm = require("../packages/charm/src") +local bench = require("./bench") + +local computed = Charm.computed +local mapped = Charm.mapped +local signal = Charm.signal + +type Key = number +type Value = number + +local count = 512 +local initialState: { [Key]: Value } = table.create(count, 0) + +local function expensiveOperation() + for _ = 1, 100 do + continue + end +end + +local function simpleMapped( + source: () -> { [Key]: Value }, + mapper: (Value, Key) -> (Value, Key?) +): () -> { [Key]: Value } + return computed(function() + local state = source() + local nextState = {} + + for keyIn, valueIn in state do + local valueOut, keyOut = mapper(valueIn, keyIn) + nextState[if keyOut == nil then keyIn else keyOut] = valueOut + end + + return nextState + end) +end + +local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value } + return function(state) + local nextState = table.clone(state) + for key in state do + if math.random() < updateChance then + nextState[key] += 1 + end + end + return nextState + end +end + +for _, updateChance in { 0.1, 0.5, 1 } do + local function mapper(value: Value, key: Key): (Value, Key?) + expensiveOperation() + return value + 1, key + 1 + end + + bench.run(`mapped {updateChance}`, function() + local src, setSrc = signal(initialState) + local out = mapped(src, mapper) + + bench.profilebegin("init") + out() + bench.profileend() + + setSrc(updateRandomly(updateChance)) + + bench.profilebegin("update") + out() + bench.profileend() + end) + + bench.run(`control {updateChance}`, function() + local src, setSrc = signal(initialState) + local out = simpleMapped(src, mapper) + + bench.profilebegin("init") + out() + bench.profileend() + + setSrc(updateRandomly(updateChance)) + + bench.profilebegin("update") + out() + bench.profileend() + end) +end diff --git a/benches/multi.luau b/benches/multi.luau new file mode 100644 index 0000000..6c8235c --- /dev/null +++ b/benches/multi.luau @@ -0,0 +1,188 @@ +local Charm = require("../packages/charm/src") +local bench = require("./bench") + +type Atom = Charm.Atom + +local signal = Charm.atom +local computed = Charm.computed +local effect = Charm.effect + +local function busy(): number + local a = 0 + for i = 1, 100 do + a += 1 + end + return a +end + +do + local head = signal(0) + local computed1 = computed(function() + return head() + end) + local computed2 = computed(function() + computed1() + return 0 + end) + local computed3 = computed(function() + busy() -- heavy computation + return computed2() + 1 + end) + local computed4 = computed(function() + return computed3() + 2 + end) + local computed5 = computed(function() + return computed4() + 3 + end) + effect(function() + computed5() + busy() -- heavy side effect + end) + + bench.run("avoidablePropagation", function() + head(1) + assert(computed5() == 6) + for i = 0, 999 do + head(i) + end + assert(computed5() == 6) + end) +end + +do + local head = signal(0) + local double = computed(function() + return head() * 2 + end) + local inverse = computed(function() + return -head() + end) + local current = computed(function() + local result = 0 + for i = 0, 19 do + result += if head() % 2 == 1 then double() else inverse() + end + return result + end) + + local callCounter = 0 + effect(function() + current() + callCounter += 1 + end) + + bench.run("unstable", function() + head(1) + assert(current() == 40) + callCounter = 0 + for i = 0, 99 do + head(i) + end + assert(callCounter == 100) + end) +end + +do + local head = signal(0) + local double = computed(function() + return head() * 2 + end) + local inverse = computed(function() + return -head() + end) + local current = computed(function() + local result = 0 + for i = 0, 19 do + result += if head() % 2 == 1 and i % 2 == 1 then double() else inverse() + end + return result + end) + + local callCounter = 0 + effect(function() + current() + callCounter += 1 + end) + + bench.run("unstable 50%", function() + head(1) + assert(current() == 10) + callCounter = 0 + for i = 0, 99 do + head(i) + end + assert(callCounter == 100) + end) +end + +do + local head = signal(0) + local last: () -> number = head + local callCounter = 0 + + for i = 0, 49 do + local current = computed(function() + return head() + i + end) + local current2 = computed(function() + return current() + 1 + end) + effect(function() + current2() + callCounter += 1 + end) + last = current2 + end + + bench.run("broadPropagation", function() + head(1) + callCounter = 0 + for i = 0, 49 do + head(i) + assert(last() == i + 50) + end + assert(callCounter == 50 * 50) + end) +end + +do + local heads = {} + for index = 0, 99 do + heads[index] = signal(0) + end + + local mux = computed(function() + local result = {} + for index = 0, 99 do + result[index] = heads[index]() + end + return result + end) + + local splited = {} + for index = 0, 99 do + local x = computed(function() + return mux()[index] + end) + splited[index] = computed(function() + return x() + 1 + end) + end + + for _, x in splited do + effect(function() + x() + end) + end + + bench.run("mux", function() + for i = 0, 9 do + heads[i](i) + assert(splited[i]() == i + 1) + end + for i = 0, 9 do + heads[i](i * 2) + assert(splited[i]() == i * 2 + 1) + end + end) +end diff --git a/benches/propagate.luau b/benches/propagate.luau new file mode 100644 index 0000000..463ee2e --- /dev/null +++ b/benches/propagate.luau @@ -0,0 +1,47 @@ +local Charm = require("../packages/charm/src") +local bench = require("./bench") + +local computed = Charm.computed +local effect = Charm.effect +local listen = Charm.listen +local signal = Charm.signal + +for _, w in { 10, 100 } do + for _, h in { 10, 100 } do + bench.run(`propagate {w}*{h}`, function() + local src, setSrc = signal(1) + for _ = 1, w do + local last = src + for _ = 1, h do + local prev = last + last = computed(function() + return prev() + 1 + end) + end + effect(function() + last() + end) + end + setSrc(src() + 1) + end) + end +end + +for _, w in { 10, 100 } do + for _, h in { 10, 100 } do + bench.run(`propagate (listen) {w}*{h}`, function() + local src, setSrc = signal(1) + for _ = 1, w do + local last = src + for _ = 1, h do + local prev = last + last = computed(function() + return prev() + 1 + end) + end + listen(last, function() end) + end + setSrc(src() + 1) + end) + end +end diff --git a/benches/reactive.luau b/benches/reactive.luau new file mode 100644 index 0000000..c272651 --- /dev/null +++ b/benches/reactive.luau @@ -0,0 +1,88 @@ +local Charm = require("../packages/charm/src") +local DeepCharm = require("../packages/deep-charm/src") +local bench = require("./bench") + +local effect = Charm.effect +local reactive = DeepCharm.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/bin/build.sh b/bin/build.sh deleted file mode 100644 index 547e4e7..0000000 --- a/bin/build.sh +++ /dev/null @@ -1,5 +0,0 @@ -mkdir build -rojo build packages/charm -o build/charm.rbxm -rojo build packages/charm-sync -o build/charm-sync.rbxm -rojo build packages/react -o build/react-charm.rbxm -rojo build packages/vide -o build/vide-charm.rbxm diff --git a/bin/check.sh b/bin/check.sh deleted file mode 100644 index 0e948f8..0000000 --- a/bin/check.sh +++ /dev/null @@ -1,29 +0,0 @@ -curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua - -rojo sourcemap -o sourcemap.json - -check() { - echo "Checking $1" - - luau-lsp analyze \ - --defs=bin/roblox.d.luau \ - --defs=bin/testez.d.luau \ - --flag:LuauFixIndexerSubtypingOrdering=true \ - --flag:LuauInstantiateInSubtyping=true \ - --sourcemap=sourcemap.json \ - --ignore="**/_Index/**" \ - $1 - - selene $1 - stylua --check $1 -} - -eslint packages - -for i in packages/*/src; do - check $i -done - -check tests - -rm bin/roblox.d.luau diff --git a/bin/install.sh b/bin/install.sh deleted file mode 100644 index 53ccca2..0000000 --- a/bin/install.sh +++ /dev/null @@ -1,4 +0,0 @@ -cd modules -wally install -cd .. -rojo sourcemap --output sourcemap.json diff --git a/bin/testez.d.luau b/bin/testez.d.luau deleted file mode 100644 index 28bef92..0000000 --- a/bin/testez.d.luau +++ /dev/null @@ -1,24 +0,0 @@ -declare function afterAll(callback: () -> ()): () -declare function afterEach(callback: () -> ()): () - -declare function beforeAll(callback: () -> ()): () -declare function beforeEach(callback: () -> ()): () - -declare function describe(phrase: string, callback: () -> ()): () -declare function describeFOCUS(phrase: string, callback: () -> ()): () -declare function fdescribe(phrase: string, callback: () -> ()): () -declare function describeSKIP(phrase: string, callback: () -> ()): () -declare function xdescribe(phrase: string, callback: () -> ()): () - -declare function expect(value: any): any - -declare function FIXME(optionalMessage: string?): () -declare function FOCUS(): () -declare function SKIP(): () - -declare function it(phrase: string, callback: () -> ()): () -declare function itFOCUS(phrase: string, callback: () -> ()): () -declare function fit(phrase: string, callback: () -> ()): () -declare function itSKIP(phrase: string, callback: () -> ()): () -declare function xit(phrase: string, callback: () -> ()): () -declare function itFIXME(phrase: string, callback: () -> ()): () diff --git a/build.project.json b/build.project.json new file mode 100644 index 0000000..53d32b4 --- /dev/null +++ b/build.project.json @@ -0,0 +1,11 @@ +{ + "name": "Packages", + "tree": { + "$className": "Folder", + "Charm": { "$path": "packages/charm/src" }, + "CharmSync": { "$path": "packages/charm-sync/src" }, + "DeepCharm": { "$path": "packages/deep-charm/src" }, + "ReactCharm": { "$path": "packages/react-charm/src" }, + "VideCharm": { "$path": "packages/vide-charm/src" } + } +} diff --git a/default.project.json b/default.project.json index a43197e..85d8f55 100644 --- a/default.project.json +++ b/default.project.json @@ -1,28 +1,59 @@ { "name": "charm-test", + "globIgnorePaths": [ + "**/default.project.json", + "node_modules/charm", + "node_modules/charm-sync", + "node_modules/react-charm", + "node_modules/vide-charm" + ], + "emitLegacyScripts": false, "tree": { "$className": "DataModel", "ReplicatedStorage": { "$className": "ReplicatedStorage", - "DevPackages": { - "$path": "modules/DevPackages", - "Charm": { - "$path": "packages/charm" + "packages": { + "$className": "Folder", + "vide": { + "$path": "node_modules/@rbxts/vide" }, - "CharmSync": { - "$path": "packages/charm-sync" + "charm": { + "$className": "Folder", + "src": { "$path": "packages/charm/src" }, + "test": { "$path": "packages/charm/test" } }, - "ReactCharm": { - "$path": "packages/react" + "deep-charm": { + "$className": "Folder", + "src": { "$path": "packages/deep-charm/src" }, + "test": { "$path": "packages/deep-charm/test" }, + "Charm": { "$path": "packages/deep-charm/Charm.luau" } }, - "VideCharm": { - "$path": "packages/vide" + "charm-sync": { + "$className": "Folder", + "src": { "$path": "packages/charm-sync/src" }, + "test": { "$path": "packages/charm-sync/test" }, + "Charm": { "$path": "packages/charm-sync/Charm.luau" } + }, + "react-charm": { + "$className": "Folder", + "src": { "$path": "packages/react-charm/src" }, + "test": { "$path": "packages/react-charm/test" }, + "React": { "$path": "packages/react-charm/React.luau" }, + "ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" }, + "Charm": { "$path": "packages/react-charm/Charm.luau" } + }, + "vide-charm": { + "$className": "Folder", + "src": { "$path": "packages/vide-charm/src" }, + "test": { "$path": "packages/vide-charm/test" }, + "Vide": { "$path": "packages/vide-charm/Vide.luau" }, + "Charm": { "$path": "packages/vide-charm/Charm.luau" } } - } - }, - "ServerScriptService": { - "$className": "ServerScriptService", - "Tests": { + }, + "@rbxts-js": { + "$path": "node_modules/@rbxts-js" + }, + "tests": { "$path": "tests" } } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..bfccfb4 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,20 @@ +const eslint = require("@eslint/js"); +const { defineConfig, globalIgnores } = require("eslint/config"); +const prettierRecommended = require("eslint-plugin-prettier/recommended"); +const roblox = require("eslint-plugin-roblox-ts"); +const tseslint = require("typescript-eslint"); + +module.exports = defineConfig( + eslint.configs.recommended, + tseslint.configs.recommended, + roblox.configs.tsRecommendedCompat, + roblox.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + "roblox-ts/no-any": "off", + }, + }, + globalIgnores(["out/**", "eslint.config.js"]), + prettierRecommended, +); diff --git a/modules/wally.lock b/modules/wally.lock deleted file mode 100644 index 7294d77..0000000 --- a/modules/wally.lock +++ /dev/null @@ -1,103 +0,0 @@ -# This file is automatically @generated by Wally. -# It is not intended for manual editing. -registry = "test" - -[[package]] -name = "centau/vide" -version = "0.3.1" -dependencies = [] - -[[package]] -name = "jsdotlua/boolean" -version = "1.2.7" -dependencies = [["number", "jsdotlua/number@1.2.7"]] - -[[package]] -name = "jsdotlua/collections" -version = "1.2.7" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"]] - -[[package]] -name = "jsdotlua/console" -version = "1.2.7" -dependencies = [["collections", "jsdotlua/collections@1.2.7"]] - -[[package]] -name = "jsdotlua/es7-types" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/instance-of" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/luau-polyfill" -version = "1.2.7" -dependencies = [["boolean", "jsdotlua/boolean@1.2.7"], ["collections", "jsdotlua/collections@1.2.7"], ["console", "jsdotlua/console@1.2.7"], ["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"], ["math", "jsdotlua/math@1.2.7"], ["number", "jsdotlua/number@1.2.7"], ["string", "jsdotlua/string@1.2.7"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.7"]] - -[[package]] -name = "jsdotlua/math" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/number" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/promise" -version = "3.5.2" -dependencies = [] - -[[package]] -name = "jsdotlua/react" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/react-reconciler" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/react-roblox" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["react", "jsdotlua/react@17.2.1"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/scheduler" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/shared" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"]] - -[[package]] -name = "jsdotlua/string" -version = "1.2.7" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["number", "jsdotlua/number@1.2.7"]] - -[[package]] -name = "jsdotlua/symbol-luau" -version = "1.0.1" -dependencies = [] - -[[package]] -name = "jsdotlua/timers" -version = "1.2.7" -dependencies = [["collections", "jsdotlua/collections@1.2.7"]] - -[[package]] -name = "littensy/charm" -version = "0.1.0" -dependencies = [["React", "jsdotlua/react@17.2.1"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.1"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]] - -[[package]] -name = "roblox/testez" -version = "0.4.1" -dependencies = [] diff --git a/modules/wally.toml b/modules/wally.toml deleted file mode 100644 index 096ab3f..0000000 --- a/modules/wally.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "littensy/charm" -version = "0.1.0" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" - -[dev-dependencies] -React = "jsdotlua/react@17.2.0" -ReactRoblox = "jsdotlua/react-roblox@17.2.0" -TestEZ = "roblox/testez@0.4.1" -Vide = "centau/vide@0.3.1" diff --git a/package.json b/package.json index 73e2375..d5f8c09 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,29 @@ { "private": true, "scripts": { - "build": "sh bin/build.sh", - "check": "sh bin/check.sh", - "wally": "sh bin/install.sh", + "test": "lute run test", + "build": "lute run build", + "check": "lute run check", "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, + "packageManager": "pnpm@10.33.3", "devDependencies": { + "@eslint/js": "^9.39.4", "@rbxts/compiler-types": "3.0.0-types.0", - "@rbxts/types": "^1.0.811", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.2.1", - "eslint-plugin-roblox-ts": "^0.0.36", - "prettier": "^3.3.3", + "@rbxts/react": "17.3.7-ts.1", + "@rbxts/react-roblox": "17.3.7-ts.1", + "@rbxts/types": "^1.0.913", + "@rbxts/vide": "^0.6.1", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-roblox-ts": "^1.4.0", + "prettier": "^3.8.1", "roblox-ts": "^3.0.0", - "typescript": "~5.5.4" - }, - "packageManager": "pnpm@9.15.4" + "typescript": "^5.9.3", + "typescript-eslint": "^8.58.0" + } } diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau new file mode 100644 index 0000000..480f2d5 --- /dev/null +++ b/packages/charm-sync/Charm.luau @@ -0,0 +1,9 @@ +if not script then + return require("../charm/src") +end + +local Charm = require(script.Parent.Parent.charm.src) + +export type Atom = Charm.Atom + +return Charm diff --git a/packages/charm-sync/default.project.json b/packages/charm-sync/default.project.json index 5115ff0..9c44c7c 100644 --- a/packages/charm-sync/default.project.json +++ b/packages/charm-sync/default.project.json @@ -1,5 +1,5 @@ { - "name": "CharmSync", + "name": "charm-sync", "tree": { "$path": "src" } diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 5256c2d..3efa745 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,16 +1,27 @@ { "name": "@rbxts/charm-sync", - "version": "0.3.0", - "description": "Sync server state with clients using Charm", - "repository": "littensy/charm", + "version": "0.4.0-rc.7", + "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "prepare": "cp ../../LICENSE . && cp ../../README.md .", "publish:wally": "wally publish" }, - "peerDependencies": { - "@rbxts/charm": "workspace:*" + "dependencies": { + "@rbxts/charm": "workspace:^" } } diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 8f3cfb1..a514b75 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -1,83 +1,108 @@ -local Charm = require(script.Parent.Parent.Charm) -local patch = require(script.Parent.patch) -local types = require(script.Parent.types) -type AtomMap = types.AtomMap +local Charm = require("../Charm") +local patch = require("./patch") +local types = require("./types") + type SyncPayload = types.SyncPayload +type ReactiveProxy = types.ReactiveProxy -type ClientOptions = { - --[=[ - The atoms to synchronize with the server. - ]=] - atoms: AtomMap, - --[=[ - Whether to ignore patches sent before the client has been hydrated. - Default is `true`. - ]=] - ignoreUnhydrated: boolean?, -} +local trackedSignals: { [string]: (any) -> any } = {} +local trackedReactives: { [string]: ReactiveProxy } = {} -type ClientSyncer = { - --[=[ - Applies a patch or initializes the state of the atoms with the given - payload from the server. - - @param ...payloads The patches or hydration payloads to apply. - ]=] - sync: (self: ClientSyncer, ...SyncPayload) -> (), -} - ---[=[ - Creates a `ClientSyncer` object that receives patches from the server and - applies them to the local state. - - @param options The atoms to synchronize with the server. - @return A `ClientSyncer` object. -]=] -local function client(options: ClientOptions): ClientSyncer - local atoms = options.atoms - local ignoreUnhydrated = options.ignoreUnhydrated ~= false - - local self = {} :: ClientSyncer - local hydrated = false - - local function hydrate(state: { [string | number]: any }) - hydrated = true - - for key, value in next, state do - local atom = atoms[key] - atom(value) - end +local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any }) + local target = reactive.__target + for key, value in source do + target[key] = value end - - local function apply(data: { [string | number]: any }) - local target = {} - - for key, atom in next, atoms do - target[key] = atom() - end - - target = patch.apply(target, data) - - for key, atom in next, atoms do - atom(target[key]) - end - end - - function self:sync(...) - for index = 1, select("#", ...) do - local payload: SyncPayload = select(index, ...) - - Charm.batch(function() - if payload.type == "init" then - hydrate(payload.data) - elseif not ignoreUnhydrated or hydrated then - apply(payload.data) - end - end) - end - end - - return self + Charm.trigger(reactive.__track) end -return client +--[[ + 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]: ((any) -> any) | {} }) + for key, signal in signals do + if type(signal) == "function" then + trackedSignals[key] = signal + else + trackedReactives[key] = (signal :: any) :: ReactiveProxy + 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. +]] +local function removeSignals(...: string) + for index = 1, select("#", ...) do + local key = select(index, ...) + trackedSignals[key] = nil + trackedReactives[key] = nil + end +end + +--[[ + Unsubscribes from all server state updates. +]] +local function removeAllSignals() + table.clear(trackedSignals) + table.clear(trackedReactives) +end + +local function applyInitPayload(data: { [string]: any }) + for key, value in data do + if trackedSignals[key] then + trackedSignals[key](value) + elseif trackedReactives[key] then + shallowMergeReactive(trackedReactives[key], value) + end + end +end + +local function applyPatchPayload(data: { [string]: any }) + 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 + patch.applyMutable(trackedReactive.__target, diffs) + Charm.trigger(trackedReactive.__track) + 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. +]] +local function applyPatches(payloads: { SyncPayload }) + for _, payload in payloads do + if payload.type == "init" then + Charm.batch(applyInitPayload, payload.data) + elseif payload.type == "patch" then + Charm.batch(applyPatchPayload, payload.data) + end + end +end + +return { + addSignals = addSignals, + removeSignals = removeSignals, + removeAllSignals = removeAllSignals, + patch = applyPatches, +} diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau new file mode 100644 index 0000000..c3ce27c --- /dev/null +++ b/packages/charm-sync/src/config.luau @@ -0,0 +1,31 @@ +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. + ]] + 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` for performance reasons. + ]] + preserveHistory = false, + --[[ + When `true`, Charm will modify sparse arrays to prevent data loss from + remote event limitations. When an unsafe array is detected, numeric + indices will be converted to string keys so that the array can be sent + safely and then converted back to an array on the receiving end. + + 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 emit a warning. Only checked in strict mode and if + `fixArrays` is enabled. Defaults to `true`. + ]] + validatePatches = true, +} diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau deleted file mode 100644 index 9c8d012..0000000 --- a/packages/charm-sync/src/flatten.luau +++ /dev/null @@ -1,28 +0,0 @@ -local types = require(script.Parent.types) -type AtomMap = types.AtomMap - -type NestedAtomMap = { - [string]: NestedAtomMap | () -> (), -} - -local function flatten(atoms: NestedAtomMap): AtomMap - local result: AtomMap = {} - - local function visit(node: NestedAtomMap, path: string) - for key, value in node do - local location = if path == "" then key else path .. "/" .. key - - if type(value) == "table" then - visit(value, location) - else - result[location] = value - end - end - end - - visit(atoms, "") - - return result -end - -return flatten diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 52ff658..bb40eba 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -1,214 +1,292 @@ -import { Atom, Selector } from "@rbxts/charm"; - -export = CharmSync; -export as namespace CharmSync; - -type Cleanup = () => void; +import { Atom, Setter } from "@rbxts/charm"; type Key = string | number | symbol; -declare namespace CharmSync { - type AtomMap = Record>; +/** + * Represents the removal of a value from the state. + */ +export interface None { + readonly __none: "__none"; +} - type SelectorMap = Record>; +export type MaybeNone = undefined extends T ? None : never; +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. + */ +export type StateOfMap = { + readonly [P in keyof T]: T[P] extends () => infer State ? State : never; +}; + +type DataTypes = { + [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; +}; + +type DataType = DataTypes[keyof DataTypes]; + +/** + * A partial patch that can be applied to the state to update it. Represents + * the difference between the current state and the next state. + * + * If a value was removed, it is replaced with `None`. This can be checked + * using the `isNone` function. + */ +// TODO: Optimize type, this looks overly complex +export type SyncPatch = MaybeNone | State extends + | ReadonlyMap + | Map + ? ReadonlyMap | None> + : State extends Set | ReadonlySet + ? ReadonlyMap + : State extends readonly (infer T)[] + ? FixArrays extends true + ? ReadonlyMap | None> + : readonly (SyncPatch | None | undefined)[] + : State extends DataType + ? State + : State extends object + ? { readonly [P in keyof State]?: SyncPatch } + : State; + +/** + * A payload that can be sent from the server to the client to synchronize + * state between the two. + */ +export type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch, FixArrays> }; + +/** + * Global configuration options that affect the behavior of Charm Sync. + */ +export const config: { /** - * @deprecated Use `SelectorMap` instead. - */ - type MoleculeMap = SelectorMap; - - /** - * Infers the type of the return values produced by a map of functions. - */ - type StateOfMap = { - readonly [P in keyof T]: T[P] extends Selector ? State : never; - }; - - /** - * Represents the removal of a value from the state. - */ - interface None { - readonly __none: "__none"; - } - - type MaybeNone = undefined extends T ? None : never; - - /** - * Creates a `ClientSyncer` object that receives patches from the server and - * applies them to the local state. - * - * @client - * @param options The atoms to synchronize with the server. - * @returns A `ClientSyncer` object. - */ - function client(options: ClientOptions): ClientSyncer; - - /** - * Creates a `ServerSyncer` object that sends patches to the client and - * hydrates the client's state. + * 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. * * @server - * @param options The atoms to synchronize with the client. - * @returns A `ServerSyncer` object. */ - function server( - options: ServerOptions, - ): ServerSyncer; - + interval: number; /** - * Checks whether a value is `None`. If `true`, the value is scheduled to be - * removed from the state when the patch is applied. + * 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` for performance reasons. * - * @param value The value to check. - * @returns `true` if the value is `None`, otherwise `false`. + * @server */ - function isNone(value: unknown): value is None; - - interface NestedAtomMap { - readonly [key: string]: NestedAtomMap | Atom; - } - - type AppendPath = undefined extends Path - ? Name - : Name extends string - ? Path extends string - ? `${Path}/${Name}` - : never - : never; - - type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; - - type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; - - type FlattenNestedAtoms = IntersectValues<{ - readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom - ? { readonly [K in AppendPath]: Atoms[Name] } - : Atoms[Name] extends NestedAtomMap - ? FlattenNestedAtoms> - : never; - }>; - + preserveHistory: boolean; /** - * Flattens a nested atom map into a single object with slash-separated - * keys. Useful for recursively collecting atoms returned by modules. + * When `true`, Charm will modify sparse arrays to prevent data loss from + * remote event limitations. When an unsafe array is detected, numeric + * indices will be converted to string keys so that the array can be sent + * safely and then converted back to an array on the receiving end. * - * @param atoms The nested atom map to flatten. - * @return A flattened atom map. - */ - function flatten(atoms: Atoms): FlattenNestedAtoms; - - type DataTypes = { - [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; - }; - - /** - * A type that should not be made partial in patches. - */ - type DataType = DataTypes[keyof DataTypes]; - - /** - * A partial patch that can be applied to the state to update it. Represents - * the difference between the current state and the next state. + * This option should be disabled if your network library uses a custom + * serialization method (i.e. Zap, ByteNet) to prevent interference. * - * If a value was removed, it is replaced with `None`. This can be checked - * using the `sync.isNone` function. + * @server */ - type SyncPatch = MaybeNone | State extends - | ReadonlyMap - | Map - ? ReadonlyMap | None> - : State extends Set | ReadonlySet - ? ReadonlyMap - : State extends readonly (infer T)[] - ? Serialize extends true - ? ReadonlyMap | None> - : readonly (SyncPatch | None | undefined)[] - : State extends DataType - ? State - : State extends object - ? { readonly [P in keyof State]?: SyncPatch } - : State; + fixArrays: boolean; + /** + * When `true`, synced state containing unsafe sparse arrays or mixed + * tables will emit a warning. Only checked in strict mode and if + * `fixArrays` is enabled. Defaults to `true`. + * + * @server + */ + validatePatches: boolean; +}; + +/** + * @client + */ +export namespace client { + /** + * 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. + */ + export function addSignals(signals: Signals): void; /** - * A payload that can be sent from the server to the client to synchronize - * state between the two. + * 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. */ - type SyncPayload = - | { readonly type: "init"; readonly data: StateOfMap } - | { readonly type: "patch"; readonly data: SyncPatch, Serialize> }; + export function removeSignals( + ...keys: (keyof Signals)[] + ): void; - interface ClientOptions { - /** - * The atoms to synchronize with the server. - */ - atoms: Atoms; - /** - * Whether to ignore patches sent before the client has been hydrated. - * Defaults to `true`. - */ - ignoreUnhydrated?: boolean; - } + /** + * Unsubscribes from all server state updates. + */ + export function removeAllSignals(): void; - interface ServerOptions { - /** - * The atoms to synchronize with the client. - */ - atoms: Selectors; - /** - * 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?: number; - /** - * 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`. - * - * If `true`, the broadcaster will send a list of payloads to the client - * instead of a single payload. The client will apply each payload in order - * to reconstruct the state's changes over time. - */ - preserveHistory?: boolean; - /** - * 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. - */ - autoSerialize?: Serialize; - } - - interface ClientSyncer { - /** - * Applies a patch or initializes the state of the atoms with the given - * payload from the server. - * - * @param payloads The patches or hydration payloads to apply. - */ - sync(...payloads: SyncPayload[]): void; - } - - interface ServerSyncer { - /** - * Sets up a subscription to each atom that schedules a patch to be sent to - * the client whenever the state changes. When a change occurs, the `callback` - * is called with the player and the payloads to send. - * - * Note that a `payload` object should not be mutated. If you need to modify - * a payload, apply the changes to a copy of the object. - * - * @param callback The function to call when the state changes. - * @returns A cleanup function that unsubscribes all listeners. - */ - connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; - /** - * Hydrates the client's state with the server's state. This should be - * called when a player joins the game and requires the server's state. - * - * @param player The player to hydrate. - */ - hydrate(player: Player): void; - } + /** + * 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. + */ + export function patch( + payloads: SyncPayload[], + ): void; } + +/** + * @server + */ +export namespace server { + /** + * 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, computed signals, and signal getter functions are allowed. Note + * that `client.addSignals` still requires some way to set the state. + * + * @param client The client receiving state updates. + * @param signals A map of getter functions to sync with the client. + */ + export function addSignalsToClient( + client: Player, + signals: Signals, + ): void; + + /** + * Unsubscribes a client from receiving all state updates. To only unsubscribe + * from specific keys, use `removeSignalsFromClient` instead. + * + * @param client The client receiving state updates. + */ + export function removeClient(client: Player): void; + + /** + * 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. + */ + export function removeSignalsFromClient( + client: Player, + ...keys: (keyof Signals)[] + ): void; + + /** + * 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. + */ + export function connect( + onSync: (client: Player, payloads: SyncPayload[]) => void, + ): void; + + /** + * Stops syncing state updates to clients at the automatic interval. + * Flushing can still be performed manually via `flush`. + */ + export function disconnect(): void; + + /** + * 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. + */ + export function flush(): void; + + /** + * Unsubscribes all clients, stops tracking all state, and disconnects + * from the automatic sync interval. + */ + export function reset(): void; +} + +export namespace patch { + /** + * 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. + */ + export function nilToNone(value: T | undefined): T | None; + + /** + * 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. + * @returns A patch that can be applied to the current state. + */ + export function diff( + currentState: State, + nextState: State, + ): SyncPatch; + + /** + * Applies a patch to a state and returns the resulting value. + * + * @param currentState The current state. + * @param statePatch The patches to apply. + * @returns The new state with the patch applied. + */ + export function applyImmutable( + currentState: State, + statePatch: SyncPatch, + ): State; +} + +type DeepFlattenImpl = { + [K in keyof T]: T[K] extends DataType | Callback + ? { [P in `${Prefix}${Exclude}`]: T[K] } + : T[K] extends object + ? DeepFlattenImpl}/`> + : { [P in `${Prefix}${Exclude}`]: T[K] }; +}[keyof T]; + +type DeepFlatten = Reconstruct>>; + +/** + * 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. + */ +export function flatten(input: T): DeepFlatten; + +/** + * 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. + * @returns `true` if the value is `None`, otherwise `false`. + */ +export function isNone(value: unknown): value is None; + +/** + * 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. + * @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/init.luau b/packages/charm-sync/src/init.luau index 5fc9171..53a6353 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -1,18 +1,19 @@ -local client = require(script.client) -local flatten = require(script.flatten) -local patch = require(script.patch) -local server = require(script.server) -local types = require(script.types) +local client = require("@self/client") +local config = require("@self/config") +local patch = require("@self/patch") +local server = require("@self/server") +local types = require("@self/types") +local utils = require("@self/utils") +export type None = types.None export type SyncPayload = types.SyncPayload ---[=[ - Synchronizes state between the client and server. The server sends patches - to the client, which applies them to its local state. -]=] return { + config = config, client = client, server = server, - flatten = flatten, + patch = patch, isNone = patch.isNone, + flatten = utils.flatten, + signalToAtom = utils.signalToAtom, } diff --git a/packages/charm-sync/src/interval.luau b/packages/charm-sync/src/interval.luau deleted file mode 100644 index 31df067..0000000 --- a/packages/charm-sync/src/interval.luau +++ /dev/null @@ -1,30 +0,0 @@ -local RunService = game:GetService("RunService") - ---[=[ - Schedules a function to be called at a fixed interval of `seconds`. - - @param callback The function to call. - @param seconds The interval in seconds. - @return A function that cancels the interval. -]=] -local function interval(callback: () -> (), seconds: number): () -> () - if seconds < 0 then - return function() end - end - - local clock = 0 - - local connection = RunService.Heartbeat:Connect(function(delta) - clock += delta - if clock >= seconds then - clock = 0 - callback() - end - end) - - return function() - connection:Disconnect() - end -end - -return interval diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 3d021b5..d6b940f 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -1,28 +1,45 @@ -local validate = require(script.Parent.validate) +local Charm = require("../Charm") +local config = require("./config") +local types = require("./types") +local validate = require("./validate") -local __DEV__ = _G.__DEV__ +local flags = Charm.flags ---[=[ - A special symbol that denotes the absence of a value. Used to represent - deleted values in patches. -]=] -local None = { __none = "__none" } +export type None = types.None +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`. +]] 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. +]] +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 + 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 function stringifySparseArray(object: { [any]: any }): { [string | number]: any } local maxn = table.maxn(object) if maxn == 0 or maxn == #object then @@ -32,86 +49,91 @@ local function stringifySparseArray(object: { any }): { [string | number]: any } local dictionary = {} - for index, value in next, object do + for index, value in object do dictionary[tostring(index)] = value end return dictionary end ---[=[ - Compares two states and returns a patch that can be applied to the previous - state to produce the next state. +local function diff(oldState: any, newState: any): any + if oldState == newState then + return nil + elseif newState == nil then + return None + elseif type(oldState) ~= "table" or type(newState) ~= "table" then + return newState + end - @param prevState The previous state. - @param nextState The next state. - @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`. - @returns A patch that can be applied to the previous state. -]=] -local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?) - serialize = serialize ~= false + local diffs = table.clone(newState) - local patches = table.clone(nextState) + for key, oldValue in oldState do + diffs[key] = diff(oldValue, newState[key]) + end - for key, previous in next, prevState do - local current = nextState[key] + if next(diffs) == nil then + return nil + end - if previous == current then - patches[key] = nil - elseif current == nil then - patches[key] = None - elseif type(previous) == "table" and type(current) == "table" then - patches[key] = diff(previous, current, serialize) + if config.fixArrays then + -- Convert sparse array patches that could be misserialized + if oldState[1] ~= nil or newState[1] ~= nil then + diffs = stringifySparseArray(diffs) + end + if flags.strict and config.validatePatches then + validate(diffs) end end - -- Coerce sparse array patches into dictionaries - if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then - patches = stringifySparseArray(patches) - end - - if serialize and __DEV__ then - for key, value in next, patches do - validate(value, key) - end - end - - return patches + return diffs end ---[=[ - Applies a patch to a state and returns the next state. - - @param state The current state. - @param patches The patches to apply. - @returns The next state. -]=] -local function apply(state: any, patches: any): any - if type(patches) == "table" and patches.__none == "__none" then +local function apply(oldState: any, diffs: any): any + if isNone(diffs) then return nil - elseif type(state) ~= "table" or type(patches) ~= "table" then - return patches + elseif type(oldState) ~= "table" or type(diffs) ~= "table" then + return diffs end - local nextState = table.clone(state) - local stateIsArray = state[1] ~= nil + local newState = table.clone(oldState) + local stateIsArray = oldState[1] ~= nil - for key, patch in next, patches 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], patch) + newState[key] = apply(newState[key], value) end - return nextState + return newState +end + +local function applyMutable(targetState: any, diffs: any): any + if isNone(diffs) then + return nil + elseif type(targetState) ~= "table" or type(diffs) ~= "table" then + return diffs + end + + local targetIsArray = targetState[1] ~= nil + + for key, value in diffs do + if targetIsArray and type(key) == "string" then + key = tonumber(key) or key + end + targetState[key] = applyMutable(targetState[key], value) + end + + return targetState end return { isNone = isNone, + 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 f2c336e..ca04ba7 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -1,267 +1,416 @@ -local Players = game:GetService("Players") +local Charm = require("../Charm") +local config = require("./config") +local patch = require("./patch") +local types = require("./types") +local utils = require("./utils") -local Charm = require(script.Parent.Parent.Charm) -local interval = require(script.Parent.interval) -local patch = require(script.Parent.patch) -local types = require(script.Parent.types) -type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload -type ServerOptions = { - --[=[ - The atoms to synchronize with the client. - ]=] - atoms: AtomMap, - --[=[ - 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: number?, - --[=[ - 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: boolean?, - --[=[ - When `true`, Charm will apply validation and serialize unsafe arrays - to address remote event argument limitations. Defaults to `true`. +type SyncStatus = number - This option should be disabled if your network library uses a custom - serialization method (i.e. Zap, ByteNet) to prevent interference. - ]=] - autoSerialize: boolean?, +type ClientKeys = { [string]: SyncStatus } + +type TrackedState = { + identifier: unknown, + currentValue: T, + lastSyncedValue: T, + isReactive: boolean, + dispose: () -> (), } -type ServerSyncer = { - --[=[ - Sets up a subscription to each atom that schedules a patch to be sent to - the client whenever the state changes. When a change occurs, the `callback` - is called with the player and the payload to send. - - Note that the `payload` object should not be mutated. If you need to - modify the payload, apply the changes to a copy of the object. - - @param callback The function to call when the state changes. - @return A cleanup function that unsubscribes all listeners. - ]=] - connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (), - --[=[ - Hydrates the client's state with the server's state. This should be - called when a player joins the game and requires the server's state. - - @param player The player to hydrate. - ]=] - hydrate: (self: ServerSyncer, player: Player) -> (), - --[=[ - @deprecated For internal use only. - ]=] - _sendPatch: (self: ServerSyncer, player: Player) -> (), +type StateUpdate = { + key: string, + newValue: any, + oldValue: any, } ---[=[ - Creates a `ServerSyncer` object that sends patches to the client and - hydrates the client's state. - - @param options The atoms to synchronize with the client. - @return A `ServerSyncer` object. -]=] -local function server(options: ServerOptions): ServerSyncer - local atoms = options.atoms - local autoSerialize = options.autoSerialize ~= false - local preserveHistory = options.preserveHistory - local syncRate = options.interval or 0 +local PENDING_INITIAL_STATE: SyncStatus = 0 +local LISTENING_FOR_CHANGES: SyncStatus = 1 - local self = {} :: ServerSyncer - local sync: (player: Player, payload: SyncPayload) -> () +local trackedStates: { [string]: TrackedState } = {} +local globalUpdates: { StateUpdate } = {} +local clients: { [any]: ClientKeys } = {} - local function createSnapshot() - local snapshot = {} +local connection: RBXScriptConnection? +local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? - for key, atom in next, atoms do - snapshot[key] = atom() +--[[ + Manually converts a reactive proxy to its raw representation to potentially + support custom implementations of reactive proxies. +]] +local function toRaw(value: T): T + value = value :: types.ReactiveProxy + if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then + value.__track() + return toRaw(value.__target) + end + return value +end + +local function trackSignal(key: string, getter: () -> T, identifier: unknown) + if trackedStates[key] then + -- Warn against name collisions with conflicting signals or proxies + if trackedStates[key].identifier ~= identifier then + warn("[Charm] Attempted to sync different states with the same key:", key) end - - return snapshot + return end - -- Send the initial state to a player when they join the server. - function self:hydrate(player) - assert(sync, "connect() must be called before hydrate()") + local trackedState: TrackedState + local initialValue = getter() + local rawInitialValue = toRaw(initialValue) + local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue - sync(player, { - type = "init", - data = createSnapshot(), - }) - end + local dispose = Charm.effect(function() + local newValue = getter() + local rawValue = toRaw(newValue) + local isReactive = newValue ~= rawValue - -- If the history of state changes should be preserved, keep a list of - -- previous snapshots and diff-check each one from the last. - if preserveHistory then - local snapshots = { createSnapshot() } - local changed = false - - function self:connect(callback) - local subscriptions = {} - - sync = callback - - local function pushSnapshot(key: string | number, current: any, previous: any) - local lastSnapshot = snapshots[#snapshots] - local previousSnapshot = snapshots[#snapshots - 1] - - -- Optimize snapshots by updating the most recent snapshot if the - -- previous and current values are the same, since this allows us - -- to group multiple changes into a single snapshot. - if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then - lastSnapshot[key] = current - else - local nextSnapshot = table.clone(lastSnapshot) - nextSnapshot[key] = current - table.insert(snapshots, nextSnapshot) - end - end - - -- Populate the snapshot with the initial state of each atom. - -- Subscribe to each atom and update the state when it changes. - for key, atom in next, atoms do - subscriptions[key] = Charm.subscribe(atom, function(current, previous) - pushSnapshot(key, current, previous) - changed = true - end) - end - - local disconnect = interval(function() - if not changed then - return - end - - local payloads: { SyncPayload } = {} - local lastSnapshot - - for index, snapshot in next, snapshots do - lastSnapshot = snapshot - - if index == 1 then - continue - end - - table.insert(payloads, { - type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, autoSerialize), - }) - end - - snapshots = { lastSnapshot } - changed = false - - for _, player in next, Players:GetPlayers() do - callback(player, unpack(payloads)) - end - end, syncRate) - - return function() - disconnect() - - for _, unsubscribe in next, subscriptions do - unsubscribe() - end - end - end - - function self:_sendPatch(player: Player) - assert(sync, "connect() must be called before _sendPatch()") - - if not changed then - return - end - - local payloads: { SyncPayload } = {} - - for index, snapshot in next, snapshots do - if index == 1 then - continue - end - - table.insert(payloads, { - type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, autoSerialize), - }) - end - - snapshots = { snapshots[#snapshots] } - changed = false - - sync(player, unpack(payloads)) - end - - return self - end - - local previousSnapshot = createSnapshot() - local currentSnapshot = table.clone(previousSnapshot) - local changed = false - - function self:connect(callback) - local subscriptions = {} - - sync = callback - - -- Subscribe to each atom and update the state when one changes. - for key, atom in next, atoms do - subscriptions[key] = Charm.subscribe(atom, function(state) - currentSnapshot[key] = state - changed = true - end) - end - - local disconnect = interval(function() - if not changed then - return - end - - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), - } - - previousSnapshot = table.clone(currentSnapshot) - changed = false - - for _, player in next, Players:GetPlayers() do - callback(player, payload) - end - end, syncRate) - - return function() - disconnect() - - for _, unsubscribe in next, subscriptions do - unsubscribe() - end - end - end - - function self:_sendPatch(player) - assert(sync, "connect() must be called before _sendPatch()") - - if not changed then + if not trackedState then return end - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), - } + -- When preserving history, we need to keep track of previous values + -- so we can create granular patches for each change. + if config.preserveHistory then + local prevSnapshot = snapshot + snapshot = if isReactive then utils.deepCopy(rawValue) else newValue + table.insert(globalUpdates, { + key = key, + newValue = snapshot, + oldValue = prevSnapshot, + }) + end - previousSnapshot = table.clone(currentSnapshot) - changed = false + trackedState.currentValue = rawValue + trackedState.isReactive = isReactive + end) - sync(player, payload) - end + trackedState = { + identifier = identifier, + currentValue = rawInitialValue, + lastSyncedValue = snapshot, + isReactive = initialValue ~= rawInitialValue, + dispose = dispose, + } - return self + trackedStates[key] = trackedState end -return server +local function stopTracking(key: string) + local trackedState = trackedStates[key] + if trackedState then + trackedState.dispose() + trackedStates[key] = nil + end +end + +local function purgeUntracked() + local active: { [string]: boolean } = {} + + for _, clientKeys in clients do + for key in clientKeys do + active[key] = true + end + end + + for key in trackedStates do + if not active[key] then + stopTracking(key) + end + end +end + +local function diffGlobalUpdateBuffer(): { any } + local patchCount = #globalUpdates + local patches = table.create(patchCount) + + for _, update in globalUpdates do + table.insert(patches, patch.diff(update.oldValue, update.newValue)) + end + + return patches +end + +local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }? + local clientKeys = clients[client] + + if not clientKeys then + return nil + end + + local payloads: { SyncPayload } = {} + + -- Iterate over all global updates and see if the client is interested in + -- any of them. If so, we create a patch payload for the update. + for index, update in globalUpdates do + local patchData = globalPatches[index] + + if not patchData then + continue + end + + local key = update.key + local status = clientKeys[key] + + if status == LISTENING_FOR_CHANGES then + local payload: SyncPayload = { + type = "patch", + data = { [key] = patchData }, + } + table.insert(payloads, payload) + elseif status == PENDING_INITIAL_STATE then + local payload: SyncPayload = { + type = "init", + data = { [key] = update.newValue }, + } + table.insert(payloads, payload) + clientKeys[key] = LISTENING_FOR_CHANGES + end + end + + -- If a pending signal was never updated, we still need to send its initial + -- state. This can happen if a signal was registered after its last change. + local initData = {} + + for key, status in clientKeys do + local trackedState = trackedStates[key] + + if status == PENDING_INITIAL_STATE and trackedState then + initData[key] = trackedState.currentValue + clientKeys[key] = LISTENING_FOR_CHANGES + end + end + + if next(initData) ~= nil then + table.insert(payloads, 1, { type = "init" :: "init", data = initData }) + end + + return payloads +end + +local function diffGlobalState(): { [string]: any }? + local currentGlobalState: { [string]: any } = {} + local previousGlobalState: { [string]: any } = {} + + 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 = currentValue + end + + return patch.diff(previousGlobalState, currentGlobalState) +end + +local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }? + local clientKeys = clients[client] + + if not clientKeys then + return nil + end + + local payloads: { SyncPayload } = {} + local initData: { [string]: any } = {} + local patchData: { [string]: any } = {} + + -- 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 = trackedStates[key] + + if status == LISTENING_FOR_CHANGES and globalPatch then + patchData[key] = globalPatch[key] + elseif status == PENDING_INITIAL_STATE then + initData[key] = trackedState.currentValue + clientKeys[key] = LISTENING_FOR_CHANGES + end + end + + if next(initData) ~= nil then + local payload: SyncPayload = { + type = "init", + data = initData, + } + table.insert(payloads, payload) + end + + if next(patchData) ~= nil then + local payload: SyncPayload = { + type = "patch", + data = patchData, + } + table.insert(payloads, payload) + end + + return payloads +end + +local function startInterval(callback: () -> ()) + if not game then + return + end + + if connection then + connection:Disconnect() + end + + local timestamp = os.clock() + + connection = game:GetService("RunService").Heartbeat:Connect(function() + if config.interval >= 0 and os.clock() >= timestamp + config.interval then + timestamp = os.clock() + debug.profilebegin("CharmSync") + callback() + debug.profileend() + end + end) +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 signals A map of getter functions to sync with the client. +]] +local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any }) + if not clients[client] then + clients[client] = {} + end + + local clientKeys = clients[client] + + for key, signal in signals do + if type(signal) == "function" then + trackSignal(key, signal, signal) + else + trackSignal(key, function() + return signal + end, signal) + end + + if not clientKeys[key] then + -- 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. +]] +local function removeClient(client: any) + clients[client] = nil + purgeUntracked() +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. +]] +local function removeSignalsFromClient(client: any, ...: string) + local clientKeys = clients[client] + + if not clientKeys then + return + end + + for index = 1, select("#", ...) do + local key = select(index, ...) + clientKeys[key] = nil + end + + if next(clientKeys) == nil then + removeClient(client) + else + purgeUntracked() + 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. +]] +local function flush() + if not syncCallback then + return + end + + local granular = config.preserveHistory + local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState() + + for client in clients do + local payloads = if granular + then selectFromGlobalPatchList(client, globalPatch :: { any }) + else selectFromGlobalPatch(client, globalPatch) + + if payloads and payloads[1] then + if task then + task.spawn(syncCallback, client, payloads) + else + syncCallback(client, payloads) + end + end + end + + 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. +]] +local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ()) + syncCallback = onSync + startInterval(flush) +end + +--[[ + 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() + connection = nil + end +end + +--[[ + Unsubscribes all clients, stops tracking all state, and disconnects + from the automatic sync interval. +]] +local function reset() + table.clear(trackedStates) + table.clear(clients) + table.clear(globalUpdates) + disconnect() +end + +return { + addSignalsToClient = addSignalsToClient, + removeClient = removeClient, + removeSignalsFromClient = removeSignalsFromClient, + connect = connect, + disconnect = disconnect, + flush = flush, + reset = reset, +} diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 641f744..664ee61 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -1,20 +1,30 @@ -local Charm = require(script.Parent.Parent.Charm) -type Atom = Charm.Atom - ---[=[ +--[[ A payload that can be sent from the server to the client to synchronize - state between the two. -]=] -export type SyncPayload = { - type: "init", - data: { [string | number]: any }, -} | { - type: "patch", - data: { [string | number]: any }, -} + the states of the client's atoms with the server. +]] +export type SyncPayload = + | { + type: "init", + data: { [string]: any }, + } + | { + type: "patch", + data: { [string]: any }, + } -export type AtomMap = { - [string | number]: Atom, -} +--[[ + 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 nil +export type ReactiveProxy = typeof(setmetatable( + {} :: { + __track: () -> (), + __target: T, + __signals: { [unknown]: () -> unknown }, + }, + {} :: { __index: T } +)) + +return {} diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau new file mode 100644 index 0000000..4a49871 --- /dev/null +++ b/packages/charm-sync/src/utils.luau @@ -0,0 +1,65 @@ +local Charm = require("../Charm") + +type Atom = Charm.Atom + +--[[ + 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: { [any]: any }): Flattened + local result = {} + + local function visit(node: { [any]: any }, path: string) + for key, value in node do + local location = if path == "" then tostring(key) else path .. "/" .. tostring(key) + + if type(value) == "table" then + visit(value, location) + else + result[location] = value + end + end + end + + visit(input, "") + + return result :: any +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. +]] +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. Skips tables with metatables to + prevent copying reactive proxies. +]] +local function deepCopy(object: T): T + local copy: any = table.clone(object :: any) + for key, value in copy do + if type(value) == "table" and getmetatable(value) == nil 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 c832542..dfba716 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,67 +1,46 @@ -local SAFE_KEYS = { string = true, number = true } -local UNSAFE_VALUES = { ["function"] = true, thread = true } - local function isTableUnsafe(object: { [any]: any }) - local keyType = nil - local objectSize = 0 + local firstKey = next(object) - -- All keys must have the same type - for key in next, object do - local currentType = type(key) - - if not keyType and SAFE_KEYS[currentType] then - keyType = currentType - elseif keyType ~= currentType then - return true - end - - objectSize += 1 + if firstKey == nil then + return false end - -- If there are more keys than the length of the array, it's an array with - -- non-sequential keys. - if objectSize > #object and keyType == "number" then + local keyType = type(firstKey) + local maxKey = 0 + + -- Keys must all be the same type + for key in object do + if type(key) ~= keyType then + return true + elseif type(key) == "number" and key > maxKey then + maxKey = key + end + end + + -- Array keys must be sequential + if keyType == "number" and maxKey > #object then return true end return false end ---[=[ - Validates a value to ensure it can be synced over a remote event. +--[[ + 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 value to validate. - @param key The key of the value in the table. - @error Throws an error if the value cannot be synced. -]=] -local function validate(value: any, key: any) - local typeOfKey = type(key) - local typeOfValue = type(value) - - if not SAFE_KEYS[typeOfKey] then - error(`Invalid key type '{typeOfKey}' at key '{key}'`) - elseif UNSAFE_VALUES[typeOfValue] then - error(`Invalid value type '{typeOfValue}' at key '{key}'`) - elseif typeOfValue == "table" then - if getmetatable(value) ~= nil then - error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) - elseif isTableUnsafe(value) then - error( - `Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n` - .. "This can be for the following reasons:\n" - .. "1. The object is an array with non-sequential keys\n" - .. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n" - .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations" - ) - end - end - - if typeOfKey == "number" then - if key == math.huge or key == -math.huge then - error("Cannot sync infinity as key") - elseif key ~= math.floor(key) then - error("Cannot sync non-integer number as key") - end + @param value The table value. +]] +local function validate(value: { [any]: any }) + if isTableUnsafe(value) then + warn( + "Cannot sync tables unsupported by remote events! 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\n", + value + ) end end diff --git a/packages/charm-sync/test/client.test.luau b/packages/charm-sync/test/client.test.luau new file mode 100644 index 0000000..20c41a8 --- /dev/null +++ b/packages/charm-sync/test/client.test.luau @@ -0,0 +1,137 @@ +local Charm = require("../Charm") +local DeepCharm = require("../../deep-charm/src") +local client = require("../src/client") +local test = require("../../../tests/test") + +test.afterEach(function() + client.removeAllSignals() +end) + +test("should hydrate atom", function() + local a = Charm.atom(1) + + client.addSignals({ a = a }) + client.patch({ + { type = "init", data = { a = 2 } }, + }) + + assert(a() == 2, "should set a to the initial value") +end) + +test("should merge patch", function() + local a = Charm.atom({ b = 1, c = 2 }) + local previous = a() + + client.addSignals({ a = a }) + client.patch({ + { type = "init", data = { a = a() } }, + }) + + assert(a() == previous, "should set a to the initial value") + + client.patch({ + { type = "patch", data = { a = { b = 3 } } }, + }) + + assert(a() ~= previous, "should apply patch") + assert(a().b == 3, "should set b to the new value") + assert(a().c == 2, "should not change c") +end) + +test("should merge patch (reactive)", function() + local a = DeepCharm.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 }) + + client.addSignals({ a = a }) + client.patch({ + { type = "init", data = { a = a() } }, + }) + + -- This patch would be the result of a diff between { 1 } and { 1, 2 } that + -- was JSON-serialized and deserialized. The client should be able to catch + -- this and convert the string key back to a number. + client.patch({ + { type = "patch", data = { a = { ["2"] = 2 } } }, + }) + + assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers") + assert(a()[2] == 2, "should convert serialized arrays to use numbers") +end) + +test("should merge multiple patches", function() + local a = Charm.atom(1) + local calls = {} + + client.addSignals({ a = a }) + + Charm.subscribe(a, function(value) + table.insert(calls, value) + end) + + client.patch({ + { type = "init", data = { a = a() } }, + }) + + client.patch({ + { type = "patch", data = { a = 2 } }, + { type = "patch", data = { a = 3 } }, + }) + + assert(a() == 3, "should apply the last patch") + assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`) +end) + +test("should batch patches", function() + local a = Charm.atom(0) + local b = Charm.atom(0) + local states = {} + + client.addSignals({ a = a, b = b }) + + Charm.subscribe(a, function() + states.b = b() + end) + + Charm.subscribe(b, function() + states.a = a() + end) + + client.patch({ + { type = "init", data = { a = a(), b = b() } }, + }) + + client.patch({ + { type = "patch", data = { a = 1, b = 2 } }, + }) + + assert(a() == 1, "should set a to the new value") + assert(b() == 2, "should set b to the new value") + assert(states.a == 1, "sub to a should run after b updates") + assert(states.b == 2, "sub to b should run after a updates") +end) + +return {} diff --git a/packages/charm-sync/test/e2e.test.luau b/packages/charm-sync/test/e2e.test.luau new file mode 100644 index 0000000..62ca4a7 --- /dev/null +++ b/packages/charm-sync/test/e2e.test.luau @@ -0,0 +1,97 @@ +local Charm = require("../Charm") +local DeepCharm = require("../../deep-charm/src") +local client = require("../src/client") +local config = require("../src/config") +local server = require("../src/server") +local test = require("../../../tests/test") + +test.afterEach(function() + server.reset() + client.removeAllSignals() + config.preserveHistory = false +end) + +test("should replicate server state to client", function() + local target = {} + local serverA = Charm.atom({ count = 0 }) + local serverB = DeepCharm.reactive({ count = 0 }) + local clientA = Charm.atom({ count = -1 }) + local clientB = DeepCharm.reactive({ count = -1 }) + + client.addSignals({ a = clientA, b = clientB }) + server.addSignalsToClient(target, { a = serverA, b = serverB }) + server.connect(function(syncTarget, payloads) + if syncTarget == target then + client.patch(payloads) + end + end) + + server.flush() + assert(clientA().count == 0, "Client atom A should be initialized to 0") + assert(clientB.count == 0, "Client reactive B should be initialized to 0") + + serverA({ count = 1 }) + serverB.count = 1 + server.flush() + assert(clientA().count == 1, "Client atom A 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) + + serverA({ count = 2 }) + server.flush() + assert(clientA().count == 2, "Client atom A should be updated to 2") + 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 = DeepCharm.reactive({ count = 0 }) + local clientA = Charm.atom({ count = -1 }) + local clientB = DeepCharm.reactive({ count = -1 }) + + config.preserveHistory = true + + client.addSignals({ a = clientA, b = clientB }) + server.addSignalsToClient(target, { a = serverA, b = serverB }) + server.connect(function(syncTarget, payloads) + if syncTarget == target then + client.patch(payloads) + end + end) + + local expectedA = { -1, 0, 1, 2, 3, 4 } + local expectedB = { -1, 0, 1, 2 } + + Charm.effect(function() + local expected = table.remove(expectedA, 1) + 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 reactive B should be {expected}`) + end) + + server.flush() + + serverA({ count = 1 }) + serverA({ 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 reactive B should be updated to 2") + + serverA({ count = 3 }) + serverA({ count = 4 }) + server.flush() + assert(clientA().count == 4, "Client atom A should be updated to 4") +end) + +return {} diff --git a/packages/charm-sync/test/init.luau b/packages/charm-sync/test/init.luau new file mode 100644 index 0000000..111dc1d --- /dev/null +++ b/packages/charm-sync/test/init.luau @@ -0,0 +1,23 @@ +local test = require("../../tests/test") + +test.describe("client", function() + require("@self/client.test") +end) + +test.describe("e2e", function() + require("@self/e2e.test") +end) + +test.describe("utils", function() + require("@self/utils.test") +end) + +test.describe("patch", function() + require("@self/patch.test") +end) + +test.describe("server", function() + require("@self/server.test") +end) + +return {} diff --git a/packages/charm-sync/test/patch.test.luau b/packages/charm-sync/test/patch.test.luau new file mode 100644 index 0000000..076544e --- /dev/null +++ b/packages/charm-sync/test/patch.test.luau @@ -0,0 +1,133 @@ +local config = require("../src/config") +local patch = require("../src/patch") +local test = require("../../../tests/test") + +local function mockArraySerdes(object: { [any]: any }): { [any]: any } + if object[1] then + local copy = {} + for index = 1, #object do + copy[index] = object[index] + end + return copy + elseif table.maxn(object) ~= #object then + local copy = {} + for key, value in object do + copy[tostring(key)] = value + end + return copy + else + return object + end +end + +test.afterEach(function() + config.fixArrays = true + config.validatePatches = true +end) + +test("returns a patch", function() + local prevState = { foo = "bar", baz = "qux" } + local nextState = { foo = "bar", baz = "quux" } + local diffs = patch.diff(prevState, nextState) + + 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 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") + assert(applied.foo == "bar", "should keep the unchanged value") + assert(applied.qaz == "qux", "should add the new value") +end) + +test("allows nested diffs", function() + local prevState = { foo = { bar = "baz" } } + local nextState = { foo = { bar = "qux" } } + local diffs = patch.diff(prevState, nextState) + local applied = patch.apply(prevState, diffs) + + assert(applied.foo.bar == "qux", "should apply the nested patch") +end) + +test("preserves array density when applying", function() + local prevState = { "a", "b", "c", "d" } + local nextState = { ["2"] = "B", ["4"] = "D" } + local applied = patch.apply(prevState, nextState) + + assert(applied[1] == "a", "should keep a") + assert(applied[2] == "B", "should apply B") + assert(applied[3] == "c", "should keep c") + assert(applied[4] == "D", "should apply D") + + assert(applied["2"] == nil, "should convert serialized arrays to use numbers") + assert(applied["4"] == nil, "should convert serialized arrays to use numbers") +end) + +test("preserves array density after serdes", function() + -- This particular change results in a patch that cannot be serialized + -- without losing information. Charm resolves this issue internally. + local prevState = { "a", "b", "c", "d", "e" } + local nextState = { "A", "b", "c", "D" } + + 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]}`) + end + + for index in applied do + assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) + end +end) + +test("cannot preserve sparse arrays", function() + config.validatePatches = false + + local prevState = {} + local nextState = { [2] = "foo" } + + local diffs = mockArraySerdes(patch.diff(prevState, nextState)) + local applied = patch.apply(prevState, diffs) + + -- 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") +end) + +test("optionally fixes sparse arrays", function() + config.fixArrays = true + + for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do + assert(type(index) == "string", `expected {type(index)} {index} to be string`) + end + + config.fixArrays = false + + for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do + assert(type(index) == "number", `expected {type(index)} {index} to be number`) + end +end) + +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) + +test("removes table from mutable state", function() + local targetState = { foo = { bar = "baz" } } + patch.applyMutable(targetState, { foo = { __none = "__none" } }) + assert(targetState.foo == nil, "should remove the table") +end) + +return {} diff --git a/packages/charm-sync/test/server.test.luau b/packages/charm-sync/test/server.test.luau new file mode 100644 index 0000000..380fbb0 --- /dev/null +++ b/packages/charm-sync/test/server.test.luau @@ -0,0 +1,408 @@ +local Charm = require("../Charm") +local DeepCharm = require("../../deep-charm/src") +local config = require("../src/config") +local patch = require("../src/patch") +local server = require("../src/server") +local test = require("../../../tests/test") +local types = require("../src/types") + +test.afterEach(function() + server.reset() +end) + +test("should replicate state changes", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local c = Charm.atom(0) + local target: unknown, payloads: { types.SyncPayload } + + 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 == a(), "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'") + + a(1) + b(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 == a(), "Expected patch payload to contain atom 'a'") + assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'") +end) + +test("should replicate partial state patches", function() + local client = {} + local a = Charm.atom({ x = 0, y = 0 }) + local b = Charm.atom(0 :: number?) + local target: unknown, payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a, b = b }) + 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 atom 'a.x'") + assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + + a({ x = 1, y = 0 }) + b(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 atom 'a.x'") + assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'") + 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 = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? }) + local target: unknown, payloads: { types.SyncPayload } + + 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 ~= nil, "Expected init payload to contain value 'a.x'") + assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'") + assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'") + + a.x.y = 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.y == 1, "Expected patch payload to contain value 'a.x'") + assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None") + + a.z = 1 + 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.z == 1, "Expected patch payload to contain value 'a.z'") + assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'") +end) + +test("should handle nested proxies", function() + local client = {} + local src = DeepCharm.reactive({ (DeepCharm.reactive({})) }) + local payloads: {}? = nil + + server.addSignalsToClient(client, { src = src }) + server.connect(function(_, newPayloads) + payloads = newPayloads + end) + server.flush() + + payloads = nil + server.flush() + assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy") +end) + +test("should init new clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + + server.addSignalsToClient(clientA, { a = a }) + server.connect(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + a(1) + server.flush() + 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") + + 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") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B") +end) + +test("should init new atoms", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local target: unknown, payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(...) + target, payloads = ... + end) + + server.flush() + server.addSignalsToClient(client, { b = b }) + a(1) + b(1) + 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.b == b(), "Expected init payload to contain atom 'b'") + assert(payloads[2].type == "patch", "Expected second payload to be patch") + assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'") +end) + +test("should only replicate to synced clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + + server.addSignalsToClient(clientA, { a = a }) + server.addSignalsToClient(clientB, { b = b }) + server.connect(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") + assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B") + assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B") + + payloadsA = {} + payloadsB = {} + a(1) + 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") + assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A") + assert(#payloadsB == 0, "Expected no payloads for client B") + + payloadsA = {} + payloadsB = {} + b(1) + server.flush() + assert(#payloadsA == 0, "Expected no payloads for client A") + assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B") + assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B") + assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B") +end) + +test("should serialize state", function() + local client = {} + local a = Charm.atom({ 1, 2, 3, 4, 5 }) + local payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + server.flush() + + a({ 1, 2, 0, 4 }) + server.flush() + assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key") + assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key") +end) + +test("should not send unchanged state", function() + local client = {} + local a = Charm.atom(0) + local payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + payloads = {} + a(0) + server.flush() + assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") +end) + +test.describe("historical", function() + test.beforeEach(function() + config.preserveHistory = true + end) + + test.afterEach(function() + config.preserveHistory = false + end) + + test("should replicate granular state changes", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local c = Charm.atom(0) + local target: unknown, payloads: { types.SyncPayload } + + 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 == 0, "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'") + + a(1) + a(2) + b(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 == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'") + 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 = DeepCharm.reactive({ value = 0 }) + local b = DeepCharm.reactive({ value = 0 }) + local c = DeepCharm.reactive({ value = 0 }) + local target: unknown, payloads: { types.SyncPayload } + + 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 = {} + local a = Charm.atom(0) + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + + server.addSignalsToClient(clientA, { a = a }) + server.connect(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + a(1) + a(2) + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A") + assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A") + assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A") + + server.addSignalsToClient(clientB, { a = a }) + a(3) + a(4) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A") + assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B") + assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B") + assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B") + end) + + test("should init new atoms", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, ...) + payloads = ... + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + server.addSignalsToClient(client, { b = b }) + a(1) + a(2) + b(1) + b(2) + server.flush() + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'") + assert(payloads[3].type == "init", "Expected third payload to be init") + assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'") + assert(payloads[4].type == "patch", "Expected fourth payload to be patch") + assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'") + end) + + test("should not send unchanged state", function() + local client = {} + local a = Charm.atom(0) + local payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + payloads = {} + a(0) + server.flush() + assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") + end) +end) + +return {} diff --git a/packages/charm-sync/test/utils.test.luau b/packages/charm-sync/test/utils.test.luau new file mode 100644 index 0000000..15dcabf --- /dev/null +++ b/packages/charm-sync/test/utils.test.luau @@ -0,0 +1,22 @@ +local Charm = require("../Charm") +local test = require("../../../tests/test") +local utils = require("../src/utils") + +test("should flatten functions", function() + local input = { + a = Charm.atom(0), + b = { c = Charm.atom(0) }, + d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, + } + + local result = utils.flatten<<{ [string]: Charm.Atom }>>(input) + + assert(result.a == input.a, "should flatten the first level") + assert(result["b/c"] == input.b.c, "should flatten the second level") + assert(result["d/e/f"] == input.d.e.f, "should flatten the third level") + + assert(result.b == nil, "should remove the original nested tables") + assert(result.d == nil, "should remove the original nested tables") +end) + +return {} diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index f1a78f0..28a6fc9 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,8 +1,15 @@ [package] name = "littensy/charm-sync" -version = "0.3.0" +version = "0.4.0-rc.7" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = [ + "node_modules", + "test", + "src/index.d.ts", + "package.json", + "Charm.luau", +] [dependencies] -charm = "littensy/charm@^0.10" +Charm = "littensy/charm@^0.11.0-rc.6" diff --git a/packages/charm/default.project.json b/packages/charm/default.project.json index 89d3ada..a485e4d 100644 --- a/packages/charm/default.project.json +++ b/packages/charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "Charm", + "name": "charm", "tree": { "$path": "src" } diff --git a/packages/charm/package.json b/packages/charm/package.json index ea40fa6..28f5c7c 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,13 +1,24 @@ { "name": "@rbxts/charm", - "version": "0.10.0", - "description": "An atomic state management library for Roblox", - "repository": "littensy/charm", + "version": "0.11.0-rc.6", + "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "prepare": "cp ../../LICENSE . && cp ../../README.md .", "publish:wally": "wally publish" } } diff --git a/packages/charm/src/atom.luau b/packages/charm/src/atom.luau deleted file mode 100644 index f10dbd6..0000000 --- a/packages/charm/src/atom.luau +++ /dev/null @@ -1,42 +0,0 @@ -local store = require(script.Parent.store) -local types = require(script.Parent.types) -type Atom = types.Atom -type AtomOptions = types.AtomOptions - ---[=[ - Creates a new atom with the given state. - - @param state The initial state. - @param options Optional configuration. - @return A new atom. -]=] -local function atom(state: T, options: AtomOptions?): Atom - local equals = options and options.equals - - local function atom(...) - if select("#", ...) == 0 then - local index = store.capturing.index - - if index > 0 then - store.capturing.stack[index][atom] = true - end - - return state - end - - local nextState = store.peek(..., state) - - if state ~= nextState and not (equals and equals(state, nextState)) then - state = nextState - store.notify(atom) - end - - return state - end - - store.listeners[atom] = setmetatable({}, { __mode = "v" }) - - return atom -end - -return atom diff --git a/packages/charm/src/computed.luau b/packages/charm/src/computed.luau deleted file mode 100644 index e786c45..0000000 --- a/packages/charm/src/computed.luau +++ /dev/null @@ -1,37 +0,0 @@ -local atom = require(script.Parent.atom) -local store = require(script.Parent.store) -local types = require(script.Parent.types) -type AtomOptions = types.AtomOptions -type Selector = types.Selector - ---[=[ - Creates a read-only atom that derives its state from one or more atoms. - Used to avoid unnecessary recomputations if multiple listeners depend on - the same atoms. - - @param callback The function that produces the state. - @param options Optional configuration. - @return A new read-only atom. -]=] -local function computed(callback: Selector, options: AtomOptions?): Selector - local dependencies, state = store.capture(callback) - local computedAtom = atom(state, options) - local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" }) - - local function listener() - local computedAtom = computedRef.current - - if computedAtom then - store.disconnect(dependencies, listener) - dependencies, state = store.capture(callback) - store.connect(dependencies, listener, computedAtom) - computedAtom(state) - end - end - - store.connect(dependencies, listener, computedAtom) - - return computedAtom -end - -return computed diff --git a/packages/charm/src/effect.luau b/packages/charm/src/effect.luau deleted file mode 100644 index 23f3294..0000000 --- a/packages/charm/src/effect.luau +++ /dev/null @@ -1,53 +0,0 @@ -local store = require(script.Parent.store) - -type Cleanup = () -> () - ---[=[ - Runs the given callback immediately and whenever any atom it depends on - changes. Returns a cleanup function that unsubscribes the callback. - - @param callback The function to run. - @return A function that unsubscribes the callback. -]=] -local function effect(callback: (cleanup: Cleanup) -> Cleanup?): Cleanup - local dependencies = {} - local cleanup: Cleanup? - local disconnected = false - local disconnect - - local function listener() - if cleanup then - cleanup() - end - - store.disconnect(dependencies, listener) - dependencies, cleanup = store.capture(callback, disconnect) - - if not disconnected then - store.connect(dependencies, listener) - end - end - - function disconnect() - if disconnected then - return - end - - disconnected = true - store.disconnect(dependencies, listener) - - if cleanup then - cleanup() - end - end - - dependencies, cleanup = store.capture(callback, disconnect) - - if not disconnected then - store.connect(dependencies, listener) - end - - return disconnect -end - -return effect :: (callback: ((cleanup: Cleanup) -> ()) | ((cleanup: Cleanup) -> Cleanup?)) -> Cleanup diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 8336177..4e4ad8d 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1,187 +1,280 @@ -export = Charm; -export as namespace Charm; +type Key = string | number | symbol; type Cleanup = () => void; -declare namespace Charm { - /** - * A primitive state container that can be read from or written to. When - * the state changes, all subscribers are notified. - * - * @param state The next state or a function that produces the next state. - * @returns The current state. - */ - interface Atom extends Selector { - (nextState: State | ((prevState: State) => State)): State; - } - - /** - * A function that depends on one or more atoms and produces a value. Can be - * used to derive state from atoms. - * - * @returns The current state. - */ - type Selector = () => State; - - /** - * A function that depends on one or more atoms and produces a value. Can be - * used to derive state from atoms. - * - * @deprecated Use `Selector` instead. - * @returns The current state. - */ - type Molecule = Selector; - - /** - * Infers the type of the state produced by the given function. - */ - type StateOf = T extends Selector ? State : never; - - interface AtomOptions { - /** - * A function that determines whether the state has changed. By default, - * a strict equality check (`===`) is used. - */ - equals?: (prev: State, next: State) => boolean; - } - - /** - * Creates a new atom with the given state. - * - * @param state The initial state. - * @param options Optional configuration. - * @returns A new atom. - */ - function atom(state: State, options?: AtomOptions): Atom; - - // Overload for no arguments - function atom(state?: State, options?: AtomOptions): Atom; - - /** - * Creates a read-only atom that derives its state from one or more atoms. - * Used to avoid unnecessary recomputations if multiple listeners depend on - * the same atoms. - * - * @param callback The function that produces the state. - * @param options Optional configuration. - * @returns A new read-only atom. - */ - function computed(callback: Selector, options?: AtomOptions): Selector; - - /** - * Subscribes to changes in the given atom or selector. The callback is - * called with the current state and the previous state immediately after a - * change occurs. - * - * @param callback The atom or selector to subscribe to. - * @param listener The function to call when the state changes. - * @returns A function that unsubscribes the callback. - */ - function subscribe(callback: Selector, listener: (state: State, prev: State) => void): Cleanup; - - /** - * Runs the given callback immediately and whenever any atom it depends on - * changes. Returns a cleanup function that unsubscribes the callback. - * - * @param callback The function to run. - * @returns A function that unsubscribes the callback. - */ - function effect(callback: (cleanup: Cleanup) => Cleanup | void): Cleanup; - - /** - * Returns the result of the function without subscribing to changes. If a - * non-function value is provided, it is returned as is. - * - * @param callback The atom or selector to get the state of. - * @param args Arguments to pass to the function. - * @returns The current state. - */ - function peek(callback: State | ((...args: Args) => State), ...args: Args): State; - - /** - * Returns whether the given value is an atom. - * - * @param value The value to check. - * @returns `true` if the value is an atom, otherwise `false`. - */ - function isAtom(value: unknown): value is Atom; - - /** - * Runs the given function and schedules listeners to be notified only once - * after the function has completed. Useful for batching multiple changes. - * - * @param callback The function to run. - */ - function batch(callback: () => void): void; - - /** - * Captures all atoms that are read during the function call and returns them - * along with the result of the function. Useful for tracking dependencies. - * - * @param callback The function to run. - * @returns A tuple containing the captured atoms and the result of the function. - */ - function capture(callback: Selector): LuaTuple<[dependencies: Set>, state: State]>; - - /** - * Notifies all subscribers of the given atom that the state has changed. - * Mainly for running effects, since subscriptions check for equality. - * - * @param atom The atom to notify. - */ - function notify(atom: Atom): void; - - /** - * Creates an instance of `factory` for each item in the atom's state, and - * cleans up the instance when the item is removed. Returns a cleanup function - * that unsubscribes all instances. - * - * @param callback The atom or selector to observe. - * @param factory The function that tracks the lifecycle of each item. - * @returns A function that unsubscribes all instances. - */ - function observe( - callback: Selector, - factory: (item: Item, index: number) => Cleanup | void, - ): Cleanup; - - function observe( - callback: Selector | ReadonlyMap>, - factory: (item: Item, key: Key) => Cleanup | void, - ): Cleanup; - - function observe( - callback: Selector<{ readonly [P in Key]: Item }>, - factory: (item: Item, key: Key) => Cleanup | void, - ): Cleanup; - - /** - * Maps each entry in the atom's state to a new key-value pair. If the `mapper` - * function returns `undefined`, the entry is omitted from the resulting map. - * When the atom changes, the `mapper` is called for each entry in the state - * to compute the new state. - * - * @param callback The atom or selector to map. - * @param mapper The function that maps each entry. - * @returns A new atom with the mapped state. - */ - function mapped( - callback: Selector, - mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>, - ): Selector>; - - function mapped( - callback: Selector, - mapper: (value: V0, index: number) => V1, - ): Selector; - - function mapped( - callback: Selector | ReadonlyMap>, - mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, - ): Selector>; - - function mapped( - callback: Selector<{ readonly [P in K0]: V0 }>, - mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, - ): Selector>; +export enum ReactiveFlags { + None = 0b000000, + Mutable = 0b000001, + Watching = 0b000010, + RecursedCheck = 0b000100, + Recursed = 0b001000, + Dirty = 0b010000, + Pending = 0b100000, } + +export interface ReactiveNode { + deps?: Link; + depsTail?: Link; + subs?: Link; + subsTail?: Link; + flags: ReactiveFlags; +} + +export interface Link { + version: number; + dep: ReactiveNode; + sub: ReactiveNode; + prevSub?: Link; + nextSub?: Link; + prevDep?: Link; + nextDep?: Link; +} + +export interface Atom { + (update: Update): T; + (): T; +} + +export type Getter = () => T; + +export type Setter = (update: Update) => T; + +export type Update = ((currentValue: T) => T) | T; + +export type Equals = (current: T, incoming: T) => boolean; + +/** + * Global flags that modify the behavior of Charm's reactive system. + */ +export const 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. + */ + strict: boolean; + /** + * Enforces immutability of tables by deep-freezing table values. Enabled + * if the optimization level is below 2, which is true in studio. + */ + frozen: boolean; + /** + * 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: boolean; +}; + +/** + * 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. + * @returns A tuple containing the getter and setter functions for the signal. + * @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals + */ +export function signal(initialValue: T, equals?: Equals): LuaTuple<[getter: Getter, setter: Setter]>; +export function signal(): LuaTuple<[getter: Getter, setter: Setter]>; + +/** + * 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 + */ +export function atom(initialValue: T, equals?: Equals): Atom; +export function atom(): Atom; + +/** + * 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 + */ +export function computed(getter: (previousValue?: T) => T): Getter; + +/** + * 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. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback + */ +export function effect(fn: () => Cleanup | void): Cleanup; + +/** + * 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. + * @returns A function for disposing all effects created within the scope. + * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached + */ +export function effectScope(fn: () => Cleanup | void, detached?: boolean): Cleanup; + +/** + * 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 + */ +export function trigger(fn: () => void): void; + +/** + * 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 + */ +export function onCleanup(fn: Cleanup, failSilently?: boolean): void; + +/** + * Runs the function without subscribing to signal updates and prevents the + * current effect or scope from tracking inner effects created within the + * function. + * + * To avoid tracking signals while still allowing inner effects to be tracked + * by parent effects/scopes, use `effectScope()` instead. + * + * @param fn A function that may read signals or create effects. + * @param args Arguments to pass to the function. + * @returns The return value of the function. + * @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback + */ +export function untracked(fn: (...args: Args) => Result, ...args: Args): Result; + +/** + * Combines multiple updates into a single "commit" that runs effects and + * computed signals after the provided function finishes running. + * + * @param fn A function that performs multiple updates. + * @param args Arguments to pass to the function. + * @returns The return value of the function. + * @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback + */ +export function batch(fn: (...args: Args) => Result, ...args: Args): Result; + +/** + * 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. + * + * @param getter A function that returns the value to watch. + * @param callback The function to run when the value changes. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback + */ +export function listen(getter: Getter, callback: (value: T, previousValue: T | undefined) => void): Cleanup; + +/** + * 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. + * + * @param getter A function that returns the value to watch. + * @param callback The function to run when the value changes. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback + */ +export function subscribe(getter: Getter, callback: (value: T, previousValue: T) => void): Cleanup; + +/** + * Creates a new read-only signal that is computed by mapping over the entries + * of the source table. If the key is omitted from the transform function's + * return value, the original key is preserved. + * + * This function is optimized to minimize unnecessary updates by only calling + * the transform function for entries that have changed since the last run. + * + * @param getter A signal or function that returns a table + * @param transform Function that maps each entry from the source to a new entry in the mapped table + * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform + */ +export function mapped( + getter: Getter | ReadonlyMap>, + transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): Getter>; +// Overload for arrays +export function mapped( + getter: Getter, + transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, +): Getter; +// Overload for objects +export function mapped( + getter: Getter<{ readonly [K in KI]: VI }>, + transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): Getter<{ readonly [K in KO]: VO }>; + +/** + * 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 when the entire callback is disposed. + * + * Effects created within the callback are cleaned up when the item is removed + * from the table or when the callback is disposed. + * + * @param getter A function that returns the table to observe. + * @param callback A function that is called for each key in the table. + * @returns A function for disposing the observer. + * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback + */ +export function observe( + getter: Getter | ReadonlyMap | readonly V[]>, + callback: (value: V, key: K) => Cleanup | void, +): Cleanup; +// Overload for objects +export function observe( + getter: Getter<{ readonly [K in Key]: V }>, + callback: (value: V, key: K) => Cleanup | void, +): Cleanup; + +/** + * Returns the currently active subscriber (effect or computed node). + * + * @return Current active subscriber or `nil` + */ +export function getActiveSub(): ReactiveNode | undefined; + +/** + * Sets the currently active subscriber (effect or computed node) and returns + * the previous subscriber. + * + * @param sub New subscriber to set as active + * @return Previous active subscriber + */ +export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; + +/** + * Starts a batch operation, incrementing the batch depth counter. Effects + * will not run until the outermost batch ends. + */ +export function startBatch(): void; + +/** + * Ends a batch operation, decrementing the batch depth counter. If this is + * the outermost batch, queued effects are flushed and run. + */ +export function endBatch(): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 4bc1a8c..d47ede5 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,27 +1,1000 @@ -local atom = require(script.atom) -local computed = require(script.computed) -local effect = require(script.effect) -local mapped = require(script.mapped) -local observe = require(script.observe) -local store = require(script.store) -local subscribe = require(script.subscribe) -local types = require(script.types) +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/system.ts +]] -export type Atom = types.Atom -export type Selector = types.Selector -export type Molecule = types.Selector -export type SyncPayload = types.SyncPayload +local system = require("@self/system") + +export type Link = system.Link +export type ReactiveFlags = system.ReactiveFlags +export type ReactiveNode = system.ReactiveNode + +local link = system.link +local unlink = system.unlink +local propagate = system.propagate +local checkDirty = system.checkDirty +local shallowPropagate = system.shallowPropagate +local createReactiveSystem = system.createReactiveSystem + +export type Getter = () -> T +export type Setter = (update: Update) -> T +export type Atom = (...Update) -> T +export type Update = ((current: T) -> T) | T +export type Equals = (current: T, incoming: T) -> boolean +export type Cleanup = () -> () + +type EffectScopeNode = ReactiveNode & { + cleanups: { Cleanup }, +} + +type EffectNode = ReactiveNode & { + fn: () -> ...Cleanup?, + cleanups: { Cleanup }, +} + +type ComputedNode = ReactiveNode & { + value: T?, + getter: (previousValue: T?) -> T, +} + +type SignalNode = ReactiveNode & { + currentValue: T, + pendingValue: T, +} + +-- Bitmask flags for tracking the state of reactive nodes. Flags are combined +-- and checked using bitwise operations for efficiency. +local NONE = 0b000000 -- Default state +local MUTABLE = 0b000001 -- Can be written to (signal or computed) +local WATCHING = 0b000010 -- Notified for changes in dependencies (effects) +local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies +local RECURSED = 0b001000 -- Has been visited in a recursion check +local DIRTY = 0b010000 -- Value is outdated and needs recomputation +local PENDING = 0b100000 -- Might be dirty, pending verification + +-- Global version counter for dependency link deduplication. Incremented at the +-- start of each effect/computed run to ensure links created in the same cycle +-- are deduplicated. +local cycle = 0 +-- Effects runs are queued to prevent redundant executions when multiple +-- signals update in the same batch. +local queued: { EffectNode } = {} +local queuedLength = 0 +local notifyIndex = 0 +-- Batch operations group multiple updates together, deferring effect runs +-- until the batch completes. Effects only run when the outermost batch ends. +local batchDepth = 0 +-- Tracks the currently active effect or computed signal during execution. +-- Dependencies accessed during this time will link to the active subscriber. +local activeSub: ReactiveNode? + +local function isO2(): boolean + return debug.info(1, "n") ~= "isO2" +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. + ]] + strict = not isO2(), + --[[ + Enforces immutability of tables by deep-freezing table values. Enabled + if the optimization level is below 2, which is true in studio. + ]] + frozen = not isO2(), + --[[ + 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, +} + +--[[ + Wraps a user-created function to prevent yielding when in strict mode. To + shorten error stack traces, only functions created outside of this module + are wrapped. +]] +local function wrapUserSpace(callback: (Args...) -> Results...): (Args...) -> Results... + if not flags.strict then + return callback + end + + -- If the sources match, the function was created in this module + if debug.info(1, "s") == debug.info(callback, "s") then + return callback + end + + local handler = function(thread: thread, ok: boolean, ...: Results...): Results... + if not ok then + local err = (...) + if type(err) == "string" then + error(debug.traceback(thread, err), 0) + else + error(tostring(err), 0) + end + elseif coroutine.status(thread) ~= "dead" then + error(debug.traceback(thread, "Attempted to yield in an effect or scope"), 0) + else + return ... + end + end + + return function(...: Args...): Results... + local thread = coroutine.create(callback) + return handler(thread, coroutine.resume(thread, ...)) + end +end + +--[[ + Recursively freezes a table and all nested tables to enforce immutability. + Tables with metatables are skipped, as they are assumed to be mutable. +]] +local function deepFreeze(value: any) + if type(value) == "table" and not table.isfrozen(value) and getmetatable(value) == nil then + table.freeze(value) + for _, entry in value do + deepFreeze(entry) + end + end +end + +--[[ + Returns the currently active subscriber (effect or computed node). +]] +local function getActiveSub(): ReactiveNode? + return activeSub +end + +--[[ + Sets the currently active subscriber (effect or computed node) and returns + the previous subscriber. +]] +local function setActiveSub(sub: ReactiveNode?) + local prevSub = activeSub + activeSub = sub + return prevSub +end + +--[[ + Removes dependencies from the subscriber's dependency list. When called + after tracking is complete, this ensures that only currently tracked + dependencies remain linked. +]] +local function purgeDeps(sub: ReactiveNode) + local depsTail = sub.depsTail + local dep = if depsTail then depsTail.nextDep else sub.deps + while dep do + dep = unlink(dep, sub) + end +end + +--[[ + Runs all cleanup functions registered with the effect node. Any errors + thrown during cleanup are caught and logged in a single error after all + cleanups are complete. +]] +local function runCleanups(cleanups: { Cleanup }) + local hasErrors = false + local errors: { string } = {} + + for index, cleanup in cleanups do + cleanups[index] = nil + + local prevSub = setActiveSub(nil) + local success, err = pcall(cleanup) + activeSub = prevSub + + if not success then + hasErrors = true + table.insert(errors, tostring(err)) + end + end + + if hasErrors then + error(`Errors occurred during effect cleanup:\n\n{table.concat(errors, "\n\n")}`, 0) + end +end + +--[[ + Checks if the effect node needs to run based on its flags and dependencies. + Effects run if they are marked dirty or pending with dirty dependencies. +]] +local function run(effect: EffectNode) + local effectFlags = effect.flags + + if + bit32.btest(effectFlags, DIRTY) + or (bit32.btest(effectFlags, PENDING) and checkDirty(effect.deps :: Link, effect)) + then + runCleanups(effect.cleanups) + + -- Cancel the run if the effect was disposed during cleanup + if effect.flags == NONE then + return + end + + cycle += 1 + effect.depsTail = nil + effect.flags = WATCHING + + local prevSub = setActiveSub(effect) + local success, result = pcall(effect.fn) + activeSub = prevSub + + purgeDeps(effect) + + if success and result then + table.insert(effect.cleanups, wrapUserSpace(result)) + elseif not success then + error(result, 0) + end + + -- If the effect was disposed during execution but before a cleanup was + -- was registered, run the cleanups that would have been missed + if effect.flags == NONE then + runCleanups(effect.cleanups) + end + else + effect.flags = WATCHING + end +end + +--[[ + Processes the queued effect runs. Effects are run in the order they were + queued, ensuring that all updates are processed. +]] +local function flush() + -- Bail out early if a flush is already in progress to allow effects to + -- complete before the next effect runs + if batchDepth ~= 0 or notifyIndex ~= 0 then + return + end + + local success, err = pcall(function() + while notifyIndex < queuedLength do + notifyIndex += 1 + local effect = queued[notifyIndex] :: EffectNode + queued[notifyIndex] = nil + run(effect) + end + end) + + -- Skip over any remaining queued effects that were not run due to an error + while notifyIndex < queuedLength do + notifyIndex += 1 + local effect = queued[notifyIndex] :: EffectNode + queued[notifyIndex] = nil + effect.flags = bit32.bor(effect.flags, bit32.bor(WATCHING, RECURSED)) + end + + notifyIndex = 0 + queuedLength = 0 + + if not success then + error(err, 0) + end +end + +--[[ + Starts a batch operation, incrementing the batch depth counter. Effects + will not run until the outermost batch ends. +]] +local function startBatch() + batchDepth += 1 +end + +--[[ + Ends a batch operation, decrementing the batch depth counter. If this is + the outermost batch, queued effects are flushed and run. +]] +local function endBatch() + batchDepth -= 1 + flush() +end + +--[[ + Updates a computed signal by re-evaluating its getter function. Returns + whether the computed value changed. +]] +local function updateComputed(computed: ComputedNode): boolean + cycle += 1 + computed.depsTail = nil + computed.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + + local prevSub = setActiveSub(computed) + local oldValue = computed.value + local success, newValue = pcall(computed.getter, oldValue) + activeSub = prevSub + + computed.flags = bit32.band(computed.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(computed) + + if success then + computed.value = newValue + return oldValue ~= newValue + else + error(newValue, 0) + end +end + +--[[ + Updates a signal by applying its pending value. Returns whether the + signal's value changed. +]] +local function updateSignal(signal: SignalNode): boolean + signal.flags = MUTABLE + local pendingValue = signal.pendingValue + if signal.currentValue ~= pendingValue then + signal.currentValue = pendingValue + return true + else + return false + end +end + +--[[ + Clears all dependencies and subscribers from the effect node. After calling + this, the node will no longer be subscribed to any dependencies and will be + inactive, with all cleanup functions run and resources released. +]] +local function stopEffect(effect: EffectScopeNode) + effect.depsTail = nil + effect.flags = NONE + + purgeDeps(effect) + + -- Unlink from the parent effect, if it exists + local sub = effect.subs + if sub then + unlink(sub, effect) + end + + runCleanups(effect.cleanups) +end + +--[[ + Updates a signal or computed value and returns whether the value changed. +]] +local function update(node: ReactiveNode): boolean + if node.depsTail then + return updateComputed(node :: ComputedNode) + else + return updateSignal(node :: SignalNode) + end +end + +--[[ + Queues an effect for execution, ensuring inner effects are notified in the + correct order. +]] +local function notify(effect: ReactiveNode) + local startIndex = queuedLength + 1 + + -- Collect inner effects in a chain + repeat + queuedLength += 1 + queued[queuedLength] = effect :: EffectNode + + -- Mark as not watching to prevent duplicates in the same flush + effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) + + -- Traverse to the parent effect, if it exists and is not queued, to + -- ensure it gets notified before the child effect + effect = (effect.subs and effect.subs.sub) :: EffectNode + + if not effect or bit32.band(effect.flags, WATCHING) == 0 then + break + end + until false + + local endIndex = queuedLength + + -- Reverse the collected effects since they were collected from child to + -- parent, but should be notified from parent to child + while startIndex < endIndex do + queued[startIndex], queued[endIndex] = queued[endIndex], queued[startIndex] + startIndex += 1 + endIndex -= 1 + end +end + +--[[ + Called when a node is not being watched by any subscribers. Cleans up the + node's dependencies and performs the necessary cleanup operations: + + - Computed signals: Unlink dependencies and mark as dirty for potential + future updates + - Effects/scopes: Perform full cleanup to free resources +]] +local function unwatched(node: ReactiveNode) + if bit32.band(node.flags, MUTABLE) == 0 then + stopEffect(node :: EffectScopeNode) + elseif node.depsTail then + node.depsTail = nil + node.flags = bit32.bor(MUTABLE, DIRTY) + purgeDeps(node) + 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 Initial value for the signal + @param equals Optional comparator function to check for value changes + @return Getter function to read the signal value + @return Setter function to update the signal value + @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals +]] +local function signal(initialValue: T, equals: Equals?): (Getter, Setter) + local node: SignalNode = { + currentValue = initialValue, + pendingValue = initialValue, + flags = MUTABLE, + } + + if flags.frozen then + deepFreeze(initialValue) + end + + local function signalGetter(): T + -- Check if the signal has a pending update that needs to be applied + if bit32.btest(node.flags, DIRTY) then + if updateSignal(node) then + local subs = node.subs + if subs then + shallowPropagate(subs) + end + end + end + + -- Register this signal as a dependency of the first subscriber in the + -- chain that is a computed signal or an effect + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(node, sub, cycle) + break + end + local subs = sub.subs + sub = if subs then subs.sub else nil + end + + return node.currentValue + end + + local function signalSetter(update: Update): T + local value: T + + -- Determine the new value based on whether the update is a function or + -- a direct value + if type(update) == "function" then + local prevSub = setActiveSub(nil) + local success, result = pcall(wrapUserSpace(update), node.pendingValue) + activeSub = prevSub + + if success then + value = result + else + error(result, 2) + end + else + value = update + end + + -- Check if the new value is different from the pending value + if if equals then not equals(node.pendingValue, value) else node.pendingValue ~= value then + if flags.frozen then + deepFreeze(value) + end + + node.pendingValue = value + node.flags = bit32.bor(MUTABLE, DIRTY) + + local subs = node.subs + if subs then + propagate(subs) + flush() + end + end + + return node.pendingValue + end + + return signalGetter, signalSetter +end + +--[[ + Creates a signal and returns a function that can act as both the getter and + setter. Calling the atom with one argument sets its value, while calling it + with no arguments returns its current value. + + @param initialValue Initial value for the atom + @param equals Optional comparator function to check for value changes + @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals +]] +local function atom(initialValue: T, equals: Equals?): Atom + local get, set = signal(initialValue, equals) + + local function atomOper(...) + return if select("#", ...) == 0 then get() else set((...)) + end + + 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 Function that calculates the next value + @return A function that returns the cached value + @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter +]] +local function computed(getter: (previousValue: T?) -> T): Getter + local node: ComputedNode = { + flags = NONE, + getter = wrapUserSpace(getter), + } + + local function computedOper(): T + local nodeFlags = node.flags + + if bit32.btest(nodeFlags, DIRTY) then + if updateComputed(node) then + local subs = node.subs + if subs then + shallowPropagate(subs) + end + end + elseif bit32.btest(nodeFlags, PENDING) then + if checkDirty(node.deps :: Link, node) then + if updateComputed(node) then + local subs = node.subs + if subs then + shallowPropagate(subs) + end + end + else + node.flags = bit32.band(nodeFlags, bit32.bnot(PENDING)) + end + elseif nodeFlags == NONE then + -- First access should initialize the computed value + node.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + + local prevSub = setActiveSub(node) + local success, result = pcall(node.getter) + activeSub = prevSub + + node.flags = bit32.band(node.flags, bit32.bnot(RECURSED_CHECK)) + + if success then + node.value = result + else + error(result, 2) + end + end + + if activeSub then + link(node, activeSub, cycle) + end + + return node.value + end + + return computedOper +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 Function to execute reactively + @return A cleanup function that stops the effect + @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback +]] +local function effect(fn: () -> ...Cleanup?): Cleanup + local node: EffectNode = { + fn = wrapUserSpace(fn), + flags = WATCHING, + cleanups = {}, + } + + -- Inner effects are tracked as a dependency of the parent effect, so that + -- they can be automatically cleaned up when the parent effect re-runs, and + -- to ensure the correct execution order when nested effects are notified + if activeSub and (flags.trackInnerEffects or activeSub.flags == NONE) then + link(node, activeSub, 0) + end + + -- Start a batch so that the effect can register a cleanup function before + -- it can receive notifications, ensuring the cleanup always runs + startBatch() + + local prevSub = setActiveSub(node) + local success, result = pcall(node.fn) + activeSub = prevSub + + if success and result then + table.insert(node.cleanups, wrapUserSpace(result)) + elseif not success then + endBatch() + error(result, 2) + end + + endBatch() + + return function() + stopEffect(node) + end +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 Function that creates effects within the scope + @param detached If true, the scope will not be tracked by the parent effect or scope + @return A cleanup function that stops all effects in the scope + @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached +]] +local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup + local node: EffectScopeNode = { + flags = NONE, + cleanups = {}, + } + + -- Inner effect scopes are tracked as a dependency of the parent effect + if activeSub and not detached then + link(node, activeSub, 0) + end + + local prevSub = setActiveSub(node) + local success, result = pcall(wrapUserSpace(fn)) + activeSub = prevSub + + if success and result then + table.insert(node.cleanups, wrapUserSpace(result)) + elseif not success then + error(result, 2) + end + + return function() + stopEffect(node) + end +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 A signal or function that accesses signals + @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback +]] +local function trigger(fn: () -> ...any) + local sub: ReactiveNode = { + flags = WATCHING, + } + + local prevSub = setActiveSub(sub) + local success, err = pcall(wrapUserSpace(fn)) + activeSub = prevSub + sub.flags = NONE + + local currentLink = sub.deps + while currentLink do + local dep = currentLink.dep + local subs = dep.subs + currentLink = unlink(currentLink, sub) + if subs then + propagate(subs) + shallowPropagate(subs) + end + end + + flush() + + if not success then + error(err, 2) + 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 Function to run during cleanup + @param failSilently If true, silences the warning when called outside an effect or scope + @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently +]] +local function onCleanup(fn: Cleanup, failSilently: boolean?) + if activeSub and (activeSub :: EffectNode).cleanups then + table.insert((activeSub :: EffectNode).cleanups, wrapUserSpace(fn)) + elseif not failSilently then + warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) + end +end + +--[[ + Runs the function without subscribing to signal updates and prevents the + current effect or scope from tracking inner effects created within the + function. + + To avoid tracking signals while still allowing inner effects to be tracked + by parent effects/scopes, use `effectScope()` instead. + + @param fn A signal or function that accesses signals + @param ...args Arguments to pass to the function + @return Values returned by 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) + local results: { any } = { pcall(wrapUserSpace(fn), ...) } + activeSub = prevSub + + if not results[1] then + error(results[2], 2) + end + + return unpack(results, 2) + else + return fn(...) + end +end + +--[[ + Combines multiple updates into a single "commit" that runs effects and + computed signals after the provided callback finishes running. + + @param fn Function that updates signals + @param ...args Arguments to pass to the function + @return Values returned by 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(wrapUserSpace(fn), ...) } + endBatch() + + if not results[1] then + error(results[2], 2) + end + + return unpack(results, 2) +end + +--[[ + Returns true if the given function is a signal getter, computed signal, or + atom function. Used for determining whether a function should be wrapped in + a computed signal so that it can be memoized in `listen()` and `subscribe()`. +]] +local function isSignal(fn: Getter): boolean + local name = debug.info(fn, "n") + return name == "signalGetter" or name == "computedOper" or name == "atomOper" +end + +--[[ + Creates an effect that runs the callback once immediately, and then again + when the value returned by the getter changes. The callback receives the + new value and the previous value as arguments. + + @param getter A signal or function that accesses signals + @param callback Function to run when the value changes + @return A cleanup function that stops the effect + @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +]] +local function listen(getter: Getter, callback: (state: T, lastState: T?) -> ()): Cleanup + local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) + local current: T + + return effect(function() + local previous = current + current = tracked() + untracked(callback, current, previous) + 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. + + @param getter A signal or function that accesses signals + @param callback Function to run when the value changes + @return A cleanup function that stops the effect + @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback +]] +local function subscribe(getter: Getter, callback: (state: T, lastState: T) -> ()): Cleanup + local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) + local current: T + local firstRun = true + + return effect(function() + local previous = current + current = tracked() + if firstRun then + firstRun = false + else + untracked(callback, current, previous) + end + 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 signal or function that returns a table + @param callback Function that is called for each key added to the table + @return A cleanup function that disposes scopes for all keys + @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback +]] +local function observe(getter: Getter<{ [K]: V }>, callback: (item: V, key: K) -> ...Cleanup?): Cleanup + getter = wrapUserSpace(getter) + callback = wrapUserSpace(callback) + + local scopes: { [K]: Cleanup } = {} + local stopObserving = false + + local function updateScopes(data: { [K]: V }) + for key, dispose in scopes do + if data[key] == nil then + scopes[key] = nil + dispose() + if stopObserving then + return + end + end + end + + for key, value in data do + if scopes[key] == nil then + local dispose = effectScope(function() + return callback(value, key) + end, true) + if stopObserving then + dispose() + return + end + scopes[key] = dispose + end + end + end + + return effectScope(function() + effect(function() + -- Wrap in untracked so that calling fn inside updateScopes does + -- not register dependencies to this effect + untracked(updateScopes, getter()) + end) + + return function() + stopObserving = true + for key, dispose in scopes do + scopes[key] = nil + dispose() + end + end + end) +end + +--[[ + Creates a new read-only signal that is computed by mapping over the entries + of the source table. If the key is omitted from the transform function's + return value, the original key is preserved. + + This function is optimized to minimize unnecessary updates by only calling + the transform function for entries that have changed since the last run. + + @param getter A signal or function that returns a table + @param transform Function that maps each entry from the source to a new entry in the mapped table + @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform +]] +local function mapped(getter: Getter<{ [KI]: VI }>, transform: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> + getter = wrapUserSpace(getter) + transform = wrapUserSpace(transform) + + local nextInput: { [KI]: VI } = {} + local keyMap: { [KI]: KO } = {} + + return computed(function(lastOutput: { [KO]: VO }?): { [KO]: VO } + local nextOutput: { [KO]: VO } = lastOutput or {} + local prevInput = nextInput + nextInput = getter() + + -- For keys that were removed from the input, remove the corresponding + -- keys from the output. + for keyIn in prevInput do + if nextInput[keyIn] ~= nil then + continue + end + + local keyOut = keyMap[keyIn] + + if keyOut ~= nil then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) + end + nextOutput[keyOut] = nil + keyMap[keyIn] = nil + end + end + + -- For keys that are new or changed, pass it to the mapper and update + -- the output. If the mapper returns a new key that is different from + -- the previous call, the old mapped key should be removed from the + -- output. + for keyIn, nextValueIn in nextInput do + if nextValueIn == prevInput[keyIn] then + continue + end + + local nextValueOut, keyOutOrNil = transform(nextValueIn, keyIn) + local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn + local prevKeyOut = keyMap[keyIn] + + if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) + end + nextOutput[prevKeyOut] = nil + nextOutput[nextKeyOut] = nextValueOut + keyMap[keyIn] = nextKeyOut + elseif nextOutput[nextKeyOut] ~= nextValueOut then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) + end + nextOutput[nextKeyOut] = nextValueOut + keyMap[keyIn] = nextKeyOut + end + end + + return nextOutput + end) +end + +createReactiveSystem(update, notify, unwatched) return { + ReactiveFlags = system.ReactiveFlags, + flags = flags, atom = atom, + signal = signal, computed = computed, effect = effect, - mapped = mapped, - observe = observe, + effectScope = effectScope, + trigger = trigger, + untracked = untracked, + batch = batch, subscribe = subscribe, - batch = store.batch, - capture = store.capture, - isAtom = store.isAtom, - notify = store.notify, - peek = store.peek, + listen = listen, + observe = observe, + mapped = mapped :: (( + getter: Getter<{ [KI]: VI }>, + transform: (VI, KI) -> (VO, KO) + ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, transform: (VI, K) -> VO) -> Getter<{ [K]: VO }>), + onCleanup = onCleanup, + getActiveSub = getActiveSub, + setActiveSub = setActiveSub, + startBatch = startBatch, + endBatch = endBatch, } diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau deleted file mode 100644 index 555aa3f..0000000 --- a/packages/charm/src/mapped.luau +++ /dev/null @@ -1,71 +0,0 @@ -local atom = require(script.Parent.atom) -local store = require(script.Parent.store) -local subscribe = require(script.Parent.subscribe) -local types = require(script.Parent.types) -type Atom = types.Atom -type Selector = types.Selector - -type Map = - ((fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Selector<{ [K1]: V1 }>) - & ((fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Selector<{ [K0]: V1 }>) - & ((fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Selector<{ [K1]: V1 }>) - ---[=[ - Maps each entry in the atom's state to a new key-value pair. If the `mapper` - function returns `undefined`, the entry is omitted from the resulting map. - When the atom changes, the `mapper` is called for each entry in the state - to compute the new state. - - @param callback The atom or selector to map. - @param mapper The function that maps each entry. - @return A new atom with the mapped state. -]=] -local function mapped(callback: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Selector<{ [K1]: V1 }> - local mappedAtom = atom({}) - local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" }) - local prevMappedItems: { [K1]: V1 } = {} - local unsubscribe - - local function listener(items: { [K0]: V0 }) - local mappedAtom = mappedAtomRef.current - - if not mappedAtom then - return unsubscribe() - end - - local mappedItems = table.clone(mappedAtom()) - local mappedKeys = {} - - -- TODO: Only call mapper if the item has changed. - for key, item in next, items do - local newItem, newKey = mapper(item, key) - if newKey == nil then - newKey = key :: any - end - if mappedItems[newKey :: K1] ~= newItem then - mappedItems[newKey :: K1] = newItem :: V1 - else - mappedKeys[newKey] = key - end - end - - for key in next, prevMappedItems do - if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then - mappedItems[key] = nil - end - end - - prevMappedItems = mappedItems - mappedAtom(mappedItems) - end - - unsubscribe = subscribe(callback, listener) - - store.peek(function(): () - listener(callback()) - end) - - return mappedAtom -end - -return mapped :: Map diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau deleted file mode 100644 index 120ae3b..0000000 --- a/packages/charm/src/observe.luau +++ /dev/null @@ -1,50 +0,0 @@ -local store = require(script.Parent.store) -local subscribe = require(script.Parent.subscribe) -local types = require(script.Parent.types) -type Selector = types.Selector - -local function noop() end - ---[=[ - Creates an instance of `factory` for each item in the atom's state, and - cleans up the instance when the item is removed. Returns a cleanup function - that unsubscribes all instances. - - @param callback The atom or selector to observe. - @param factory The function that tracks the lifecycle of each item. - @return A function that unsubscribes all instances. -]=] -local function observe(callback: Selector<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> () - local connections: { [K]: () -> () } = {} - - local function listener(state: { [K]: V }) - for key, disconnect in next, connections do - if state[key] == nil then - connections[key] = nil - disconnect() - end - end - - for key, value in next, state do - if not connections[key] then - connections[key] = factory(value, key) or noop - end - end - end - - local unsubscribe = subscribe(callback, listener) - - store.peek(function(): () - listener(callback()) - end) - - return function() - unsubscribe() - for _, disconnect in next, connections do - disconnect() - end - table.clear(connections) - end -end - -return observe diff --git a/packages/charm/src/store.luau b/packages/charm/src/store.luau deleted file mode 100644 index fbffa90..0000000 --- a/packages/charm/src/store.luau +++ /dev/null @@ -1,214 +0,0 @@ -local types = require(script.Parent.types) -type Atom = types.Atom -type Set = types.Set -type WeakMap = types.WeakMap - -local __DEV__ = _G.__DEV__ - -local listeners: WeakMap, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" }) - -local batched: Set<() -> ()> = {} -local batching = false - -local capturing = { - stack = {} :: { Set> }, - index = 0, -} - ---[=[ - Calls the given function and returns the result. If the function yields or - throws an error, the thread is closed and an error is thrown. Regardless of - the outcome, the `finally` function is called to clean up any resources. - - @param callback The function to run. - @param finally Cleanup logic to run before error handling. - @param ... Arguments to pass to the callback. - @return The result of the callback. -]=] -local function try(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T - if __DEV__ then - local thread = coroutine.create(callback) - local success, result = coroutine.resume(thread, ...) - - if finally then - finally() - end - - if coroutine.status(thread) == "suspended" then - local source, line, name = debug.info(callback, "sln") - - coroutine.close(thread) - - error( - "Yielding is not allowed in atom functions. Consider wrapping this code in a Promise or task.defer instead." - .. `\nFunction defined at: {source}:{line}` - .. if name == "" then "" else ` function {name}` - ) - elseif not success then - local source, line, name = debug.info(callback, "sln") - - error( - "An error occurred while running an atom function" - .. `\nFunction defined at: {source}:{line}` - .. (if name == "" then "" else ` function {name}`) - .. `\nError: {result}` - ) - end - - return result - end - - if not finally then - return callback(...) - end - - local success, result = pcall(callback, ...) - - finally() - assert(success, result) - - return result -end - ---[=[ - Returns whether the given value is an atom. - - @param value The value to check. - @return `true` if the value is an atom, otherwise `false`. -]=] -local function isAtom(value: any): boolean - return not not (value and listeners[value]) -end - ---[=[ - Notifies all subscribers of the given atom that the state has changed. - - @param atom The atom to notify. -]=] -local function notify(atom: Atom) - if batching then - for listener in next, listeners[atom] do - batched[listener] = true - end - return - end - - for listener in next, table.clone(listeners[atom]) do - try(listener) - end -end - ---[=[ - Returns the result of the function without subscribing to changes. If a - non-function value is provided, it is returned as is. - - @param callback The atom or selector to get the state of. - @param args Arguments to pass to the function. - @return The current state. -]=] -local function peek(callback: ((U...) -> T) | T, ...: U...): T - if type(callback) ~= "function" then - return callback - end - - if capturing.index == 0 then - return callback(...) - end - - capturing.index += 1 - capturing.stack[capturing.index] = {} - - local result = try(callback, function() - capturing.stack[capturing.index] = nil - capturing.index -= 1 - end, ...) - - return result -end - ---[=[ - Captures all atoms that are read during the function call and returns them - along with the result of the function. Useful for tracking dependencies. - - @param callback The function to run. - @return A tuple containing the captured atoms and the result of the function. -]=] -local function capture(callback: (U...) -> T, ...: U...): (Set>, T) - -- If the callback is an atom, return it immediately - if listeners[callback :: any] then - return { [callback :: any] = true }, peek(callback) - end - - local dependencies: Set> = {} - - capturing.index += 1 - capturing.stack[capturing.index] = dependencies - - local result = try(callback, function() - capturing.stack[capturing.index] = nil - capturing.index -= 1 - end, ...) - - return dependencies, result -end - ---[=[ - Runs the given function and schedules listeners to be notified only once - after the function has completed. Useful for batching multiple changes. - - @param callback The function to run. -]=] -local function batch(callback: () -> ()) - if batching then - return callback() - end - - batching = true - - try(callback, function() - batching = false - end) - - for listener in next, batched do - try(listener) - end - - table.clear(batched) -end - ---[=[ - Subscribes the listener to the changes of the given atoms. - - @param atoms The atoms to listen to. - @param listener The function to call when the atoms change. - @param ref Optionally bind the lifetime of the listener to a value. -]=] -local function connect(atoms: Set>, listener: () -> (), ref: unknown?) - for atom in next, atoms do - listeners[atom][listener] = ref or true - end -end - ---[=[ - Unsubscribes the listener from every atom it was connected to. - - @param atoms The atoms to stop listening to. - @param listener The function to stop calling when the atoms change. -]=] -local function disconnect(atoms: Set>, listener: () -> ()) - for atom in next, atoms do - listeners[atom][listener] = nil - end -end - -return { - listeners = listeners, - capturing = capturing, - isAtom = isAtom, - notify = notify, - capture = capture, - batch = batch, - peek = peek, - connect = connect, - disconnect = disconnect, -} diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau deleted file mode 100644 index 0286d68..0000000 --- a/packages/charm/src/subscribe.luau +++ /dev/null @@ -1,43 +0,0 @@ -local store = require(script.Parent.store) -local types = require(script.Parent.types) -type Selector = types.Selector - ---[=[ - Subscribes to changes in the given atom or selector. The callback is - called with the current state and the previous state immediately after a - change occurs. - - @param callback The atom or selector to subscribe to. - @param listener The function to call when the state changes. - @return A function that unsubscribes the callback. -]=] -local function subscribe(callback: Selector, listener: (state: T, prev: T) -> ()): () -> () - local dependencies, state = store.capture(callback) - local disconnected = false - - local function handler() - local prevState = state - - store.disconnect(dependencies, handler) - dependencies, state = store.capture(callback) - - if not disconnected then - store.connect(dependencies, handler) - end - - if state ~= prevState then - listener(state, prevState) - end - end - - store.connect(dependencies, handler) - - return function() - if not disconnected then - disconnected = true - store.disconnect(dependencies, handler) - end - end -end - -return subscribe diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau new file mode 100644 index 0000000..a01aa5e --- /dev/null +++ b/packages/charm/src/system.luau @@ -0,0 +1,237 @@ +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/system.ts +]] + +export type ReactiveNode = { + deps: Link?, + depsTail: Link?, + subs: Link?, + subsTail: Link?, + flags: ReactiveFlags, +} + +export type Link = { + version: number, + dep: ReactiveNode, + sub: ReactiveNode, + prevSub: Link?, + nextSub: Link?, + prevDep: Link?, + nextDep: Link?, +} + +type Stack = { + value: T, + prev: Stack?, +} + +export type ReactiveFlags = number + +local NONE = 0b000000 +local MUTABLE = 0b000001 +local WATCHING = 0b000010 +local RECURSED_CHECK = 0b000100 +local RECURSED = 0b001000 +local DIRTY = 0b010000 +local PENDING = 0b100000 + +local ReactiveFlags = table.freeze({ + None = NONE, + Mutable = MUTABLE, + Watching = WATCHING, + RecursedCheck = RECURSED_CHECK, + Recursed = RECURSED, + Dirty = DIRTY, + Pending = PENDING, +}) + +local update: (sub: ReactiveNode) -> boolean +local notify: (sub: ReactiveNode) -> () +local unwatched: (sub: ReactiveNode) -> () + +local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean + local link = sub.depsTail + while link do + if link == checkLink then + return true + end + link = link.prevDep + end + return false +end + +local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) + local prevDep = sub.depsTail + if prevDep and prevDep.dep == dep then + return + end + local nextDep = if prevDep then prevDep.nextDep else sub.deps + if nextDep and nextDep.dep == dep then + nextDep.version = version + sub.depsTail = nextDep + return + end + local prevSub = dep.subsTail + if prevSub and prevSub.version == version and prevSub.sub == sub then + return + end + local newLink: Link = { + version = version, + dep = dep, + sub = sub, + prevDep = prevDep, + nextDep = nextDep, + prevSub = prevSub, + } + sub.depsTail = newLink + dep.subsTail = newLink + if nextDep then + nextDep.prevDep = newLink + end + if prevDep then + prevDep.nextDep = newLink + else + sub.deps = newLink + end + if prevSub then + prevSub.nextSub = newLink + else + dep.subs = newLink + end +end + +local function unlink(link: Link, sub: ReactiveNode?): Link? + sub = sub or link.sub + local dep = link.dep + local prevDep = link.prevDep + local nextDep = link.nextDep + local nextSub = link.nextSub + local prevSub = link.prevSub + if nextDep then + nextDep.prevDep = prevDep + else + sub.depsTail = prevDep + end + if prevDep then + prevDep.nextDep = nextDep + else + sub.deps = nextDep + end + if nextSub then + nextSub.prevSub = prevSub + else + dep.subsTail = prevSub + end + if prevSub then + prevSub.nextSub = nextSub + else + dep.subs = nextSub + if not nextSub then + unwatched(dep) + end + end + return nextDep +end + +local function propagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + + if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then + sub.flags = bit32.bor(flags, PENDING) + elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then + flags = NONE + elseif bit32.band(flags, RECURSED_CHECK) == 0 then + sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) + elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then + sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) + flags = bit32.band(flags, MUTABLE) + else + flags = NONE + end + + if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then + notify(sub) + end + + if bit32.btest(flags, MUTABLE) then + local subSubs = sub.subs + if subSubs then + propagate(subSubs) + end + end + + link = link.nextSub :: Link + until not link +end + +local function shallowPropagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then + sub.flags = bit32.bor(flags, DIRTY) + if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then + notify(sub) + end + end + link = link.nextSub :: Link + until not link +end + +local function checkDirty(link: Link, sub: ReactiveNode): boolean + repeat + local dep = link.dep + local depFlags = dep.flags + + if bit32.btest(sub.flags, DIRTY) then + return true + elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then + if update(dep) then + local subs = dep.subs :: Link + if subs.nextSub then + shallowPropagate(subs) + end + return true + end + elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then + if checkDirty(dep.deps :: Link, dep) then + if update(dep) then + local subs = dep.subs :: Link + if subs.nextSub then + shallowPropagate(subs) + end + return true + end + else + dep.flags = bit32.band(depFlags, bit32.bnot(PENDING)) + end + end + + link = link.nextDep :: Link + until not link + + return false +end + +local function createReactiveSystem( + updateFn: (sub: ReactiveNode) -> boolean, + notifyFn: (sub: ReactiveNode) -> (), + unwatchedFn: (sub: ReactiveNode) -> () +) + update = updateFn + notify = notifyFn + unwatched = unwatchedFn +end + +return { + ReactiveFlags = ReactiveFlags, + link = link, + unlink = unlink, + propagate = propagate, + checkDirty = checkDirty, + shallowPropagate = shallowPropagate, + createReactiveSystem = createReactiveSystem, +} diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau deleted file mode 100644 index fd3ba6b..0000000 --- a/packages/charm/src/types.luau +++ /dev/null @@ -1,43 +0,0 @@ -export type Set = { [T]: true } - -export type WeakMap = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" })) - ---[=[ - A primitive state container that can be read from and written to. When the - state changes, all subscribers are notified. - - @param state The next state or a function that produces the next state. - @return The current state, if no arguments are provided. -]=] -export type Atom = (state: (T | (T) -> T)?) -> T - ---[=[ - A function that depends on one or more atoms and produces a state. Can be - used to derive state from atoms. - - @template State The type of the state. - @return The current state. -]=] -export type Selector = () -> T - ---[=[ - Optional configuration for creating an atom. -]=] -export type AtomOptions = { - --[=[ - A function that determines whether the state has changed. By default, - a strict equality check (`===`) is used. - ]=] - equals: (prev: T, next: T) -> boolean, -} - ---[=[ - A payload that can be sent from the server to the client to synchronize - state between the two. -]=] -export type SyncPayload = { - type: "patch" | "init", - data: { [any]: any }, -} - -return nil diff --git a/packages/charm/test/computed.test.luau b/packages/charm/test/computed.test.luau new file mode 100644 index 0000000..c0095cf --- /dev/null +++ b/packages/charm/test/computed.test.luau @@ -0,0 +1,220 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local signal = Charm.signal + +test("should correctly propagate changes through computed core", function() + local src, setSrc = signal(0) + local c1 = computed(function() + return src() % 2 + end) + local c2 = computed(function() + return c1() + end) + local c3 = computed(function() + return c2() + end) + + c3() + setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty + c2() -- c1 -> none, c2 -> none + setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty + + assert(c3() == 1, "bad final value") +end) + +test("should propagate updated source value through chained computations", function() + local src, setSrc = signal(0) + local a = computed(function() + return src() + end) + local b = computed(function() + return a() % 2 + end) + local c = computed(function() + return src() + end) + local d = computed(function() + return b() + c() + end) + + assert(d() == 0, "bad initial value") + setSrc(2) + assert(d() == 2, "bad updated value") +end) + +test("should handle flags are indirectly updated during checkDirty", function() + local a, setA = signal(false) + local b = computed(function() + return a() + end) + local c = computed(function() + b() + return 0 + end) + local d = computed(function() + c() + return b() + end) + + assert(d() == false, "bad initial value") + setA(true) + assert(d() == true, "bad updated value") +end) + +test("should not update if the signal value is reverted", function() + local times = 0 + + local src, setSrc = signal(0) + local c1 = computed(function() + times += 1 + return src() + end) + c1() + assert(times == 1, "computed did not run") + setSrc(1) + setSrc(0) + c1() + assert(times == 1, "computed ran when it should not") +end) + +test("should receive previous value", function() + local calls = {} + local src, setSrc = signal(0) + local c1 = computed(function(current: number?): number + local incoming = src() + return if current == incoming or incoming % 2 == 1 then current or incoming else incoming + end) + + effect(function() + table.insert(calls, c1()) + end) + + setSrc(2) + setSrc(3) + assert(c1() == 2, "should skip odd value") + + setSrc(4) + setSrc(5) + assert(c1() == 4, "should skip odd value") + assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L875 +test("should chained computeds keep reactivity when computed effect happens", function() + local v = atom("Hello") + local c = computed(function() + v(v() .. " World") + return v() + end) + local d = computed(function() + return c() + end) + local e = computed(function() + return d() + end) + local res = computed(function() + return d() .. " | " .. e() + end) + + assert(res() == "Hello World | Hello World", res()) + + v(v() .. " World") + assert(res() == "Hello World World World World | Hello World World World World", res()) +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L580 +test("should not trigger effect scheduler by recursive computed effect", function() + local v = atom("Hello") + local c = computed(function() + v(v() .. " World") + return v() + end) + local comp = computed(function() + return c() + end) + + assert(comp() == "Hello World", comp()) + + v(v() .. " World") + assert(comp() == "Hello World World World World", comp()) +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528 +test("should not override queried MaybeDirty result", function() + local v1 = atom(nil :: any) + local v2 = atom(false) + local c1 = computed(function() + local c = v1() + if not v1() then + c = { v = atom(0) } + v1(c) + end + return c.v() + end) + local c2 = computed(function() + if not v2() then + return "no" + end + return if c1() then "yes" else "no" + end) + local c3 = computed(function() + return c2() + end) + + c3() + v2(true) + + c3() + v1().v(999) + + assert(c3() == "yes", "should return correct value after update") +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L503 +test("should trigger effect even computed already dirty", function() + local calls: { string } = {} + local v = atom(0) + local c1 = computed(function() + if v() == 0 then + v(1) + end + return "foo" + end) + local c2 = computed(function() + return v() .. c1() + end) + + effect(function() + table.insert(calls, c2()) + end) + assert(table.concat(calls, ",") == "0foo", table.concat(calls, ",")) + assert(v() == 1, `expected 1 for v(), got {v()}`) + v(2) + assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ",")) + assert(v() == 2, `expected 2 for v(), got {v()}`) +end) + +test("should handle inner effect clearing outer computed's deps on rerun", function() + local src, setSrc = signal(false) + local stopEffect: () -> () + local c = computed(function() + src() + effect(function() + return function() + stopEffect() + end + end) + effect(function() end) + return 0 + end) + stopEffect = effect(function() + c() + end) + setSrc(true) +end) + +return {} diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau new file mode 100644 index 0000000..c94ec51 --- /dev/null +++ b/packages/charm/test/effect.test.luau @@ -0,0 +1,576 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local endBatch = Charm.endBatch +local onCleanup = Charm.onCleanup +local setActiveSub = Charm.setActiveSub +local signal = Charm.signal +local startBatch = Charm.startBatch + +test("should clear subscriptions when untracked by all subscribers", function() + local bRunTimes = 0 + + local a, setA = signal(1) + local b = computed(function() + bRunTimes += 1 + return a() * 2 + end) + local stopEffect = effect(function() + b() + end) + + assert(bRunTimes == 1, "should run initially") + setA(2) + assert(bRunTimes == 2, "should run after change") + stopEffect() + setA(3) + assert(bRunTimes == 2, "should not run after stop") +end) + +test("should not run untracked inner effect", function() + local a, setA = signal(3) + local b = computed(function() + return a() > 0 + end) + + effect(function() + if b() then + effect(function() + if a() == 0 then + error("bad") + end + end) + end + end) + + setA(2) + setA(1) + setA(0) +end) + +test("should run outer effect first", function() + local a, setA = signal(1) + local b, setB = signal(1) + + effect(function() + if a() > 0 then + effect(function() + b() + if a() == 0 then + error("bad") + end + end) + end + end) + + startBatch() + setB(0) + setA(0) + endBatch() +end) + +test("should not trigger inner effect when resolve maybe dirty", function() + local a, setA = signal(0) + local b = computed(function() + return a() % 2 + end) + + local innerTriggerTimes = 0 + + effect(function() + effect(function() + b() + innerTriggerTimes += 1 + if innerTriggerTimes >= 2 then + error("bad") + end + end) + end) + + setA(2) +end) + +test("should notify inner effects in the same order as non-inner effects", function() + local getA, setA = signal(0) + local getB, setB = signal(0) + local c = computed(function() + return getA() - getB() + end) + local order1 = {} + local order2 = {} + local order3 = {} + + effect(function() + table.insert(order1, "effect1") + getA() + end) + effect(function() + table.insert(order1, "effect2") + getA() + getB() + end) + + effect(function() + c() + effect(function() + table.insert(order2, "effect1") + getA() + end) + effect(function() + table.insert(order2, "effect2") + getA() + getB() + end) + end) + + effectScope(function() + effect(function() + table.insert(order3, "effect1") + getA() + end) + effect(function() + table.insert(order3, "effect2") + getA() + getB() + end) + end) + + table.clear(order1) + table.clear(order2) + table.clear(order3) + + startBatch() + setB(1) + setA(1) + endBatch() + + assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1") + assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2") + assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3") +end) + +test("should custom effect support batch", function() + local function batchEffect(fn: () -> ()) + return effect(function() + startBatch() + local success, result: unknown = pcall(fn) + endBatch() + if not success then + error(result) + end + end) + end + + local logs: { string } = {} + local a = signal(0) + local b, setB = signal(0) + + local aa = computed(function() + table.insert(logs, "aa-0") + if a() == 0 then + setB(1) + end + table.insert(logs, "aa-1") + return nil + end) + + local bb = computed(function() + table.insert(logs, "bb") + return b() + end) + + batchEffect(function() + bb() + end) + batchEffect(function() + aa() + end) + + assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`) +end) + +test("should duplicate subscribers do not affect the notify order", function() + local src1, setSrc1 = signal(0) + local src2, setSrc2 = signal(0) + local order: { string } = {} + + effect(function() + table.insert(order, "a") + local currentSub = setActiveSub(nil) + local isOne = src2() == 1 + setActiveSub(currentSub) + if isOne then + src1() + end + src2() + src1() + end) + effect(function() + table.insert(order, "b") + src1() + end) + setSrc2(1) -- src1.subs: a -> b -> a + + table.clear(order) + setSrc1(src1() + 1) + + assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) +end) + +test("should handle side effect with inner effects", function() + local a, setA = signal(0) + local b, setB = signal(0) + local order: { string } = {} + + effect(function() + effect(function() + a() + table.insert(order, "a") + end) + effect(function() + b() + table.insert(order, "b") + end) + assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) + + table.clear(order) + setB(1) + setA(1) + end) + + -- DEVIATION: This assertion was moved outside of the effect so that it + -- runs after the batch updates have been processed + assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) +end) + +test("should handle flags are indirectly updated during checkDirty", function() + local a, setA = signal(false) + local b = computed(function() + return a() + end) + local c = computed(function() + b() + return 0 + end) + local d = computed(function() + c() + return b() + end) + + local triggers = 0 + + effect(function() + d() + triggers += 1 + end) + assert(triggers == 1, "did not run initially") + setA(true) + assert(triggers == 2, "did not update indirectly") +end) + +test("should not execute skipped effects from previous failed flush when updating unrelated signal", function() + local a, setA = signal(0) + local b, setB = signal(0) + local c, setC = signal(0) + local d = computed(function() + c() + return 0 + end) + + local effect3Executed = false + + effect(function() + a() + end) + effect(function() + if a() == 2 then + error("Error in effect 2") + end + end) + effect(function() + a() + d() + effect3Executed = true + end) + effect(function() + b() + end) + + setA(1) + + effect3Executed = false + local success, err = pcall(setA, 2) + assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`) + + assert(not effect3Executed, "effect 3 should not have executed after failed flush") + setB(1) + assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b") + setC(1) + assert(effect3Executed, "effect 3 should have executed after updating signal c") +end) + +test("should allow finite recursion", function() + local src, setSrc = signal(0) + local order = {} + + effect(function() + table.insert(order, src()) + setSrc(math.min(src() + 1, 5)) + end) + assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`) +end) + +test("should run cleanups in sequence", function() + local order = {} + + local a, setA = signal(false) + local stopEffect = effect(function() + a() + onCleanup(function() + table.insert(order, "a") + end) + onCleanup(function() + table.insert(order, "b") + end) + end) + + assert(table.concat(order) == "", "should not cleanup immediately") + + setA(true) + assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`) + + table.clear(order) + stopEffect() + assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`) + + table.clear(order) + setA(false) + assert(table.concat(order) == "", "should not cleanup after update") +end) + +test("should run inner effect cleanup", function() + local cleanups = 0 + + local src, setSrc = signal(false) + local stopEffect = effect(function() + src() + effect(function() + onCleanup(function() + cleanups += 1 + end) + end) + effect(function() + onCleanup(function() + cleanups += 1 + end) + end) + end) + + assert(cleanups == 0, "should not cleanup immediately") + setSrc(true) + assert(cleanups == 2, "should cleanup after outer update") + stopEffect() + assert(cleanups == 4, "should cleanup after outer dispose") + setSrc(false) + assert(cleanups == 4, "should not cleanup after update") +end) + +test("should run cleanups outside any evaluation context", function() + local calls = 0 + local a, setA = signal(0) + local b, setB = signal(0) + local c = computed(function() + if a() == 0 then + effect(function() + onCleanup(function() + b() + end) + end) + end + return a() + end) + + effect(function() + calls += 1 + c() + end) + assert(calls == 1, "should run once") + + calls = 0 + setA(1) + assert(calls == 1, "should run once after a update") + + calls = 0 + setB(1) + assert(calls == 0, "should not run after b update") +end) + +test("should not retrigger the effect if the cleanup modifies one of the dependencies", function() + local a, setA = signal(0) + local calls = {} + + effect(function() + table.insert(calls, a()) + return function() + setA(2) + end + end) + assert(#calls == 1, "should have run once initially") + table.clear(calls) + + setA(1) + assert(#calls == 1, "should run once after an update") + assert(calls[1] == 2, `bad call: {calls[1]}`) +end) + +test("should run the cleanup if the effect disposes itself", function() + local a, setA = signal(0) + local calls = 0 + local dispose: () -> () + + dispose = effect(function() + if a() > 0 then + dispose() + return function() + calls += 1 + end + end + end) + assert(calls == 0, "should not have called cleanup initially") + setA(1) + assert(calls == 1, "should have called cleanup once after first update") + setA(2) + assert(calls == 1, "should not call cleanup again after second update") +end) + +test("should not run the effect if the cleanup function disposes it", function() + local a, setA = signal(0) + local calls = 0 + local dispose: () -> () + + dispose = effect(function() + a() + calls += 1 + return function() + dispose() + end + end) + assert(calls == 1, "should have run effect once initially") + setA(1) + assert(calls == 1, "should not run effect again after update") +end) + +test("should reset the cleanup if the effect throws", function() + local a, setA = signal(0) + local calls = 0 + + effect(function() + if a() == 0 then + return function() + calls += 1 + end + else + error("hello") + end + end) + assert(calls == 0, "should not have called cleanup initially") + local success, err = pcall(setA, 1) + assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`) + assert(calls == 1, "should have called cleanup once after throwing") + setA(0) + assert(calls == 1, "should not call cleanup again after resetting value") +end) + +test("recursive effects should run and cleanup in order", function() + local a, setA = signal(0) + local order: { string } = {} + + effect(function() + local value = a() + table.insert(order, `+{value}`) + if value < 3 then + setA(value + 1) + end + return function() + table.insert(order, `-{value}`) + end + end) + assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`) + table.clear(order) + setA(100) + assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`) +end) + +test("should effects dispose in order", function() + local order: { number } = {} + + local src, setSrc = signal(false) + local stopEffect = effect(function() + src() + for i = 1, 3 do + effect(function() + return function() + table.insert(order, i) + end + end) + end + end) + + assert(#order == 0, "should not cleanup immediately") + setSrc(true) + assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`) + table.clear(order) + stopEffect() + assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`) + table.clear(order) + setSrc(false) + assert(#order == 0, "should not cleanup after dispose") +end) + +test("should handle inner effect clearing outer effect's deps on rerun", function() + local src, setSrc = signal(false) + local stopEffect: () -> () + stopEffect = effect(function() + src() + effect(function() + return function() + stopEffect() + end + end) + effect(function() end) + end) + setSrc(true) +end) + +test.skip("should handle inner effect cleanup disposing outer effect", function() + local order: { number } = {} + + local src, setSrc = signal(false) + local stopEffect: () -> () + stopEffect = effect(function() + src() + table.insert(order, 0) + for i = 1, 4 do + effect(function() + return function() + table.insert(order, i) + stopEffect() + end + end) + end + end) + assert(table.concat(order) == "0", "should not cleanup immediately") + + table.clear(order) + setSrc(true) + assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`) + + table.clear(order) + setSrc(false) + assert(#order == 0, "should not cleanup after dispose") +end) + +return {} diff --git a/packages/charm/test/effectScope.test.luau b/packages/charm/test/effectScope.test.luau new file mode 100644 index 0000000..964b7d5 --- /dev/null +++ b/packages/charm/test/effectScope.test.luau @@ -0,0 +1,95 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local effect = Charm.effect +local effectScope = Charm.effectScope +local signal = Charm.signal +local onCleanup = Charm.onCleanup + +test("should not trigger after stop", function() + local count, setCount = signal(1) + + local triggers = 0 + + local stopScope = effectScope(function() + effect(function() + triggers += 1 + count() + end) + assert(triggers == 1, "did not run initially") + + setCount(2) + assert(triggers == 2, "did not run after update") + end) + + setCount(3) + assert(triggers == 3, "did not run after update outside scope") + stopScope() + setCount(4) + assert(triggers == 3, "ran after stop") +end) + +test("should run indirect scope cleanup", function() + local cleanups = 0 + local cleanup: () -> () + + local stopScope = effectScope(function() + cleanup = effectScope(function() + onCleanup(function() + cleanups += 1 + end) + end) + end) + + assert(cleanups == 0, "should not cleanup immediately") + stopScope() + assert(cleanups == 1, "should cleanup from parent dispose") + cleanup() + assert(cleanups == 1, "should skip redundant dispose") +end) + +test("should run indirect effect cleanup", function() + local cleanups = 0 + local a, setA = signal(false) + + local stopScope = effectScope(function() + effect(function() + a() + onCleanup(function() + cleanups += 1 + end) + end) + end) + + assert(cleanups == 0, "should not cleanup immediately") + setA(true) + assert(cleanups == 1, "should cleanup once") + stopScope() + assert(cleanups == 2, "should cleanup after outer dispose") + setA(false) + assert(cleanups == 2, "should not cleanup after update") +end) + +test("should dispose inner effects if created in an effect", function() + local source, setSource = signal(1) + local triggers = 0 + local dispose: () -> () + + effect(function() + dispose = effectScope(function() + effect(function() + source() + triggers += 1 + end) + end) + assert(triggers == 1, "did not run initially") + end) + + setSource(2) + assert(triggers == 2, "did not run after update") + dispose() + setSource(3) + assert(triggers == 2, "ran after dispose") +end) + +return {} diff --git a/packages/charm/test/errors.test.luau b/packages/charm/test/errors.test.luau new file mode 100644 index 0000000..151a0d5 --- /dev/null +++ b/packages/charm/test/errors.test.luau @@ -0,0 +1,200 @@ +--!optimize 1 + +local Charm = require("../src") +local test = require("../../../tests/test") + +test("should disallow yielding", function() + local success = pcall(function() + Charm.effectScope(function() + coroutine.yield() + end) + end) + assert(not success, "should error on yield") +end) + +test.describe("traceback", function() + local DEFAULT_ERROR = "Test error" + + local function fail(message: string?) + error(message or DEFAULT_ERROR, 0) + end + + local function indirectError(message: string?) + fail(message) + end + + local function assertStackTrace(err: unknown, expectation: string?) + expectation = expectation or DEFAULT_ERROR + assert(type(err) == "string", "Expected error to be a string") + assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`) + assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`) + assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`) + end + + test("signal update", function() + local _, err = pcall(function() + local _, setSrc = Charm.signal(0) + setSrc(function() + indirectError() + return 1 + end) + end) + assertStackTrace(err) + end) + + test("atom update", function() + local _, err = pcall(function() + local src = Charm.atom(0) + src(function() + indirectError() + return 1 + end) + end) + assertStackTrace(err) + end) + + test("computed", function() + local _, err = pcall(function() + local src = Charm.computed(function() + indirectError() + end) + src() + end) + assertStackTrace(err) + end) + + test("effect init", function() + local _, err = pcall(function() + Charm.effect(function() + indirectError() + end) + end) + assertStackTrace(err) + end) + + test("effect run", function() + local _, err = pcall(function() + local src, setSrc = Charm.signal(0) + Charm.effect(function() + if src() == 1 then + indirectError() + end + end) + setSrc(1) + end) + assertStackTrace(err) + end) + + test("effect cleanups", function() + local _, err = pcall(function() + local dispose = Charm.effect(function() + Charm.onCleanup(function() + indirectError("onCleanup") + end) + return function() + indirectError("returned cleanup") + end + end) + dispose() + end) + assertStackTrace(err, "onCleanup") + assertStackTrace(err, "returned cleanup") + end) + + test("trigger", function() + local _, err = pcall(function() + Charm.trigger(function() + indirectError() + end) + end) + assertStackTrace(err) + end) + + test("untracked", function() + local _, err = pcall(function() + Charm.effectScope(function() + Charm.untracked(function() + indirectError() + end) + end) + end) + assertStackTrace(err) + end) + + test("batch", function() + local _, err = pcall(function() + Charm.batch(function() + indirectError() + end) + end) + assertStackTrace(err) + end) + + test("subscribe getter", function() + local _, err = pcall(function() + Charm.subscribe(function() + indirectError() + end, function() end) + end) + assertStackTrace(err) + end) + + test("subscribe callback", function() + local _, err = pcall(function() + local src, setSrc = Charm.signal(0) + Charm.subscribe(src, function() + indirectError() + end) + setSrc(1) + end) + assertStackTrace(err) + end) + + test("listen getter", function() + local _, err = pcall(function() + Charm.listen(function() + indirectError() + end, function() end) + end) + assertStackTrace(err) + end) + + test("listen callback", function() + local _, err = pcall(function() + local src, setSrc = Charm.signal(0) + Charm.listen(src, function() + indirectError() + end) + setSrc(1) + end) + assertStackTrace(err) + end) + + test("mapped getter", function() + local _, err = pcall(function() + local src = Charm.mapped(function() + indirectError() + return { 1 } + end, function() + return 1 + end) + src() + end) + assertStackTrace(err) + end) + + test("mapped transform", function() + local _, err = pcall(function() + local src = Charm.mapped(function() + return { 1 } + end, function() + indirectError() + return 1 + end) + src() + end) + assertStackTrace(err) + end) +end) + +return {} diff --git a/packages/charm/test/gc.test.luau b/packages/charm/test/gc.test.luau new file mode 100644 index 0000000..aed0af1 --- /dev/null +++ b/packages/charm/test/gc.test.luau @@ -0,0 +1,65 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope + +local function weakRef(value: T): { value: T? } + return setmetatable({ value = value }, { __mode = "v" }) :: any +end + +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. +]] +local function unlink(getter: () -> unknown) + effectScope(function() + getter() + end)() +end + +test("should release computed cache", function() + local srcRef = weakRef({}) + local src, setSrc = signal(srcRef.value) + local c = computed(function() + return src() + end) + + c() -- cache src value + setSrc(nil) -- release value + unlink(c) -- release computed + + gc() + assert(srcRef.value == nil, "should release computed cache") +end) + +test("should not release effect for atom", function() + local effectCalls = 0 + local src, setSrc = signal(0) + + effect(function() + effectCalls += 1 + src() + end) + assert(effectCalls == 1, "should call effect once") + + gc() + setSrc(src() + 1) + assert(effectCalls == 2, "should call effect again") +end) + +return {} diff --git a/packages/charm/test/init.luau b/packages/charm/test/init.luau new file mode 100644 index 0000000..52da435 --- /dev/null +++ b/packages/charm/test/init.luau @@ -0,0 +1,55 @@ +local test = require("../../tests/test") + +test.describe("computed", function() + require("@self/computed.test") +end) + +test.describe("effect", function() + require("@self/effect.test") +end) + +test.describe("effectScope", function() + require("@self/effectScope.test") +end) + +test.describe("error", function() + require("@self/errors.test") +end) + +test.describe("gc", function() + require("@self/gc.test") +end) + +test.describe("listen", function() + require("@self/listen.test") +end) + +test.describe("mapped", function() + require("@self/mapped.test") +end) + +test.describe("observe", function() + require("@self/observe.test") +end) + +test.describe("signal", function() + require("@self/signal.test") +end) + +test.describe("subscribe", function() + require("@self/subscribe.test") +end) + +test.describe("topology", function() + require("@self/topology.test") +end) + +test.describe("trigger", function() + require("@self/trigger.test") +end) + +test.describe("untracked", function() + require("@self/untracked.test") +end) + +return {} diff --git a/packages/charm/test/listen.test.luau b/packages/charm/test/listen.test.luau new file mode 100644 index 0000000..5d9c335 --- /dev/null +++ b/packages/charm/test/listen.test.luau @@ -0,0 +1,77 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local listen = Charm.listen + +test("should listen to selector", function() + local source, setSource = signal(0) + local current, previous + + local unsub = listen(function() + return source() + 1 + end, function(a, b) + current = a + previous = b + end) + + assert(current == 1 and previous == nil, "should run initially") + setSource(0) + assert(current == 1 and previous == nil, "should skip redundant update") + setSource(1) + assert(current == 2 and previous == 1, "should run with new value") + unsub() + setSource(2) + assert(current == 2 and previous == 1, "should not run after unsub") +end) + +test("should run in untracked", function() + local runs = 0 + local a, setA = signal(0) + local b, setB = signal(0) + + listen(a, function() + runs += 1 + b() + end) + + assert(runs == 1, "should run once on init") + setA(1) + assert(runs == 2, "should run once on change") + setB(1) + assert(runs == 2, "should not run on b change") + setA(2) + assert(runs == 3, "should run on a change") +end) + +test("should run if first value is nil", function() + local runs = 0 + local source, setSource = signal(nil :: number?) + + listen(source, function() + runs += 1 + end) + + assert(runs == 1, "should run once on init") + setSource(1) + assert(runs == 2, "should run once on change") +end) + +test("should skip redundant updates", function() + local order = {} + local a, setA = signal(0) + + listen(function(): "even" | "odd" + return if a() % 2 == 0 then "even" else "odd" + end, function(value) + table.insert(order, value) + end) + + setA(1) + setA(3) + setA(4) + setA(6) + assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`) +end) + +return {} diff --git a/packages/charm/test/mapped.test.luau b/packages/charm/test/mapped.test.luau new file mode 100644 index 0000000..33620d4 --- /dev/null +++ b/packages/charm/test/mapped.test.luau @@ -0,0 +1,128 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local mapped = Charm.mapped + +test("should map state", function() + local src, setSrc = signal({ 1, 2, 3 }) + local map = mapped(src, function(value: number) + return value + 1 + end) + + assert(table.concat(map()) == "234", table.concat(map())) + setSrc({ 4, 5, 6 }) + assert(table.concat(map()) == "567", table.concat(map())) +end) + +test("should not map unchanged values", function() + local calls = 0 + + local src, setSrc = signal({ 1, 2, 3 }) + local map = mapped(src, function(value: number) + calls += 1 + return value + 1 + end) + + assert(table.concat(map()) == "234", table.concat(map())) + assert(calls == 3, "should map 3 values") + + setSrc({ 1, 3, 3 }) + assert(table.concat(map()) == "244", table.concat(map())) + assert(calls == 4, "should map 1 change") + + setSrc({}) + assert(#map() == 0, "should be empty") + assert(calls == 4, "should not map for removed values") +end) + +test("should not update if no values change", function() + local calls = 0 + + local src, setSrc = signal({ 1, 2, 3 }) + local map = mapped(src, function(value: number) + calls += 1 + return value + 1 + end) + + local prev = map() + assert(calls == 3, "should map 3 values") + assert(table.concat(map()) == "234", table.concat(map())) + + setSrc({ 4, 5, 6, 7 }) + setSrc({ 1, 2, 3 }) + map() + assert(calls == 3, "should not map unchanged values") + assert(table.concat(map()) == "234", table.concat(map())) + assert(prev == map(), "should not create new table") +end) + +test("should filter values", function() + local calls = 0 + + local src, setSrc = signal({ a = 1, b = 2, c = 3 }) + local map = mapped(src, function(value: number) + calls += 1 + return if value < 0 then nil else value + end) + + local state = map() + assert(calls == 3, "should map 3 values") + assert(state.a and state.b and state.c, "should map all values") + + calls = 0 + setSrc({ a = -1, b = 2, c = 3, d = 4 }) + state = map() + assert(calls == 2, "should map 2 values") + assert(state.a == nil, "should remove a") + assert(state.b and state.c, "should keep b and c") + assert(state.d == 4, "should add d") +end) + +test("should map keys", function() + local calls = 0 + + local src, setSrc = signal({ a = 1, b = 2, c = 3 }) + local map = mapped(src, function(value: number, key: string) + calls += 1 + return if value < 0 then nil else value, string.upper(key) + end) + + local state = map() + assert(calls == 3, "should map 3 values") + assert(not (state.a and state.b and state.c), "should discard old keys") + assert(state.A == 1, "should map a") + assert(state.B == 2, "should map b") + assert(state.C == 3, "should map c") + + calls = 0 + setSrc({ a = -1, b = 2, c = 3, d = 4 }) + state = map() + assert(calls == 2, "should map 2 values") + assert(state.A == nil and state.a == nil, "should remove a") + assert(state.B and state.C, "should keep b and c") + assert(state.D == 4, "should add d") +end) + +test("should remove old key if mapper returns a new key", function() + local src, setSrc = signal({ 1 }) + local map = mapped(src, function(value: number, index: number) + return true, `{index}={value}` + end) + + local state = map() + assert(state["1=1"], "should have mapped first element to key 1=1") + + setSrc({ 2 }) + state = map() + assert(state["1=1"] == nil, "should have removed old key 1=1") + assert(state["1=2"], "should have remapped element to key 1=2") + + setSrc({ 3, 1 }) + state = map() + assert(state["1=2"] == nil, "should have removed old key 1=2") + assert(state["1=3"], "should have remapped element to key 1=3") + assert(state["2=1"], "should have mapped second element to key 2=1") +end) + +return {} diff --git a/packages/charm/test/observe.test.luau b/packages/charm/test/observe.test.luau new file mode 100644 index 0000000..64bbd7d --- /dev/null +++ b/packages/charm/test/observe.test.luau @@ -0,0 +1,198 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local observe = Charm.observe + +test("should observe items", function() + local items, setItems = signal({ 0, 0, 0 }) + local updates = {} + local cleanups = {} + + observe(items, function(_, key: number) + local get = computed(function() + return items()[key] + end) + + effect(function() + table.insert(updates, get()) + end) + + return function() + table.insert(cleanups, key) + end + end) + + assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`) + + table.clear(updates) + setItems({ 1, 2, 3 }) + assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`) + + table.clear(updates) + setItems({ 1, 2, nil :: any, 4 }) + assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`) + assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`) +end) + +test("should dispose scopes", function() + local items, setItems = signal({ 1, 2, 3 }) + local a, setA = signal(0) + local runs = {} + local disposes = {} + + local dispose = observe(items, function(item) + effect(function() + table.insert(runs, item) + a() + end) + effectScope(function() + return function() + table.insert(disposes, item) + end + end) + end) + + table.clear(runs) + setItems({ 1, 2 }) + setA(1) + assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`) + assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`) + + table.clear(runs) + table.clear(disposes) + dispose() + setA(2) + assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`) + assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`) +end) + +test("should queue addition and removal", function() + local items, setItems = signal({ 1 }) + local edits = {} + + observe(items, function(item) + if item == 1 then + setItems({ 1, 2, 3, 4 }) + elseif item == 4 then + setItems({ 1 }) + end + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`) +end) + +test("should handle recursive additions and removals", function() + local items, setItems = signal({ 1 }) + local edits: { string } = {} + + observe(items, function(item) + if item < 5 then + setItems(function(state: { number }) + state = table.clone(state) + table.insert(state, item + 1) + return state + end) + else + setItems({}) + end + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`) +end) + +test("should handle self removal", function() + local items, setItems = signal({ 1, 2, 3, 4 }) + local edits: { string } = {} + + observe(items, function(item, index) + setItems(function(state: { number }) + state = table.clone(state) + state[index] = nil + return state + end) + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) +end) + +test("should allow disposal during addition", function() + local items, setItems = signal({}) + local edits = {} + local dispose: () -> () + + dispose = observe(items, function(item) + if item == 1 then + dispose() + end + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + setItems({ 1, 2, 3, 4 }) + assert(table.concat(edits) == "+1-1", `bad edits: {table.concat(edits)}`) +end) + +test("should allow disposal during removal", function() + local items, setItems = signal({ 1, 2, 3, 4 }) + local edits = {} + local dispose: () -> () + + dispose = observe(items, function(item) + table.insert(edits, `+{item}`) + return function() + dispose() + table.insert(edits, `-{item}`) + end + end) + + setItems({ 1, 2, 3, nil :: any, 5 }) + assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) +end) + +test("should not hang on error after recursive update", function() + local items, setItems = signal({}) + + observe(items, function() + setItems({ {} }) + error("test error") + end) + + local success, err = pcall(function() + setItems({ {} }) + end) + assert(not success, "should error") + assert(string.find(tostring(err), "test error"), `bad error: {err}`) +end) + +test("should not hang on error after recursive init", function() + local items, setItems = signal({ {} }) + + local success, err = pcall(function() + observe(items, function() + setItems({ {} }) + error("test error") + end) + end) + assert(not success, "should error") + assert(string.find(tostring(err), "test error"), `bad error: {err}`) +end) + +return {} diff --git a/packages/charm/test/signal.test.luau b/packages/charm/test/signal.test.luau new file mode 100644 index 0000000..21c1746 --- /dev/null +++ b/packages/charm/test/signal.test.luau @@ -0,0 +1,67 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local effect = Charm.effect + +test("should accept equality function", function() + local calls = {} + local src, setSrc = signal(0, function(current: number, incoming: number) + return current == incoming or incoming % 2 == 1 + end) + + effect(function() + table.insert(calls, src()) + end) + + setSrc(2) + setSrc(3) + assert(src() == 2, "should skip odd value") + + setSrc(4) + setSrc(5) + assert(src() == 4, "should skip odd value") + assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) +end) + +test("should enforce immutability", function() + local src, setSrc = signal({ nested = {} }) + assert(not pcall(function() + src().nested.a = 1 + end), "should not allow mutation of initial value") + + setSrc({ nested = { a = 1 } }) + assert(not pcall(function() + src().nested.a = 2 + end), "should not allow mutation of new value") +end) + +test("should not freeze values with metatables", function() + local src = signal({ object = setmetatable({}, {}) }) + assert( + pcall(function() + src().object.a = 2 + end), + "should allow mutation of values with metatables" + ) +end) + +test("should setter return same value as getter even if equal", function() + local src, setSrc = signal(0, function() + return true + end) + assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`) +end) + +test("should setter return latest value", function() + local src, setSrc = signal(0) + effect(function() + if src() == 1 then + setSrc(2) + end + end) + assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`) + assert(src() == 2, `src() expected 2, got {src()}`) +end) + +return {} diff --git a/packages/charm/test/subscribe.test.luau b/packages/charm/test/subscribe.test.luau new file mode 100644 index 0000000..10ab932 --- /dev/null +++ b/packages/charm/test/subscribe.test.luau @@ -0,0 +1,91 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local computed = Charm.computed +local subscribe = Charm.subscribe + +test("should subscribe to selector", function() + local source, setSource = signal(0) + local current, previous + + local unsub = subscribe(function() + return source() + 1 + end, function(a, b) + current = a + previous = b + end) + + assert(not current and not previous, "should not run initially") + setSource(0) + assert(not current and not previous, "should skip redundant update") + setSource(1) + assert(current == 2 and previous == 1, "should run with new value") + unsub() + setSource(2) + assert(current == 2 and previous == 1, "should not run after unsub") +end) + +test("should run in untracked", function() + local runs = 0 + local a, setA = signal(0) + local b, setB = signal(0) + + subscribe(a, function() + runs += 1 + b() + end) + + assert(runs == 0, "should not run once on init") + setA(1) + assert(runs == 1, "should run once on change") + setB(1) + assert(runs == 1, "should not run on b change") + setA(2) + assert(runs == 2, "should run on a change") +end) + +test("should ensure correct execution order", function() + local order: { number } = {} + local n1, setN1 = signal(0) + local n2, setN2 = signal(0) + local sum = computed(function() + return n1() + n2() + end) + + subscribe(n1, function() + table.insert(order, 1) + setN2(n2() + 1) + end) + + subscribe(sum, function() + table.insert(order, 2) + end) + + subscribe(n1, function() + table.insert(order, 3) + end) + + setN1(n1() + 1) + + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) +end) + +test("should skip redundant updates", function() + local order: { "even" | "odd" } = {} + local a, setA = signal(0) + + subscribe(function(): "even" | "odd" + return if a() % 2 == 0 then "even" else "odd" + end, function(value) + table.insert(order, value) + end) + + setA(1) + setA(3) + setA(4) + setA(6) + assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`) +end) + +return {} diff --git a/packages/charm/test/topology.test.luau b/packages/charm/test/topology.test.luau new file mode 100644 index 0000000..3675b3d --- /dev/null +++ b/packages/charm/test/topology.test.luau @@ -0,0 +1,484 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect + +--[[ + Tests adopted with thanks from preact-core implementation at + https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx + + The MIT License (MIT) + + Copyright (c) 2022-present Preact Team + + 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 +]] + +test.describe("graph updates", function() + test("should drop A->B->A updates", function() + -- A + -- / | + -- B | <- Looks like a flag doesn't it? :D + -- \ | + -- C + -- | + -- D + local a = atom(2) + + local b = computed(function() + return a() - 1 + end) + local c = computed(function() + return a() + b() + end) + + local computeCalls = 0 + local compute = function() + computeCalls += 1 + return "d: " .. c() + end + local d = computed(compute) + + -- Trigger read + assert(d() == "d: 3", "d should be 3") + assert(computeCalls == 1, "compute should be called once") + computeCalls = 0 + + a(4) + d() + assert(computeCalls == 1, "compute should drop update") + end) + + test("should only update every signal once (diamond graph)", function() + -- In this scenario "D" should only update once when "A" receives + -- an update. This is sometimes referred to as the "diamond" scenario. + -- A + -- / \ + -- B C + -- \ / + -- D + local a = atom("a") + local b = computed(function() + return a() + end) + local c = computed(function() + return a() + end) + + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return b() .. " " .. c() + end + local d = computed(spy) + + assert(d() == "a a", "d should be 'a a'") + assert(spyCalls == 1, "compute should be called once") + + a("aa") + assert(d() == "aa aa", "d should be 'aa aa'") + assert(spyCalls == 2, "compute should be called only twice") + end) + + test("should only update every signal once (diamond graph + tail)", function() + -- "E" will be likely updated twice if our mark+sweep logic is buggy. + -- A + -- / \ + -- B C + -- \ / + -- D + -- | + -- E + local a = atom("a") + local b = computed(function() + return a() + end) + local c = computed(function() + return a() + end) + + local d = computed(function() + return b() .. " " .. c() + end) + + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return d() + end + local e = computed(spy) + + assert(e() == "a a", "e should be 'a a'") + assert(spyCalls == 1, "compute should be called once") + + a("aa") + assert(e() == "aa aa", "e should be 'aa aa'") + assert(spyCalls == 2, "compute should be called only twice") + end) + + test("should bail out if result is the same", function() + -- Bail out if value of "B" never changes + -- A->B->C + local a = atom("a") + local b = computed(function() + a() + return "foo" + end) + + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return b() + end + local c = computed(spy) + + assert(c() == "foo", "c should be 'foo'") + assert(spyCalls == 1, "compute should be called once") + + a("aa") + assert(c() == "foo", "c should be 'foo'") + assert(spyCalls == 1, "compute should bail out") + end) + + test("should only update every signal once (jagged diamond graph + tails)", function() + -- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy. + -- A + -- / \ + -- B C + -- | | + -- | D + -- \ / + -- E + -- / \ + -- F G + local a = atom("a") + + local b = computed(function() + return a() + end) + local c = computed(function() + return a() + end) + + local d = computed(function() + return c() + end) + + local eSpyCalls = 0 + local eSpyTime = 0 + local eSpy = function() + eSpyCalls += 1 + eSpyTime = os.clock() + return b() .. " " .. d() + end + local e = computed(eSpy) + + local fSpyCalls = 0 + local fSpyTime = 0 + local fSpy = function() + fSpyCalls += 1 + fSpyTime = os.clock() + return e() + end + local f = computed(fSpy) + local gSpyCalls = 0 + local gSpyTime = 0 + local gSpy = function() + gSpyCalls += 1 + gSpyTime = os.clock() + return e() + end + local g = computed(gSpy) + + assert(f() == "a a", "f should be 'a a'") + assert(fSpyCalls == 1, "f should be called once") + + assert(g() == "a a", "g should be 'a a'") + assert(gSpyCalls == 1, "g should be called once") + + eSpyCalls, eSpyTime = 0, 0 + fSpyCalls, fSpyTime = 0, 0 + gSpyCalls, gSpyTime = 0, 0 + + a("b") + + assert(e() == "b b", "e should be 'b b'") + assert(eSpyCalls == 1, "e should run once after update") + + assert(f() == "b b", "f should be 'b b'") + assert(fSpyCalls == 1, "f should run once after update") + + assert(g() == "b b", "g should be 'b b'") + assert(gSpyCalls == 1, "g should run once after update") + + eSpyCalls, eSpyTime = 0, 0 + fSpyCalls, fSpyTime = 0, 0 + gSpyCalls, gSpyTime = 0, 0 + + a("c") + + assert(e() == "c c", "e should be 'c c'") + assert(eSpyCalls == 1, "e should run once after update") + + assert(f() == "c c", "f should be 'c c'") + assert(fSpyCalls == 1, "f should run once after update") + + assert(g() == "c c", "g should be 'c c'") + assert(gSpyCalls == 1, "g should run once after update") + + -- top to bottom + assert(eSpyTime < fSpyTime, "e should be called before f") + -- left to right + assert(eSpyTime < gSpyTime, "e should be called before g") + end) + + test("should only subscribe to core listened to", function() + -- *A + -- / \ + -- *B C <- we don't listen to C + local a = atom("a") + + local b = computed(function() + return a() + end) + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return a() + end + computed(spy) + + assert(b() == "a", "b should be 'a'") + assert(spyCalls == 0, "compute should not run") + + a("aa") + assert(b() == "aa", "b should be 'aa'") + assert(spyCalls == 0, "compute should not run") + end) + + test("should only subscribe to core listened to II", function() + -- Here both "B" and "C" are active in the beginning, but + -- "B" becomes inactive later. At that point it should + -- not receive any updates anymore. + -- *A + -- / \ + -- *B D <- we don't listen to C + -- | + -- *C + local a = atom("a") + local spyBCalls = 0 + local spyB = function() + spyBCalls += 1 + return a() + end + local b = computed(spyB) + + local spyCCalls = 0 + local spyC = function() + spyCCalls += 1 + return b() + end + local c = computed(spyC) + + local d = computed(function() + return a() + end) + + local result = "" + local unsub = effect(function() + result = c() + end) + + assert(result == "a", "result should be 'a'") + assert(d() == "a", "d should be 'a'") + + spyBCalls = 0 + spyCCalls = 0 + unsub() + + a("aa") + + assert(spyBCalls == 0, "b should not run") + assert(spyCCalls == 0, "c should not run") + assert(d() == "aa", "d should be 'aa'") + end) + + test("should ensure subs update even if one dep unmarks it", function() + -- In this scenario "C" always returns the same value. When "A" + -- changes, "B" will update, then "C" at which point its update + -- to "D" will be unmarked. But "D" must still update because + -- "B" marked it. If "D" isn't updated, then we have a bug. + -- A + -- / \ + -- B *C <- returns same value every time + -- \ / + -- D + local a = atom("a") + local b = computed(function() + return a() + end) + local c = computed(function() + a() + return "c" + end) + local spyResult: string? + local spy = function() + spyResult = b() .. " " .. c() + return spyResult + end + local d = computed(spy) + + assert(d() == "a c", "d should be 'a c'") + spyResult = nil + + a("aa") + d() + assert(spyResult == "aa c", "d should have returned 'aa c'") + end) + + test("should ensure subs update even if two deps unmark it", function() + -- In this scenario both "C" and "D" always return the same + -- value. But "E" must still update because "A" marked it. + -- If "E" isn't updated, then we have a bug. + -- A + -- / | \ + -- B *C *D + -- \ | / + -- E + local a = atom("a") + local b = computed(function() + return a() + end) + local c = computed(function() + a() + return "c" + end) + local d = computed(function() + a() + return "d" + end) + local spyResult: string? + local spy = function() + spyResult = b() .. " " .. c() .. " " .. d() + return spyResult + end + local e = computed(spy) + + assert(e() == "a c d", "e should be 'a c d'") + spyResult = nil + + a("aa") + e() + assert(spyResult == "aa c d", "e should have returned 'aa c d'") + end) + + test("should support lazy branches", function() + local a = atom(0) + local b = computed(function() + return a() + end) + local c = computed(function() + return if a() > 0 then a() else b() + end) + + assert(c() == 0, "c should be 0") + a(1) + assert(c() == 1, "c should be 1") + + a(0) + assert(c() == 0, "c should be 0 again") + end) + + test("should not update a sub if all deps unmark it", function() + -- In this scenario "B" and "C" always return the same value. When "A" + -- changes, "D" should not update. + -- A + -- / \ + -- *B *C + -- \ / + -- D + local a = atom("a") + local b = computed(function() + a() + return "b" + end) + local c = computed(function() + a() + return "c" + end) + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return b() .. " " .. c() + end + local d = computed(spy) + + assert(d() == "b c", "d should be 'b c'") + spyCalls = 0 + + a("aa") + assert(spyCalls == 0, "d should not run") + end) +end) + +test.describe("error handling", function() + test("should keep graph consistent on errors during activation", function() + local a = atom(0) + local b = computed(function(): nil + error("fail") + end) + local c = computed(function() + return a() + end) + + local success, result = pcall(b) + assert(not success, "should throw") + assert(string.match(tostring(result), "fail"), "should throw fail") + + a(1) + assert(c() == 1, "c should recover and return 1") + end) + + test("should keep graph consistent on errors in computeds", function() + local a = atom(0) + local b = computed(function(): number + if a() == 1 then + error("fail") + end + return a() + end) + local c = computed(function() + return b() + end) + + assert(c() == 0, "c should be 0") + + a(1) + local success, result = pcall(b) + assert(not success, "should throw") + assert(string.match(tostring(result), "fail"), "should throw fail") + + a(2) + assert(c() == 2, "c should recover and return 2") + end) +end) + +return {} diff --git a/packages/charm/test/trigger.test.luau b/packages/charm/test/trigger.test.luau new file mode 100644 index 0000000..20ebf32 --- /dev/null +++ b/packages/charm/test/trigger.test.luau @@ -0,0 +1,111 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local computed = Charm.computed +local effect = Charm.effect +local listen = Charm.listen +local signal = Charm.signal +local subscribe = Charm.subscribe +local trigger = Charm.trigger + +test.beforeEach(function() + Charm.flags.frozen = false +end) + +test.afterEach(function() + Charm.flags.frozen = true +end) + +test("should not throw when triggering with no dependencies", function() + trigger(function() end) +end) + +test("should trigger updates for dependent computed signals", function() + local arr = signal({} :: { number }) + local length = computed(function() + return #arr() + end) + + assert(length() == 0, "initial length should be 0") + table.insert(arr(), 1) + trigger(arr) + assert(length() == 1, "length should be 1") +end) + +test("should trigger updates for the second source signal", function() + local src1 = signal({} :: { number }) + local src2 = signal({} :: { number }) + local length = computed(function() + return #src2() + end) + + assert(length() == 0, "initial length should be 0") + table.insert(src2(), 1) + trigger(function() + src1() + src2() + end) + assert(length() == 1, "length should be 1") +end) + +test("should trigger effect once", function() + local src1 = signal({} :: { number }) + local src2 = signal({} :: { number }) + local triggers = 0 + + effect(function() + triggers += 1 + src1() + src2() + end) + assert(triggers == 1, "effect should run once initially") + + trigger(function() + src1() + src2() + end) + assert(triggers == 2, "effect should run twice") +end) + +test("should not notify the trigger function sub", function() + local src1 = signal({} :: { number }) + local src2 = computed(function() + return src1() + end) + + effect(function() + src1() + src2() + end) + trigger(function() + src1() + src2() + end) +end) + +test("should trigger updates for subscribe", function() + local src = signal({} :: { number }) + local triggers = 0 + + subscribe(src, function() + triggers += 1 + end) + + trigger(src) + assert(triggers == 1, "subscribe callback should run after trigger") +end) + +test("should trigger updates for listen", function() + local src = signal({} :: { number }) + local triggers = 0 + + listen(src, function() + triggers += 1 + end) + assert(triggers == 1, "listen callback should run once initially") + + trigger(src) + assert(triggers == 2, "listen callback should run after trigger") +end) + +return {} diff --git a/packages/charm/test/untracked.test.luau b/packages/charm/test/untracked.test.luau new file mode 100644 index 0000000..3c7fea4 --- /dev/null +++ b/packages/charm/test/untracked.test.luau @@ -0,0 +1,176 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local untracked = Charm.untracked + +test("should run detached from effect", function() + local a, setA = signal(1) + local effects = 0 + local innerEffects = {} + + local stopEffect = effect(function() + a() + untracked(function() + local stopInnerEffect = effect(function() + effects += 1 + return function() + effects -= 1 + end + end) + table.insert(innerEffects, stopInnerEffect) + end) + end) + + assert(effects == 1, "should have one effect") + setA(2) + assert(effects == 2, "should have two effects") + stopEffect() + assert(effects == 2, "should still have two effects") + for _, stopInnerEffect in innerEffects do + stopInnerEffect() + end + assert(effects == 0, "should have zero effects") +end) + +test("should run detached from effect scope", function() + local effects = 0 + local stopEffect: () -> () + + local stopScope = effectScope(function() + untracked(function() + stopEffect = effect(function() + effects += 1 + return function() + effects -= 1 + end + end) + end) + end) + + assert(effects == 1, "should have one effect") + stopScope() + assert(effects == 1, "should still have one effect") + stopEffect() + assert(effects == 0, "should have zero effects") +end) + +test("should block tracking inside effects", function() + local a, setA = signal(1) + local b, setB = signal(2) + local spyCalls = 0 + + effect(function() + untracked(function() + spyCalls += 1 + return a() + b() + end) + end) + assert(spyCalls == 1, "should be called once") + + setA(10) + setB(20) + assert(spyCalls == 1, "should be called once") +end) + +test("should block tracking even when run inside effect run inside untracked", function() + local s, setS = signal(1) + local spyCalls = 0 + + untracked(function() + effect(function() + untracked(function() + spyCalls += 1 + return s() + end) + end) + end) + assert(spyCalls == 1, "should be called once") + + setS(2) + assert(spyCalls == 1, "should be called once") +end) + +test("should not cause signal assignments to throw", function() + local a, setA = signal(1) + local aChangedTime, setAChangedTime = signal(0) + + local dispose = effect(function() + a() + untracked(function() + setAChangedTime(aChangedTime() + 1) + end) + end) + + setA(2) + assert(aChangedTime() == 2, "aChangedTime should be 2") + setA(3) + assert(aChangedTime() == 3, "aChangedTime should be 3") + + dispose() +end) + +test("should block tracking inside computed signals", function() + local a, setA = signal(1) + local b, setB = signal(2) + local spyCalls = 0 + local c = computed(function() + return untracked(function() + spyCalls += 1 + return a() + b() + end) + end) + + assert(spyCalls == 0, "should not be called yet") + assert(c() == 3, "c should be 3") + setA(10) + c() + setB(20) + c() + assert(spyCalls == 1, "should be called once") + assert(c() == 3, "c should be 3") +end) + +test("should track outside untracked", function() + local a, setA = signal(1) + local b, setB = signal(2) + local c, setC = signal(3) + local spyCalls = 0 + + effect(function() + a() + untracked(c) + b() + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + setA(10) + assert(spyCalls == 2, "should be called twice") + setB(20) + assert(spyCalls == 3, "should be called three times") + setC(30) + assert(spyCalls == 3, "should not run on c change") +end) + +test("should stack untracked", function() + local a, setA = signal(1) + local spyCalls = 0 + + effect(function() + untracked(function() + untracked(a) + return a() + end) + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + setA(2) + assert(spyCalls == 1, "should not be called twice") +end) + +return {} diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 4497d3a..6298b39 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,7 +1,8 @@ [package] name = "littensy/charm" -version = "0.10.0" +version = "0.11.0-rc.6" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] [dependencies] diff --git a/packages/deep-charm/Charm.luau b/packages/deep-charm/Charm.luau new file mode 100644 index 0000000..619a57a --- /dev/null +++ b/packages/deep-charm/Charm.luau @@ -0,0 +1,5 @@ +if not script then + return require("../charm/src") +end + +return require(script.Parent.Parent.charm.src) diff --git a/packages/react/default.project.json b/packages/deep-charm/default.project.json similarity index 60% rename from packages/react/default.project.json rename to packages/deep-charm/default.project.json index c1ee26a..1937acb 100644 --- a/packages/react/default.project.json +++ b/packages/deep-charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "ReactCharm", + "name": "deep-charm", "tree": { "$path": "src" } diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json new file mode 100644 index 0000000..96d06af --- /dev/null +++ b/packages/deep-charm/package.json @@ -0,0 +1,27 @@ +{ + "name": "@rbxts/deep-charm", + "version": "0.1.0-rc.1", + "description": "Use Charm with plain Luau tables", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:^" + } +} diff --git a/packages/deep-charm/src/index.d.ts b/packages/deep-charm/src/index.d.ts new file mode 100644 index 0000000..dc1aa58 --- /dev/null +++ b/packages/deep-charm/src/index.d.ts @@ -0,0 +1,53 @@ +export type ReactiveProxy = T & { + __track: () => void; + __target: T; + __signals: Map unknown>; +}; + +/** + * 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, updateUsers = reactive({ + * user = { name = "John", surname = "Doe" }, + * }) + * + * effect(function() + * print(users.user and `{users.user.name} {users.user.surname}`) + * end) + * + * updateUsers(function(state) + * state.user.name = "Jane" + * state.user.surname = "Smith" + * end) + * ``` + * + * @param initialValue The value to make deeply reactive. + * @returns A proxy table and a function to mutate the original table. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function reactive( + initialValue: T, +): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>; + +/** + * Determines if the provided value is a reactive proxy created by `reactive()`. + * + * @param value A value to test. + * @returns `true` if the value is a reactive proxy, `false` otherwise. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function isReactive(value: unknown): boolean; + +/** + * Unwraps a reactive proxy to get the original table. If the provided value + * is not a proxy, it is returned as-is. + * + * @param value A reactive proxy or a non-proxy value. + * @returns The unwrapped target value. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function toRaw(value: T): T; diff --git a/packages/deep-charm/src/init.luau b/packages/deep-charm/src/init.luau new file mode 100644 index 0000000..9eb9943 --- /dev/null +++ b/packages/deep-charm/src/init.luau @@ -0,0 +1,165 @@ +local Charm = require("./Charm") + +local computed = Charm.computed +local trigger = Charm.trigger +local getActiveSub = Charm.getActiveSub + +export type ReactiveProxy = typeof(setmetatable( + {} :: { + __track: () -> (), + __target: T, + __signals: { [unknown]: () -> unknown }, + }, + {} :: { __index: T } +)) + +local ReactiveProxy = { + proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), +} + +--[[ + Determines if the provided value is a reactive proxy created by `reactive()`. + + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function isReactive(value: any): boolean + return type(value) == "table" and getmetatable(value) == ReactiveProxy +end + +--[[ + Unwraps a reactive proxy to get the original table. If the provided value + is not a proxy, it is returned as-is. + + @param value A reactive proxy or a non-proxy value + @return The source table if `value` is a reactive proxy + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function toRaw(value: T): T + if isReactive(value) then + value = value :: ReactiveProxy + value.__track() + return toRaw(value.__target) + end + return value +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, updateUsers = reactive({ + { name = "John", surname = "Doe" }, + }) + + effect(function() + print(`{users[1].name} {users[1].surname}`) + end) + + updateUsers(function(state) + state[1].name = "Jane" + state[1].surname = "Smith" + end) + ``` + + @param initialValue Source table to make deeply reactive + @return A proxy table that tracks reads to nested properties + @return A function for batching mutations to the source table + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) + initialValue = toRaw(initialValue) + + local proxy = ReactiveProxy.proxyMap[initialValue] + + if not proxy then + local track = computed(function(version: number?) + -- Reference the proxy to avoid GCing while an effect is actively + -- tracking this reactive object. + local _ref = proxy + -- Allow updates from the parent to propagate down when the + -- target is mutated directly through the setter. + if trackParent then + trackParent() + end + return (version or 0) + 1 + end) + + proxy = setmetatable({ + __target = initialValue, + __signals = setmetatable({}, { __mode = "v" }) :: any, + __track = track, + }, ReactiveProxy) + + ReactiveProxy.proxyMap[initialValue] = proxy + end + + local function setValue(update: ((T) -> ())?) + if update then + update(proxy.__target) + end + trigger(proxy.__track) + end + + return proxy :: any, setValue +end + +--[[ + Wraps a value in `reactive()` if it is a table; otherwise, returns the + value as-is. + + @param value Source value to make deeply reactive + @param trackParent Optional tracking function to propagate deep updates + @return A reactive proxy if the value is a table +]] +local function toReactive(value: T, trackParent: (() -> ())?): T + return if type(value) == "table" then reactive(value, trackParent) else value +end + +function ReactiveProxy.__index(self: ReactiveProxy, key: any): any + local track = self.__track + local signals = self.__signals + local target = self.__target + + if getActiveSub() then + if not signals[key] then + signals[key] = computed(function() + track() + return target[key] + end) + end + signals[key]() + end + + return toReactive(target[key], track) +end + +function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any) + value = toRaw(value) + if self.__target[key] ~= value then + self.__target[key] = value + trigger(self.__track) + end +end + +function ReactiveProxy.__iter(self: ReactiveProxy) + self.__track() + local function nextReactive(target: any, prevKey: any) + local nextKey, nextValue = next(target, prevKey) + return nextKey, toReactive(nextValue, self.__track) + end + return nextReactive, self.__target +end + +function ReactiveProxy.__len(self: ReactiveProxy) + self.__track() + return #self.__target +end + +return { + reactive = reactive, + isReactive = isReactive, + toRaw = toRaw, +} diff --git a/packages/deep-charm/test/gc.test.luau b/packages/deep-charm/test/gc.test.luau new file mode 100644 index 0000000..4fe9f3d --- /dev/null +++ b/packages/deep-charm/test/gc.test.luau @@ -0,0 +1,79 @@ +local Charm = require("../Charm") +local DeepCharm = require("../src") +local test = require("../../../tests/test") + +type ReactiveProxy = DeepCharm.ReactiveProxy + +local computed = Charm.computed +local effect = Charm.effect +local reactive = DeepCharm.reactive + +local function weakRef(value: T): { value: T? } + return setmetatable({ value = value }, { __mode = "v" }) :: any +end + +local function gc() + for _ = 1, 2000 do + local _ = table.create(100, 0) + end +end + +test("should release inner reactive proxy", function() + local valueRef = weakRef({}) + local src: any = reactive({ value = valueRef.value }) + + local dispose = effect(function() + local _ = src.value -- create proxy + end) + src.value = nil :: any -- release proxy + dispose() + + gc() + assert(src.__signals.value == nil, "should release signals") + assert(valueRef.value == nil, "should release inner proxy") +end) + +test("should release reactive property deps", function() + local src: any = reactive({ foo = 1 }) + + local c = computed(function() + return src.foo + end) + + c() + assert(src.__signals.foo ~= nil, "should have deps for foo") + + c = nil :: any + gc() + assert(src.__signals.foo == nil, "should release deps for foo") +end) + +test("should not release effect for reactive", function() + local src = reactive({ foo = 1 }) + local calls = 0 + + effect(function() + calls += 1 + local _ = src.foo + end) + assert(calls == 1, "should call effect once") + + gc() + src.foo += 1 + assert(calls == 2, "should call effect again") +end) + +test("should not release tracked reactive", function() + local src = reactive({ users = {} :: { [string]: {}? } }) + local user + + effect(function() + user = src.users.user + end) + + gc() + src.users.user = {} + assert(user ~= nil, "src.users.user should not be released") +end) + +return {} diff --git a/packages/deep-charm/test/init.luau b/packages/deep-charm/test/init.luau new file mode 100644 index 0000000..b7123d6 --- /dev/null +++ b/packages/deep-charm/test/init.luau @@ -0,0 +1,11 @@ +local test = require("../../tests/test") + +test.describe("reactive", function() + require(`@self/reactive.test`) +end) + +test.describe("gc", function() + require(`@self/gc.test`) +end) + +return {} diff --git a/packages/deep-charm/test/reactive.test.luau b/packages/deep-charm/test/reactive.test.luau new file mode 100644 index 0000000..41654b1 --- /dev/null +++ b/packages/deep-charm/test/reactive.test.luau @@ -0,0 +1,232 @@ +local Charm = require("../Charm") +local DeepCharm = require("../src") +local test = require("../../../tests/test") + +local batch = Charm.batch +local computed = Charm.computed +local effect = Charm.effect +local observe = Charm.observe +local reactive = DeepCharm.reactive +local toRaw = DeepCharm.toRaw + +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, `bad runs: {runs}`) + + src.a, src.b = src.b, src.a + assert(runs == 3, `bad runs: {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 iteration for nested tables", function() + local src = reactive({} :: { [string]: { value: number } }) + local calls = {} + + effect(function() + for key, obj in src do + table.insert(calls, `{key}={obj.value};`) + end + end) + + src.a = { value = 1 } + + table.clear(calls) + src.b = { value = 1 } + assert(table.concat(calls) == "a=1;b=1;", table.concat(calls)) + + table.clear(calls) + src.a.value = 2 + assert(table.concat(calls) == "a=2;b=1;", 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, `bad calls: {calls}`) + + src.value.b = 3 + assert(calls == 1, `bad calls: {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(function(src) + src.a = 2 + src.b = nil + src.c = 4 + end) + assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ",")) +end) + +test("should unwrap proxy", function() + local raw = {} + local src = reactive(raw) + local runs = 0 + + effect(function() + local _ = toRaw(src) + runs += 1 + end) + + assert(toRaw(src) == raw, "unwrap did not return the original table") + assert(toRaw(raw) == raw, "unwrap modified a non-proxy value") + assert(runs == 1, `bad runs: {runs}`) + + src.x = 1 + assert(runs == 2, `bad runs: {runs}`) +end) + +test("should unwrap nested proxy", function() + local raw = {} + local inner = reactive(raw) + local src = reactive(inner) + local runs = 0 + + effect(function() + local _ = toRaw(src) + runs += 1 + end) + + assert(toRaw(src) == raw, "unwrap did not return the original table") + assert(runs == 1, `bad runs: {runs}`) + + inner.x = 1 + assert(runs == 2, `bad runs: {runs}`) + + src.y = 2 + assert(runs == 3, `bad runs: {runs}`) +end) + +test("should observe items", function() + local items, updateItems = reactive({ 0, 0, 0 }) + local updates = {} + local cleanups = {} + + observe(function() + return toRaw(items) + end, function(_, key: number) + local get = computed(function() + return items[key] + end) + + effect(function() + table.insert(updates, get()) + end) + + return function() + table.insert(cleanups, key) + end + end) + + assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`) + + table.clear(updates) + updateItems(function(state) + state[1] = 1 + state[2] = 2 + state[3] = 3 + end) + assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`) + + table.clear(updates) + batch(function() + items[3] = nil + items[4] = 4 + end) + assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`) + assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`) +end) + +return {} diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml new file mode 100644 index 0000000..f1a2b11 --- /dev/null +++ b/packages/deep-charm/wally.toml @@ -0,0 +1,9 @@ +[package] +name = "littensy/deep-charm" +version = "0.1.0-rc.1" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] + +[dependencies] +Charm = "littensy/charm@^0.11.0-rc.6" diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau new file mode 100644 index 0000000..49baa5a --- /dev/null +++ b/packages/react-charm/Charm.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.charm.src) diff --git a/packages/react-charm/React.luau b/packages/react-charm/React.luau new file mode 100644 index 0000000..1c7cfd8 --- /dev/null +++ b/packages/react-charm/React.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React) diff --git a/packages/react-charm/ReactRoblox.luau b/packages/react-charm/ReactRoblox.luau new file mode 100644 index 0000000..0350e6b --- /dev/null +++ b/packages/react-charm/ReactRoblox.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").ReactRoblox) diff --git a/packages/react-charm/default.project.json b/packages/react-charm/default.project.json new file mode 100644 index 0000000..baf768c --- /dev/null +++ b/packages/react-charm/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "react-charm", + "tree": { + "$path": "src" + } +} diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json new file mode 100644 index 0000000..d58d8c7 --- /dev/null +++ b/packages/react-charm/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rbxts/react-charm", + "version": "0.4.0-rc.4", + "description": "React bindings for Charm state management", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:^" + }, + "peerDependencies": { + "@rbxts/react": ">=17.0.0" + } +} diff --git a/packages/react-charm/src/index.d.ts b/packages/react-charm/src/index.d.ts new file mode 100644 index 0000000..db8ac24 --- /dev/null +++ b/packages/react-charm/src/index.d.ts @@ -0,0 +1,31 @@ +import { Binding } from "@rbxts/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. + */ +export function useSignalState(getter: () => T, dependencies?: any[]): T; + +/** + * 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. + */ +export function useSignalBinding(getter: () => T, dependencies?: any[]): Binding; + +// Aliases +export { useSignalState as useAtom }; diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau new file mode 100644 index 0000000..19a95e8 --- /dev/null +++ b/packages/react-charm/src/init.luau @@ -0,0 +1,52 @@ +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. +]] +local function useSignalState(getter: () -> T, dependencies: {}?): T + local value, setValue = React.useState(getter) + + React.useLayoutEffect(function(): () -> () + return Charm.listen(getter, setValue :: (T, T?) -> ()) + end, dependencies :: { any } or {}) + + 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. +]] +local function useSignalBinding(getter: () -> T, dependencies: {}?) + local initialValue = React.useMemo(getter, {}) + local binding, setBinding = React.useBinding(initialValue) + + React.useLayoutEffect(function(): () -> () + return Charm.listen(getter, setBinding :: (T, T?) -> ()) + end, dependencies :: { any } or {}) + + return binding +end + +return table.freeze({ + useSignalState = useSignalState, + useSignalBinding = useSignalBinding, + useAtom = useSignalState, +}) diff --git a/tests/utils/renderHook.luau b/packages/react-charm/test/helpers/renderHook.luau similarity index 62% rename from tests/utils/renderHook.luau rename to packages/react-charm/test/helpers/renderHook.luau index 0d2d19f..661d2e0 100644 --- a/tests/utils/renderHook.luau +++ b/packages/react-charm/test/helpers/renderHook.luau @@ -1,9 +1,5 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -_G.__ROACT_17_MOCK_SCHEDULER__ = true - -local React = require(ReplicatedStorage.DevPackages.React) -local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox) +local React = require("../../React") +local ReactRoblox = require("../../ReactRoblox") type RenderedHook = { result: Result, @@ -15,14 +11,15 @@ local function renderHook(callback: (Props) -> Result, props: Pro local root = ReactRoblox.createLegacyRoot(Instance.new("Folder")) local hook = {} - local function Component(props) + local function Component(props: Props) hook.result = callback(props) + return nil end - function hook.rerender(newProps) + function hook.rerender(newProps: Props?) props = newProps or props ReactRoblox.act(function() - root:render(React.createElement(Component, props)) + root:render(React.createElement(Component, props) :: any) end) end diff --git a/packages/react-charm/test/init.luau b/packages/react-charm/test/init.luau new file mode 100644 index 0000000..25679e5 --- /dev/null +++ b/packages/react-charm/test/init.luau @@ -0,0 +1,13 @@ +local test = require("../../tests/test") + +_G.__ROACT_17_MOCK_SCHEDULER__ = true + +test.describe("useSignalState", function() + require("@self/useSignalState.test") +end) + +test.describe("useSignalBinding", function() + require("@self/useSignalBinding.test") +end) + +return {} diff --git a/packages/react-charm/test/useSignalBinding.test.luau b/packages/react-charm/test/useSignalBinding.test.luau new file mode 100644 index 0000000..8b721ed --- /dev/null +++ b/packages/react-charm/test/useSignalBinding.test.luau @@ -0,0 +1,44 @@ +local Charm = require("../Charm") +local ReactCharm = require("../src") +local renderHook = require("./helpers/renderHook") +local test = require("../../../tests/test") + +test("tracks signal state", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalBinding(getState) + end) + assert(hook.result:getValue() == 0, "should return 0") + setState(1) + assert(hook.result:getValue() == 1, "should return 1") +end) + +test("accepts a selector", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalBinding(function() + return 2 * getState() + end) + end) + assert(hook.result:getValue() == 0, "should return 0") + setState(1) + assert(hook.result:getValue() == 2, "should return 2") +end) + +test("tracks dependencies", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalBinding(getState, { getState }) + end) + assert(hook.result:getValue() == 0, "should return 0") + setState(1) + assert(hook.result:getValue() == 1, "should return 1") + + getState, setState = Charm.signal(0) + hook.rerender() + assert(hook.result:getValue() == 0, "should return 0") + setState(1) + assert(hook.result:getValue() == 1, "should return 1") +end) + +return {} diff --git a/packages/react-charm/test/useSignalState.test.luau b/packages/react-charm/test/useSignalState.test.luau new file mode 100644 index 0000000..9618577 --- /dev/null +++ b/packages/react-charm/test/useSignalState.test.luau @@ -0,0 +1,44 @@ +local Charm = require("../Charm") +local ReactCharm = require("../src") +local renderHook = require("./helpers/renderHook") +local test = require("../../../tests/test") + +test("tracks signal state", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalState(getState) + end) + assert(hook.result == 0, "should return 0") + setState(1) + assert(hook.result == 1, "should return 1") +end) + +test("accepts a selector", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalState(function() + return 2 * getState() + end) + end) + assert(hook.result == 0, "should return 0") + setState(1) + assert(hook.result == 2, "should return 2") +end) + +test("tracks dependencies", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalState(getState, { getState }) + end) + assert(hook.result == 0, "should return 0") + setState(1) + assert(hook.result == 1, "should return 1") + + getState, setState = Charm.signal(0) + hook.rerender() + assert(hook.result == 0, "should return 0") + setState(1) + assert(hook.result == 1, "should return 1") +end) + +return {} diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml new file mode 100644 index 0000000..a1de6d4 --- /dev/null +++ b/packages/react-charm/wally.toml @@ -0,0 +1,17 @@ +[package] +name = "littensy/react-charm" +version = "0.4.0-rc.4" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +exclude = [ + "node_modules", + "test", + "src/index.d.ts", + "package.json", + "React.luau", + "Charm.luau", +] + +[dependencies] +Charm = "littensy/charm@^0.11.0-rc.6" +React = "jsdotlua/react@^17" diff --git a/packages/react/package.json b/packages/react/package.json deleted file mode 100644 index df178aa..0000000 --- a/packages/react/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@rbxts/react-charm", - "version": "0.3.0", - "description": "React bindings for Charm", - "repository": "littensy/charm", - "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", - "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", - "publish:wally": "wally publish" - }, - "peerDependencies": { - "@rbxts/charm": "workspace:*", - "@rbxts/react": "*" - } -} diff --git a/packages/react/src/index.d.ts b/packages/react/src/index.d.ts deleted file mode 100644 index e3eb917..0000000 --- a/packages/react/src/index.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Selector } from "@rbxts/charm"; - -/** - * A hook that subscribes to changes in the given atom or selector. The - * component is re-rendered whenever the state changes. - * - * If the `dependencies` array is provided, the subscription to the atom or - * selector is re-created whenever the dependencies change. Otherwise, the - * subscription is created once when the component is mounted. - * - * @param callback The atom or selector to subscribe to. - * @param dependencies An array of values that the subscription depends on. - * @returns The current state. - */ -export function useAtom(callback: Selector, dependencies?: unknown[]): State; diff --git a/packages/react/src/init.luau b/packages/react/src/init.luau deleted file mode 100644 index e200551..0000000 --- a/packages/react/src/init.luau +++ /dev/null @@ -1,31 +0,0 @@ -local Charm = require(script.Parent.Charm) -local React = if script.Parent:FindFirstChild("react") - then require(script.Parent.react) :: never - else require(script.Parent.React) - ---[=[ - A hook that subscribes to changes in the given atom or selector. The - component is re-rendered whenever the state changes. - - If the `dependencies` array is provided, the subscription to the atom or - selector is re-created whenever the dependencies change. Otherwise, the - subscription is created once when the component is mounted. - - @param callback The atom or selector to subscribe to. - @param dependencies An array of values that the subscription depends on. - @return The current state. -]=] -local function useAtom(callback: Charm.Selector, dependencies: { any }?): State - local state, setState = React.useState(callback) - - React.useEffect(function() - setState(callback()) - return Charm.subscribe(callback, setState) - end, dependencies or {}) - - return state -end - -return { - useAtom = useAtom, -} diff --git a/packages/react/wally.toml b/packages/react/wally.toml deleted file mode 100644 index 91073ad..0000000 --- a/packages/react/wally.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "littensy/react-charm" -version = "0.3.0" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" - -[dependencies] -charm = "littensy/charm@^0.10" -React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau new file mode 100644 index 0000000..49baa5a --- /dev/null +++ b/packages/vide-charm/Charm.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.charm.src) diff --git a/packages/vide-charm/Vide.luau b/packages/vide-charm/Vide.luau new file mode 100644 index 0000000..0c1ac14 --- /dev/null +++ b/packages/vide-charm/Vide.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.vide.src) diff --git a/packages/vide/default.project.json b/packages/vide-charm/default.project.json similarity index 60% rename from packages/vide/default.project.json rename to packages/vide-charm/default.project.json index 3a447c6..e2a35ad 100644 --- a/packages/vide/default.project.json +++ b/packages/vide-charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "VideCharm", + "name": "vide-charm", "tree": { "$path": "src" } diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json new file mode 100644 index 0000000..2b4517a --- /dev/null +++ b/packages/vide-charm/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rbxts/vide-charm", + "version": "0.4.0-rc.4", + "description": "Vide bindings for Charm state management", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:^" + }, + "peerDependencies": { + "@rbxts/vide": "*" + } +} diff --git a/packages/vide-charm/src/index.d.ts b/packages/vide-charm/src/index.d.ts new file mode 100644 index 0000000..eb12134 --- /dev/null +++ b/packages/vide-charm/src/index.d.ts @@ -0,0 +1,11 @@ +/** + * 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. + */ +export function useSignalState(getter: () => T, dependencies?: any[]): () => T; + +// Aliases +export { useSignalState as useAtom }; diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau new file mode 100644 index 0000000..5a27f54 --- /dev/null +++ b/packages/vide-charm/src/init.luau @@ -0,0 +1,22 @@ +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. +]] +local function useSignalState(getter: () -> State): () -> State + local state = Vide.source(getter()) + + Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ())) + + return state +end + +return { + useSignalState = useSignalState, + useAtom = useSignalState, +} diff --git a/packages/vide-charm/test/init.luau b/packages/vide-charm/test/init.luau new file mode 100644 index 0000000..7b99c24 --- /dev/null +++ b/packages/vide-charm/test/init.luau @@ -0,0 +1,7 @@ +local test = require("../../tests/test") + +test.describe("useSignalState", function() + require("@self/useSignalState.test") +end) + +return {} diff --git a/packages/vide-charm/test/useSignalState.test.luau b/packages/vide-charm/test/useSignalState.test.luau new file mode 100644 index 0000000..9661501 --- /dev/null +++ b/packages/vide-charm/test/useSignalState.test.luau @@ -0,0 +1,59 @@ +local Charm = require("../Charm") +local Vide = require("../Vide") +local VideCharm = require("../src") +local test = require("../../../tests/test") + +test("tracks signal state", function() + local getState, setState = Charm.signal(0) + local source: () -> number + local cleanup = Vide.root(function() + source = VideCharm.useSignalState(getState) + end) + assert(source() == 0, "should return 0") + setState(1) + assert(source() == 1, "should return 1") + cleanup() +end) + +test("accepts a selector", function() + local getState, setState = Charm.signal(0) + local source: () -> number + local cleanup = Vide.root(function() + source = VideCharm.useSignalState(function() + return 2 * getState() + end) + end) + assert(source() == 0, "should return 0") + setState(1) + assert(source() == 2, "should return 2") + cleanup() +end) + +test("updates source in untracked", function() + local getOuter, setOuter = Charm.signal(nil :: any) + local getInner, setInner = Charm.signal(0) + local spyCalls = 0 + local cleanup = Vide.root(function() + return Vide.show(VideCharm.useSignalState(getOuter), function() + -- Charm effects created in Vide scopes triggered by useSignalState might + -- incorrectly get treated as an inner effect in useSignalState. This test + -- ensures that doesn't happen. + Vide.cleanup(Charm.effect(function() + getInner() + spyCalls += 1 + end)) + return nil + end) + end) + assert(spyCalls == 0, "should not call spy initially") + setOuter(true) + assert(spyCalls == 1, "should call spy when visible") + setInner(1) + assert(spyCalls == 2, "spy should run when counter updates") + setOuter({}) -- set to a truthy value to trigger an outer effect re-run + setInner(2) + assert(spyCalls == 3, "spy should still run when counter updates") + cleanup() +end) + +return {} diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml new file mode 100644 index 0000000..5c59947 --- /dev/null +++ b/packages/vide-charm/wally.toml @@ -0,0 +1,17 @@ +[package] +name = "littensy/vide-charm" +version = "0.4.0-rc.5" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +exclude = [ + "node_modules", + "test", + "src/index.d.ts", + "package.json", + "Vide.luau", + "Charm.luau", +] + +[dependencies] +Charm = "littensy/charm@^0.11.0-rc.6" +Vide = "centau/vide@^0.4" diff --git a/packages/vide/package.json b/packages/vide/package.json deleted file mode 100644 index adfcef4..0000000 --- a/packages/vide/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@rbxts/vide-charm", - "version": "0.3.0", - "description": "Vide bindings for Charm", - "repository": "littensy/charm", - "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", - "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", - "publish:wally": "wally publish" - }, - "peerDependencies": { - "@rbxts/charm": "workspace:*", - "@rbxts/vide": "*" - } -} diff --git a/packages/vide/src/index.d.ts b/packages/vide/src/index.d.ts deleted file mode 100644 index 21ace65..0000000 --- a/packages/vide/src/index.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Selector } from "@rbxts/charm"; - -/** - * Subscribes to the state of an atom and returns a Vide source. - * - * @param callback The atom or selector to subscribe to. - * @returns The reactive source. - */ -export function useAtom(callback: Selector): () => T; diff --git a/packages/vide/src/init.luau b/packages/vide/src/init.luau deleted file mode 100644 index df962b2..0000000 --- a/packages/vide/src/init.luau +++ /dev/null @@ -1,25 +0,0 @@ -local Charm = require(script.Parent.Charm) -local Vide = if script.Parent:FindFirstChild("vide") - then require(script.Parent.vide.src) :: never - else require(script.Parent.Vide) - ---[=[ - Subscribes to the state of an atom and returns a Vide source. - - @param callback The atom or selector to subscribe to. - @return The reactive source. -]=] -local function useAtom(callback: () -> State): () -> State - local state = Vide.source(callback()) - local unsubscribe = Charm.subscribe(callback, function(value) - task.spawn(state :: (State) -> State, value) - end) - - Vide.cleanup(unsubscribe) - - return state -end - -return { - useAtom = useAtom, -} diff --git a/packages/vide/wally.toml b/packages/vide/wally.toml deleted file mode 100644 index de0f253..0000000 --- a/packages/vide/wally.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "littensy/vide-charm" -version = "0.3.0" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" - -[dependencies] -charm = "littensy/charm@^0.10" -Vide = "centau/vide@^0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90e0e73..3c0486d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,257 +8,366 @@ importers: .: devDependencies: + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 + '@rbxts/react': + specifier: 17.3.7-ts.1 + version: 17.3.7-ts.1 + '@rbxts/react-roblox': + specifier: 17.3.7-ts.1 + version: 17.3.7-ts.1(@rbxts/react@17.3.7-ts.1) '@rbxts/types': - specifier: ^1.0.811 - version: 1.0.811 + specifier: ^1.0.913 + version: 1.0.913 + '@rbxts/vide': + specifier: ^0.6.1 + version: 0.6.1 '@typescript-eslint/eslint-plugin': - specifier: ^7.18.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) + specifier: ^8.58.0 + version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^7.18.0 - version: 7.18.0(eslint@8.57.1)(typescript@5.5.4) + specifier: ^8.58.0 + version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) eslint: - specifier: ^8.57.1 - version: 8.57.1 + specifier: ^9.39.4 + version: 9.39.4 eslint-config-prettier: - specifier: ^9.1.0 - version: 9.1.0(eslint@8.57.1) + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.4) eslint-plugin-prettier: - specifier: ^5.2.1 - version: 5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3) + specifier: ^5.5.5 + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1) eslint-plugin-roblox-ts: - specifier: ^0.0.36 - version: 0.0.36(eslint@8.57.1) + specifier: ^1.4.0 + version: 1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) prettier: - specifier: ^3.3.3 - version: 3.3.3 + specifier: ^3.8.1 + version: 3.8.1 roblox-ts: specifier: ^3.0.0 version: 3.0.0 typescript: - specifier: ~5.5.4 - version: 5.5.4 + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.58.0 + version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) packages/charm: {} packages/charm-sync: dependencies: '@rbxts/charm': - specifier: workspace:* + specifier: workspace:^ version: link:../charm - packages/react: + packages/deep-charm: dependencies: '@rbxts/charm': - specifier: workspace:* + specifier: workspace:^ + version: link:../charm + + packages/react-charm: + dependencies: + '@rbxts/charm': + specifier: workspace:^ version: link:../charm '@rbxts/react': - specifier: '*' + specifier: '>=17.0.0' version: 17.2.3 - packages/vide: + packages/vide-charm: dependencies: '@rbxts/charm': - specifier: workspace:* + specifier: workspace:^ version: link:../charm '@rbxts/vide': specifier: '*' - version: 0.5.3 + version: 0.6.0 packages: - '@eslint-community/eslint-utils@4.4.0': - resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.11.1': - resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@pkgr/core@0.1.1': - resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@rbxts-js/boolean@1.2.3-ts.1': + resolution: {integrity: sha512-IlYFG9uatXQKXOLR1mdptVeEcxudwBCierO+Vu6VZRRaI+/+JDxUClm+QFZyce/kitwnDTmtNvhqVAUtFvCU0Q==} + + '@rbxts-js/collections@1.2.3-ts.1': + resolution: {integrity: sha512-RYT73QyEdsV+DCQMtyzT2bZ1P12j2b+JSHVdb1x6xrDq/KdVEkk/QAzW+o22vPrLXbHa6/jA5bmVlgVcOOrIjA==} + + '@rbxts-js/console@1.2.3-ts.1': + resolution: {integrity: sha512-qacyJE7bYf4n9448F7mn9e8Y5qljZb7QkSmsmnbQT2HA9Mecvg3gqc9RwCIdVkBV6o1AGVAHbatNQ3a39zPjaw==} + + '@rbxts-js/es7-types@1.2.3-ts.1': + resolution: {integrity: sha512-Z7NitBQp6tyzsr5eKlSX54rmopKXpzGu33lYikjF37llrnGFBpnRcZgCJwHKYciloRmjUtnR/UohUNwbWyG9Zg==} + + '@rbxts-js/instance-of@1.2.3-ts.1': + resolution: {integrity: sha512-3fKvDPvxHuEZALNBP2XdbXfzjEfbgsXAheCm1Jf5kC2r+HgszW+yqg4zY6TjW0pcsJM2JjH+fM52oomSNu3LLQ==} + + '@rbxts-js/luau-polyfill@1.2.3-ts.1': + resolution: {integrity: sha512-2asgvWrOhf311rNgEM/fxMO3+xOVI63Cz1K1PobyZVtDdd87ZJ6RGiZSFl1EAeU77SZc8280toJDVs9YSwxWnA==} + + '@rbxts-js/math@1.2.3-ts.1': + resolution: {integrity: sha512-mF7IMJJyxYE02cRNWJ1wsq4pPaAEXKX+CbGlmI0G3QBgWVHj+Oi02IR6pssyUyE6Y2BKvgvP3dcJuTO51OX25A==} + + '@rbxts-js/number@1.2.3-ts.1': + resolution: {integrity: sha512-tPdt2s7fEBSxImVFBUvuSMtBKIvaZUCtlOjBl58Y0+GKB15VPXFIRW/5biVruXPnv9d4sDE3mh986ZxxN4UQ5w==} + + '@rbxts-js/react-globals@17.3.7-ts.1': + resolution: {integrity: sha512-vwMHsc3Oy2MDAw2M212nQJ8A7nEG5O/lyXDyMGzYJp4832awhrf34YCkU4dxBE4sdMDcoWykhmuPjmzwrcS9Eg==} + + '@rbxts-js/react-reconciler@17.3.7-ts.1': + resolution: {integrity: sha512-MxKU48i1gv4djcgHTq3geMvl6MzXPW85FO+itLg0YCvqT3WgE3jsMAc0nneOFi5Mwd5tqybQY6UFF1SbQtBopA==} + + '@rbxts-js/react-roblox@17.3.7-ts.1': + resolution: {integrity: sha512-xiJQXmJAho/mStcWYC7AXFqgUqW78whUMacaNeODMimQXgoVqaIfo0sEhLtLtS/N3qZRs1KR64nsNG/0n1r/NQ==} + + '@rbxts-js/react@17.3.7-ts.1': + resolution: {integrity: sha512-nRHYoBzk9DSbOvk281jmN5EM3TlmKv2jOzLgSIGtmNMuwZKi9yEjjxdluS1wHmjSvus1YiPHhBUwrKnbu01Obg==} + + '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': + resolution: {integrity: sha512-H8fxKVJkRnM60yTZ/riO4X43GZD/MViFm+ftCzzAALkAhE4vSaBDNv9E7IUjZZoWi21bG6vZ8mGIiYfavaljvw==} + + '@rbxts-js/safe-flags@17.3.7-ts.1': + resolution: {integrity: sha512-aQ/5aQ/wQO5npifAbTEpR3jQRMeyGsVc91AQEyhBW461lPv6ZEg+Ad2HJFSzg4eeK2mb8Z9WyIMBrb01Jt+GnQ==} + + '@rbxts-js/scheduler@17.3.7-ts.1': + resolution: {integrity: sha512-WCgGQqH9/+A5oJd6HpxMj7Vp1VGqhycnJK0CZTsJl/Ceyf/VH/vif8fo1FrBWkxYEgz/7y0q3LOeRj8ArhyDWQ==} + + '@rbxts-js/shared@17.3.7-ts.1': + resolution: {integrity: sha512-B/OCkRmbR6ho1yuf0M0hQIb78coDY//2XdmNBaX2XCN5pKa7dquiX6ufk4dizJ/OMBoy9br/cnxqcI0PLvla3w==} + + '@rbxts-js/string@1.2.3-ts.1': + resolution: {integrity: sha512-Je/WKgZbXF/sAHEsQPuns5n4rWzux327zjr0KRf9Gy62ieUzl2FRZZHjVYce3xdMtHif2JstIf/J3p513TUtRg==} + + '@rbxts-js/symbol-luau@1.0.0-ts.1': + resolution: {integrity: sha512-TQS/ImDCK5KPGvbmyScH5EZS0ELSCDvZEFR3W+ItPlsLl+bDRSI/0uaKtJ7rbwXgdGwBDKM1QXFPjqe+KOphIA==} + + '@rbxts-js/timers@1.2.3-ts.1': + resolution: {integrity: sha512-HKP4ymFX3QLWbR/2Gy4LXwvkhjC9OzbwZ5bNunxu+M8Ocbi1cjRduiZK/y5ogLr9cNIfSoxs6e1DQ3soc8/hwA==} + '@rbxts/compiler-types@3.0.0-types.0': resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} + '@rbxts/react-roblox@17.3.7-ts.1': + resolution: {integrity: sha512-lsH3lP+X0JUS/6Fg5MJfP8OjMn7bjxapbg3r1ajrpvwZGPGU1WOFcFDS/xMMk/2RK/GNYRmkduNM8pQ9vHw54Q==} + peerDependencies: + '@rbxts/react': ^17.3.7-ts.1 + '@rbxts/react-vendor@17.2.3': resolution: {integrity: sha512-dWCiVelvTK6h920geaFH/OQ5zoWHyhyqyfH51hKx2/iVZ6OiiyNVJ5UqEIkJMG4HsJ8JnkTORizV3+MtQ41BtA==} '@rbxts/react@17.2.3': resolution: {integrity: sha512-IoOB8e2W8MxT0MKWFf0Ls0vT61ImYL6ctHQgz5+oazma6z8bqFtJjgPxOuy6jmMSuyfBe7rYp/muou0kIjZT+Q==} - '@rbxts/types@1.0.811': - resolution: {integrity: sha512-MPhs5lxzxvDqie34Y9FzJ1R+k+33jjrkj03agX2y2WzBKyGmO0rD/kPuUaKXQgEiACXuk36C5FxUTYgkKJPbvA==} + '@rbxts/react@17.3.7-ts.1': + resolution: {integrity: sha512-/jqZ2TvyLSAPwC30h95loUmy4XN2PwPhv6fBARJKtZfyozFx2RuxC9FiQSjpEZ2uOOPzr60It0MBUf94D+tsMg==} - '@rbxts/vide@0.5.3': - resolution: {integrity: sha512-evaZbBRZzaqU8ibddTMaomb30pmW5OhWTT7ba1PLSA/iGBCid0eOF6BX1o0ArKPyMFnNj6pMM2yMTQzL5b9cIA==} + '@rbxts/types@1.0.913': + resolution: {integrity: sha512-AweuNYRVJb6y4PNajx2mnpqv+hm0+8GWtH9Dj4EMUuOVN5qcfcOMNSqjvQ4dmF5u0yQ89HKGeXa+4sLnaTIEng==} + + '@rbxts/vide@0.6.0': + resolution: {integrity: sha512-OLfs4kqV2IP79xS+gZAprMujuYEc+n0JwMhJF+puNVoxBXSzOv2kKlgeLJhscCy7Jdhr9TbqsaRNLJP5akbfog==} + + '@rbxts/vide@0.6.1': + resolution: {integrity: sha512-syFqx+HYXy0TauDCGLhc68K2cAlfgYookIJSRtJZ01W7mUI+EKyj80x2L91dQxos3BwAAGJqBD/ovWU4M//+Lg==} '@roblox-ts/luau-ast@2.0.0': resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} + '@roblox-ts/luau-ast@2.0.1': + resolution: {integrity: sha512-jej2mzRGwhR9wu4woAtgTwqsKADlegZYlQ+M5qVHRojvjoyJVFkLrSbhBKRr8HR+fVW5nCz0qX7kNa0tSpAY+A==} + '@roblox-ts/path-translator@1.1.0': resolution: {integrity: sha512-D0akTmnNYqBw+ZIek5JxocT3BjmbgGOuOy0x1nIIxHBPNLGCpzseToY8jyYs/0mlvnN2xnSP/k8Tv+jvGOQSwQ==} '@roblox-ts/rojo-resolver@1.1.0': resolution: {integrity: sha512-QmvVryu1EeME+3QUoG5j/gHGJoJUaffCgZ92mhlG7cJSd1uyhgpY4CNWriZAwZJYkTlzd5Htkpn+18yDFbOFXA==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@20.16.11': - resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==} - - '@types/semver@7.5.8': - resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} - - '@typescript-eslint/eslint-plugin@7.18.0': - resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^7.0.0 - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/parser': ^8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/experimental-utils@5.62.0': - resolution: {integrity: sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@7.18.0': - resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@5.62.0': - resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@typescript-eslint/scope-manager@7.18.0': - resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} - engines: {node: ^18.18.0 || >=20.0.0} - - '@typescript-eslint/type-utils@7.18.0': - resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@5.62.0': - resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/scope-manager@8.56.0': + resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@7.18.0': - resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@5.62.0': - resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@7.18.0': - resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@5.62.0': - resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@7.18.0': - resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@5.62.0': - resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@7.18.0': - resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.2.0': - resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.12.1: - resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -275,22 +384,26 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -322,12 +435,12 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -338,14 +451,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -357,19 +462,19 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-prettier@9.1.0: - resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' - eslint-plugin-prettier@5.2.1: - resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==} + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' eslint: '>=8.0.0' - eslint-config-prettier: '*' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' prettier: '>=3.0.0' peerDependenciesMeta: '@types/eslint': @@ -377,44 +482,52 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-roblox-ts@0.0.36: - resolution: {integrity: sha512-vlbgfHY1heWSyDunVX3gIOamCy28KqrJlDkmuDOyYVBo8uLqdvbFkbWkdGxn59Vky1m6pFcvZGU6TObfmwm5Pg==} - engines: {node: '>=0.10.0'} + eslint-plugin-roblox-ts@1.4.0: + resolution: {integrity: sha512-OWj+JfQlvGSiAJ5YOmiQc+M29ZJFc7VFYP0U1J3orZNT9GP5CWfpRNqPHuDhsvz+ni0BwoI4aqGv2bIPDueGrQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=22.5.1} + peerDependencies: + '@typescript-eslint/parser': '>=6.0.0' + eslint: '>=8.0.0' + typescript: '>=5.2.0' - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -429,25 +542,27 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-glob@3.3.2: - resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.0.2: - resolution: {integrity: sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fastq@1.17.1: - resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} @@ -457,20 +572,17 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} - flatted@3.3.1: - resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - fs-extra@11.2.0: - resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -491,24 +603,13 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -521,27 +622,24 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-core-module@2.15.1: - resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} is-extglob@2.1.1: @@ -560,15 +658,11 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true json-buffer@3.0.1: @@ -583,8 +677,8 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -604,19 +698,15 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} ms@2.1.3: @@ -629,9 +719,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -652,10 +739,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -663,24 +746,24 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.0: - resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} - prettier@3.3.3: - resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -688,9 +771,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -707,28 +787,17 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve@1.22.8: - resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} - hasBin: true - - reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} hasBin: true roblox-ts@3.0.0: resolution: {integrity: sha512-hwAC2frIFlLJOtHd6F+5opMEhBgfAMK9z5l1mP+bykLBbMO5cn1q5lIwhhXbeh9Pq07rlhF8uGHlmeRLPd/3AA==} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -740,10 +809,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -764,56 +829,45 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.9.2: - resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-api-utils@1.3.0: - resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} - engines: {node: '>=16'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} peerDependencies: - typescript: '>=4.2.0' - - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - - tslib@2.7.0: - resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} - - tsutils@3.21.0: - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + typescript: '>=4.8.4' type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' typescript@5.5.3: resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} engines: {node: '>=14.17'} hasBin: true - typescript@5.5.4: - resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -834,9 +888,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -855,236 +906,368 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: - eslint: 8.57.1 + eslint: 9.39.4 eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.11.1': {} + '@eslint-community/regexpp@4.12.2': {} - '@eslint/eslintrc@2.1.4': + '@eslint/config-array@0.21.2': dependencies: - ajv: 6.12.6 - debug: 4.3.7 - espree: 9.6.1 - globals: 13.24.0 + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 ignore: 5.3.2 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@8.57.1': {} + '@eslint/js@9.39.4': {} - '@humanwhocodes/config-array@0.13.0': + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.7 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/object-schema@2.0.3': {} + '@humanwhocodes/retry@0.4.3': {} - '@nodelib/fs.scandir@2.1.5': + '@pkgr/core@0.2.9': {} + + '@rbxts-js/boolean@1.2.3-ts.1': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 + '@rbxts-js/collections': 1.2.3-ts.1 - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': + '@rbxts-js/collections@1.2.3-ts.1': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.17.1 + '@rbxts-js/es7-types': 1.2.3-ts.1 + '@rbxts-js/instance-of': 1.2.3-ts.1 - '@pkgr/core@0.1.1': {} + '@rbxts-js/console@1.2.3-ts.1': + dependencies: + '@rbxts-js/collections': 1.2.3-ts.1 + + '@rbxts-js/es7-types@1.2.3-ts.1': {} + + '@rbxts-js/instance-of@1.2.3-ts.1': {} + + '@rbxts-js/luau-polyfill@1.2.3-ts.1': + dependencies: + '@rbxts-js/boolean': 1.2.3-ts.1 + '@rbxts-js/collections': 1.2.3-ts.1 + '@rbxts-js/console': 1.2.3-ts.1 + '@rbxts-js/es7-types': 1.2.3-ts.1 + '@rbxts-js/instance-of': 1.2.3-ts.1 + '@rbxts-js/math': 1.2.3-ts.1 + '@rbxts-js/number': 1.2.3-ts.1 + '@rbxts-js/string': 1.2.3-ts.1 + '@rbxts-js/symbol-luau': 1.0.0-ts.1 + '@rbxts-js/timers': 1.2.3-ts.1 + + '@rbxts-js/math@1.2.3-ts.1': {} + + '@rbxts-js/number@1.2.3-ts.1': {} + + '@rbxts-js/react-globals@17.3.7-ts.1': + dependencies: + '@rbxts-js/safe-flags': 17.3.7-ts.1 + + '@rbxts-js/react-reconciler@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react': 17.3.7-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/roblox-lua-promise': 3.5.0-ts.2 + '@rbxts-js/safe-flags': 17.3.7-ts.1 + '@rbxts-js/scheduler': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 + + '@rbxts-js/react-roblox@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react': 17.3.7-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/react-reconciler': 17.3.7-ts.1 + '@rbxts-js/scheduler': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 + + '@rbxts-js/react@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 + + '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': {} + + '@rbxts-js/safe-flags@17.3.7-ts.1': {} + + '@rbxts-js/scheduler@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/safe-flags': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 + + '@rbxts-js/shared@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/safe-flags': 17.3.7-ts.1 + + '@rbxts-js/string@1.2.3-ts.1': + dependencies: + '@rbxts-js/es7-types': 1.2.3-ts.1 + '@rbxts-js/number': 1.2.3-ts.1 + + '@rbxts-js/symbol-luau@1.0.0-ts.1': {} + + '@rbxts-js/timers@1.2.3-ts.1': + dependencies: + '@rbxts-js/collections': 1.2.3-ts.1 '@rbxts/compiler-types@3.0.0-types.0': {} + '@rbxts/react-roblox@17.3.7-ts.1(@rbxts/react@17.3.7-ts.1)': + dependencies: + '@rbxts-js/react-roblox': 17.3.7-ts.1 + '@rbxts/react': 17.3.7-ts.1 + '@rbxts/react-vendor@17.2.3': {} '@rbxts/react@17.2.3': dependencies: '@rbxts/react-vendor': 17.2.3 - '@rbxts/types@1.0.811': {} + '@rbxts/react@17.3.7-ts.1': + dependencies: + '@rbxts-js/react': 17.3.7-ts.1 - '@rbxts/vide@0.5.3': {} + '@rbxts/types@1.0.913': {} + + '@rbxts/vide@0.6.0': {} + + '@rbxts/vide@0.6.1': {} '@roblox-ts/luau-ast@2.0.0': {} + '@roblox-ts/luau-ast@2.0.1': {} + '@roblox-ts/path-translator@1.1.0': dependencies: - ajv: 8.17.1 - fs-extra: 11.2.0 + ajv: 8.18.0 + fs-extra: 11.3.4 '@roblox-ts/rojo-resolver@1.1.0': dependencies: - ajv: 8.17.1 - fs-extra: 11.2.0 + ajv: 8.18.0 + fs-extra: 11.3.4 + + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} - '@types/node@20.16.11': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: - undici-types: 6.19.8 - - '@types/semver@7.5.8': {} - - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@eslint-community/regexpp': 4.11.1 - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 9.39.4 + ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': + '@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) - eslint: 8.57.1 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7 - eslint: 8.57.1 - optionalDependencies: - typescript: 5.5.4 + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@5.62.0': + '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - - '@typescript-eslint/scope-manager@7.18.0': - dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - - '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - debug: 4.3.7 - eslint: 8.57.1 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + debug: 4.4.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@5.62.0': {} - - '@typescript-eslint/types@7.18.0': {} - - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.5.4)': + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.7 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.6.3 - tsutils: 3.21.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.5.4)': + '@typescript-eslint/scope-manager@8.56.0': dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.5.8 - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.4) - eslint: 8.57.1 - eslint-scope: 5.1.1 - semver: 7.6.3 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - - typescript - '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': + '@typescript-eslint/types@8.56.0': {} + + '@typescript-eslint/types@8.58.0': {} + + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - eslint: 8.57.1 + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + minimatch: 9.0.9 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - - typescript - '@typescript-eslint/visitor-keys@5.62.0': + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 5.62.0 - eslint-visitor-keys: 3.4.3 + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - '@typescript-eslint/visitor-keys@7.18.0': + '@typescript-eslint/utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 7.18.0 - eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - '@ungap/structured-clone@1.2.0': {} - - acorn-jsx@5.3.2(acorn@8.12.1): + '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - acorn: 8.12.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - acorn@8.12.1: {} + '@typescript-eslint/visitor-keys@8.56.0': + dependencies: + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.1 - ajv@6.12.6: + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.0.2 + fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -1097,25 +1280,29 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 argparse@2.0.1: {} - array-union@2.1.0: {} - balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + binary-extensions@2.3.0: {} - brace-expansion@1.1.11: + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.1: + brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1153,116 +1340,105 @@ snapshots: concat-map@0.0.1: {} - cross-spawn@7.0.3: + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - debug@4.3.7: + debug@4.4.3: dependencies: ms: 2.1.3 deep-is@0.1.4: {} - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - emoji-regex@8.0.0: {} escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@9.1.0(eslint@8.57.1): + eslint-config-prettier@10.1.8(eslint@9.39.4): dependencies: - eslint: 8.57.1 + eslint: 9.39.4 - eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1): dependencies: - eslint: 8.57.1 - prettier: 3.3.3 - prettier-linter-helpers: 1.0.0 - synckit: 0.9.2 + eslint: 9.39.4 + prettier: 3.8.1 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 9.1.0(eslint@8.57.1) + eslint-config-prettier: 10.1.8(eslint@9.39.4) - eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1): + eslint-plugin-roblox-ts@1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3): dependencies: - '@types/node': 20.16.11 - '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) - typescript: 5.5.4 + '@roblox-ts/luau-ast': 2.0.1 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 transitivePeerDependencies: - - eslint - supports-color - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - - eslint-scope@7.2.2: + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint@8.57.1: + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@eslint-community/regexpp': 4.11.1 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.2.0 - ajv: 6.12.6 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 chalk: 4.1.2 - cross-spawn: 7.0.3 - debug: 4.3.7 - doctrine: 3.0.0 + cross-spawn: 7.0.6 + debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 - esquery: 1.6.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 + file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.0 json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 transitivePeerDependencies: - supports-color - espree@9.6.1: + espree@10.4.0: dependencies: - acorn: 8.12.1 - acorn-jsx: 5.3.2(acorn@8.12.1) - eslint-visitor-keys: 3.4.3 + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -1270,8 +1446,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} esutils@2.0.3: {} @@ -1280,27 +1454,19 @@ snapshots: fast-diff@1.3.0: {} - fast-glob@3.3.2: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fast-uri@3.0.2: {} + fast-uri@3.1.0: {} - fastq@1.17.1: - dependencies: - reusify: 1.0.4 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 - file-entry-cache@6.0.1: + file-entry-cache@8.0.0: dependencies: - flat-cache: 3.2.0 + flat-cache: 4.0.1 fill-range@7.1.1: dependencies: @@ -1311,22 +1477,19 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@3.2.0: + flat-cache@4.0.1: dependencies: - flatted: 3.3.1 + flatted: 3.4.2 keyv: 4.5.4 - rimraf: 3.0.2 - flatted@3.3.1: {} + flatted@3.4.2: {} - fs-extra@11.2.0: + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.1.0 + jsonfile: 6.2.0 universalify: 2.0.1 - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true @@ -1342,32 +1505,10 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - globals@13.24.0: - dependencies: - type-fest: 0.20.2 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.2 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 + globals@14.0.0: {} graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - has-flag@4.0.0: {} hasown@2.0.2: @@ -1376,25 +1517,20 @@ snapshots: ignore@5.3.2: {} - import-fresh@3.3.0: + ignore@7.0.5: {} + + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 imurmurhash@0.1.4: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - is-core-module@2.15.1: + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -1408,11 +1544,9 @@ snapshots: is-number@7.0.0: {} - is-path-inside@3.0.3: {} - isexe@2.0.0: {} - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -1424,7 +1558,7 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - jsonfile@6.1.0: + jsonfile@6.2.0: dependencies: universalify: 2.0.1 optionalDependencies: @@ -1447,20 +1581,17 @@ snapshots: lodash.merge@4.6.2: {} - merge2@1.4.1: {} - - micromatch@4.0.8: + minimatch@10.2.5: dependencies: - braces: 3.0.3 - picomatch: 2.3.1 + brace-expansion: 5.0.5 - minimatch@3.1.2: + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.13 - minimatch@9.0.5: + minimatch@9.0.9: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.0.3 ms@2.1.3: {} @@ -1468,10 +1599,6 @@ snapshots: normalize-path@3.0.0: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1495,31 +1622,27 @@ snapshots: path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-parse@1.0.7: {} - path-type@4.0.0: {} + picomatch@2.3.2: {} - picomatch@2.3.1: {} + picomatch@4.0.4: {} prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.0: + prettier-linter-helpers@1.0.1: dependencies: fast-diff: 1.3.0 - prettier@3.3.3: {} + prettier@3.8.1: {} punycode@2.3.1: {} - queue-microtask@1.2.3: {} - readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 require-directory@2.1.1: {} @@ -1527,35 +1650,25 @@ snapshots: resolve-from@4.0.0: {} - resolve@1.22.8: + resolve@1.22.11: dependencies: - is-core-module: 2.15.1 + is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - reusify@1.0.4: {} - - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - roblox-ts@3.0.0: dependencies: '@roblox-ts/luau-ast': 2.0.0 '@roblox-ts/path-translator': 1.1.0 '@roblox-ts/rojo-resolver': 1.1.0 chokidar: 3.6.0 - fs-extra: 11.2.0 + fs-extra: 11.3.4 kleur: 4.1.5 - resolve: 1.22.8 + resolve: 1.22.11 typescript: 5.5.3 yargs: 17.7.2 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - semver@7.6.3: {} + semver@7.7.4: {} shebang-command@2.0.0: dependencies: @@ -1563,8 +1676,6 @@ snapshots: shebang-regex@3.0.0: {} - slash@3.0.0: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -1583,41 +1694,41 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.9.2: + synckit@0.11.12: dependencies: - '@pkgr/core': 0.1.1 - tslib: 2.7.0 + '@pkgr/core': 0.2.9 - text-table@0.2.0: {} + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ts-api-utils@1.3.0(typescript@5.5.4): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: - typescript: 5.5.4 - - tslib@1.14.1: {} - - tslib@2.7.0: {} - - tsutils@3.21.0(typescript@5.5.4): - dependencies: - tslib: 1.14.1 - typescript: 5.5.4 + typescript: 5.9.3 type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - type-fest@0.20.2: {} + typescript-eslint@8.58.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color typescript@5.5.3: {} - typescript@5.5.4: {} - - undici-types@6.19.8: {} + typescript@5.9.3: {} universalify@2.0.1: {} @@ -1637,8 +1748,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrappy@1.0.2: {} - y18n@5.0.8: {} yargs-parser@21.1.1: {} diff --git a/rokit.toml b/rokit.toml index 02a00e8..9348712 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,12 +1,6 @@ -# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. -# For more information, see https://github.com/rojo-rbx/rokit - -# New tools can be added by running `rokit add ` in a terminal. - [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.4.4" -wally-package-types = "JohnnyMorganz/wally-package-types@1.4.2" -stylua = "johnnymorganz/stylua@2.0.2" -selene = "Kampfkarren/selene@0.28.0" -luau-lsp = "johnnymorganz/luau-lsp@1.38.1" +rojo = "rojo-rbx/rojo@7.6.1" +stylua = "johnnymorganz/stylua@2.5.2" +luau-lsp = "johnnymorganz/luau-lsp@1.68.1" +lute = "luau-lang/lute@1.0.0" diff --git a/selene.toml b/selene.toml deleted file mode 100644 index 56479a7..0000000 --- a/selene.toml +++ /dev/null @@ -1,5 +0,0 @@ -std = "roblox+testez" - -[lints] -global_usage = "allow" -shadowing = "allow" diff --git a/stylua.toml b/stylua.toml index caaa48d..26bd9ac 100644 --- a/stylua.toml +++ b/stylua.toml @@ -1 +1,4 @@ syntax = "Luau" + +[sort_requires] +enabled = true diff --git a/testez-companion.toml b/testez-companion.toml deleted file mode 100644 index 204e049..0000000 --- a/testez-companion.toml +++ /dev/null @@ -1 +0,0 @@ -roots = ["ServerScriptService/Tests"] diff --git a/testez.yml b/testez.yml deleted file mode 100644 index 08be74e..0000000 --- a/testez.yml +++ /dev/null @@ -1,53 +0,0 @@ ---- -globals: - FIXME: - args: - - required: false - type: string - FOCUS: - args: [] - SKIP: - args: [] - afterAll: - args: - - type: function - afterEach: - args: - - type: function - beforeAll: - args: - - type: function - beforeEach: - args: - - type: function - describe: - args: - - type: string - - type: function - describeFOCUS: - args: - - type: string - - type: function - describeSKIP: - args: - - type: string - - type: function - expect: - args: - - type: any - it: - args: - - type: string - - type: function - itFIXME: - args: - - type: string - - type: function - itFOCUS: - args: - - type: string - - type: function - itSKIP: - args: - - type: string - - type: function diff --git a/tests/benchmarks/atom.bench.luau b/tests/benchmarks/atom.bench.luau deleted file mode 100644 index 3bae3ca..0000000 --- a/tests/benchmarks/atom.bench.luau +++ /dev/null @@ -1,30 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local computed = Charm.computed -local atom = Charm.atom - -return { - ParameterGenerator = function() end, - Functions = { - atom = function() - local state = atom(0) - - for _ = 1, 500 do - state(math.random(1, 2)) - end - end, - - computed = function() - local state = atom(0) - - local _double = computed(function() - return 2 * state() - end) - - for _ = 1, 500 do - state(math.random(1, 2)) - end - end, - }, -} diff --git a/tests/benchmarks/patch.bench.luau b/tests/benchmarks/patch.bench.luau deleted file mode 100644 index 6ea53f6..0000000 --- a/tests/benchmarks/patch.bench.luau +++ /dev/null @@ -1,48 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local patch = require(ReplicatedStorage.DevPackages.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 - if math.random() > 0.5 then - continue - end - - state.users[user] = { - coins = math.random(1, 2), - gems = math.random(1, 2), - } - 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/tests/charm-sync/client.spec.luau b/tests/charm-sync/client.spec.luau deleted file mode 100644 index 2d2de15..0000000 --- a/tests/charm-sync/client.spec.luau +++ /dev/null @@ -1,121 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local subscribe = Charm.subscribe -local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) -local client = CharmSync.client - -return function() - it("syncs state payload", function() - local a = atom(1) - local client = client({ atoms = { a = a } }) - - client:sync({ - type = "init", - data = { a = 2 }, - }) - - expect(a()).to.equal(2) - end) - - it("syncs state patch", function() - local a = atom({ b = 1, c = 2 }) - local previous = a() - local client = client({ atoms = { a = a } }) - - client:sync({ - type = "init", - data = { a = a() }, - }) - - expect(a()).to.equal(previous) - - client:sync({ - type = "patch", - data = { a = { b = 3 } }, - }) - - expect(a()).to.never.equal(previous) - expect(a().b).to.equal(3) - expect(a().c).to.equal(2) - end) - - it("fixes sparse array patches", function() - local a = atom({ 1 }) - local client = client({ atoms = { a = a } }) - - client:sync({ - type = "init", - data = { a = a() }, - }) - - -- This patch would be the result of a diff between { 1 } and { 1, 2 } that - -- was JSON-serialized and deserialized. The client should be able to catch - -- this and convert the string key back to a number. - client:sync({ - type = "patch", - data = { a = { ["2"] = 2 } }, - }) - - expect(a()["2" :: any]).to.never.be.ok() - expect(a()[2]).to.equal(2) - end) - - it("applies multiple patches", function() - local a = atom(1) - local client = client({ atoms = { a = a } }) - local calls = {} - - subscribe(a, function(value) - table.insert(calls, value) - end) - - client:sync({ - type = "init", - data = { a = a() }, - }) - - client:sync({ - type = "patch", - data = { a = 2 }, - }, { - type = "patch", - data = { a = 3 }, - }) - - expect(a()).to.equal(3) - expect(calls[1]).to.equal(2) - expect(calls[2]).to.equal(3) - end) - - it("batches patches", function() - local a = atom(0) - local b = atom(0) - local client = client({ atoms = { a = a, b = b } }) - local states = {} - - subscribe(a, function() - states.b = b() - end) - - subscribe(b, function() - states.a = a() - end) - - client:sync({ - type = "init", - data = { a = a(), b = b() }, - }) - - client:sync({ - type = "patch", - data = { a = 1, b = 2 }, - }) - - expect(a()).to.equal(1) - expect(b()).to.equal(2) - expect(states.a).to.equal(1) - expect(states.b).to.equal(2) - end) -end diff --git a/tests/charm-sync/flatten.spec.luau b/tests/charm-sync/flatten.spec.luau deleted file mode 100644 index c274d05..0000000 --- a/tests/charm-sync/flatten.spec.luau +++ /dev/null @@ -1,24 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) -local flatten = CharmSync.flatten - -return function() - it("flattens atoms", function() - local input = { - a = atom(0), - b = { c = atom(0) }, - d = { e = { f = atom(0) } }, - } - local result = flatten(input) - - expect(result.a).to.equal(input.a) - expect(result["b/c"]).to.equal(input.b.c) - expect(result["d/e/f"]).to.equal(input.d.e.f) - - expect(result.b).to.never.be.ok() - expect(result.d).to.never.be.ok() - end) -end diff --git a/tests/charm-sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau deleted file mode 100644 index 6e850b2..0000000 --- a/tests/charm-sync/patch.spec.luau +++ /dev/null @@ -1,121 +0,0 @@ -local HttpService = game:GetService("HttpService") -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) - -return function() - local function serdes(value: { [any]: any }) - return HttpService:JSONDecode(HttpService:JSONEncode(value)) - end - - it("returns a patch", function() - local prevState = { foo = "bar", baz = "qux" } - local nextState = { foo = "bar", baz = "quux" } - local patches = patch.diff(prevState, nextState) - - expect(patches).to.be.ok() - expect(patches.baz).to.equal("quux") - expect(patches.foo).to.never.be.ok() - end) - - it("applies a patch", function() - local prevState = { foo = "bar", baz = "qux" } - local patches = { baz = "quux", qaz = "qux" } - local applied = patch.apply(prevState, patches) - - expect(applied).to.be.ok() - expect(applied.baz).to.equal("quux") - expect(applied.foo).to.equal("bar") - expect(applied.qaz).to.equal("qux") - end) - - it("allows nested patches", function() - local prevState = { foo = { bar = "baz" } } - local nextState = { foo = { bar = "qux" } } - local patches = patch.diff(prevState, nextState) - local applied = patch.apply(prevState, patches) - - expect(applied.foo.bar).to.equal("qux") - end) - - it("preserves array density when applying", function() - local prevState = { "a", "b", "c", "d" } - local nextState = { ["2"] = "B", ["4"] = "D" } - local applied = patch.apply(prevState, nextState) - - expect(applied[1]).to.equal("a") - expect(applied[2]).to.equal("B") - expect(applied[3]).to.equal("c") - expect(applied[4]).to.equal("D") - - expect(applied["2"]).to.never.be.ok() - expect(applied["4"]).to.never.be.ok() - end) - - it("preserves array density after serdes", function() - -- This particular change results in a patch that cannot be serialized - -- without losing information. Charm resolves this issue internally. - local prevState = { "a", "b", "c", "d", "e" } - local nextState = { "A", "b", "c", "D" } - - local patches = serdes(patch.diff(prevState, nextState)) - local applied = patch.apply(prevState, patches) - - for index in next, prevState do - expect(applied[index]).to.equal(nextState[index]) - end - - for index in next, applied do - expect(applied[index]).to.equal(nextState[index]) - end - end) - - it("preserves large gaps between keys", function() - local prevState = table.create(10000, "foo") - local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" }) - - local start = os.clock() - - local patches = serdes(patch.diff(prevState, nextState)) - local applied = patch.apply(prevState, patches) - - assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`) - - for index in next, prevState do - expect(applied[index]).to.equal(nextState[index]) - end - end) - - it("cannot preserve sparse arrays", function() - local prevState = {} - local nextState = { [2] = "foo" } - - local patches = serdes(patch.diff(prevState, nextState)) - local applied = patch.apply(prevState, patches) - - -- Not enough information is available to determine whether 'patches' - -- should keep the string keys added during remote event serialization. - expect(applied["2"]).to.be.ok() - expect(applied[2]).to.never.be.ok() - end) - - it("optionally fixes sparse arrays", function() - for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do - expect(index).to.be.a("string") - end - - for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do - expect(index).to.be.a("number") - end - end) - - it("optionally validates state", function() - expect(function() - patch.diff({}, { [0.1] = true }, true) - end).to.throw() - - expect(function() - patch.diff({}, { [0.1] = true }, false) - end).to.never.throw() - end) -end diff --git a/tests/charm-sync/server.spec.luau b/tests/charm-sync/server.spec.luau deleted file mode 100644 index 5fc33f9..0000000 --- a/tests/charm-sync/server.spec.luau +++ /dev/null @@ -1,97 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) -type SyncPayload = CharmSync.SyncPayload -local server = CharmSync.server -local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) - -return function() - local target: Player = {} :: any - - it("sends state to new players", function() - local a = atom(1) - local server = server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: SyncPayload - - local disconnect = server:connect(function(...) - player, payload = ... - end) - - server:hydrate(target) - - expect(player).to.equal(target) - expect(payload.type).to.equal("init") - expect(payload.data.a).to.equal(1) - - disconnect() - end) - - it("sends state patches", function() - local a = atom({ b = 1, c = 2 } :: { [string]: number? }) - local server = server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: SyncPayload - - local disconnect = server:connect(function(...) - player, payload = ... - end) - - a({ b = 1, c = 5 }) - server:_sendPatch(target) - - expect(player).to.equal(target) - expect(payload.type).to.equal("patch") - expect(payload.data.a.b).to.never.be.ok() - expect(payload.data.a.c).to.equal(5) - - a({ b = 1, d = 1 }) - server:_sendPatch(target) - - expect(payload.data.a.b).to.never.be.ok() - expect(patch.isNone(payload.data.a.c)).to.equal(true) - expect(payload.data.a.d).to.equal(1) - - disconnect() - end) - - it("preserves history", function() - local a = atom(1) - local b = atom(1) - local payloads: { SyncPayload } - - local server = server({ - atoms = { a = a, b = b }, - interval = -1, - preserveHistory = true, - }) - - local disconnect = server:connect(function(_, ...) - payloads = { ... } - end) - - a(2) - b(2) - server:_sendPatch(target) - - expect(#payloads).to.equal(1) - expect(payloads[1].type).to.equal("patch") - expect(payloads[1].data.a).to.equal(2) - expect(payloads[1].data.b).to.equal(2) - - a(3) - a(4) - b(3) - server:_sendPatch(target) - - expect(#payloads).to.equal(2) - expect(payloads[1].type).to.equal("patch") - expect(payloads[1].data.a).to.equal(3) - expect(payloads[1].data.b).to.never.be.ok() - expect(payloads[2].type).to.equal("patch") - expect(payloads[2].data.a).to.equal(4) - expect(payloads[2].data.b).to.equal(3) - - disconnect() - end) -end diff --git a/tests/charm-sync/validate.spec.luau b/tests/charm-sync/validate.spec.luau deleted file mode 100644 index 0a378d8..0000000 --- a/tests/charm-sync/validate.spec.luau +++ /dev/null @@ -1,33 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate) - -return function() - it("validates inputs", function() - local cases: { [string]: { value: any, expected: any } } = { - array = { value = { 1, 2, 3 }, expected = true }, - array_sparse = { value = { 1, nil :: any, 3 }, expected = true }, - array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false }, - - map_decimal_key = { value = { [1.5] = 0 }, expected = false }, - map_infinity_key = { value = { [math.huge] = true }, expected = false }, - map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false }, - - nested_atom = { value = atom(0), expected = false }, - } - - for key, case in next, cases do - local success, result = pcall(function() - validate(case.value, key) - end) - - 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) -end diff --git a/tests/charm/atom.spec.luau b/tests/charm/atom.spec.luau deleted file mode 100644 index 0872037..0000000 --- a/tests/charm/atom.spec.luau +++ /dev/null @@ -1,62 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local store = require(ReplicatedStorage.DevPackages.Charm.store) - -return function() - it("updates state", function() - local source = atom(1) - source(2) - expect(source()).to.equal(2) - source(function(x) - return x + 1 - end) - expect(source()).to.equal(3) - end) - - it("calls listeners", function() - local source = atom(1) - local value - local function listener() - value = source() - end - store.listeners[source][listener] = true - source(2) - expect(value).to.equal(2) - end) - - it("skips pending listeners", function() - local source = atom(1) - local outer = 0 - local inner = 0 - local function listener() - outer += 1 - local function listener() - inner += 1 - end - store.listeners[source][listener] = true - end - store.listeners[source][listener] = true - source(2) - expect(outer).to.equal(1) - expect(inner).to.equal(0) - source(3) - expect(outer).to.equal(2) - expect(inner).to.equal(1) - end) - - it("takes a custom comparator", function() - local max = atom(1, { - equals = function(prev, next) - return next < prev - end, - }) - max(2) - expect(max()).to.equal(2) - max(1) - expect(max()).to.equal(2) - max(3) - expect(max()).to.equal(3) - end) -end diff --git a/tests/charm/batch.spec.luau b/tests/charm/batch.spec.luau deleted file mode 100644 index 375f515..0000000 --- a/tests/charm/batch.spec.luau +++ /dev/null @@ -1,64 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local computed = Charm.computed -local subscribe = Charm.subscribe -local effect = Charm.effect -local store = require(ReplicatedStorage.DevPackages.Charm.store) - -return function() - it("batches updates", function() - local source = atom(1) - local value = 0 - subscribe(source, function() - value += 1 - end) - store.batch(function() - source(2) - source(3) - source(4) - end) - expect(value).to.equal(1) - end) - - it("handles duplicate listeners", function() - local a = atom(1) - local b = atom(1) - local value = 0 - effect(function() - value += 1 - a() - b() - end) - value = 0 -- effect() will run once initially - store.batch(function() - a(2) - b(2) - a(3) - end) - expect(value).to.equal(1) - end) - - it("batches computed atoms", function() - local source = atom(1) - local updates = 0 - local computations = 0 - local double = computed(function() - computations += 1 - return source() * 2 - end) - computations = 0 -- computed atoms will run once initially - subscribe(double, function() - updates += 1 - end) - store.batch(function() - source(2) - source(3) - source(4) - end) - expect(updates).to.equal(1) - expect(computations).to.equal(1) - expect(double()).to.equal(8) - end) -end diff --git a/tests/charm/capture.spec.luau b/tests/charm/capture.spec.luau deleted file mode 100644 index 80669a5..0000000 --- a/tests/charm/capture.spec.luau +++ /dev/null @@ -1,64 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local subscribe = Charm.subscribe -local store = require(ReplicatedStorage.DevPackages.Charm.store) - -return function() - it("gracefully handles yielding", function() - expect(function() - store.capture(function() - task.wait(10) - return 1 - end) - end).to.throw() - end) - - it("captures atoms from a function", function() - local a, b, c = atom(1), atom(1), atom(2) - local deps, result = store.capture(function() - return a() + b() - end) - expect(deps[a]).to.be.ok() - expect(deps[b]).to.be.ok() - expect(deps[c]).to.never.be.ok() - expect(result).to.equal(2) - end) - - it("captures an atom by itself", function() - local a = atom(1) - local deps, result = store.capture(a) - expect(deps[a]).to.be.ok() - expect(result).to.equal(1) - end) - - it("skips atoms in inner capture calls", function() - local a = atom(1) - local b = atom(1) - local inner - local outer = store.capture(function() - inner = store.capture(function() - return b() - end) - return a() - end) - - assert(outer[a], "outer did not capture a") - assert(outer[b] == nil, "outer captured b") - assert(inner[a] == nil, "inner captured a") - assert(inner[b], "inner did not capture b") - end) - - it("should not capture notified dependents", function() - local state = atom(1) - - subscribe(state, function() end) - - local deps = store.capture(function() - return state(2) - end) - - expect(deps[state]).to.never.be.ok() - end) -end diff --git a/tests/charm/computed.spec.luau b/tests/charm/computed.spec.luau deleted file mode 100644 index d71d46d..0000000 --- a/tests/charm/computed.spec.luau +++ /dev/null @@ -1,105 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local computed = Charm.computed -local store = require(ReplicatedStorage.DevPackages.Charm.store) -local collect = require(script.Parent.Parent.utils.collect) -local count = require(script.Parent.Parent.utils.count) - -return function() - it("memoizes the result", function() - local calls = 0 - local a = atom(1) - local b = atom(2) - local double = computed(function() - calls += 1 - return a() * b() - end) - expect(calls).to.equal(1) - a(1) - expect(calls).to.equal(1) - a(2) - expect(calls).to.equal(2) - b(3) - expect(calls).to.equal(3) - double() - expect(calls).to.equal(3) - end) - - it("can be nested", function() - local source = atom(1) - local double = computed(function() - return source() * 2 - end) - local quadruple = computed(function() - return double() * 2 - end) - expect(quadruple()).to.equal(4) - source(2) - expect(quadruple()).to.equal(8) - end) - - it("collects computed atoms", function() - local source = atom(0) - - computed(function() - return source() - end) - - collect() - - assert(count(store.listeners[source]) == 0, "computed atom did not gc") - end) - - it("collects nested computed atoms", function() - local source = atom(0) - - do - -- Declare this in another scope to ensure no references exist at the - -- time of garbage collection. - local double = computed(function() - return source() * 2 - end) - - computed(function() - return double() * 2 - end) - end - - collect() - - assert(count(store.listeners[source]) == 0, "nested computed atom did not gc") - end) - - it("tracks conditional dependencies", function() - local condition = atom(false) - local a = atom(0) - local b = atom(1) - local reruns = 0 - local current - - -- assign variable to avoid gc - local _ = computed(function() - reruns += 1 - current = if condition() then a() else b() - return current - end) - - expect(reruns).to.equal(1) - expect(current).to.equal(1) - - condition(true) - expect(reruns).to.equal(2) - expect(current).to.equal(0) - - a(1) - b(2) - expect(reruns).to.equal(3) - expect(current).to.equal(1) - - condition(false) - expect(reruns).to.equal(4) - expect(current).to.equal(2) - end) -end diff --git a/tests/charm/effect.spec.luau b/tests/charm/effect.spec.luau deleted file mode 100644 index 1e834d0..0000000 --- a/tests/charm/effect.spec.luau +++ /dev/null @@ -1,111 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local effect = Charm.effect - -return function() - it("runs on state change", function() - local a = atom(0) - local b = atom(0) - local current - local unsubscribe = effect(function() - current = a() + b() - end) - - expect(current).to.equal(0) - - for index = 1, 3 do - a(index) - expect(current).to.equal(index) - end - - for index = 1, 3 do - b(index) - expect(current).to.equal(a() + index) - end - - unsubscribe() - a(4) - expect(current).to.equal(6) - end) - - it("accepts a cleanup function", function() - local a = atom(0) - local cleanups = 0 - local unsubscribe = effect(function() - a() - return function() - cleanups += 1 - end - end) - - expect(cleanups).to.equal(0) - a(1) - expect(cleanups).to.equal(1) - unsubscribe() - expect(cleanups).to.equal(2) - end) - - it("tracks conditional dependencies", function() - local condition = atom(false) - local a = atom(0) - local b = atom(1) - local reruns = 0 - local current - - effect(function() - reruns += 1 - current = if condition() then a() else b() - end) - - expect(reruns).to.equal(1) - expect(current).to.equal(1) - - condition(true) - expect(reruns).to.equal(2) - expect(current).to.equal(0) - - a(1) - b(2) - expect(reruns).to.equal(3) - expect(current).to.equal(1) - - condition(false) - expect(reruns).to.equal(4) - expect(current).to.equal(2) - end) - - it("allows self-cleanup", function() - local a = atom(1) - local cleanedUp = false - local cleanup - - cleanup = effect(function() - if cleanup then - assert(not cleanedUp, "cleanup ran twice") - cleanup() - cleanedUp = true - end - a() - end) - - a(2) - a(3) - end) - - it("allows immediate self-cleanup", function() - local a = atom(1) - local cleanedUp = false - - effect(function(cleanup) - assert(not cleanedUp, "cleanup ran twice") - cleanup() - cleanedUp = true - a() - end) - - a(2) - a(3) - end) -end diff --git a/tests/charm/mapped.spec.luau b/tests/charm/mapped.spec.luau deleted file mode 100644 index ea3d406..0000000 --- a/tests/charm/mapped.spec.luau +++ /dev/null @@ -1,97 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local mapped = Charm.mapped -local observe = Charm.observe -local store = require(ReplicatedStorage.DevPackages.Charm.store) - -return function() - it("returns mapped atoms", function() - local itemsAtom = atom({ "a", "b", "c" }) - local mappedAtom = mapped(itemsAtom, function(value, key) - return string.upper(value), key - end) - local updates = 0 - - store.listeners[mappedAtom][function() - updates += 1 - end] = true - - for key = 1, 3 do - local item = mappedAtom()[key] - expect(item).to.equal(string.upper(itemsAtom()[key])) - end - - itemsAtom({ "d", "e", "f" }) - expect(updates).to.equal(1) - - for key = 1, 3 do - local item = mappedAtom()[key] - expect(item).to.equal(string.upper(itemsAtom()[key])) - end - - itemsAtom({ [1] = "d", [3] = "f" }) - expect(mappedAtom()[2]).to.never.be.ok() - expect(updates).to.equal(2) - end) - - it("uses key extractor", function() - local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } }) - local mappedAtom = mapped(itemsAtom, function(item) - return item, item.key - end) - - for key = 1, 3 do - local item = mappedAtom()[key] - expect(item).to.equal(itemsAtom()[key]) - end - - local previous = mappedAtom() - itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) - - expect(mappedAtom()[1]).to.equal(previous[1]) - expect(mappedAtom()[1]).to.equal(itemsAtom()[1]) - expect(mappedAtom()[2]).to.never.be.ok() - expect(mappedAtom()[3]).to.equal(previous[3]) - expect(mappedAtom()[3]).to.equal(itemsAtom()[2]) - end) - - it("allows observe()", function() - local itemsAtom = atom({ - { key = 1, value = "a" }, - { key = 2, value = "b" }, - { key = 3, value = "c" }, - }) - - local mappedAtom = mapped(itemsAtom, function(item) - return item, item.key - end) - - local added = 0 - local deleted = 0 - - observe(mappedAtom, function(item, key) - expect(item).to.equal(itemsAtom()[key]) - added += 1 - - return function() - expect(mappedAtom()[key]).to.never.be.ok() - deleted += 1 - end - end) - - expect(added).to.equal(3) - expect(deleted).to.equal(0) - - itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) - - expect(added).to.equal(3) - expect(deleted).to.equal(1) - - itemsAtom({}) - - expect(added).to.equal(3) - expect(deleted).to.equal(3) - end) -end diff --git a/tests/charm/observe.spec.luau b/tests/charm/observe.spec.luau deleted file mode 100644 index 314366c..0000000 --- a/tests/charm/observe.spec.luau +++ /dev/null @@ -1,35 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local observe = Charm.observe - -return function() - it("observes lifetime", function() - local itemsAtom = atom({ "a", "b", "c" }) - local state = {} - - local cleanup = observe(itemsAtom, function(item, key) - state[key] = item - return function() - state[key] = nil - end - end) - - for key = 1, 3 do - expect(state[key]).to.equal(itemsAtom()[key]) - end - - itemsAtom({ [4] = "d", [5] = "e", [6] = "f" }) - - for key = 1, 6 do - expect(state[key]).to.equal(itemsAtom()[key]) - end - - cleanup() - - for key = 1, 6 do - expect(state[key]).to.equal(nil) - end - end) -end diff --git a/tests/charm/peek.spec.luau b/tests/charm/peek.spec.luau deleted file mode 100644 index 7b72b86..0000000 --- a/tests/charm/peek.spec.luau +++ /dev/null @@ -1,26 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local capture = Charm.capture -local peek = Charm.peek - -return function() - it("calls an atom without capturing it", function() - local a, b, c = atom(1), atom(1), atom(1) - local deps, result = capture(function() - return a() + peek(b) + c() - end) - expect(deps[a]).to.be.ok() - expect(deps[b]).to.never.be.ok() - expect(deps[c]).to.be.ok() - expect(result).to.equal(3) - end) - - it("passes through arguments", function() - expect(peek(1)).to.equal(1) - expect(peek(function() - return 1 - end)).to.equal(1) - end) -end diff --git a/tests/charm/subscribe.spec.luau b/tests/charm/subscribe.spec.luau deleted file mode 100644 index a732be9..0000000 --- a/tests/charm/subscribe.spec.luau +++ /dev/null @@ -1,101 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local computed = Charm.computed -local subscribe = Charm.subscribe -local store = require(ReplicatedStorage.DevPackages.Charm.store) -local collect = require(script.Parent.Parent.utils.collect) -local count = require(script.Parent.Parent.utils.count) - -return function() - it("runs on state change", function() - local current, previous - local source = atom(0) - local unsubscribe = subscribe(source, function(...) - current, previous = ... - end) - - for index = 1, 3 do - source(index) - expect(current).to.equal(index) - expect(previous).to.equal(index - 1) - end - - unsubscribe() - source(4) - - expect(current).to.equal(3) - expect(previous).to.equal(2) - end) - - it("prevents garbage collect", function() - local source = atom(1) - local updates = 0 - - -- Create a new scope to encapsulate variables with a limited lifetime, like - -- 'unsubscribe' and 'computed', to ensure they are garbage collected. - -- - -- The idea here is to test whether subscribe() prevents every computed - -- atom in a chain from being garbage collected. If any are GCed, then - -- the subscription will stop updating and the test will fail. - do - local unsubscribe - - do - local double = computed(function() - return source() * 2 - end) - - unsubscribe = subscribe( - computed(function() - return double() * 2 - end), - function() - updates += 1 - end - ) - end - - collect() - source(source() + 1) - - assert(updates == 1, "subscription stopped updating") - assert(count(store.listeners[source]) == 1, "computed atom was garbage collected") - - unsubscribe() - end - - collect() - - assert(count(store.listeners[source]) == 0, "computed atom did not collect") - end) - - it("tracks conditional dependencies", function() - local condition = atom(false) - local a = atom(0) - local b = atom(1) - local reruns = 0 - local current - - subscribe(function() - return if condition() then a() else b() - end, function(state) - reruns += 1 - current = state - end) - - condition(true) - expect(reruns).to.equal(1) - expect(current).to.equal(0) - - a(1) - b(2) - expect(reruns).to.equal(2) - expect(current).to.equal(1) - - condition(false) - expect(reruns).to.equal(3) - expect(current).to.equal(2) - end) -end diff --git a/tests/init.server.luau b/tests/init.server.luau deleted file mode 100644 index 518d08b..0000000 --- a/tests/init.server.luau +++ /dev/null @@ -1,13 +0,0 @@ ---!nocheck - -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ) - --- Start Charm in debug mode -_G.__DEV__ = true -require(ReplicatedStorage.DevPackages.Charm) -require(ReplicatedStorage.DevPackages.CharmSync) -_G.__DEV__ = false - -TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter) diff --git a/tests/init.spec.luau b/tests/init.spec.luau deleted file mode 100644 index aed8099..0000000 --- a/tests/init.spec.luau +++ /dev/null @@ -1,11 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local store = require(ReplicatedStorage.DevPackages.Charm.store) - -return function() - afterEach(function() - table.clear(store.listeners :: any) - table.clear(store.capturing.stack) - store.capturing.index = 0 - end) -end diff --git a/tests/react/useAtom.spec.luau b/tests/react/useAtom.spec.luau deleted file mode 100644 index 5476f28..0000000 --- a/tests/react/useAtom.spec.luau +++ /dev/null @@ -1,46 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm) -local useAtom = ReactCharm.useAtom -local renderHook = require(script.Parent.Parent.utils.renderHook) - -return function() - it("tracks atom state", function() - local state = atom(0) - local hook = renderHook(function() - return useAtom(state) - end) - expect(hook.result).to.equal(0) - state(1) - expect(hook.result).to.equal(1) - end) - - it("accepts a selector", function() - local state = atom(0) - local hook = renderHook(function() - return useAtom(function() - return 2 * state() - end) - end) - expect(hook.result).to.equal(0) - state(1) - expect(hook.result).to.equal(2) - end) - - it("tracks dependencies", function() - local state = atom(0) - local hook = renderHook(function() - return useAtom(state, { state }) - end) - expect(hook.result).to.equal(0) - state(1) - expect(hook.result).to.equal(1) - state = atom(0) - hook.rerender() - expect(hook.result).to.equal(0) - state(1) - expect(hook.result).to.equal(1) - end) -end diff --git a/tests/run.server.luau b/tests/run.server.luau new file mode 100644 index 0000000..44015cb --- /dev/null +++ b/tests/run.server.luau @@ -0,0 +1,33 @@ +local Charm = require("../packages/charm/src") +local test = require("./test") + +Charm.flags.strict = true +Charm.flags.frozen = true + +local describeInRoblox = test.describeIf(function() + return not not game +end) + +test.describe("charm", function() + require("../packages/charm/test") +end) + +test.describe("charm-sync", function() + require("../packages/charm-sync/test") +end) + +test.describe("deep-charm", function() + require("../packages/deep-charm/test") +end) + +describeInRoblox("react-charm", function() + require("../packages/react-charm/test") +end) + +describeInRoblox("vide-charm", function() + require("../packages/vide-charm/test") +end) + +test.finish() + +return {} diff --git a/tests/test b/tests/test new file mode 160000 index 0000000..7ef2b88 --- /dev/null +++ b/tests/test @@ -0,0 +1 @@ +Subproject commit 7ef2b88191af884d543e7392d50c7e2adc484e32 diff --git a/tests/utils/collect.luau b/tests/utils/collect.luau deleted file mode 100644 index 0f498aa..0000000 --- a/tests/utils/collect.luau +++ /dev/null @@ -1,12 +0,0 @@ ---[=[ - Trigger a garbage collection cycle by allocating a large amount of junk memory - and then waiting for the next frame. -]=] -local function collect() - for _ = 1, 1e4 do - local _ = table.create(1e3) - end - task.wait() -end - -return collect diff --git a/tests/utils/count.luau b/tests/utils/count.luau deleted file mode 100644 index 251776a..0000000 --- a/tests/utils/count.luau +++ /dev/null @@ -1,15 +0,0 @@ ---[=[ - Counts the number of elements in a table. - - @param object The table to count. - @return The number of elements in the table. -]=] -local function count(object: any): number - local count = 0 - for _ in next, object do - count += 1 - end - return count -end - -return count diff --git a/tests/vide/useAtom.spec.luau b/tests/vide/useAtom.spec.luau deleted file mode 100644 index cc4dd40..0000000 --- a/tests/vide/useAtom.spec.luau +++ /dev/null @@ -1,36 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local Vide = require(ReplicatedStorage.DevPackages.Vide) -local root = Vide.root -local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm) -local useAtom = VideCharm.useAtom - -return function() - it("tracks atom state", function() - local state = atom(0) - local source - local cleanup = root(function() - source = useAtom(state) - end) - expect(source()).to.equal(0) - state(1) - expect(source()).to.equal(1) - cleanup() - end) - - it("accepts a selector", function() - local state = atom(0) - local source - local cleanup = root(function() - source = useAtom(function() - return 2 * state() - end) - end) - expect(source()).to.equal(0) - state(1) - expect(source()).to.equal(2) - cleanup() - end) -end diff --git a/tsconfig.json b/tsconfig.json index b255ab8..bf99b80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,13 +15,6 @@ "moduleDetection": "force", "strict": true, "target": "ESNext", - "typeRoots": ["node_modules/@rbxts"], - - // configurable - "rootDir": "src", - "outDir": "out", - "incremental": true, - "tsBuildInfoFile": "out/tsconfig.tsbuildinfo", - "declaration": true + "typeRoots": ["node_modules/@rbxts"] } } From d09fb5536b8d64b84e12b48bf1d2d14327534807 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:07:50 -0700 Subject: [PATCH 58/68] fix: set strict flags if `RunService.IsStudio` is true --- README.md | 2 +- packages/charm/src/index.d.ts | 4 ++-- packages/charm/src/init.luau | 12 ++++++------ rokit.toml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 35895ac..c630eae 100644 --- a/README.md +++ b/README.md @@ -602,7 +602,7 @@ Charm exposes the following global flags to customize behavior: | frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. | | trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | -The `strict` and `frozen` flags are automatically enabled in Roblox Studio and other development environments where the Luau optimization level is `O1` or lower. +The `strict` and `frozen` flags are automatically enabled in Roblox Studio. --- diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 4e4ad8d..e4689e5 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -50,12 +50,12 @@ export const 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. + * Enabled by default in Roblox Studio. */ strict: boolean; /** * Enforces immutability of tables by deep-freezing table values. Enabled - * if the optimization level is below 2, which is true in studio. + * by default in Roblox Studio. */ frozen: boolean; /** diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index d47ede5..ff87785 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -68,22 +68,22 @@ local batchDepth = 0 -- Dependencies accessed during this time will link to the active subscriber. local activeSub: ReactiveNode? -local function isO2(): boolean - return debug.info(1, "n") ~= "isO2" +local function isStudio(): boolean + return game ~= nil and game:GetService("RunService"):IsStudio() 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. + Enabled by default in Roblox Studio. ]] - strict = not isO2(), + strict = isStudio(), --[[ Enforces immutability of tables by deep-freezing table values. Enabled - if the optimization level is below 2, which is true in studio. + by default in Roblox Studio. ]] - frozen = not isO2(), + frozen = isStudio(), --[[ Whether parent effects should track inner effects and clean them up when the parent effect re-runs. This is usually desirable, but can be diff --git a/rokit.toml b/rokit.toml index 9348712..c99bf5d 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,6 @@ [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.6.1" +rojo = "rojo-rbx/rojo@7.7.0-rc.1" stylua = "johnnymorganz/stylua@2.5.2" luau-lsp = "johnnymorganz/luau-lsp@1.68.1" lute = "luau-lang/lute@1.0.0" From 29be66fa94523e4032aae44089aa8670cccec851 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:16:14 -0700 Subject: [PATCH 59/68] test: add skipped inner effect dispose order test --- packages/charm/test/effect.test.luau | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau index c94ec51..17cd0a0 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -573,4 +573,21 @@ test.skip("should handle inner effect cleanup disposing outer effect", function( assert(#order == 0, "should not cleanup after dispose") end) +test.skip("should old inner effect dispose before outer effect re-runs", function() + local order: { string } = {} + local src, setSrc = signal(false) + effect(function() + src() + table.insert(order, "outer") + effect(function() + return function() + table.insert(order, "inner") + end + end) + end) + table.clear(order) + setSrc(true) + assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`) +end) + return {} From ba870a8c2cbd1a96a7d2703a5bf7440aa06f95eb Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:34:35 -0700 Subject: [PATCH 60/68] release: charm v0.11.0 --- .npmrc | 1 - package.json | 2 +- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 4 ++-- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/deep-charm/package.json | 2 +- packages/deep-charm/wally.toml | 4 ++-- packages/react-charm/package.json | 2 +- packages/react-charm/wally.toml | 4 ++-- packages/vide-charm/package.json | 2 +- packages/vide-charm/wally.toml | 4 ++-- pnpm-workspace.yaml | 2 ++ 13 files changed, 17 insertions(+), 16 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index b644580..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -public-hoist-pattern[]=*@rbxts* diff --git a/package.json b/package.json index d5f8c09..ce5bb44 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.33.3", + "packageManager": "pnpm@11.8.0", "devDependencies": { "@eslint/js": "^9.39.4", "@rbxts/compiler-types": "3.0.0-types.0", diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 3efa745..6727a4a 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0-rc.7", + "version": "0.4.0", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 28a6fc9..b24a5e4 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0-rc.7" +version = "0.4.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.6" +Charm = "littensy/charm@^0.11.0" diff --git a/packages/charm/package.json b/packages/charm/package.json index 28f5c7c..fa04726 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0-rc.6", + "version": "0.11.0", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 6298b39..345d2b8 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0-rc.6" +version = "0.11.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json index 96d06af..0fa10b8 100644 --- a/packages/deep-charm/package.json +++ b/packages/deep-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/deep-charm", - "version": "0.1.0-rc.1", + "version": "0.1.0", "description": "Use Charm with plain Luau tables", "license": "MIT", "main": "src/init.luau", diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml index f1a2b11..f2ef6e9 100644 --- a/packages/deep-charm/wally.toml +++ b/packages/deep-charm/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/deep-charm" -version = "0.1.0-rc.1" +version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.6" +Charm = "littensy/charm@^0.11.0" diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index d58d8c7..64bf4a5 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.0-rc.4", + "version": "0.4.0", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index a1de6d4..71c822d 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.4.0-rc.4" +version = "0.4.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.6" +Charm = "littensy/charm@^0.11.0" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 2b4517a..19ced68 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.0-rc.4", + "version": "0.4.0", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 5c59947..4b473c3 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.4.0-rc.5" +version = "0.4.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.6" +Charm = "littensy/charm@^0.11.0" Vide = "centau/vide@^0.4" diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 924b55f..d8424bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - packages/* +publicHoistPattern: + - "*@rbxts*" From fdb506dc23994e00b3639a7fada044e47a17b5cf Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:40:03 -0700 Subject: [PATCH 61/68] docs: update readme with new charm-sync details --- README.md | 125 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 81 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index c630eae..43a7c64 100644 --- a/README.md +++ b/README.md @@ -623,34 +623,43 @@ CharmSync = "littensy/charm-sync@VERSION" ### Quick Start -Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: +Start by specifying the signals that the server should sync to clients. For this example, we'll replicate a round status and timer signal, and each player's inventory: ```luau --- nameStore -local getName, setName = signal("John") -local getSurname, setSurname = signal("Doe") -local ageAtom = atom(20) +-- store.luau + +local getStatus, setStatus = signal("intermission") +local timerAtom = atom(20) +local getInventories, setInventories = signal({} :: { [Username]: Inventory }) return { - getName = getName, - setName = setName, - getSurname = getSurname, - setSurname = setSurname, - ageAtom = ageAtom, + getStatus = getStatus, + setStatus = setStatus, + timerAtom = timerAtom, + getInventories = getInventories, + setInventories = setInventories, } ``` +#### Server setup + When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to: ```luau +-- sync.server.luau + playerReadyEvent.OnServerEvent:Connect(function(player) -- Sync signal getters, computed signals, atoms, or reactive proxies server.addSignalsToClient(player, { - name = nameStore.getName, - surname = nameStore.getSurname, - age = nameStore.ageAtom, + status = store.getStatus, + timer = store.timerAtom, + -- Signals unique to a player should also have a key unique to + -- that player, to avoid overwriting other synced signals + [`inventory-{player.UserId}`] = computed(function() + return store.getInventories()[player.Name] + end), }) end) @@ -659,30 +668,51 @@ Players.PlayerRemoving:Connect(function(player) end) server.connect(function(player, updates) - -- Customize how you send state updates to clients + -- Customize how you replicate state updates to clients syncEvent:FireClient(player, updates) end) + +-- Changes now get replicated to clients +store.setStatus("started") +store.timerAtom(30) ``` -> [!NOTE] -> On the server, make sure each key corresponds to the same data across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. +> [!WARNING] +> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player. +> +> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning. + +#### Client setup To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau +-- sync.client.luau + +-- The server is replicating a signal containing the client's +-- inventory, so this signal will receive those updates +local getInventory, setInventory = signal({} :: Inventory) + -- Add signal setters, atoms, or reactive proxies client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, - age = nameStore.ageAtom, + status = store.setStatus, + timer = store.timerAtom, + -- Signals unique to a player should also have a key unique to + -- that player, to avoid overwriting other synced signals + [`inventory-{Players.LocalPlayer.UserId}`] = setInventory, }) -- Update client signals when receiving updates from the server syncEvent.OnClientEvent:Connect(function(updates) client.patch(updates) end) + +-- The signals will now update to match the server's state +subscribe(getInventory, function(inventory) + print("My inventory is", table.concat(inventory)) +end) ``` --- @@ -702,38 +732,42 @@ A configuration table that customizes the behavior of Charm Sync on the server. ### `server.addSignalsToClient(client, getters)` -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. +The `addSignalsToClient` function replicates changes in the given signals to a specific client. When an update occurs, the client will receive a delta-compressed state patch containing only the values that changed. -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. +You can pass signal getter functions, computed signals, atoms, and [reactive proxies](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. ```luau playerReadyEvent.OnServerEvent:Connect(function(player) server.addSignalsToClient(player, { - name = nameStore.getName, - surname = nameStore.getSurname, - age = nameStore.ageAtom, + status = store.getStatus, + timer = store.timerAtom, }) end) ``` -You're also allowed to create new signals to sync to specific players, as long as the key is unique to that data: +You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal: ```luau playerReadyEvent.OnServerEvent:Connect(function(player) server.addSignalsToClient(player, { - -- 🟢 Good: Player-specific data is synced with a unique key - [`data-{player.UserId}`] = computed(function() - return getPlayerData(player.UserId) + -- 🟢 Good: Player-specific signals are synced with a unique key + [`playerData-{player.UserId}`] = computed(function() + return clientStore.getPlayerData(player.UserId) end), - -- 🔴 Bad: Syncing different data with the same keys does not work + -- 🔴 Bad: Syncing different signals with the same keys does not work playerData = computed(function() - return getPlayerData(player.UserId) + return clientStore.getPlayerData(player.UserId) end), }) end) ``` +> [!NOTE] +> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player. +> +> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning. + --- ### `server.removeSignalsFromClient(client, ...keys)` @@ -742,11 +776,11 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke ```luau server.addSignalsToClient(player, { - name = nameStore.getName, - surname = nameStore.getSurname, + status = store.getStatus, + timer = store.timerAtom, }) -server.removeSignalsFromClient(player, "surname") +server.removeSignalsFromClient(player, "status") ``` --- @@ -807,9 +841,11 @@ You can pass either writable signals, atoms, or [reactive objects](#reactiveinit ```luau client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, - age = nameStore.ageAtom, + status = store.setStatus, + timer = store.timerAtom, + -- Signals unique to a player should also have a key unique to + -- that player, to avoid overwriting other synced signals + [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, }) ``` @@ -821,11 +857,11 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta ```luau client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, + status = store.setStatus, + timer = store.timerAtom, }) -client.removeSignals("name") +client.removeSignals("status") ``` --- @@ -838,8 +874,9 @@ You should call `patch` when receiving updates from the server from a remote eve ```luau client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, + status = store.setStatus, + timer = store.timerAtom, + [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, }) syncEvent.OnClientEvent:Connect(function(updates) @@ -855,8 +892,8 @@ If you have a lot of signals to sync between the server and clients, it might be ```luau local sharedState = { - name = signalToAtom(nameStore.getName, nameStore.setName), - surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), + status = signalToAtom(store.getStatus, store.setStatus), + inventories = signalToAtom(store.getInventories, store.setInventories), } client.addSignals(sharedState) @@ -869,7 +906,7 @@ server.addSignalsToClient(client, sharedState) 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" }`. +But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. Charm addresses this by representing deleted values with a special `None` value denoted by `{ __none = "__none" }`. 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. From b05f3a9de6fc5b784ad95681908c52d1ee418cc0 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:54:46 -0700 Subject: [PATCH 62/68] docs: add missing `playerReadyEvent` call --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 43a7c64..07d9641 100644 --- a/README.md +++ b/README.md @@ -700,7 +700,7 @@ client.addSignals({ status = store.setStatus, timer = store.timerAtom, -- Signals unique to a player should also have a key unique to - -- that player, to avoid overwriting other synced signals + -- that player, to avoid name collisions [`inventory-{Players.LocalPlayer.UserId}`] = setInventory, }) @@ -709,6 +709,9 @@ syncEvent.OnClientEvent:Connect(function(updates) client.patch(updates) end) +-- Notify the server that the client is ready to receive updates +playerReadyEvent:FireServer() + -- The signals will now update to match the server's state subscribe(getInventory, function(inventory) print("My inventory is", table.concat(inventory)) @@ -844,7 +847,7 @@ client.addSignals({ status = store.setStatus, timer = store.timerAtom, -- Signals unique to a player should also have a key unique to - -- that player, to avoid overwriting other synced signals + -- that player, to avoid name collisions [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, }) ``` From 73fbfb2685f293cc98e5c0706668897ede2ea69a Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:52:58 -0700 Subject: [PATCH 63/68] fix: resolve type errors for new luau version --- packages/charm-sync/src/server.luau | 8 ++++---- packages/charm-sync/test/utils.test.luau | 2 +- packages/charm/src/init.luau | 2 +- packages/deep-charm/src/init.luau | 8 ++++---- packages/react-charm/src/init.luau | 2 +- rokit.toml | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index ca04ba7..4e07941 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -39,10 +39,10 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? support custom implementations of reactive proxies. ]] local function toRaw(value: T): T - value = value :: types.ReactiveProxy - if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then - value.__track() - return toRaw(value.__target) + local proxy = value :: types.ReactiveProxy + if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then + proxy.__track() + return toRaw(proxy.__target) end return value end diff --git a/packages/charm-sync/test/utils.test.luau b/packages/charm-sync/test/utils.test.luau index 15dcabf..1d50f55 100644 --- a/packages/charm-sync/test/utils.test.luau +++ b/packages/charm-sync/test/utils.test.luau @@ -6,7 +6,7 @@ test("should flatten functions", function() local input = { a = Charm.atom(0), b = { c = Charm.atom(0) }, - d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, + d = { e = { f = utils.signalToAtom<>(Charm.signal(0)) } }, } local result = utils.flatten<<{ [string]: Charm.Atom }>>(input) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index ff87785..0ba77d7 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -486,7 +486,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette end -- Check if the new value is different from the pending value - if if equals then not equals(node.pendingValue, value) else node.pendingValue ~= value then + if if equals then not equals(node.pendingValue, value :: T) else node.pendingValue ~= value then if flags.frozen then deepFreeze(value) end diff --git a/packages/deep-charm/src/init.luau b/packages/deep-charm/src/init.luau index 9eb9943..9e16cb1 100644 --- a/packages/deep-charm/src/init.luau +++ b/packages/deep-charm/src/init.luau @@ -36,9 +36,9 @@ end ]] local function toRaw(value: T): T if isReactive(value) then - value = value :: ReactiveProxy - value.__track() - return toRaw(value.__target) + local proxy = value :: ReactiveProxy + proxy.__track() + return toRaw(proxy.__target) end return value end @@ -115,7 +115,7 @@ end @return A reactive proxy if the value is a table ]] local function toReactive(value: T, trackParent: (() -> ())?): T - return if type(value) == "table" then reactive(value, trackParent) else value + return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T end function ReactiveProxy.__index(self: ReactiveProxy, key: any): any diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 19a95e8..08b5342 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -14,7 +14,7 @@ local React = require("./React") @return The current state. ]] local function useSignalState(getter: () -> T, dependencies: {}?): T - local value, setValue = React.useState(getter) + local value: T, setValue = React.useState(getter) React.useLayoutEffect(function(): () -> () return Charm.listen(getter, setValue :: (T, T?) -> ()) diff --git a/rokit.toml b/rokit.toml index c99bf5d..ca169e2 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,6 @@ [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.7.0-rc.1" +rojo = "rojo-rbx/rojo@7.7.0" stylua = "johnnymorganz/stylua@2.5.2" -luau-lsp = "johnnymorganz/luau-lsp@1.68.1" +luau-lsp = "johnnymorganz/luau-lsp@1.70.1" lute = "luau-lang/lute@1.0.0" From c31e5c5a3465036596f2dc8d4d7d1609b56b3771 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:06:38 -0700 Subject: [PATCH 64/68] fix(charm-sync): run addSignalsToClient outside tracking scope --- packages/charm-sync/src/server.luau | 2 +- packages/charm-sync/test/server.test.luau | 27 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 4e07941..0494804 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -61,7 +61,7 @@ local function trackSignal(key: string, getter: () -> T, identifier: unknown) local rawInitialValue = toRaw(initialValue) local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue - local dispose = Charm.effect(function() + local dispose = Charm.untracked(Charm.effect, function() local newValue = getter() local rawValue = toRaw(newValue) local isReactive = newValue ~= rawValue diff --git a/packages/charm-sync/test/server.test.luau b/packages/charm-sync/test/server.test.luau index 380fbb0..d43e7b8 100644 --- a/packages/charm-sync/test/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -405,4 +405,31 @@ test.describe("historical", function() end) end) +test("addSignalsToClient should ignore tracking context", function() + local counter = Charm.atom(0) + local payloads: { any }? + + server.connect(function(_client, _payloads) + payloads = _payloads + end) + + local dispose = Charm.effectScope(function() + server.addSignalsToClient("PLAYER_ID", { counter = counter }) + end) + + server.flush() + assert(payloads, "Expected payloads to be sent") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'") + + dispose() + + payloads = nil + counter(1) + server.flush() + assert(payloads, "Expected payloads to be sent") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'") +end) + return {} From 2fc3be34d0256fd0d6d57895930ce25175d9fe67 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:20:51 -0700 Subject: [PATCH 65/68] fix(charm-sync): preserve global patch list order for nil patches Resolves #67 --- packages/charm-sync/src/server.luau | 6 +++--- packages/charm-sync/test/server.test.luau | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 0494804..6485e18 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -125,8 +125,8 @@ local function diffGlobalUpdateBuffer(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) - for _, update in globalUpdates do - table.insert(patches, patch.diff(update.oldValue, update.newValue)) + for index, update in globalUpdates do + patches[index] = patch.diff(update.oldValue, update.newValue) end return patches @@ -146,7 +146,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { for index, update in globalUpdates do local patchData = globalPatches[index] - if not patchData then + if patchData == nil then continue end diff --git a/packages/charm-sync/test/server.test.luau b/packages/charm-sync/test/server.test.luau index d43e7b8..d24970c 100644 --- a/packages/charm-sync/test/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -403,6 +403,27 @@ test.describe("historical", function() server.flush() assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") end) + + test("should maintain patch list order for stale update", function() + local a = Charm.atom({ x = 1 }) + local b = Charm.atom({ y = 1 }) + local payloads + + server.connect(function(_client, _payloads) + payloads = _payloads + end) + + server.addSignalsToClient("client", { a = a, b = b }) + server.flush() + + payloads = nil + a({ x = 1 }) -- new table, same contents: diff is nil + b({ y = 2 }) + server.flush() + assert(payloads, "Expected payloads to be sent") + assert(payloads[1].type == "patch", "Expected payload to be patch") + assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'") + end) end) test("addSignalsToClient should ignore tracking context", function() From aa84fa1e3d99876c15fab9fbed5620d15a891e77 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:25:45 -0700 Subject: [PATCH 66/68] fix(charm-sync): warn if attempting to sync non-registered signal on the client --- packages/charm-sync/src/client.luau | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index a514b75..973a983 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -64,6 +64,8 @@ local function applyInitPayload(data: { [string]: any }) trackedSignals[key](value) elseif trackedReactives[key] then shallowMergeReactive(trackedReactives[key], value) + else + warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`) end end end @@ -80,6 +82,8 @@ local function applyPatchPayload(data: { [string]: any }) elseif trackedReactive then patch.applyMutable(trackedReactive.__target, diffs) Charm.trigger(trackedReactive.__track) + else + warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`) end end end From 1b856b065ee518b1c6c57a22d9b51b5cdca62107 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:30:33 -0700 Subject: [PATCH 67/68] release: charm v0.11.1 --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 2 +- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/deep-charm/package.json | 2 +- packages/deep-charm/wally.toml | 2 +- packages/react-charm/package.json | 2 +- packages/react-charm/wally.toml | 2 +- packages/vide-charm/package.json | 2 +- packages/vide-charm/wally.toml | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 6727a4a..f2c84a3 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0", + "version": "0.4.1", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index b24a5e4..589e7ed 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ diff --git a/packages/charm/package.json b/packages/charm/package.json index fa04726..7ef2e29 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0", + "version": "0.11.1", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 345d2b8..25eb0a5 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0" +version = "0.11.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json index 0fa10b8..081b711 100644 --- a/packages/deep-charm/package.json +++ b/packages/deep-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/deep-charm", - "version": "0.1.0", + "version": "0.1.1", "description": "Use Charm with plain Luau tables", "license": "MIT", "main": "src/init.luau", diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml index f2ef6e9..3f2702f 100644 --- a/packages/deep-charm/wally.toml +++ b/packages/deep-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/deep-charm" -version = "0.1.0" +version = "0.1.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index 64bf4a5..ca08eca 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.0", + "version": "0.4.1", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 71c822d..51ee9ab 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.4.0" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 19ced68..9b64809 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.0", + "version": "0.4.1", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 4b473c3..4aa1f71 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.4.0" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ From af4eb5262d640cf89c56217ab7feb403e939cbd2 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:31:07 -0700 Subject: [PATCH 68/68] chore: bump pnpm --- package.json | 2 +- pnpm-lock.yaml | 183 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ce5bb44..d0bf03d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@11.8.0", + "packageManager": "pnpm@12.8.1", "devDependencies": { "@eslint/js": "^9.39.4", "@rbxts/compiler-types": "3.0.0-types.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c0486d..e5c8ddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,186 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + '@pnpm/exe': + specifier: 12.8.1 + version: 12.8.1 + pnpm: + specifier: 12.8.1 + version: 12.8.1 + +packages: + + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + cpu: [x64] + os: [win32] + + '@pnpm/exe@12.8.1': + resolution: {integrity: sha512-ju5MeefhEEXI7NO3u6oj54uGBOkN0pVhI+tcmuZDkTTqVdRZvEVEo+zhjwAoe8vEXguQCvsa6cTDhmFPxpOXeQ==} + engines: {node: '>=18.*'} + hasBin: true + + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.8.1': + optional: true + + '@pnpm/exe.android-x64@12.8.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.8.1': + optional: true + + '@pnpm/exe.darwin-x64@12.8.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64@12.8.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.8.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.8.1': + optional: true + + '@pnpm/exe.linux-s390x@12.8.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-x64@12.8.1': + optional: true + + '@pnpm/exe.win32-arm64@12.8.1': + optional: true + + '@pnpm/exe.win32-x64@12.8.1': + optional: true + + '@pnpm/exe@12.8.1': + optionalDependencies: + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 + + pnpm@12.8.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 + +--- lockfileVersion: '9.0' settings: