From ea40833d3bf241e3c1121dfdcbf9a5b18c17cdb4 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:12:06 -0800 Subject: [PATCH] feat: finalize sync docs --- README.md | 248 +++++++++++++++++- packages/charm-sync/Charm.luau | 1 - .../charm-sync/src/__tests__/client.spec.luau | 12 +- .../charm-sync/src/__tests__/e2e.spec.luau | 10 +- .../charm-sync/src/__tests__/server.spec.luau | 30 +-- .../charm-sync/src/__tests__/utils.spec.luau | 12 +- packages/charm-sync/src/client.luau | 63 +++-- packages/charm-sync/src/index.d.ts | 96 +++---- packages/charm-sync/src/init.luau | 2 +- packages/charm-sync/src/server.luau | 75 +++--- packages/charm-sync/src/utils.luau | 37 ++- packages/charm/src/index.d.ts | 10 +- packages/charm/src/init.luau | 8 +- packages/react-charm/Charm.luau | 1 - packages/vide-charm/Charm.luau | 1 - 15 files changed, 400 insertions(+), 206 deletions(-) diff --git a/README.md b/README.md index 01d0b60..e007e07 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ **Charm** is a state management library that aims to bring [reactive signals](https://preactjs.com/blog/signal-boosting/) from libraries like Preact and Solid to Roblox. It's built on the [alien-signals](https://github.com/stackblitz/alien-signals) signal algorithm, expanding upon it further to handle a wide range of use cases, from rendering user interfaces to handling server game logic. -**Charm has a few core principles:** +**Charm works under a few core principles.** - Manage state with reactive signals: state containers that hold a value - React to state updates: reading a signal automatically subscribes to it -- Combine multiple signals: derive a new value from existing state that stays up-to-date +- Combine multiple signals: derive new values from existing state that stay up-to-date - Fine-grained reactivity: make targeted updates in response to specific changes in the state - Use immutable data: values are compared directly (`==`) to optimize change detection @@ -34,6 +34,28 @@ - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity - https://angular.dev/guide/signals +
+Table of Contents + +- [Installation](#installation) +- [Reference](#reference) + - [`signal(initialValue, equals?)`](#signalinitialvalue-equals) + - [`computed(getter)`](#computedgetter) + - [`effect(callback)`](#effectcallback) + - [`untracked(callback)`](#untrackedcallback) + - [`peek(callback)`](#peekcallback) + - [`batched(callback)`](#batchedcallback) + - [`effectScope(callback)`](#effectscopecallback) + - [`listen(getter, callback)`](#listengetter-callback) + - [`subscribe(getter, callback)`](#subscribegetter-callback) + - [`observe(getter, callback)`](#observegetter-callback) + - [`mapped(getter, mapper)`](#mappedgetter-mapper) + - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) + - [`atom(initialValue, equals?)`](#atominitialvalue-equals) +- [Examples](#examples) + +
+ ## At a Glance ```luau @@ -104,7 +126,6 @@ setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) setQuery("^Buy") ``` - ## Installation @@ -122,7 +143,7 @@ Charm = "littensy/charm@VERSION" --- -## Guide +## Reference ### `signal(initialValue, equals?)` @@ -143,12 +164,13 @@ Accessing the signal's value in an effect or computed signal will subscribe to i 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 getMax, setMax = signal(0, function(incoming, current) +local getMax, setMax = signal(0, function(current, incoming) return incoming <= current end) setMax(1) --> 1 -setMax(-1) --> 1 +setMax(2) --> 2 +setMax(-1) --> 2 ``` > [!NOTE] @@ -223,9 +245,6 @@ dispose() --> Cleanup 1 Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`. -> [!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. - ```luau local getCounter, setCounter = signal(0) @@ -240,11 +259,14 @@ setCounter(1) --> Outer: 1, Inner: 1 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. + --- ### `untracked(callback)` -In case you want to read signals that you don't want to subscribe to, you can use `untracked()` to essentially call a function _outside_ the current effect, preventing signals and effects in the callback from being tracked. +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) @@ -283,10 +305,11 @@ disposeInner() --> Cleaned up inner effect ### `peek(callback)` -Similar to `untracked()`, but does not prevent effects created by the callback from automatically disposing. If your callback reads a signal and creates effects, only the signals get untracked. +Similar to `untracked()`, but does not prevent the parent effect from tracking nested effects created in the callback. If your callback reads a signal and creates effects, only the signals get untracked. ```luau local getCounter, setCounter = signal(0) + local disposeOuter = effect(function() peek(function() print(`Count is {getCounter()}`) @@ -306,7 +329,7 @@ disposeOuter() --> Cleaned up inner effect ### `batched(callback)` -Combines multiple signal updates made by the callback into a single commit that gets triggered when the callback completes. +Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. ```luau local getName, setName = signal("John") @@ -435,7 +458,7 @@ dispose() --> Cleanup a = 1 ### `mapped(getter, mapper)` -The `mapped` function iterates over every key in a table and uses the mapper to generate a new key and value. The resulting table is returned in 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, the mapper is called for that key and its current value. +The `mapped` function iterates over every key in a table and uses the mapper 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, the mapper is called for that key and its current value. The first value returned by the mapper is used as the new value: @@ -500,7 +523,7 @@ 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(incoming, current) +local max = atom(0, function(current, incoming) return incoming <= current end) @@ -510,6 +533,203 @@ max(-1) --> 1 --- +### `globals` + +Global flags that customize the behavior of Charm. The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio. + +| Flag | Default | Description | +| ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| strict | `true` in Studio | Enforces synchronous, non-yielding behavior in signals, effects, and critical code. | +| frozen | `true` in Studio | Enforces immutability by deep-freezing tables passed to signals. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. Should only be disabled to debug issues during migration. | + +--- + +## Client-server sync + +### Installation + +```sh +npm install @rbxts/charm-sync +yarn add @rbxts/charm-sync +pnpm add @rbxts/charm-sync +``` + +```toml +[dependencies] +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: + +```luau +-- nameStore +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") + +return { + getName = getName, + setName = setName, + getSurname = getSurname, + setSurname = setSurname, +} +``` + +When a player joins on the server, call `server.addSignalsToClient` with the keyed 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. + +> [!NOTE] +> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. + +```luau +local function onPlayerAdded(player: Player) + -- Add signal getters, computed signals, or atoms + server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, + }) +end + +for _, player in Players:GetPlayers() do + onPlayerAdded(player) +end + +Players.PlayerAdded:Connect(onPlayerAdded) + +Players.PlayerRemoving:Connect(function(player) + server.removeClient(player) +end) + +server.connect(function(player, updates) + -- Customize how you send state updates to clients + syncEvent:FireClient(player, updates) +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. + +```luau +-- Add signal setters or atoms +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, +}) + +-- Update client signals when receiving updates from the server +syncEvent.OnClientEvent:Connect(function(updates) + client.hydrate(updates) +end) +``` + +--- + +### `config` + +A configuration table that customizes the behavior of Charm Sync on the server. + +| 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 Roblox remote event limitations regarding 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, patches will be validated to enforce remote event limitations. | + +--- + +### `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. + +You can pass signal getter functions, computed signals, and atoms in the `getters` table. This function can also be called multiple on the same client to subscribe to new signals. + +```luau +Players.PlayerAdded:Connect(function(player) + server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, + }) +end) +``` + +You're also allowed to create new signals to sync to specific players, as long as the key is also unique to that player: + +```luau +server.addSignalsToClient(player, { + [`data-{player.UserId}`] = computed(function() + return getPlayerData(player.UserId) + 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, { + 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 +Players.PlayerAdded: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() +``` + +--- + ## Examples Check out this simple [example project →](https://github.com/littensy/charm-example) diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index 0070134..9a55fe0 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -5,6 +5,5 @@ end local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom -export type Selector = Charm.Selector return Charm diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index d3a9fb9..4b13e1d 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -6,13 +6,13 @@ local test = suite.test local afterEach = suite.afterEach afterEach(function() - client.reset() + client.removeAllSignals() end) test("should hydrate atom", function() local a = Charm.atom(1) - client.register({ a = a }) + client.addSignals({ a = a }) client.hydrate({ { type = "init", data = { a = 2 } }, }) @@ -24,7 +24,7 @@ test("should merge patch", function() local a = Charm.atom({ b = 1, c = 2 }) local previous = a() - client.register({ a = a }) + client.addSignals({ a = a }) client.hydrate({ { type = "init", data = { a = a() } }, }) @@ -43,7 +43,7 @@ end) test("should fix sparse array patches", function() local a = Charm.atom({ 1 }) - client.register({ a = a }) + client.addSignals({ a = a }) client.hydrate({ { type = "init", data = { a = a() } }, }) @@ -63,7 +63,7 @@ test("should merge multiple patches", function() local a = Charm.atom(1) local calls = {} - client.register({ a = a }) + client.addSignals({ a = a }) Charm.subscribe(a, function(value) table.insert(calls, value) @@ -87,7 +87,7 @@ test("should batch patches", function() local b = Charm.atom(0) local states = {} - client.register({ a = a, b = b }) + client.addSignals({ a = a, b = b }) Charm.subscribe(a, function() states.b = b() diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau index 2f2c647..f0cae67 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -9,7 +9,7 @@ local test = suite.test afterEach(function() server.reset() - client.reset() + client.removeAllSignals() config.preserveHistory = false end) @@ -20,8 +20,8 @@ test("should replicate server state to client", function() local clientA = Charm.atom({ count = -1 }) local clientB = Charm.atom({ count = -1 }) - client.register({ a = clientA, b = clientB }) - server.subscribeClient(target, { a = serverA, b = serverB }) + client.addSignals({ a = clientA, b = clientB }) + server.addSignalsToClient(target, { a = serverA, b = serverB }) server.connect(function(syncTarget, payloads) if syncTarget == target then client.hydrate(payloads) @@ -54,8 +54,8 @@ test("should replicate historical server state to client", function() config.preserveHistory = true - client.register({ a = clientA, b = clientB }) - server.subscribeClient(target, { a = serverA, b = serverB }) + client.addSignals({ a = clientA, b = clientB }) + server.addSignalsToClient(target, { a = serverA, b = serverB }) server.connect(function(syncTarget, payloads) if syncTarget == target then client.hydrate(payloads) diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau index 19e60cd..f26b753 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -20,7 +20,7 @@ test("should replicate state changes", function() local c = Charm.atom(0) local target, payloads - server.subscribeClient(client, { a = a, b = b, c = c }) + server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) target, payloads = ... end) @@ -47,7 +47,7 @@ test("should replicate partial state patches", function() local b = Charm.atom(0 :: number?) local target, payloads - server.subscribeClient(client, { a = a, b = b }) + server.addSignalsToClient(client, { a = a, b = b }) server.connect(function(...) target, payloads = ... end) @@ -75,7 +75,7 @@ test("should init new clients", function() local a = Charm.atom(0) local payloadsA, payloadsB - server.subscribeClient(clientA, { a = a }) + server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) if target == clientA then payloadsA = payloads @@ -89,7 +89,7 @@ test("should init new clients", function() assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") - server.subscribeClient(clientB, { a = a }) + server.addSignalsToClient(clientB, { a = a }) a(2) server.flush() assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") @@ -104,13 +104,13 @@ test("should init new atoms", function() local b = Charm.atom(0) local target, payloads - server.subscribeClient(client, { a = a }) + server.addSignalsToClient(client, { a = a }) server.connect(function(...) target, payloads = ... end) server.flush() - server.subscribeClient(client, { b = b }) + server.addSignalsToClient(client, { b = b }) a(1) b(1) server.flush() @@ -129,8 +129,8 @@ test("should only replicate to synced clients", function() local b = Charm.atom(0) local payloadsA, payloadsB - server.subscribeClient(clientA, { a = a }) - server.subscribeClient(clientB, { b = b }) + server.addSignalsToClient(clientA, { a = a }) + server.addSignalsToClient(clientB, { b = b }) server.connect(function(target, payloads) if target == clientA then payloadsA = payloads @@ -170,7 +170,7 @@ test("should validate state", function() local client = {} local a = Charm.atom({}) - server.subscribeClient(client, { a = a }) + server.addSignalsToClient(client, { a = a }) server.connect(function() end) a({ [0.5] = true }) @@ -185,7 +185,7 @@ test("should serialize state", function() local a = Charm.atom({ 1, 2, 3, 4, 5 }) local payloads - server.subscribeClient(client, { a = a }) + server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) payloads = p end) @@ -213,7 +213,7 @@ describe("historical", function() local c = Charm.atom(0) local target, payloads - server.subscribeClient(client, { a = a, b = b, c = c }) + server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) target, payloads = ... end) @@ -242,7 +242,7 @@ describe("historical", function() local a = Charm.atom(0) local payloadsA, payloadsB - server.subscribeClient(clientA, { a = a }) + server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) if target == clientA then payloadsA = payloads @@ -259,7 +259,7 @@ describe("historical", function() 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.subscribeClient(clientB, { a = a }) + server.addSignalsToClient(clientB, { a = a }) a(3) a(4) server.flush() @@ -278,7 +278,7 @@ describe("historical", function() local b = Charm.atom(0) local payloads - server.subscribeClient(client, { a = a }) + server.addSignalsToClient(client, { a = a }) server.connect(function(_, ...) payloads = ... end) @@ -287,7 +287,7 @@ describe("historical", function() 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.subscribeClient(client, { b = b }) + server.addSignalsToClient(client, { b = b }) a(1) a(2) b(1) diff --git a/packages/charm-sync/src/__tests__/utils.spec.luau b/packages/charm-sync/src/__tests__/utils.spec.luau index e4215e7..47d2e39 100644 --- a/packages/charm-sync/src/__tests__/utils.spec.luau +++ b/packages/charm-sync/src/__tests__/utils.spec.luau @@ -2,17 +2,15 @@ local Charm = require("../../Charm") local suite = require("../../../../test/suite") local utils = require("../utils") -local atom = Charm.atom local test = suite.test -test("should flatten atoms", function() - local getF, setF = Charm.signal(0) +test("should flatten functions", function() local input = { - a = atom(0), - b = { c = atom(0) }, - d = { e = { f = utils.signalToAtom(getF, setF) } }, + a = Charm.atom(0), + b = { c = Charm.atom(0) }, + d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, } - local result = utils.flattenState(input) + local result = utils.flatten(input) assert(result.a == input.a, "should flatten the first level") assert(result["b/c"] == input.b.c, "should flatten the second level") diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 6b4ad08..e067be5 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,60 +2,59 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") -type Atom = Charm.Atom type SyncPayload = types.SyncPayload -local trackedAtoms: { [string]: Atom } = {} +type Setter = (update: ((T) -> T) | T) -> () ---- Registers a map of atoms to the state with the corresponding keys on the ---- server. When the server sends updates, these atoms will be hydrated with +local trackedSetters: { [string]: Setter } = {} + +--- 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. --- ---- If syncing signals instead of atoms, you can use `signalToAtom` to sync ---- signal getters and setters. +--- Atoms, signals converted to atoms, and signal setter functions are allowed. +--- Note that `server.addSignalsToClient` still requires some way to get the +--- state. --- ---- @param atoms A map of atoms to sync with the server. -local function register(atoms: { [string]: Atom }) - for key, atom in atoms do - trackedAtoms[key] = atom +--- @param setters A map of setter functions to sync with the server. +local function addSignals(setters: { [string]: Setter }) + for key, setter in setters do + trackedSetters[key] = setter end end ---- Unregisters from server state updates for the given keys. The atoms +--- 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 unregister(...: string) +local function removeSignals(...: string) for index = 1, select("#", ...) do local key = select(index, ...) - trackedAtoms[key] = nil + trackedSetters[key] = nil end end --- Unsubscribes from all server state updates. -local function reset() - table.clear(trackedAtoms) +local function removeAllSignals() + table.clear(trackedSetters) end local function applyInitPayload(data: { [string]: any }) for key, value in data do - local atom = trackedAtoms[key] - if atom then - atom(value) + local setter = trackedSetters[key] + if setter then + setter(value) end end end -local function applyPatchPayload(data: { [string | number]: any }) - local target = {} - - for key, atom in trackedAtoms do - target[key] = atom() - end - - target = patch.apply(target, data) - - for key, atom in trackedAtoms do - atom(target[key]) +local function applyPatchPayload(data: { [string]: any }) + for key, value in data do + local setter = trackedSetters[key] + if setter then + setter(function(current) + return patch.apply(current, value) + end) + end end end @@ -74,8 +73,8 @@ local function hydrate(payloads: { SyncPayload }) end return { - register = register, - unregister = unregister, - reset = reset, + addSignals = addSignals, + removeSignals = removeSignals, + removeAllSignals = removeAllSignals, hydrate = hydrate, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 85a70b0..70a0f22 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -1,4 +1,4 @@ -import { Atom } from "@rbxts/charm"; +import { Atom, Getter, Setter } from "@rbxts/charm"; type Key = string | number | symbol; @@ -11,7 +11,8 @@ export interface None { export type MaybeNone = undefined extends T ? None : never; -type SelectorMap = Record any>; +type GetterMap = Record>; +type SetterMap = Record>; /** * Infers the type of the return values produced by a map of functions. @@ -33,6 +34,7 @@ type DataType = DataTypes[keyof DataTypes]; * 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 @@ -53,9 +55,9 @@ export type SyncPatch = MaybeNone = - | { type: "init"; data: StateOfMap } - | { type: "patch"; data: SyncPatch, FixArrays> }; +export type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch, FixArrays> }; /** * Global configuration options that affect the behavior of Charm Sync. @@ -102,28 +104,29 @@ export const config: { */ export namespace client { /** - * Registers a map of atoms to the state with the corresponding keys on the - * server. When the server sends updates, these atoms will be hydrated with + * 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. * - * If syncing signals instead of atoms, you can use `signalToAtom` to sync - * signal getters and setters as atoms. + * Atoms, signals converted to atoms, and signal setter functions are allowed. + * Note that `server.addSignalsToClient` still requires some way to get the + * state. * - * @param atoms A map of atoms to sync with the server. + * @param setters A map of setter functions to sync with the server. */ - export function register(atoms: { [key: string]: Atom }): void; + export function addSignals(setters: Setters): void; /** - * Unregisters from server state updates for the given keys. The atoms + * Unregisters from server state updates for the given keys. The signals * will retain their current values, but will no longer receive updates * from the server. */ - export function unregister(...keys: string[]): void; + export function removeSignals(...keys: (keyof Setters)[]): void; /** * Unsubscribes from all server state updates. */ - export function reset(): void; + export function removeAllSignals(): void; /** * Merges the server's state patches into the client's state, either @@ -131,8 +134,8 @@ export namespace client { * * @param payloads The state updates received from the server. */ - export function hydrate }, FixArrays extends boolean = true>( - payloads: SyncPayload[], + export function hydrate( + payloads: SyncPayload[], ): void; } @@ -145,22 +148,21 @@ export namespace server { * 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 the client still requires some way to set the state, so - * registering signals on the client should be done through `signalToAtom()`. + * 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 signals to sync with the client. + * @param getters A map of getter functions to sync with the client. */ - export function subscribeClient(client: Player, signals: { [key: string]: () => any }): void; + export function addSignalsToClient(client: Player, getters: Getters): void; /** * Unsubscribes a client from receiving all state updates. To only unsubscribe - * from specific keys, use `unsubscribeClientFrom` instead. + * from specific keys, use `removeSignalsFromClient` instead. * * @param client The client receiving state updates. */ - export function unsubscribeClient(client: Player): void; + export function removeClient(client: Player): void; /** * Unsubscribes a client from receiving updates for the given keys. @@ -168,7 +170,10 @@ export namespace server { * @param client The client receiving state updates. * @param keys The keys of the state to unsubscribe from. */ - export function unsubscribeClientFrom(client: Player, ...keys: string[]): void; + export function removeSignalsFromClient( + client: Player, + ...keys: (keyof Getters)[] + ): void; /** * Uses the callback to send state updates to clients that are subscribed @@ -177,8 +182,8 @@ export namespace server { * * @param onSync Called when sending patches to a client. */ - export function connect }, FixArrays extends boolean = true>( - onSync: (client: Player, payloads: SyncPayload[]) => void, + export function connect( + onSync: (client: Player, payloads: SyncPayload[]) => void, ): void; /** @@ -235,39 +240,24 @@ export namespace patch { ): State; } -interface NestedAtomMap { - readonly [key: string]: NestedAtomMap | Atom; -} +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 AppendPath = undefined extends Path - ? Name - : Name extends string - ? Path extends string - ? `${Path}/${Name}` - : never - : never; - -type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; - -export type FlattenNestedAtoms< - Atoms extends NestedAtomMap, - Path extends Key | undefined = undefined, -> = IntersectValues<{ - readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom - ? { readonly [K in AppendPath]: Atoms[Name] } - : Atoms[Name] extends NestedAtomMap - ? FlattenNestedAtoms> - : never; -}>; +type DeepFlatten = Reconstruct>>; /** - * Flattens a nested atom map into a single object with slash-separated keys. + * Flattens a nested table into a single table with slash-separated keys. * Useful for recursively collecting atoms returned by modules. * - * @param atoms The nested atom map to flatten. - * @returns A flattened atom map. + * @param input A nested table. + * @return A flattened table with slash-separated keys. */ -export function flattenAtoms(atoms: Atoms): FlattenNestedAtoms; +export function flatten(input: T): DeepFlatten; /** * Checks whether a value is `None`. If `true`, the value is scheduled to be diff --git a/packages/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau index aca6061..53a6353 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -14,6 +14,6 @@ return { server = server, patch = patch, isNone = patch.isNone, - flattenState = utils.flattenState, + flatten = utils.flatten, signalToAtom = utils.signalToAtom, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 528a4e2..6692d0c 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -8,12 +8,9 @@ type SyncPayload = types.SyncPayload local globals = Charm.globals type SyncStatus = number +type ClientKeys = { [string]: SyncStatus } -type ClientKeys = { - [string]: SyncStatus, -} - -type TrackedState = { +type GetterState = { getter: () -> T, currentValue: T, previousValue: T, @@ -29,7 +26,7 @@ type StateUpdate = { local PENDING_INITIAL_STATE: SyncStatus = 0 local LISTENING_FOR_CHANGES: SyncStatus = 1 -local trackedSignals: { [string]: TrackedState } = {} +local trackedGetters: { [string]: GetterState } = {} local globalUpdates: { StateUpdate } = {} local clients: { [any]: ClientKeys } = {} @@ -37,9 +34,9 @@ local connection: RBXScriptConnection? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? local function startTracking(key: string, getter: () -> T) - if trackedSignals[key] then + if trackedGetters[key] then -- Ignore duplicates, but warn against name collisions - if trackedSignals[key].getter ~= getter then + if trackedGetters[key].getter ~= getter then warn("[Charm] Attempted to sync different states with the same key:", key) end return @@ -68,14 +65,14 @@ local function startTracking(key: string, getter: () -> T) trackedState.currentValue = newValue end) - trackedSignals[key] = trackedState + trackedGetters[key] = trackedState end local function stopTracking(key: string) - local tracker = trackedSignals[key] + local tracker = trackedGetters[key] if tracker then tracker.dispose() - trackedSignals[key] = nil + trackedGetters[key] = nil end end @@ -88,7 +85,7 @@ local function purgeUntracked() end end - for key in trackedSignals do + for key in trackedGetters do if not active[key] then stopTracking(key) end @@ -106,11 +103,11 @@ local function diffGlobalUpdateBuffer(): { any } return patches end -local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload } +local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }? local clientKeys = clients[client] if not clientKeys then - return {} + return nil end local payloads: { SyncPayload } = {} @@ -146,10 +143,10 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { local initData: { [string]: any } = {} for key, status in clientKeys do - local trackedState = trackedSignals[key] + local getterState = trackedGetters[key] - if status == PENDING_INITIAL_STATE and trackedState then - initData[key] = trackedState.currentValue + if status == PENDING_INITIAL_STATE and getterState then + initData[key] = getterState.currentValue clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -165,7 +162,7 @@ local function diffGlobalState(): any local currentGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {} - for key, tracker in trackedSignals do + for key, tracker in trackedGetters do currentGlobalState[key] = tracker.currentValue previousGlobalState[key] = tracker.previousValue tracker.previousValue = tracker.currentValue @@ -174,11 +171,11 @@ local function diffGlobalState(): any return patch.diff(previousGlobalState, currentGlobalState) end -local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload } +local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }? local clientKeys = clients[client] if not clientKeys then - return {} + return nil end local payloads: { SyncPayload } = {} @@ -188,12 +185,12 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo -- Create patches for initialized signals that changed, and add signals -- that are still pending initial state to the init payload. for key, status in clientKeys do - local trackedState = trackedSignals[key] + local getterState = trackedGetters[key] if status == LISTENING_FOR_CHANGES then patchData[key] = globalPatch[key] elseif status == PENDING_INITIAL_STATE then - initData[key] = trackedState.currentValue + initData[key] = getterState.currentValue clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -214,23 +211,22 @@ end --- called multiple times to subscribe to additional keys. --- --- Atoms, signals converted to atoms, and signal getter functions are allowed. ---- Note that the client still requires some way to set the state, so ---- registering signals on the client should be done through `signalToAtom()`. +--- Note that `client.addSignals` still requires some way to set the state. --- --- @param client The client receiving state updates. ---- @param signals A map of signals to sync with the client. -local function subscribeClient(client: any, signals: { [string]: () -> any }) +--- @param getters A map of getter functions to sync with the client. +local function addSignalsToClient(client: any, getters: { [string]: () -> any }) if not clients[client] then clients[client] = {} end local clientKeys = clients[client] - for key, state in signals do - startTracking(key, if type(state) == "function" then state else state.get) + for key, getter in getters do + startTracking(key, if type(getter) == "function" then getter else getter.get) if not clientKeys[key] then - -- Mark the signal as pending initial state so we send its full + -- Mark the getter as pending initial state so we send its full -- value the next time we sync. clientKeys[key] = PENDING_INITIAL_STATE end @@ -241,7 +237,7 @@ end --- from specific keys, use `unsubscribeClientFrom` instead. --- --- @param client The client receiving state updates. -local function unsubscribeClient(client: any) +local function removeClient(client: any) clients[client] = nil end @@ -249,7 +245,7 @@ end --- --- @param client The client receiving state updates. --- @param keys The keys of the state to unsubscribe from. -local function unsubscribeClientFrom(client: any, ...: string) +local function removeSignalsFromClient(client: any, ...: string) local clientKeys = clients[client] if not clientKeys then @@ -262,7 +258,7 @@ local function unsubscribeClientFrom(client: any, ...: string) end if next(clientKeys) == nil then - clients[client] = nil + removeClient(client) end end @@ -274,7 +270,8 @@ local function flush() return end - local globalPatch = if config.preserveHistory then diffGlobalUpdateBuffer() else diffGlobalState() + local granular = config.preserveHistory + local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState() -- While payload objects are technically mutable as they are unique to -- each client, the data inside them is not safe to mutate. @@ -283,11 +280,11 @@ local function flush() end for client in clients do - local payloads: { SyncPayload } = if config.preserveHistory + local payloads = if granular then selectFromGlobalPatchList(client, globalPatch) else selectFromGlobalPatch(client, globalPatch) - if payloads[1] then + if payloads and payloads[1] then if task then task.spawn(syncCallback, client, payloads) else @@ -346,16 +343,16 @@ end --- Unsubscribes all clients, stops tracking all state, and disconnects --- from the automatic sync interval. local function reset() - table.clear(trackedSignals) + table.clear(trackedGetters) table.clear(clients) table.clear(globalUpdates) disconnect() end return { - subscribeClient = subscribeClient, - unsubscribeClient = unsubscribeClient, - unsubscribeClientFrom = unsubscribeClientFrom, + addSignalsToClient = addSignalsToClient, + removeClient = removeClient, + removeSignalsFromClient = removeSignalsFromClient, connect = connect, disconnect = disconnect, flush = flush, diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index 962d1dc..6a7f7b0 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -2,40 +2,35 @@ local Charm = require("../Charm") type Atom = Charm.Atom -type NestedState = { - [string]: NestedState | () -> any, +type NestedTable = { + [any]: NestedTable | any, } -type FlattenedState = { - [string]: () -> any, +type FlattenedTable = { + [any]: any, } -local function isAtomOrSignal(value: any): boolean - return type(value) ~= "table" or (type(value.get) == "function" and type(value.set) == "function") -end - ---- Flattens a nested map of atoms or signals into a single object with ---- slash-separated keys. Useful for recursively collecting atoms returned by ---- modules. +--- Flattens a nested table into a single table with slash-separated keys. +--- Useful for recursively collecting atoms returned by modules. --- ---- @param states A nested table of atoms or signals. ---- @return A flattened map of atoms or signals. -local function flattenState(states: NestedState): FlattenedState - local result: FlattenedState = {} +--- @param input A nested table. +--- @return A flattened table with slash-separated keys. +local function flatten(input: NestedTable): FlattenedTable + local result: FlattenedTable = {} - local function visit(node: NestedState, path: string) + local function visit(node: NestedTable, path: string) for key, value in node do local location = if path == "" then key else path .. "/" .. key - if isAtomOrSignal(value) then - result[location] = value :: () -> any + if type(value) == "table" then + visit(value :: NestedTable, location) else - visit(value :: NestedState, location) + result[location] = value end end end - visit(states, "") + visit(input, "") return result end @@ -54,6 +49,6 @@ local function signalToAtom(getter: () -> T, setter: (value: ((T) -> T) | T) end return { - flattenState = flattenState, + flatten = flatten, signalToAtom = signalToAtom, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 43d56d8..926a5ea 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -2,17 +2,17 @@ type Key = string | number | symbol; type Cleanup = () => void; /** - * A reactive atom that acts as both a getter and setter. Calling the atom with - * an argument sets its value, while calling it with no arguments returns its - * current value. + * A reactive signal that acts as both a getter and setter. Calling the signal + * with an argument sets its value, while calling it with no arguments returns + * its current value. */ export interface Atom { (newValue: T | ((currentValue: T) => T)): T; (): T; } -export type Selector = () => T; - +export type Getter = () => T; +export type Setter = (newValue: T | ((currentValue: T) => T)) => T; export type Equals = (current: T, incoming: T) => boolean; /** diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 1275509..62a0daf 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -6,13 +6,11 @@ local utils = require("@self/utils") type Update = ((previous: T) -> T) | T ---- A reactive atom that acts as both a getter and setter. Calling the atom with ---- an argument sets its value, while calling it with no arguments returns its ---- current value. +--- A reactive signal that acts as both a getter and setter. Calling the signal +--- with an argument sets its value, while calling it with no arguments returns +--- its current value. export type Atom = (...Update) -> T -export type Selector = () -> T - return { core = core, globals = globals, diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau index 6b6fb92..9588118 100644 --- a/packages/react-charm/Charm.luau +++ b/packages/react-charm/Charm.luau @@ -1,6 +1,5 @@ local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom -export type Selector = Charm.Selector return Charm diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau index 6b6fb92..9588118 100644 --- a/packages/vide-charm/Charm.luau +++ b/packages/vide-charm/Charm.luau @@ -1,6 +1,5 @@ local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom -export type Selector = Charm.Selector return Charm