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/README.md b/README.md index 07b361d..07d9641 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. --- @@ -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,54 @@ 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 name collisions + [`inventory-{Players.LocalPlayer.UserId}`] = setInventory, }) -- Update client signals when receiving updates from the server 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)) +end) ``` --- @@ -702,38 +735,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 +779,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 +844,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 name collisions + [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, }) ``` @@ -821,11 +860,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 +877,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 +895,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 +909,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. @@ -885,6 +925,9 @@ 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 @@ -940,6 +983,22 @@ 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)` diff --git a/package.json b/package.json index d5f8c09..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@10.33.3", + "packageManager": "pnpm@12.8.1", "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..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-rc.7", + "version": "0.4.1", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", 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 diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index ca04ba7..6485e18 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 @@ -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 @@ -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 380fbb0..d24970c 100644 --- a/packages/charm-sync/test/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -403,6 +403,54 @@ 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() + 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 {} 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-sync/wally.toml b/packages/charm-sync/wally.toml index 28a6fc9..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-rc.7" +version = "0.4.1" 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..7ef2e29 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.1", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", 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..0ba77d7 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 @@ -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/charm/test/computed.test.luau b/packages/charm/test/computed.test.luau index 8985db2..c0095cf 100644 --- a/packages/charm/test/computed.test.luau +++ b/packages/charm/test/computed.test.luau @@ -198,4 +198,23 @@ test("should trigger effect even computed already dirty", function() 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 index d697513..17cd0a0 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -504,4 +504,90 @@ test("recursive effects should run and cleanup in order", function() 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) + +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 {} diff --git a/packages/charm/test/observe.test.luau b/packages/charm/test/observe.test.luau index cd01b83..64bbd7d 100644 --- a/packages/charm/test/observe.test.luau +++ b/packages/charm/test/observe.test.luau @@ -12,7 +12,7 @@ test("should observe items", function() local updates = {} local cleanups = {} - observe(items, function(_, key) + observe(items, function(_, key: number) local get = computed(function() return items()[key] end) diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 6298b39..25eb0a5 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.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 96d06af..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-rc.1", + "version": "0.1.1", "description": "Use Charm with plain Luau tables", "license": "MIT", "main": "src/init.luau", 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/deep-charm/test/reactive.test.luau b/packages/deep-charm/test/reactive.test.luau index b663147..41654b1 100644 --- a/packages/deep-charm/test/reactive.test.luau +++ b/packages/deep-charm/test/reactive.test.luau @@ -2,7 +2,10 @@ 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 @@ -186,4 +189,44 @@ test("should unwrap nested proxy", function() 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 index f1a2b11..3f2702f 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.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" +Charm = "littensy/charm@^0.11.0" diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index d58d8c7..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-rc.4", + "version": "0.4.1", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 2d0cb9c..08b5342 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -14,9 +14,9 @@ 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() + React.useLayoutEffect(function(): () -> () return Charm.listen(getter, setValue :: (T, T?) -> ()) end, dependencies :: { any } or {}) @@ -38,7 +38,7 @@ local function useSignalBinding(getter: () -> T, dependencies: {}?) local initialValue = React.useMemo(getter, {}) local binding, setBinding = React.useBinding(initialValue) - React.useLayoutEffect(function() + React.useLayoutEffect(function(): () -> () return Charm.listen(getter, setBinding :: (T, T?) -> ()) end, dependencies :: { any } or {}) diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index a1de6d4..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-rc.4" +version = "0.4.1" 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..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-rc.4", + "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 5c59947..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-rc.5" +version = "0.4.1" 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-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: 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*" diff --git a/rokit.toml b/rokit.toml index 30b60e4..ca169e2 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" -stylua = "johnnymorganz/stylua@2.4.1" -luau-lsp = "johnnymorganz/luau-lsp@1.66.1" +rojo = "rojo-rbx/rojo@7.7.0" +stylua = "johnnymorganz/stylua@2.5.2" +luau-lsp = "johnnymorganz/luau-lsp@1.70.1" lute = "luau-lang/lute@1.0.0"