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