feat: finalize sync docs

This commit is contained in:
littensy 2025-11-03 00:12:06 -08:00
parent 37f79ecc3f
commit ea40833d3b
15 changed files with 400 additions and 206 deletions

248
README.md
View file

@ -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
<details>
<summary><b>Table of Contents</b></summary>
- [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)
</details>
## At a Glance
```luau
@ -104,7 +126,6 @@ setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
setQuery("^Buy")
```
</summary>
</details>
## 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)

View file

@ -5,6 +5,5 @@ end
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
export type Selector<T = any> = Charm.Selector<T>
return Charm

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

@ -2,60 +2,59 @@ local Charm = require("../Charm")
local patch = require("./patch")
local types = require("./types")
type Atom<T = any> = Charm.Atom<T>
type SyncPayload = types.SyncPayload
local trackedAtoms: { [string]: Atom } = {}
type Setter<T = any> = (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,
}

View file

@ -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<T> = undefined extends T ? None : never;
type SelectorMap = Record<string, () => any>;
type GetterMap = Record<string, Getter<any>>;
type SetterMap = Record<string, Setter<any>>;
/**
* 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<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends
| ReadonlyMap<infer K, infer V>
| Map<infer K, infer V>
@ -53,9 +55,9 @@ export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State
* A payload that can be sent from the server to the client to synchronize
* state between the two.
*/
export type SyncPayload<Selectors extends SelectorMap = SelectorMap, FixArrays extends boolean = true> =
| { type: "init"; data: StateOfMap<Selectors> }
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>, FixArrays> };
export type SyncPayload<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true> =
| { type: "init"; data: StateOfMap<Getters> }
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, 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<any> }): void;
export function addSignals<Setters extends SetterMap = SetterMap>(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<Setters extends GetterMap | SetterMap = SetterMap>(...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<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>(
payloads: SyncPayload<Atoms, FixArrays>[],
export function hydrate<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
payloads: SyncPayload<Getters, FixArrays>[],
): 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<Getters extends GetterMap = GetterMap>(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<Getters extends GetterMap = GetterMap>(
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<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Atoms, FixArrays>[]) => void,
export function connect<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
): void;
/**
@ -235,39 +240,24 @@ export namespace patch {
): State;
}
interface NestedAtomMap {
readonly [key: string]: NestedAtomMap | Atom<any>;
}
type DeepFlattenImpl<T, Prefix extends string = ""> = {
[K in keyof T]: T[K] extends DataType | Callback
? { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] }
: T[K] extends object
? DeepFlattenImpl<T[K], `${Prefix}${Exclude<K, symbol>}/`>
: { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] };
}[keyof T];
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
? Name
: Name extends string
? Path extends string
? `${Path}/${Name}`
: never
: never;
type IntersectValues<T> = 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<Path, Name>]: Atoms[Name] extends Atom<any>
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
: Atoms[Name] extends NestedAtomMap
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
: never;
}>;
type DeepFlatten<T> = Reconstruct<UnionToIntersection<DeepFlattenImpl<T>>>;
/**
* 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 extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
export function flatten<T>(input: T): DeepFlatten<T>;
/**
* Checks whether a value is `None`. If `true`, the value is scheduled to be

View file

@ -14,6 +14,6 @@ return {
server = server,
patch = patch,
isNone = patch.isNone,
flattenState = utils.flattenState,
flatten = utils.flatten,
signalToAtom = utils.signalToAtom,
}

View file

@ -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<T = any> = {
type GetterState<T = any> = {
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<T>(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<T>(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,

View file

@ -2,40 +2,35 @@ local Charm = require("../Charm")
type Atom<T> = Charm.Atom<T>
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<T>(getter: () -> T, setter: (value: ((T) -> T) | T)
end
return {
flattenState = flattenState,
flatten = flatten,
signalToAtom = signalToAtom,
}

View file

@ -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<T> {
(newValue: T | ((currentValue: T) => T)): T;
(): T;
}
export type Selector<T> = () => T;
export type Getter<T> = () => T;
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
export type Equals<T> = (current: T, incoming: T) => boolean;
/**

View file

@ -6,13 +6,11 @@ local utils = require("@self/utils")
type Update<T> = ((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<T = any> = (...Update<T>) -> T
export type Selector<T = any> = () -> T
return {
core = core,
globals = globals,

View file

@ -1,6 +1,5 @@
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
export type Selector<T = any> = Charm.Selector<T>
return Charm

View file

@ -1,6 +1,5 @@
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
export type Selector<T = any> = Charm.Selector<T>
return Charm