feat!: use alien-signals for reactivity (#50)

* feat: use alien-signals

* refactor: remove pnpm, fix tests

* fix: `effectScope` should set scope

* fix: check previous value before calling listener

* refactor: improve imports

* refactor: exclude re-exports from wally

* refactor: embrace scope cleanup, restructure code

* fix: update pkgs dir references

* ci: simplify pesde install

* build: install dev deps with wally

* feat: add detached, add more tests

* test: simplify mapped test update

* chore: rephrase doc comment

* perf: optimize subscribe and mapped

* refactor: avoid ambiguous optional function type

* refactor: add assertion error messages

* perf: optimize mapped, add benches

* chore: delete redundant benchmarks

* perf: simplify mapped logic

* test: prettify test library

* test: try different gray

* test: color brackets

* refactor: sync 2.0.4

* refactor: use setCurrentSub instead of pauseTracking

* test: improve test suite

* test: separate parent from root

* refactor: iterate over previous data in mapped

* refactor: improve logic

* style: use `---` doc comments

* refactor: improve logic

* chore: housekeeping

* ci: add pnpm step

* ci: init submodules

* test: use lune

* refactor: improve structure and alien-signals integration

* ci: update test file path

* feat: add peek and roblox-ts types

* feat: add deep freeze flag, misc refactors

* refactor: reduce type casting, housekeeping

* refactor: rewrite untracked, update alien-signals

* chore: update eslint, housekeeping

* refactor: start charm-sync rewrite

* fix: send init payload even if atom did not update

* test: add all sync tests

* feat: prepare for alpha release

* feat: add peek, make untracked escape scope

* fix: wrap hooks in untracked, update types

* feat: add flag for inner effect tracking

* refactor: add docs, remove some modules

* refactor: align with alien-signals 3.0

* feat: add signal api

* refactor: update charm sync api

* feat: update alien-signals

* refactor: remove unnecessary features, update docs

* feat: finalize sync docs

* docs: update sync section

* docs: simplify examples

* docs: capitalize headers

* docs: add migration tips

* docs: fix whitespace

* docs: rephrase computed arg change

* refactor: rename batch, misc fixes & perf updates

* refactor: simplify charm sync validation

* feat: update alien-signals, add recursion flag

* feat: add `recursive()`

* fix: allow dispose in observers

* feat: add `trigger()`, refactor source

* docs: update readme

* chore: prepare release candidate

* test: make some tests more reliable

* docs: fix typo

* feat: add `reactive()`

* chore: bump versions

* docs: reword reactive docs

* docs: fix typo

* feat: add `toRaw()`

* fix: check for nil diffs

* chore: bump versions

* perf: do not create proxies for iteration

* fix: make `toRaw` track all calls

* fix: check none before patching mutable tables

* chore: bump charm-sync version

* docs: add fishing game example project

* feat: sync signals that return reactive proxy

* fix: refine reactive proxy logic

* fix: prevent early reactive gc, alien-signals 3.1.1

* fix: wrap iterated values in `reactive()`

* fix: loosen client signal types

* chore: bump release candidate

* fix: remove unsynced signals immediately

* test: use lute for tests

* chore: bump versions

* ci: set up lute

* refactor: improve source readability

* ci: upgrade lute

* build: loosen workspace version requirements

* chore: bump deps

* fix: update signal overload for setter (#58)

* fix: add missing recursed check

* test: add important vue reactivity tests

* chore: fix react tests, run stylua

* chore: update vide

* docs: improve intro and explain getters

* docs: adjust ordering by importance

* docs: move listen nesting note

* build: fix react peer dep for ci

* chore: update lockfile

* ci: use javascript eslint config

* ci: ignore eslint config file

* refactor: move test directories

* feat: allow recursion in effects by default

* release: charm v0.11.0-rc.4

* refactor: remove recursive()

* refactor: reorganize repo, create deep-charm

* docs: rephrase some sections

* feat: export getter and setter types

* docs: rephrase intro again

* feat!: run listen and subscribe callback in untracked

* refactor!: remove peek

* docs: note peek in migration guide

* release: charm v0.11.0-rc.5

* release: charm-sync v0.4.0-rc.7

* release: react-charm v0.4.0-rc.4

* fix: remove old keys when updating key in `mapped()`

* refactor: shorten error traceback, housekeeping

* refactor: keep error level in some cases

* fix: check if cleanup functions disposed effect

* refactor!: run effects in batched

* ci: check eslint and prettier

* perf: run observe fn in untracked

* perf: call mapper as-is in `mapped()`

* refactor: error for effect cleanup errors

* refactor: use new type solver

* ci: disable new solver flag until 0.714

* chore: housekeeping

* refactor: improve errors in nonstrict, optimize noyield

* chore: housekeeping

* refactor: satisfy new solver

* refactor: improve error traceback

* docs: link roblox signals

* chore: housekeeping

* perf: remove `clearPending` inline operation

* refactor: check type of cleanup

* ci: remove selene

* fix: always return pending value in signal setter

* test: add multi benchmark

* release: charm v0.11.0-rc.6

* test: add cleanup order tests

* chore: bump deps

* docs: add warning for deep-charm and note for `observe()`

---------

Co-authored-by: safazi <safazics@gmail.com>
This commit is contained in:
richard 2026-06-20 20:51:17 -07:00 • committed by GitHub
parent d0b7cff943
commit 573533058f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 9222 additions and 4384 deletions

View file

@ -0,0 +1,9 @@
if not script then
return require("../charm/src")
end
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T> = Charm.Atom<T>
return Charm

View file

@ -1,5 +1,5 @@
{
"name": "CharmSync",
"name": "charm-sync",
"tree": {
"$path": "src"
}

View file

@ -1,16 +1,27 @@
{
"name": "@rbxts/charm-sync",
"version": "0.3.0",
"description": "Sync server state with clients using Charm",
"repository": "littensy/charm",
"version": "0.4.0-rc.7",
"description": "Simple server-to-client sync for Charm",
"license": "MIT",
"main": "src/init.luau",
"types": "src/index.d.ts",
"files": [
"src",
"*.luau",
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"peerDependencies": {
"@rbxts/charm": "workspace:*"
"dependencies": {
"@rbxts/charm": "workspace:^"
}
}

View file

@ -1,83 +1,108 @@
local Charm = require(script.Parent.Parent.Charm)
local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type AtomMap = types.AtomMap
local Charm = require("../Charm")
local patch = require("./patch")
local types = require("./types")
type SyncPayload = types.SyncPayload
type ReactiveProxy<T = { [any]: any }> = types.ReactiveProxy<T>
type ClientOptions = {
--[=[
The atoms to synchronize with the server.
]=]
atoms: AtomMap,
--[=[
Whether to ignore patches sent before the client has been hydrated.
Default is `true`.
]=]
ignoreUnhydrated: boolean?,
}
local trackedSignals: { [string]: (any) -> any } = {}
local trackedReactives: { [string]: ReactiveProxy } = {}
type ClientSyncer = {
--[=[
Applies a patch or initializes the state of the atoms with the given
payload from the server.
@param ...payloads The patches or hydration payloads to apply.
]=]
sync: (self: ClientSyncer, ...SyncPayload) -> (),
}
--[=[
Creates a `ClientSyncer` object that receives patches from the server and
applies them to the local state.
@param options The atoms to synchronize with the server.
@return A `ClientSyncer` object.
]=]
local function client(options: ClientOptions): ClientSyncer
local atoms = options.atoms
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
local self = {} :: ClientSyncer
local hydrated = false
local function hydrate(state: { [string | number]: any })
hydrated = true
for key, value in next, state do
local atom = atoms[key]
atom(value)
end
local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any })
local target = reactive.__target
for key, value in source do
target[key] = value
end
local function apply(data: { [string | number]: any })
local target = {}
for key, atom in next, atoms do
target[key] = atom()
end
target = patch.apply(target, data)
for key, atom in next, atoms do
atom(target[key])
end
end
function self:sync(...)
for index = 1, select("#", ...) do
local payload: SyncPayload = select(index, ...)
Charm.batch(function()
if payload.type == "init" then
hydrate(payload.data)
elseif not ignoreUnhydrated or hydrated then
apply(payload.data)
end
end)
end
end
return self
Charm.trigger(reactive.__track)
end
return client
--[[
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.
Atoms, signals converted to atoms, and signal setter functions are allowed.
Note that `server.addSignalsToClient` still requires some way to get the
state.
@param signals A map of setter functions to sync with the server.
]]
local function addSignals(signals: { [string]: ((any) -> any) | {} })
for key, signal in signals do
if type(signal) == "function" then
trackedSignals[key] = signal
else
trackedReactives[key] = (signal :: any) :: ReactiveProxy
end
end
end
--[[
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 removeSignals(...: string)
for index = 1, select("#", ...) do
local key = select(index, ...)
trackedSignals[key] = nil
trackedReactives[key] = nil
end
end
--[[
Unsubscribes from all server state updates.
]]
local function removeAllSignals()
table.clear(trackedSignals)
table.clear(trackedReactives)
end
local function applyInitPayload(data: { [string]: any })
for key, value in data do
if trackedSignals[key] then
trackedSignals[key](value)
elseif trackedReactives[key] then
shallowMergeReactive(trackedReactives[key], value)
end
end
end
local function applyPatchPayload(data: { [string]: any })
for key, diffs in data do
local trackedSignal = trackedSignals[key]
local trackedReactive = trackedReactives[key]
if trackedSignal then
trackedSignal(function(value)
return patch.apply(value, diffs)
end)
elseif trackedReactive then
patch.applyMutable(trackedReactive.__target, diffs)
Charm.trigger(trackedReactive.__track)
end
end
end
--[[
Merges the server's state patches into the client's state, either
initializing it with the full state or merging a partial patch.
@param payloads The state updates received from the server.
]]
local function applyPatches(payloads: { SyncPayload })
for _, payload in payloads do
if payload.type == "init" then
Charm.batch(applyInitPayload, payload.data)
elseif payload.type == "patch" then
Charm.batch(applyPatchPayload, payload.data)
end
end
end
return {
addSignals = addSignals,
removeSignals = removeSignals,
removeAllSignals = removeAllSignals,
patch = applyPatches,
}

View file

@ -0,0 +1,31 @@
return {
--[[
The interval at which to send patches to the client, in seconds.
Defaults to `0` (patches are sent up to once per frame). Set to a
negative value to disable automatic syncing.
]]
interval = 0,
--[[
Whether the history of state changes since the client's last update
should be preserved. This is useful for values that change multiple
times per frame, where each individual change is important. Defaults
to `false` for performance reasons.
]]
preserveHistory = false,
--[[
When `true`, Charm will modify sparse arrays to prevent data loss from
remote event limitations. When an unsafe array is detected, numeric
indices will be converted to string keys so that the array can be sent
safely and then converted back to an array on the receiving end.
This option should be disabled if your network library uses a custom
serialization method (i.e. Zap, ByteNet) to prevent interference.
]]
fixArrays = true,
--[[
When `true`, synced state containing unsafe sparse arrays or mixed
tables will emit a warning. Only checked in strict mode and if
`fixArrays` is enabled. Defaults to `true`.
]]
validatePatches = true,
}

View file

@ -1,28 +0,0 @@
local types = require(script.Parent.types)
type AtomMap = types.AtomMap
type NestedAtomMap = {
[string]: NestedAtomMap | () -> (),
}
local function flatten(atoms: NestedAtomMap): AtomMap
local result: AtomMap = {}
local function visit(node: NestedAtomMap, path: string)
for key, value in node do
local location = if path == "" then key else path .. "/" .. key
if type(value) == "table" then
visit(value, location)
else
result[location] = value
end
end
end
visit(atoms, "")
return result
end
return flatten

View file

@ -1,214 +1,292 @@
import { Atom, Selector } from "@rbxts/charm";
export = CharmSync;
export as namespace CharmSync;
type Cleanup = () => void;
import { Atom, Setter } from "@rbxts/charm";
type Key = string | number | symbol;
declare namespace CharmSync {
type AtomMap = Record<string, Atom<any>>;
/**
* Represents the removal of a value from the state.
*/
export interface None {
readonly __none: "__none";
}
type SelectorMap = Record<string, Selector<any>>;
export type MaybeNone<T> = undefined extends T ? None : never;
type ReactiveObject = { [key: Key]: any };
type ServerSignalMap = Record<string, (() => any) | ReactiveObject>;
type ClientSignalMap = Record<string, Setter<any> | ReactiveObject>;
/**
* Infers the type of the return values produced by a map of functions.
*/
export type StateOfMap<T> = {
readonly [P in keyof T]: T[P] extends () => infer State ? State : never;
};
type DataTypes = {
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
};
type DataType = DataTypes[keyof DataTypes];
/**
* A partial patch that can be applied to the state to update it. Represents
* the difference between the current state and the next state.
*
* 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>
? ReadonlyMap<K, SyncPatch<V, FixArrays> | None>
: State extends Set<infer T> | ReadonlySet<infer T>
? ReadonlyMap<T, true | None>
: State extends readonly (infer T)[]
? FixArrays extends true
? ReadonlyMap<string | number, SyncPatch<T, FixArrays> | None>
: readonly (SyncPatch<T, FixArrays> | None | undefined)[]
: State extends DataType
? State
: State extends object
? { readonly [P in keyof State]?: SyncPatch<State[P], FixArrays> }
: State;
/**
* A payload that can be sent from the server to the client to synchronize
* state between the two.
*/
export type SyncPayload<Getters extends ServerSignalMap = ServerSignalMap, 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.
*/
export const config: {
/**
* @deprecated Use `SelectorMap` instead.
*/
type MoleculeMap = SelectorMap;
/**
* Infers the type of the return values produced by a map of functions.
*/
type StateOfMap<T> = {
readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never;
};
/**
* Represents the removal of a value from the state.
*/
interface None {
readonly __none: "__none";
}
type MaybeNone<T> = undefined extends T ? None : never;
/**
* Creates a `ClientSyncer` object that receives patches from the server and
* applies them to the local state.
*
* @client
* @param options The atoms to synchronize with the server.
* @returns A `ClientSyncer` object.
*/
function client<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
/**
* Creates a `ServerSyncer` object that sends patches to the client and
* hydrates the client's state.
* The interval at which to send patches to the client, in seconds.
* Defaults to `0` (patches are sent up to once per frame). Set to a
* negative value to disable automatic syncing.
*
* @server
* @param options The atoms to synchronize with the client.
* @returns A `ServerSyncer` object.
*/
function server<Selectors extends SelectorMap, Serialize extends boolean = true>(
options: ServerOptions<Selectors, Serialize>,
): ServerSyncer<Selectors, Serialize>;
interval: number;
/**
* Checks whether a value is `None`. If `true`, the value is scheduled to be
* removed from the state when the patch is applied.
* Whether the history of state changes since the client's last update
* should be preserved. This is useful for values that change multiple
* times per frame, where each individual change is important. Defaults
* to `false` for performance reasons.
*
* @param value The value to check.
* @returns `true` if the value is `None`, otherwise `false`.
* @server
*/
function isNone(value: unknown): value is None;
interface NestedAtomMap {
readonly [key: string]: NestedAtomMap | Atom<any>;
}
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
? Name
: Name extends string
? Path extends string
? `${Path}/${Name}`
: never
: never;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
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;
}>;
preserveHistory: boolean;
/**
* Flattens a nested atom map into a single object with slash-separated
* keys. Useful for recursively collecting atoms returned by modules.
* When `true`, Charm will modify sparse arrays to prevent data loss from
* remote event limitations. When an unsafe array is detected, numeric
* indices will be converted to string keys so that the array can be sent
* safely and then converted back to an array on the receiving end.
*
* @param atoms The nested atom map to flatten.
* @return A flattened atom map.
*/
function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
type DataTypes = {
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
};
/**
* A type that should not be made partial in patches.
*/
type DataType = DataTypes[keyof DataTypes];
/**
* A partial patch that can be applied to the state to update it. Represents
* the difference between the current state and the next state.
* This option should be disabled if your network library uses a custom
* serialization method (i.e. Zap, ByteNet) to prevent interference.
*
* If a value was removed, it is replaced with `None`. This can be checked
* using the `sync.isNone` function.
* @server
*/
type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends
| ReadonlyMap<infer K, infer V>
| Map<infer K, infer V>
? ReadonlyMap<K, SyncPatch<V, Serialize> | None>
: State extends Set<infer T> | ReadonlySet<infer T>
? ReadonlyMap<T, true | None>
: State extends readonly (infer T)[]
? Serialize extends true
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None>
: readonly (SyncPatch<T, Serialize> | None | undefined)[]
: State extends DataType
? State
: State extends object
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> }
: State;
fixArrays: boolean;
/**
* When `true`, synced state containing unsafe sparse arrays or mixed
* tables will emit a warning. Only checked in strict mode and if
* `fixArrays` is enabled. Defaults to `true`.
*
* @server
*/
validatePatches: boolean;
};
/**
* @client
*/
export namespace client {
/**
* 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.
*
* Atoms, signals converted to atoms, and signal setter functions are allowed.
* Note that `server.addSignalsToClient` still requires some way to get the
* state.
*
* @param signals A map of setter functions to sync with the server.
*/
export function addSignals<Signals extends ClientSignalMap = ClientSignalMap>(signals: Signals): void;
/**
* A payload that can be sent from the server to the client to synchronize
* state between the two.
* 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.
*/
type SyncPayload<Selectors extends SelectorMap, Serialize extends boolean = true> =
| { readonly type: "init"; readonly data: StateOfMap<Selectors> }
| { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>, Serialize> };
export function removeSignals<Signals extends ServerSignalMap | ClientSignalMap = ClientSignalMap>(
...keys: (keyof Signals)[]
): void;
interface ClientOptions<Atoms extends AtomMap> {
/**
* The atoms to synchronize with the server.
*/
atoms: Atoms;
/**
* Whether to ignore patches sent before the client has been hydrated.
* Defaults to `true`.
*/
ignoreUnhydrated?: boolean;
}
/**
* Unsubscribes from all server state updates.
*/
export function removeAllSignals(): void;
interface ServerOptions<Selectors extends SelectorMap, Serialize extends boolean = true> {
/**
* The atoms to synchronize with the client.
*/
atoms: Selectors;
/**
* The interval at which to send patches to the client, in seconds.
* Defaults to `0` (patches are sent up to once per frame). Set to a
* negative value to disable automatic syncing.
*/
interval?: number;
/**
* Whether the history of state changes since the client's last update
* should be preserved. This is useful for values that change multiple times
* per frame, where each individual change is important. Defaults to `false`.
*
* If `true`, the broadcaster will send a list of payloads to the client
* instead of a single payload. The client will apply each payload in order
* to reconstruct the state's changes over time.
*/
preserveHistory?: boolean;
/**
* When `true`, Charm will apply validation and serialize unsafe arrays
* to address remote event argument limitations. Defaults to `true`.
*
* This option should be disabled if your network library uses a custom
* serialization method (i.e. Zap, ByteNet) to prevent interference.
*/
autoSerialize?: Serialize;
}
interface ClientSyncer<Atoms extends AtomMap> {
/**
* Applies a patch or initializes the state of the atoms with the given
* payload from the server.
*
* @param payloads The patches or hydration payloads to apply.
*/
sync(...payloads: SyncPayload<Atoms, boolean>[]): void;
}
interface ServerSyncer<Selectors extends SelectorMap, Serialize extends boolean> {
/**
* Sets up a subscription to each atom that schedules a patch to be sent to
* the client whenever the state changes. When a change occurs, the `callback`
* is called with the player and the payloads to send.
*
* Note that a `payload` object should not be mutated. If you need to modify
* a payload, apply the changes to a copy of the object.
*
* @param callback The function to call when the state changes.
* @returns A cleanup function that unsubscribes all listeners.
*/
connect(callback: (player: Player, ...payloads: SyncPayload<Selectors, Serialize>[]) => void): Cleanup;
/**
* Hydrates the client's state with the server's state. This should be
* called when a player joins the game and requires the server's state.
*
* @param player The player to hydrate.
*/
hydrate(player: Player): void;
}
/**
* Merges the server's state patches into the client's state, either
* initializing it with the full state or merging a partial patch.
*
* @param payloads The state updates received from the server.
*/
export function patch<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
payloads: SyncPayload<Getters, FixArrays>[],
): void;
}
/**
* @server
*/
export namespace server {
/**
* Subscribes a client to the given signals. When an update occurs, the client
* will receive a partial state patch to merge into their local state. May be
* called multiple times to subscribe to additional keys.
*
* 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 getter functions to sync with the client.
*/
export function addSignalsToClient<Signals extends ServerSignalMap = ServerSignalMap>(
client: Player,
signals: Signals,
): void;
/**
* Unsubscribes a client from receiving all state updates. To only unsubscribe
* from specific keys, use `removeSignalsFromClient` instead.
*
* @param client The client receiving state updates.
*/
export function removeClient(client: Player): void;
/**
* Unsubscribes a client from receiving updates for the given keys.
*
* @param client The client receiving state updates.
* @param keys The keys of the state to unsubscribe from.
*/
export function removeSignalsFromClient<Signals extends ServerSignalMap = ServerSignalMap>(
client: Player,
...keys: (keyof Signals)[]
): void;
/**
* Uses the callback to send state updates to clients that are subscribed
* to state changes. Starts automatically sending updates at the interval
* specified in `config.interval`.
*
* @param onSync Called when sending patches to a client.
*/
export function connect<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
): void;
/**
* Stops syncing state updates to clients at the automatic interval.
* Flushing can still be performed manually via `flush`.
*/
export function disconnect(): void;
/**
* Immediately sends pending updates to all clients. Normally, updates are
* sent at the interval specified in `config.interval`, but this function
* can be used to force an immediate sync.
*/
export function flush(): void;
/**
* Unsubscribes all clients, stops tracking all state, and disconnects
* from the automatic sync interval.
*/
export function reset(): void;
}
export namespace patch {
/**
* Converts `nil` values to the special `None` symbol. This is useful when
* preparing data for serialization, since `nil` can represent both the
* absence of a value and the removal of a value in patches.
*/
export function nilToNone<T>(value: T | undefined): T | None;
/**
* Compares two states and returns a patch that can be applied to the
* current state to produce the next state.
*
* @param currentState The current state.
* @param nextState The next state.
* @returns A patch that can be applied to the current state.
*/
export function diff<State, FixArrays extends boolean = true>(
currentState: State,
nextState: State,
): SyncPatch<State, FixArrays>;
/**
* Applies a patch to a state and returns the resulting value.
*
* @param currentState The current state.
* @param statePatch The patches to apply.
* @returns The new state with the patch applied.
*/
export function applyImmutable<State, FixArrays extends boolean = true>(
currentState: State,
statePatch: SyncPatch<State, FixArrays>,
): State;
}
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 DeepFlatten<T> = Reconstruct<UnionToIntersection<DeepFlattenImpl<T>>>;
/**
* Flattens a nested table into a single table with slash-separated keys.
* Useful for recursively collecting atoms returned by modules.
*
* @param input A nested table.
* @return A flattened table with slash-separated keys.
*/
export function flatten<T>(input: T): DeepFlatten<T>;
/**
* Checks whether a value is `None`. If `true`, the value is scheduled to be
* removed from the state when the patch is applied.
*
* @param value The value to check.
* @returns `true` if the value is `None`, otherwise `false`.
*/
export function isNone(value: unknown): value is None;
/**
* Converts a signal getter and setter into an atom. This is useful for
* syncing client signals with the server, as the sync system requires both
* the getter and setter to update state.
*
* @param getter A function that returns the current value of the signal.
* @param setter A function that sets the value of the signal.
* @returns An atom that can either get or set the signal's value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter
*/
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;

View file

@ -1,18 +1,19 @@
local client = require(script.client)
local flatten = require(script.flatten)
local patch = require(script.patch)
local server = require(script.server)
local types = require(script.types)
local client = require("@self/client")
local config = require("@self/config")
local patch = require("@self/patch")
local server = require("@self/server")
local types = require("@self/types")
local utils = require("@self/utils")
export type None = types.None
export type SyncPayload = types.SyncPayload
--[=[
Synchronizes state between the client and server. The server sends patches
to the client, which applies them to its local state.
]=]
return {
config = config,
client = client,
server = server,
flatten = flatten,
patch = patch,
isNone = patch.isNone,
flatten = utils.flatten,
signalToAtom = utils.signalToAtom,
}

View file

@ -1,30 +0,0 @@
local RunService = game:GetService("RunService")
--[=[
Schedules a function to be called at a fixed interval of `seconds`.
@param callback The function to call.
@param seconds The interval in seconds.
@return A function that cancels the interval.
]=]
local function interval(callback: () -> (), seconds: number): () -> ()
if seconds < 0 then
return function() end
end
local clock = 0
local connection = RunService.Heartbeat:Connect(function(delta)
clock += delta
if clock >= seconds then
clock = 0
callback()
end
end)
return function()
connection:Disconnect()
end
end
return interval

View file

@ -1,28 +1,45 @@
local validate = require(script.Parent.validate)
local Charm = require("../Charm")
local config = require("./config")
local types = require("./types")
local validate = require("./validate")
local __DEV__ = _G.__DEV__
local flags = Charm.flags
--[=[
A special symbol that denotes the absence of a value. Used to represent
deleted values in patches.
]=]
local None = { __none = "__none" }
export type None = types.None
local None: None = { __none = "__none" }
--[[
Checks whether a value is `None`. If `true`, the value is scheduled to be
removed from the state when the patch is applied.
@param value The value to check.
@return `true` if the value is `None`, otherwise `false`.
]]
local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none"
end
--[=[
--[[
Converts `nil` values to the special `None` symbol. This is useful when
preparing data for serialization, since `nil` can represent both the absence
of a value and the removal of a value in patches.
]]
local function nilToNone<T>(value: T?): T | None
return if value == nil then None else value
end
--[[
JSON serialization can drop the last values of a sparse array. For example,
`{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
serialization of sparse arrays can be resolved by converting them to
dictionaries with string keys.
The `apply` function will convert these dictionaries back into arrays as
The `patch` functions will convert these dictionaries back into arrays as
long as the target array is not sparse. This function ensures that the diff
function produces a patch that can be correctly applied.
]=]
local function stringifySparseArray(object: { any }): { [string | number]: any }
]]
local function stringifySparseArray(object: { [any]: any }): { [string | number]: any }
local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then
@ -32,86 +49,91 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
local dictionary = {}
for index, value in next, object do
for index, value in object do
dictionary[tostring(index)] = value
end
return dictionary
end
--[=[
Compares two states and returns a patch that can be applied to the previous
state to produce the next state.
local function diff(oldState: any, newState: any): any
if oldState == newState then
return nil
elseif newState == nil then
return None
elseif type(oldState) ~= "table" or type(newState) ~= "table" then
return newState
end
@param prevState The previous state.
@param nextState The next state.
@param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`.
@returns A patch that can be applied to the previous state.
]=]
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
serialize = serialize ~= false
local diffs = table.clone(newState)
local patches = table.clone(nextState)
for key, oldValue in oldState do
diffs[key] = diff(oldValue, newState[key])
end
for key, previous in next, prevState do
local current = nextState[key]
if next(diffs) == nil then
return nil
end
if previous == current then
patches[key] = nil
elseif current == nil then
patches[key] = None
elseif type(previous) == "table" and type(current) == "table" then
patches[key] = diff(previous, current, serialize)
if config.fixArrays then
-- Convert sparse array patches that could be misserialized
if oldState[1] ~= nil or newState[1] ~= nil then
diffs = stringifySparseArray(diffs)
end
if flags.strict and config.validatePatches then
validate(diffs)
end
end
-- Coerce sparse array patches into dictionaries
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
patches = stringifySparseArray(patches)
end
if serialize and __DEV__ then
for key, value in next, patches do
validate(value, key)
end
end
return patches
return diffs
end
--[=[
Applies a patch to a state and returns the next state.
@param state The current state.
@param patches The patches to apply.
@returns The next state.
]=]
local function apply(state: any, patches: any): any
if type(patches) == "table" and patches.__none == "__none" then
local function apply(oldState: any, diffs: any): any
if isNone(diffs) then
return nil
elseif type(state) ~= "table" or type(patches) ~= "table" then
return patches
elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
return diffs
end
local nextState = table.clone(state)
local stateIsArray = state[1] ~= nil
local newState = table.clone(oldState)
local stateIsArray = oldState[1] ~= nil
for key, patch in next, patches do
for key, value in diffs do
-- Diff-checking an array produces a sparse array, which will not be
-- preserved when converted to JSON. To prevent this, we turn string
-- keys back into numeric keys.
if stateIsArray and type(key) == "string" then
key = tonumber(key) or key
end
nextState[key] = apply(nextState[key], patch)
newState[key] = apply(newState[key], value)
end
return nextState
return newState
end
local function applyMutable(targetState: any, diffs: any): any
if isNone(diffs) then
return nil
elseif type(targetState) ~= "table" or type(diffs) ~= "table" then
return diffs
end
local targetIsArray = targetState[1] ~= nil
for key, value in diffs do
if targetIsArray and type(key) == "string" then
key = tonumber(key) or key
end
targetState[key] = applyMutable(targetState[key], value)
end
return targetState
end
return {
isNone = isNone,
nilToNone = nilToNone,
diff = diff,
apply = apply,
applyMutable = applyMutable,
}

View file

@ -1,267 +1,416 @@
local Players = game:GetService("Players")
local Charm = require("../Charm")
local config = require("./config")
local patch = require("./patch")
local types = require("./types")
local utils = require("./utils")
local Charm = require(script.Parent.Parent.Charm)
local interval = require(script.Parent.interval)
local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload
type ServerOptions = {
--[=[
The atoms to synchronize with the client.
]=]
atoms: AtomMap,
--[=[
The interval at which to send patches to the client, in seconds.
Defaults to `0` (patches are sent up to once per frame). Set to a
negative value to disable automatic syncing.
]=]
interval: number?,
--[=[
Whether the history of state changes since the client's last update
should be preserved. This is useful for values that change multiple times
per frame, where each individual change is important. Defaults to `false`.
]=]
preserveHistory: boolean?,
--[=[
When `true`, Charm will apply validation and serialize unsafe arrays
to address remote event argument limitations. Defaults to `true`.
type SyncStatus = number
This option should be disabled if your network library uses a custom
serialization method (i.e. Zap, ByteNet) to prevent interference.
]=]
autoSerialize: boolean?,
type ClientKeys = { [string]: SyncStatus }
type TrackedState<T = any> = {
identifier: unknown,
currentValue: T,
lastSyncedValue: T,
isReactive: boolean,
dispose: () -> (),
}
type ServerSyncer = {
--[=[
Sets up a subscription to each atom that schedules a patch to be sent to
the client whenever the state changes. When a change occurs, the `callback`
is called with the player and the payload to send.
Note that the `payload` object should not be mutated. If you need to
modify the payload, apply the changes to a copy of the object.
@param callback The function to call when the state changes.
@return A cleanup function that unsubscribes all listeners.
]=]
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
--[=[
Hydrates the client's state with the server's state. This should be
called when a player joins the game and requires the server's state.
@param player The player to hydrate.
]=]
hydrate: (self: ServerSyncer, player: Player) -> (),
--[=[
@deprecated For internal use only.
]=]
_sendPatch: (self: ServerSyncer, player: Player) -> (),
type StateUpdate = {
key: string,
newValue: any,
oldValue: any,
}
--[=[
Creates a `ServerSyncer` object that sends patches to the client and
hydrates the client's state.
@param options The atoms to synchronize with the client.
@return A `ServerSyncer` object.
]=]
local function server(options: ServerOptions): ServerSyncer
local atoms = options.atoms
local autoSerialize = options.autoSerialize ~= false
local preserveHistory = options.preserveHistory
local syncRate = options.interval or 0
local PENDING_INITIAL_STATE: SyncStatus = 0
local LISTENING_FOR_CHANGES: SyncStatus = 1
local self = {} :: ServerSyncer
local sync: (player: Player, payload: SyncPayload) -> ()
local trackedStates: { [string]: TrackedState } = {}
local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientKeys } = {}
local function createSnapshot()
local snapshot = {}
local connection: RBXScriptConnection?
local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
for key, atom in next, atoms do
snapshot[key] = atom()
--[[
Manually converts a reactive proxy to its raw representation to potentially
support custom implementations of reactive proxies.
]]
local function toRaw<T>(value: T): T
value = value :: types.ReactiveProxy<T>
if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then
value.__track()
return toRaw(value.__target)
end
return value
end
local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
if trackedStates[key] then
-- Warn against name collisions with conflicting signals or proxies
if trackedStates[key].identifier ~= identifier then
warn("[Charm] Attempted to sync different states with the same key:", key)
end
return snapshot
return
end
-- Send the initial state to a player when they join the server.
function self:hydrate(player)
assert(sync, "connect() must be called before hydrate()")
local trackedState: TrackedState<T>
local initialValue = getter()
local rawInitialValue = toRaw(initialValue)
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
sync(player, {
type = "init",
data = createSnapshot(),
})
end
local dispose = Charm.effect(function()
local newValue = getter()
local rawValue = toRaw(newValue)
local isReactive = newValue ~= rawValue
-- If the history of state changes should be preserved, keep a list of
-- previous snapshots and diff-check each one from the last.
if preserveHistory then
local snapshots = { createSnapshot() }
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
local function pushSnapshot(key: string | number, current: any, previous: any)
local lastSnapshot = snapshots[#snapshots]
local previousSnapshot = snapshots[#snapshots - 1]
-- Optimize snapshots by updating the most recent snapshot if the
-- previous and current values are the same, since this allows us
-- to group multiple changes into a single snapshot.
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
lastSnapshot[key] = current
else
local nextSnapshot = table.clone(lastSnapshot)
nextSnapshot[key] = current
table.insert(snapshots, nextSnapshot)
end
end
-- Populate the snapshot with the initial state of each atom.
-- Subscribe to each atom and update the state when it changes.
for key, atom in next, atoms do
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
pushSnapshot(key, current, previous)
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payloads: { SyncPayload } = {}
local lastSnapshot
for index, snapshot in next, snapshots do
lastSnapshot = snapshot
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { lastSnapshot }
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, unpack(payloads))
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player: Player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local payloads: { SyncPayload } = {}
for index, snapshot in next, snapshots do
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { snapshots[#snapshots] }
changed = false
sync(player, unpack(payloads))
end
return self
end
local previousSnapshot = createSnapshot()
local currentSnapshot = table.clone(previousSnapshot)
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
-- Subscribe to each atom and update the state when one changes.
for key, atom in next, atoms do
subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, payload)
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
if not trackedState then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
}
-- When preserving history, we need to keep track of previous values
-- so we can create granular patches for each change.
if config.preserveHistory then
local prevSnapshot = snapshot
snapshot = if isReactive then utils.deepCopy(rawValue) else newValue
table.insert(globalUpdates, {
key = key,
newValue = snapshot,
oldValue = prevSnapshot,
})
end
previousSnapshot = table.clone(currentSnapshot)
changed = false
trackedState.currentValue = rawValue
trackedState.isReactive = isReactive
end)
sync(player, payload)
end
trackedState = {
identifier = identifier,
currentValue = rawInitialValue,
lastSyncedValue = snapshot,
isReactive = initialValue ~= rawInitialValue,
dispose = dispose,
}
return self
trackedStates[key] = trackedState
end
return server
local function stopTracking(key: string)
local trackedState = trackedStates[key]
if trackedState then
trackedState.dispose()
trackedStates[key] = nil
end
end
local function purgeUntracked()
local active: { [string]: boolean } = {}
for _, clientKeys in clients do
for key in clientKeys do
active[key] = true
end
end
for key in trackedStates do
if not active[key] then
stopTracking(key)
end
end
end
local function diffGlobalUpdateBuffer(): { any }
local patchCount = #globalUpdates
local patches = table.create(patchCount)
for _, update in globalUpdates do
table.insert(patches, patch.diff(update.oldValue, update.newValue))
end
return patches
end
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }?
local clientKeys = clients[client]
if not clientKeys then
return nil
end
local payloads: { SyncPayload } = {}
-- Iterate over all global updates and see if the client is interested in
-- any of them. If so, we create a patch payload for the update.
for index, update in globalUpdates do
local patchData = globalPatches[index]
if not patchData then
continue
end
local key = update.key
local status = clientKeys[key]
if status == LISTENING_FOR_CHANGES then
local payload: SyncPayload = {
type = "patch",
data = { [key] = patchData },
}
table.insert(payloads, payload)
elseif status == PENDING_INITIAL_STATE then
local payload: SyncPayload = {
type = "init",
data = { [key] = update.newValue },
}
table.insert(payloads, payload)
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
-- If a pending signal was never updated, we still need to send its initial
-- state. This can happen if a signal was registered after its last change.
local initData = {}
for key, status in clientKeys do
local trackedState = trackedStates[key]
if status == PENDING_INITIAL_STATE and trackedState then
initData[key] = trackedState.currentValue
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
if next(initData) ~= nil then
table.insert(payloads, 1, { type = "init" :: "init", data = initData })
end
return payloads
end
local function diffGlobalState(): { [string]: any }?
local currentGlobalState: { [string]: any } = {}
local previousGlobalState: { [string]: any } = {}
for key, trackedState in trackedStates do
local currentValue = if trackedState.isReactive
then utils.deepCopy(trackedState.currentValue)
else trackedState.currentValue
currentGlobalState[key] = currentValue
previousGlobalState[key] = trackedState.lastSyncedValue
trackedState.lastSyncedValue = currentValue
end
return patch.diff(previousGlobalState, currentGlobalState)
end
local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }?
local clientKeys = clients[client]
if not clientKeys then
return nil
end
local payloads: { SyncPayload } = {}
local initData: { [string]: any } = {}
local patchData: { [string]: any } = {}
-- 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 = trackedStates[key]
if status == LISTENING_FOR_CHANGES and globalPatch then
patchData[key] = globalPatch[key]
elseif status == PENDING_INITIAL_STATE then
initData[key] = trackedState.currentValue
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
if next(initData) ~= nil then
local payload: SyncPayload = {
type = "init",
data = initData,
}
table.insert(payloads, payload)
end
if next(patchData) ~= nil then
local payload: SyncPayload = {
type = "patch",
data = patchData,
}
table.insert(payloads, payload)
end
return payloads
end
local function startInterval(callback: () -> ())
if not game then
return
end
if connection then
connection:Disconnect()
end
local timestamp = os.clock()
connection = game:GetService("RunService").Heartbeat:Connect(function()
if config.interval >= 0 and os.clock() >= timestamp + config.interval then
timestamp = os.clock()
debug.profilebegin("CharmSync")
callback()
debug.profileend()
end
end)
end
--[[
Subscribes a client to the given signals. When an update occurs, the client
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 `client.addSignals` still requires some way to set the state.
@param client The client receiving state updates.
@param signals A map of getter functions to sync with the client.
]]
local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any })
if not clients[client] then
clients[client] = {}
end
local clientKeys = clients[client]
for key, signal in signals do
if type(signal) == "function" then
trackSignal(key, signal, signal)
else
trackSignal(key, function()
return signal
end, signal)
end
if not clientKeys[key] then
-- Mark the signal as pending initial state so we send its full
-- value the next time we sync.
clientKeys[key] = PENDING_INITIAL_STATE
end
end
end
--[[
Unsubscribes a client from receiving all state updates. To only unsubscribe
from specific keys, use `unsubscribeClientFrom` instead.
@param client The client receiving state updates.
]]
local function removeClient(client: any)
clients[client] = nil
purgeUntracked()
end
--[[
Unsubscribes a client from receiving updates for the given keys.
@param client The client receiving state updates.
@param keys The keys of the state to unsubscribe from.
]]
local function removeSignalsFromClient(client: any, ...: string)
local clientKeys = clients[client]
if not clientKeys then
return
end
for index = 1, select("#", ...) do
local key = select(index, ...)
clientKeys[key] = nil
end
if next(clientKeys) == nil then
removeClient(client)
else
purgeUntracked()
end
end
--[[
Immediately sends pending updates to all clients. Normally, updates are
sent at the interval specified in `config.interval`, but this function
can be used to force an immediate sync.
]]
local function flush()
if not syncCallback then
return
end
local granular = config.preserveHistory
local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
for client in clients do
local payloads = if granular
then selectFromGlobalPatchList(client, globalPatch :: { any })
else selectFromGlobalPatch(client, globalPatch)
if payloads and payloads[1] then
if task then
task.spawn(syncCallback, client, payloads)
else
syncCallback(client, payloads)
end
end
end
table.clear(globalUpdates)
end
--[[
Uses the callback to send state updates to clients that are subscribed
to state changes. Starts automatically sending updates at the interval
specified in `config.interval`.
@param onSync Called when sending patches to a client.
]]
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
syncCallback = onSync
startInterval(flush)
end
--[[
Stops syncing state updates to clients at the automatic interval.
Flushing can still be performed manually via `flush`.
]]
local function disconnect()
if connection then
connection:Disconnect()
connection = nil
end
end
--[[
Unsubscribes all clients, stops tracking all state, and disconnects
from the automatic sync interval.
]]
local function reset()
table.clear(trackedStates)
table.clear(clients)
table.clear(globalUpdates)
disconnect()
end
return {
addSignalsToClient = addSignalsToClient,
removeClient = removeClient,
removeSignalsFromClient = removeSignalsFromClient,
connect = connect,
disconnect = disconnect,
flush = flush,
reset = reset,
}

View file

@ -1,20 +1,30 @@
local Charm = require(script.Parent.Parent.Charm)
type Atom<T> = Charm.Atom<T>
--[=[
--[[
A payload that can be sent from the server to the client to synchronize
state between the two.
]=]
export type SyncPayload = {
type: "init",
data: { [string | number]: any },
} | {
type: "patch",
data: { [string | number]: any },
}
the states of the client's atoms with the server.
]]
export type SyncPayload =
| {
type: "init",
data: { [string]: any },
}
| {
type: "patch",
data: { [string]: any },
}
export type AtomMap = {
[string | number]: Atom<any>,
}
--[[
A special type that represents a removed value in a patch. This is used to
differentiate between a value that was set to `nil` and an unchanged value.
]]
export type None = { __none: "__none" }
return nil
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
return {}

View file

@ -0,0 +1,65 @@
local Charm = require("../Charm")
type Atom<T> = Charm.Atom<T>
--[[
Flattens a nested table into a single table with slash-separated keys.
Useful for recursively collecting atoms returned by modules.
@param input A nested table.
@return A flattened table with slash-separated keys.
]]
local function flatten<Flattened>(input: { [any]: any }): Flattened
local result = {}
local function visit(node: { [any]: any }, path: string)
for key, value in node do
local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
if type(value) == "table" then
visit(value, location)
else
result[location] = value
end
end
end
visit(input, "")
return result :: any
end
--[[
Converts a signal getter and setter into an atom. This is useful for
syncing client signals with the server, as the sync system requires both
the getter and setter to update state.
@param getter A function that returns the current value of the signal.
@param setter A function that sets the value of the signal.
@return An atom that can either get or set the signal's value.
]]
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
return function(...)
return if select("#", ...) == 0 then getter() else setter((...))
end
end
--[[
Returns a deep copy of the given table. Skips tables with metatables to
prevent copying reactive proxies.
]]
local function deepCopy<T>(object: T): T
local copy: any = table.clone(object :: any)
for key, value in copy do
if type(value) == "table" and getmetatable(value) == nil then
copy[key] = deepCopy(value)
end
end
return copy
end
return {
flatten = flatten,
signalToAtom = signalToAtom,
deepCopy = deepCopy,
}

View file

@ -1,67 +1,46 @@
local SAFE_KEYS = { string = true, number = true }
local UNSAFE_VALUES = { ["function"] = true, thread = true }
local function isTableUnsafe(object: { [any]: any })
local keyType = nil
local objectSize = 0
local firstKey = next(object)
-- All keys must have the same type
for key in next, object do
local currentType = type(key)
if not keyType and SAFE_KEYS[currentType] then
keyType = currentType
elseif keyType ~= currentType then
return true
end
objectSize += 1
if firstKey == nil then
return false
end
-- If there are more keys than the length of the array, it's an array with
-- non-sequential keys.
if objectSize > #object and keyType == "number" then
local keyType = type(firstKey)
local maxKey = 0
-- Keys must all be the same type
for key in object do
if type(key) ~= keyType then
return true
elseif type(key) == "number" and key > maxKey then
maxKey = key
end
end
-- Array keys must be sequential
if keyType == "number" and maxKey > #object then
return true
end
return false
end
--[=[
Validates a value to ensure it can be synced over a remote event.
--[[
Emits a warning if the value is a table that cannot be synced via
RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
with remote argument limitations, such as sparse arrays or mixed tables.
@param value The value to validate.
@param key The key of the value in the table.
@error Throws an error if the value cannot be synced.
]=]
local function validate(value: any, key: any)
local typeOfKey = type(key)
local typeOfValue = type(value)
if not SAFE_KEYS[typeOfKey] then
error(`Invalid key type '{typeOfKey}' at key '{key}'`)
elseif UNSAFE_VALUES[typeOfValue] then
error(`Invalid value type '{typeOfValue}' at key '{key}'`)
elseif typeOfValue == "table" then
if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
elseif isTableUnsafe(value) then
error(
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
.. "This can be for the following reasons:\n"
.. "1. The object is an array with non-sequential keys\n"
.. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
)
end
end
if typeOfKey == "number" then
if key == math.huge or key == -math.huge then
error("Cannot sync infinity as key")
elseif key ~= math.floor(key) then
error("Cannot sync non-integer number as key")
end
@param value The table value.
]]
local function validate(value: { [any]: any })
if isTableUnsafe(value) then
warn(
"Cannot sync tables unsupported by remote events! This can be for the following reasons:\n"
.. "1. The table is a sparse array with non-sequential indices\n"
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
value
)
end
end

View file

@ -0,0 +1,137 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local client = require("../src/client")
local test = require("../../../tests/test")
test.afterEach(function()
client.removeAllSignals()
end)
test("should hydrate atom", function()
local a = Charm.atom(1)
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = 2 } },
})
assert(a() == 2, "should set a to the initial value")
end)
test("should merge patch", function()
local a = Charm.atom({ b = 1, c = 2 })
local previous = a()
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = a() } },
})
assert(a() == previous, "should set a to the initial value")
client.patch({
{ type = "patch", data = { a = { b = 3 } } },
})
assert(a() ~= previous, "should apply patch")
assert(a().b == 3, "should set b to the new value")
assert(a().c == 2, "should not change c")
end)
test("should merge patch (reactive)", function()
local a = DeepCharm.reactive({ b = 1, c = 2 })
local runs = 0
Charm.effect(function()
local _ = a.b + a.c
runs += 1
end)
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = { b = a.b, c = a.c } } },
})
assert(runs == 1, "should not trigger effect for redundant init")
client.patch({
{ type = "patch", data = { a = { b = 3 } } },
})
assert(a.b == 3, "should set b to the new value")
assert(a.c == 2, "should not change c")
assert(runs == 2, "should trigger effect for patch")
end)
test("should fix sparse array patches", function()
local a = Charm.atom({ 1 })
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = a() } },
})
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
-- was JSON-serialized and deserialized. The client should be able to catch
-- this and convert the string key back to a number.
client.patch({
{ type = "patch", data = { a = { ["2"] = 2 } } },
})
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
assert(a()[2] == 2, "should convert serialized arrays to use numbers")
end)
test("should merge multiple patches", function()
local a = Charm.atom(1)
local calls = {}
client.addSignals({ a = a })
Charm.subscribe(a, function(value)
table.insert(calls, value)
end)
client.patch({
{ type = "init", data = { a = a() } },
})
client.patch({
{ type = "patch", data = { a = 2 } },
{ type = "patch", data = { a = 3 } },
})
assert(a() == 3, "should apply the last patch")
assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`)
end)
test("should batch patches", function()
local a = Charm.atom(0)
local b = Charm.atom(0)
local states = {}
client.addSignals({ a = a, b = b })
Charm.subscribe(a, function()
states.b = b()
end)
Charm.subscribe(b, function()
states.a = a()
end)
client.patch({
{ type = "init", data = { a = a(), b = b() } },
})
client.patch({
{ type = "patch", data = { a = 1, b = 2 } },
})
assert(a() == 1, "should set a to the new value")
assert(b() == 2, "should set b to the new value")
assert(states.a == 1, "sub to a should run after b updates")
assert(states.b == 2, "sub to b should run after a updates")
end)
return {}

View file

@ -0,0 +1,97 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local client = require("../src/client")
local config = require("../src/config")
local server = require("../src/server")
local test = require("../../../tests/test")
test.afterEach(function()
server.reset()
client.removeAllSignals()
config.preserveHistory = false
end)
test("should replicate server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = DeepCharm.reactive({ count = -1 })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
server.connect(function(syncTarget, payloads)
if syncTarget == target then
client.patch(payloads)
end
end)
server.flush()
assert(clientA().count == 0, "Client atom A should be initialized to 0")
assert(clientB.count == 0, "Client reactive B should be initialized to 0")
serverA({ count = 1 })
serverB.count = 1
server.flush()
assert(clientA().count == 1, "Client atom A should be updated to 1")
assert(clientB.count == 1, "Client reactive B should be updated to 1")
local runs = -1
Charm.effect(function()
local _ = clientB.count
runs += 1
end)
serverA({ count = 2 })
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(runs == 0, "Client reactive B should not trigger update")
end)
test("should replicate historical server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = DeepCharm.reactive({ count = -1 })
config.preserveHistory = true
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
server.connect(function(syncTarget, payloads)
if syncTarget == target then
client.patch(payloads)
end
end)
local expectedA = { -1, 0, 1, 2, 3, 4 }
local expectedB = { -1, 0, 1, 2 }
Charm.effect(function()
local expected = table.remove(expectedA, 1)
assert(expected == clientA().count, `Client reactive A should be {expected}`)
end)
Charm.effect(function()
local expected = table.remove(expectedB, 1)
assert(expected == clientB.count, `Client reactive B should be {expected}`)
end)
server.flush()
serverA({ count = 1 })
serverA({ count = 2 })
serverB.count = 1
serverB.count = 2
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(clientB.count == 2, "Client reactive B should be updated to 2")
serverA({ count = 3 })
serverA({ count = 4 })
server.flush()
assert(clientA().count == 4, "Client atom A should be updated to 4")
end)
return {}

View file

@ -0,0 +1,23 @@
local test = require("../../tests/test")
test.describe("client", function()
require("@self/client.test")
end)
test.describe("e2e", function()
require("@self/e2e.test")
end)
test.describe("utils", function()
require("@self/utils.test")
end)
test.describe("patch", function()
require("@self/patch.test")
end)
test.describe("server", function()
require("@self/server.test")
end)
return {}

View file

@ -0,0 +1,133 @@
local config = require("../src/config")
local patch = require("../src/patch")
local test = require("../../../tests/test")
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
if object[1] then
local copy = {}
for index = 1, #object do
copy[index] = object[index]
end
return copy
elseif table.maxn(object) ~= #object then
local copy = {}
for key, value in object do
copy[tostring(key)] = value
end
return copy
else
return object
end
end
test.afterEach(function()
config.fixArrays = true
config.validatePatches = true
end)
test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local diffs = patch.diff(prevState, nextState)
assert(diffs ~= nil, "should return a patch")
assert(diffs.baz == "quux", "should return the changed value")
assert(diffs.foo == nil, "should omit unchanged values")
end)
test("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local diffs = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, diffs)
assert(applied ~= nil, "should return a new state")
assert(applied.baz == "quux", "should apply the changed value")
assert(applied.foo == "bar", "should keep the unchanged value")
assert(applied.qaz == "qux", "should add the new value")
end)
test("allows nested diffs", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local diffs = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, diffs)
assert(applied.foo.bar == "qux", "should apply the nested patch")
end)
test("preserves array density when applying", function()
local prevState = { "a", "b", "c", "d" }
local nextState = { ["2"] = "B", ["4"] = "D" }
local applied = patch.apply(prevState, nextState)
assert(applied[1] == "a", "should keep a")
assert(applied[2] == "B", "should apply B")
assert(applied[3] == "c", "should keep c")
assert(applied[4] == "D", "should apply D")
assert(applied["2"] == nil, "should convert serialized arrays to use numbers")
assert(applied["4"] == nil, "should convert serialized arrays to use numbers")
end)
test("preserves array density after serdes", function()
-- This particular change results in a patch that cannot be serialized
-- without losing information. Charm resolves this issue internally.
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, diffs)
for index in prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
for index in applied do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
end)
test("cannot preserve sparse arrays", function()
config.validatePatches = false
local prevState = {}
local nextState = { [2] = "foo" }
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, diffs)
-- Not enough information is available to determine whether 'diffs'
-- should keep the string keys added during remote event serialization.
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
assert(applied[2] == nil, "will fail to convert string keys to numbers")
end)
test("optionally fixes sparse arrays", function()
config.fixArrays = true
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
end
config.fixArrays = false
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
end
end)
test("patches mutable state", function()
local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
assert(targetState.baz == "quux", "should apply the changed value")
assert(targetState.foo == "bar", "should keep the unchanged value")
assert(targetState.qaz == "qux", "should add the new value")
end)
test("removes table from mutable state", function()
local targetState = { foo = { bar = "baz" } }
patch.applyMutable(targetState, { foo = { __none = "__none" } })
assert(targetState.foo == nil, "should remove the table")
end)
return {}

View file

@ -0,0 +1,408 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local config = require("../src/config")
local patch = require("../src/patch")
local server = require("../src/server")
local test = require("../../../tests/test")
local types = require("../src/types")
test.afterEach(function()
server.reset()
end)
test("should replicate state changes", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == a(), "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'")
a(1)
b(2)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a == a(), "Expected patch payload to contain atom 'a'")
assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'")
end)
test("should replicate partial state patches", function()
local client = {}
local a = Charm.atom({ x = 0, y = 0 })
local b = Charm.atom(0 :: number?)
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.x == a().x, "Expected init payload to contain atom 'a.x'")
assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'")
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
a({ x = 1, y = 0 })
b(nil)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.x == 1, "Expected patch payload to contain atom 'a.x'")
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'")
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
end)
test("should replicate partial state patches (reactive)", function()
local client = {}
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.x ~= nil, "Expected init payload to contain value 'a.x'")
assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'")
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
a.x.y = 1
a.z = nil
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.x.y == 1, "Expected patch payload to contain value 'a.x'")
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
a.z = 1
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.z == 1, "Expected patch payload to contain value 'a.z'")
assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'")
end)
test("should handle nested proxies", function()
local client = {}
local src = DeepCharm.reactive({ (DeepCharm.reactive({})) })
local payloads: {}? = nil
server.addSignalsToClient(client, { src = src })
server.connect(function(_, newPayloads)
payloads = newPayloads
end)
server.flush()
payloads = nil
server.flush()
assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
if target == clientA then
payloadsA = payloads
elseif target == clientB then
payloadsB = payloads
end
end)
a(1)
server.flush()
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")
a(2)
server.addSignalsToClient(clientB, { a = a })
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B")
end)
test("should init new atoms", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
server.addSignalsToClient(client, { b = b })
a(1)
b(1)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
assert(payloads[2].type == "patch", "Expected second payload to be patch")
assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'")
end)
test("should only replicate to synced clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a })
server.addSignalsToClient(clientB, { b = b })
server.connect(function(target, payloads)
if target == clientA then
payloadsA = payloads
elseif target == clientB then
payloadsB = payloads
end
end)
server.flush()
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")
assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A")
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B")
assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B")
payloadsA = {}
payloadsB = {}
a(1)
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A")
assert(#payloadsB == 0, "Expected no payloads for client B")
payloadsA = {}
payloadsB = {}
b(1)
server.flush()
assert(#payloadsA == 0, "Expected no payloads for client A")
assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B")
assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B")
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
end)
test("should serialize state", function()
local client = {}
local a = Charm.atom({ 1, 2, 3, 4, 5 })
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
a({ 1, 2, 0, 4 })
server.flush()
assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key")
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
end)
test("should not send unchanged state", function()
local client = {}
local a = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
payloads = {}
a(0)
server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end)
test.describe("historical", function()
test.beforeEach(function()
config.preserveHistory = true
end)
test.afterEach(function()
config.preserveHistory = false
end)
test("should replicate granular state changes", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'")
a(1)
a(2)
b(2)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
test("should replicate granular state changes (reactive)", function()
local client = {}
local a = DeepCharm.reactive({ value = 0 })
local b = DeepCharm.reactive({ value = 0 })
local c = DeepCharm.reactive({ value = 0 })
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
a.value = 1
a.value = 2
b.value = 2
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
if target == clientA then
payloadsA = payloads
elseif target == clientB then
payloadsB = payloads
end
end)
a(1)
a(2)
server.flush()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A")
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.addSignalsToClient(clientB, { a = a })
a(3)
a(4)
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A")
assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A")
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B")
assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B")
assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B")
end)
test("should init new atoms", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, ...)
payloads = ...
end)
server.flush()
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.addSignalsToClient(client, { b = b })
a(1)
a(2)
b(1)
b(2)
server.flush()
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].type == "init", "Expected third payload to be init")
assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'")
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
test("should not send unchanged state", function()
local client = {}
local a = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
payloads = {}
a(0)
server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end)
end)
return {}

View file

@ -0,0 +1,22 @@
local Charm = require("../Charm")
local test = require("../../../tests/test")
local utils = require("../src/utils")
test("should flatten functions", function()
local input = {
a = Charm.atom(0),
b = { c = Charm.atom(0) },
d = { e = { f = utils.signalToAtom(Charm.signal(0)) } },
}
local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)
assert(result.a == input.a, "should flatten the first level")
assert(result["b/c"] == input.b.c, "should flatten the second level")
assert(result["d/e/f"] == input.d.e.f, "should flatten the third level")
assert(result.b == nil, "should remove the original nested tables")
assert(result.d == nil, "should remove the original nested tables")
end)
return {}

View file

@ -1,8 +1,15 @@
[package]
name = "littensy/charm-sync"
version = "0.3.0"
version = "0.4.0-rc.7"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
"test",
"src/index.d.ts",
"package.json",
"Charm.luau",
]
[dependencies]
charm = "littensy/charm@^0.10"
Charm = "littensy/charm@^0.11.0-rc.6"