From ed9566bfff547dba79613c2a85eed2ad9894e79a Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 15 Nov 2025 20:38:15 -0800 Subject: [PATCH] chore: prepare release candidate --- README.md | 64 ++++++++++--------- packages/charm-sync/package.json | 2 +- .../charm-sync/src/__tests__/client.spec.luau | 18 +++--- .../charm-sync/src/__tests__/e2e.spec.luau | 4 +- packages/charm-sync/src/client.luau | 6 +- packages/charm-sync/src/index.d.ts | 6 +- packages/charm-sync/wally.toml | 4 +- packages/charm/package.json | 2 +- packages/charm/src/init.luau | 6 +- packages/charm/wally.toml | 2 +- packages/react-charm/package.json | 2 +- packages/react-charm/wally.toml | 4 +- packages/vide-charm/package.json | 2 +- packages/vide-charm/wally.toml | 4 +- 14 files changed, 67 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 5670f9b..46e04d8 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,9 @@ - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - - [`trigger(callback)`](#triggercallback) - - [`flags`](#flags) + - [`recursive()`](#recursive) + - [`trigger(callback)`](#triggercallback) + - [`flags`](#flags) - [Client-Server Sync](#client-server-sync) - [Installation](#installation-1) - [Quick Start](#quick-start) @@ -229,9 +230,10 @@ Effects are fundamental to reactivity, allowing you to react to signal updates. ```luau local getCounter, setCounter = signal(0) +-- Count is 0 effect(function() print(`Count is {getCounter()}`) -end) -- Count is 0 +end) setCounter(1) -- Count is 1 ``` @@ -269,7 +271,7 @@ setCounter(2) -- Outer: 2, Inner: 2 ``` > [!NOTE] -> 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 `globals.trackInnerEffects` flag to assist with debugging. +> 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. --- @@ -278,18 +280,17 @@ setCounter(2) -- Outer: 2, Inner: 2 In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked. ```luau -local getCounter, setCounter = signal(0) -local getRuns, setRuns = signal(0) +local getTracked, setTracked = signal(0) +local getUntracked, setUntracked = signal(0) +-- Tracked: 0, Untracked: 0 effect(function() - local runs = untracked(getRuns) + 1 - setRuns(runs) - print(`Count is {getCounter()}, ran {runs} times`) -end) -- Count is 0, ran 1 time + print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`) +end) -setCounter(10) -- Count is 10, ran 2 times -setRuns(10) -- No output -setCounter(20) -- Count is 20, ran 11 times +setTracked(1) -- Tracked: 1, Untracked: 0 +setUntracked(1) -- No output +setTracked(2) -- Tracked: 2, Untracked: 1 ``` Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect: @@ -319,9 +320,10 @@ Similar to `untracked()`, but does not prevent the parent effect from tracking n ```luau local getCounter, setCounter = signal(0) +-- Count is 0 local disposeOuter = effect(function() print(`Count is {peek(getCounter)}`) -end) -- Count is 0 +end) setCounter(1) -- No output; count was accessed in peek() ``` @@ -384,9 +386,10 @@ The callback also receives the previous value, or `nil` when running for the fir ```luau local getCounter, setCounter = signal(0) +-- Count is 0 (was nil) listen(getCounter, function(count, prevCount) print(`Count is {count} (was {prevCount})`) -end) -- Count is 0 (was nil) +end) setCounter(1) -- Count is 1 (was 0) ``` @@ -421,12 +424,13 @@ Note that nested effects can be created inside `subscribe`, and they will clean ```luau local getItems, setItems = signal({ a = 0, b = 0 }) +-- Added a, Added b observe(getItems, function(value, key) print(`Added {key}`) return function() print(`Removed {key}`) end -end) -- Added a, Added b +end) setItems({ a = 0, c = 0 }) -- Removed b, Added c ``` @@ -547,7 +551,9 @@ effect(function() setCounter(function(count) return math.min(count + 1, 3) end) -end) -- 0, 1, 2, 3 +end) + +-- Output: 0, 1, 2, 3 ``` --- @@ -675,7 +681,7 @@ end) 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.hydrate` to patch the client's signals with the incoming state updates. +After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau -- Add signal setters or atoms @@ -686,7 +692,7 @@ client.addSignals({ -- Update client signals when receiving updates from the server syncEvent.OnClientEvent:Connect(function(updates) - client.hydrate(updates) + client.patch(updates) end) ``` @@ -798,7 +804,7 @@ server.flush() Subscribes the given writable signals to the states with the corresponding keys on the server. When the server sends updates, the functions associated with each key in the state will be called with the patched values. -You should pass either pass signal setter functions or atoms to this function: +You can pass either writable signals or atoms to this function: ```luau client.addSignals({ @@ -823,11 +829,11 @@ client.removeSignals("name") --- -### `client.hydrate(updates)` +### `client.patch(updates)` -The `hydrate` 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. +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 `hydrate` when receiving updates from the server from a remote event: +You should call `patch` when receiving updates from the server from a remote event: ```luau client.addSignals({ @@ -836,7 +842,7 @@ client.addSignals({ }) syncEvent.OnClientEvent:Connect(function(updates) - client.hydrate(updates) + client.patch(updates) end) ``` @@ -885,22 +891,22 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that 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 all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`. - 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 disable this feature by setting [`globals.trackInnerEffects`](#flags) to `false`. + - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`. > [!NOTE] > An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. > > This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. -5. Recursion is disallowed in effects and computed signals now that Charm uses the [alien-signals algorithm](https://github.com/stackblitz/alien-signals). This change may introduce bugs in code relying on the old behavior. +5. Recursion is now disallowed in effects and computed signals by default. This change may introduce bugs in code relying on the old behavior. - Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback. 6. 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)! - Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit - - Added [`listen()`](#listengetter-callback) for running a subscription once immediately + - Added [`listen()`](#listengetter-callback) to create a subscription that runs once immediately - Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once - - Added [`onCleanup()`](#oncleanupcallback-failsilently) for running code when the currently-running effect, subscription, or observer cleans up - - Added [`trigger()`](#triggercallback) for triggering updates for table mutations + - Added [`onCleanup()`](#oncleanupcallback-failsilently) to bind a cleanup function to the active effect + - Added [`trigger()`](#triggercallback) to trigger updates for table mutations - The [`computed()`](#computedgetter) callback now gets called with the previous computed value - Optimized `computed()` to use lazy evaluation instead of eager updates - Optimized `mapped()` to only map values that changed in the original table diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 129cf4a..d41be6c 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.3.0", + "version": "0.4.0-rc.1", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index 4b13e1d..29a172e 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -13,7 +13,7 @@ test("should hydrate atom", function() local a = Charm.atom(1) client.addSignals({ a = a }) - client.hydrate({ + client.patch({ { type = "init", data = { a = 2 } }, }) @@ -25,13 +25,13 @@ test("should merge patch", function() local previous = a() client.addSignals({ a = a }) - client.hydrate({ + client.patch({ { type = "init", data = { a = a() } }, }) assert(a() == previous, "should set a to the initial value") - client.hydrate({ + client.patch({ { type = "patch", data = { a = { b = 3 } } }, }) @@ -44,14 +44,14 @@ test("should fix sparse array patches", function() local a = Charm.atom({ 1 }) client.addSignals({ a = a }) - client.hydrate({ + 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.hydrate({ + client.patch({ { type = "patch", data = { a = { ["2"] = 2 } } }, }) @@ -69,11 +69,11 @@ test("should merge multiple patches", function() table.insert(calls, value) end) - client.hydrate({ + client.patch({ { type = "init", data = { a = a() } }, }) - client.hydrate({ + client.patch({ { type = "patch", data = { a = 2 } }, { type = "patch", data = { a = 3 } }, }) @@ -97,11 +97,11 @@ test("should batch patches", function() states.a = a() end) - client.hydrate({ + client.patch({ { type = "init", data = { a = a(), b = b() } }, }) - client.hydrate({ + client.patch({ { type = "patch", data = { a = 1, b = 2 } }, }) diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau index f0cae67..09c036c 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -24,7 +24,7 @@ test("should replicate server state to client", function() server.addSignalsToClient(target, { a = serverA, b = serverB }) server.connect(function(syncTarget, payloads) if syncTarget == target then - client.hydrate(payloads) + client.patch(payloads) end end) @@ -58,7 +58,7 @@ test("should replicate historical server state to client", function() server.addSignalsToClient(target, { a = serverA, b = serverB }) server.connect(function(syncTarget, payloads) if syncTarget == target then - client.hydrate(payloads) + client.patch(payloads) end end) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 8392792..2f98bf3 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -62,7 +62,8 @@ end --- initializing it with the full state or merging a partial patch. --- --- @param payloads The state updates received from the server. -local function hydrate(payloads: { SyncPayload }) +local function patch(payloads: { SyncPayload }) + debug.profilebegin("CharmSync") for _, payload in payloads do if payload.type == "init" then Charm.batch(applyInitPayload, payload.data) @@ -70,11 +71,12 @@ local function hydrate(payloads: { SyncPayload }) Charm.batch(applyPatchPayload, payload.data) end end + debug.profileend() end return { addSignals = addSignals, removeSignals = removeSignals, removeAllSignals = removeAllSignals, - hydrate = hydrate, + patch = patch, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 3fed7a8..69381b6 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -1,4 +1,4 @@ -import { Atom, Getter, Setter } from "@rbxts/charm"; +import { Atom, Setter } from "@rbxts/charm"; type Key = string | number | symbol; @@ -11,7 +11,7 @@ export interface None { export type MaybeNone = undefined extends T ? None : never; -type GetterMap = Record>; +type GetterMap = Record any>; type SetterMap = Record>; /** @@ -134,7 +134,7 @@ export namespace client { * * @param payloads The state updates received from the server. */ - export function hydrate( + export function patch( payloads: SyncPayload[], ): void; } diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index c5fc5a0..6d6257c 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.3.0" +version = "0.4.0-rc.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.10" +Charm = "littensy/charm@0.11.0-rc.1" diff --git a/packages/charm/package.json b/packages/charm/package.json index 67c51c8..f4167c5 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.10.0", + "version": "0.11.0-rc.1", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 6ba6de2..e1fc19f 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -367,10 +367,10 @@ end --- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom local getter, setter = signal(initialValue, equals) - local function atomGetterSetter(...) + local function atomOper(...) return if select("#", ...) == 0 then getter() else setter((...)) end - return atomGetterSetter + return atomOper end --- Creates a new read-only signal that is computed based on the values of @@ -565,7 +565,7 @@ end --- @return A computed signal that tracks the value returned by the getter. local function toSignal(fn: () -> T): () -> T local name = debug.info(fn, "n") - return if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then fn else computed(fn) + return if name == "signalGetter" or name == "computedGetter" or name == "atomOper" then fn else computed(fn) end --- Creates an effect that runs the callback one immediately, and then again diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index cd94b61..ada6018 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.10.0" +version = "0.11.0-rc.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index eeaeefb..9d73025 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.3.0", + "version": "0.4.0-rc.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 cc76252..8407302 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.3.0" +version = "0.4.0-rc.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.10" +Charm = "littensy/charm@0.11.0-rc.1" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 4182564..b9ee228 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.3.0", + "version": "0.4.0-rc.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 9e88a0f..dfc2517 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.3.0" +version = "0.4.0-rc.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.10" +Charm = "littensy/charm@0.11.0-rc.1" Vide = "centau/vide@^0.3"