mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
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:
parent
d0b7cff943
commit
573533058f
148 changed files with 9222 additions and 4384 deletions
9
packages/charm-sync/Charm.luau
Normal file
9
packages/charm-sync/Charm.luau
Normal 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
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "CharmSync",
|
||||
"name": "charm-sync",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:^"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
31
packages/charm-sync/src/config.luau
Normal file
31
packages/charm-sync/src/config.luau
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
462
packages/charm-sync/src/index.d.ts
vendored
462
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
65
packages/charm-sync/src/utils.luau
Normal file
65
packages/charm-sync/src/utils.luau
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
137
packages/charm-sync/test/client.test.luau
Normal file
137
packages/charm-sync/test/client.test.luau
Normal 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 {}
|
||||
97
packages/charm-sync/test/e2e.test.luau
Normal file
97
packages/charm-sync/test/e2e.test.luau
Normal 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 {}
|
||||
23
packages/charm-sync/test/init.luau
Normal file
23
packages/charm-sync/test/init.luau
Normal 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 {}
|
||||
133
packages/charm-sync/test/patch.test.luau
Normal file
133
packages/charm-sync/test/patch.test.luau
Normal 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 {}
|
||||
408
packages/charm-sync/test/server.test.luau
Normal file
408
packages/charm-sync/test/server.test.luau
Normal 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 {}
|
||||
22
packages/charm-sync/test/utils.test.luau
Normal file
22
packages/charm-sync/test/utils.test.luau
Normal 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 {}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "Charm",
|
||||
"name": "charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,24 @@
|
|||
{
|
||||
"name": "@rbxts/charm",
|
||||
"version": "0.10.0",
|
||||
"description": "An atomic state management library for Roblox",
|
||||
"repository": "littensy/charm",
|
||||
"version": "0.11.0-rc.6",
|
||||
"description": "Atomic state management based on alien-signals",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type AtomOptions<T> = types.AtomOptions<T>
|
||||
|
||||
--[=[
|
||||
Creates a new atom with the given state.
|
||||
|
||||
@param state The initial state.
|
||||
@param options Optional configuration.
|
||||
@return A new atom.
|
||||
]=]
|
||||
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
|
||||
local equals = options and options.equals
|
||||
|
||||
local function atom(...)
|
||||
if select("#", ...) == 0 then
|
||||
local index = store.capturing.index
|
||||
|
||||
if index > 0 then
|
||||
store.capturing.stack[index][atom] = true
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
local nextState = store.peek(..., state)
|
||||
|
||||
if state ~= nextState and not (equals and equals(state, nextState)) then
|
||||
state = nextState
|
||||
store.notify(atom)
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
store.listeners[atom] = setmetatable({}, { __mode = "v" })
|
||||
|
||||
return atom
|
||||
end
|
||||
|
||||
return atom
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type AtomOptions<T> = types.AtomOptions<T>
|
||||
type Selector<T> = types.Selector<T>
|
||||
|
||||
--[=[
|
||||
Creates a read-only atom that derives its state from one or more atoms.
|
||||
Used to avoid unnecessary recomputations if multiple listeners depend on
|
||||
the same atoms.
|
||||
|
||||
@param callback The function that produces the state.
|
||||
@param options Optional configuration.
|
||||
@return A new read-only atom.
|
||||
]=]
|
||||
local function computed<T>(callback: Selector<T>, options: AtomOptions<T>?): Selector<T>
|
||||
local dependencies, state = store.capture(callback)
|
||||
local computedAtom = atom(state, options)
|
||||
local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" })
|
||||
|
||||
local function listener()
|
||||
local computedAtom = computedRef.current
|
||||
|
||||
if computedAtom then
|
||||
store.disconnect(dependencies, listener)
|
||||
dependencies, state = store.capture(callback)
|
||||
store.connect(dependencies, listener, computedAtom)
|
||||
computedAtom(state)
|
||||
end
|
||||
end
|
||||
|
||||
store.connect(dependencies, listener, computedAtom)
|
||||
|
||||
return computedAtom
|
||||
end
|
||||
|
||||
return computed
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
local store = require(script.Parent.store)
|
||||
|
||||
type Cleanup = () -> ()
|
||||
|
||||
--[=[
|
||||
Runs the given callback immediately and whenever any atom it depends on
|
||||
changes. Returns a cleanup function that unsubscribes the callback.
|
||||
|
||||
@param callback The function to run.
|
||||
@return A function that unsubscribes the callback.
|
||||
]=]
|
||||
local function effect(callback: (cleanup: Cleanup) -> Cleanup?): Cleanup
|
||||
local dependencies = {}
|
||||
local cleanup: Cleanup?
|
||||
local disconnected = false
|
||||
local disconnect
|
||||
|
||||
local function listener()
|
||||
if cleanup then
|
||||
cleanup()
|
||||
end
|
||||
|
||||
store.disconnect(dependencies, listener)
|
||||
dependencies, cleanup = store.capture(callback, disconnect)
|
||||
|
||||
if not disconnected then
|
||||
store.connect(dependencies, listener)
|
||||
end
|
||||
end
|
||||
|
||||
function disconnect()
|
||||
if disconnected then
|
||||
return
|
||||
end
|
||||
|
||||
disconnected = true
|
||||
store.disconnect(dependencies, listener)
|
||||
|
||||
if cleanup then
|
||||
cleanup()
|
||||
end
|
||||
end
|
||||
|
||||
dependencies, cleanup = store.capture(callback, disconnect)
|
||||
|
||||
if not disconnected then
|
||||
store.connect(dependencies, listener)
|
||||
end
|
||||
|
||||
return disconnect
|
||||
end
|
||||
|
||||
return effect :: (callback: ((cleanup: Cleanup) -> ()) | ((cleanup: Cleanup) -> Cleanup?)) -> Cleanup
|
||||
459
packages/charm/src/index.d.ts
vendored
459
packages/charm/src/index.d.ts
vendored
|
|
@ -1,187 +1,280 @@
|
|||
export = Charm;
|
||||
export as namespace Charm;
|
||||
type Key = string | number | symbol;
|
||||
|
||||
type Cleanup = () => void;
|
||||
|
||||
declare namespace Charm {
|
||||
/**
|
||||
* A primitive state container that can be read from or written to. When
|
||||
* the state changes, all subscribers are notified.
|
||||
*
|
||||
* @param state The next state or a function that produces the next state.
|
||||
* @returns The current state.
|
||||
*/
|
||||
interface Atom<State> extends Selector<State> {
|
||||
(nextState: State | ((prevState: State) => State)): State;
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that depends on one or more atoms and produces a value. Can be
|
||||
* used to derive state from atoms.
|
||||
*
|
||||
* @returns The current state.
|
||||
*/
|
||||
type Selector<State> = () => State;
|
||||
|
||||
/**
|
||||
* A function that depends on one or more atoms and produces a value. Can be
|
||||
* used to derive state from atoms.
|
||||
*
|
||||
* @deprecated Use `Selector<T>` instead.
|
||||
* @returns The current state.
|
||||
*/
|
||||
type Molecule<State> = Selector<State>;
|
||||
|
||||
/**
|
||||
* Infers the type of the state produced by the given function.
|
||||
*/
|
||||
type StateOf<T> = T extends Selector<infer State> ? State : never;
|
||||
|
||||
interface AtomOptions<State> {
|
||||
/**
|
||||
* A function that determines whether the state has changed. By default,
|
||||
* a strict equality check (`===`) is used.
|
||||
*/
|
||||
equals?: (prev: State, next: State) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new atom with the given state.
|
||||
*
|
||||
* @param state The initial state.
|
||||
* @param options Optional configuration.
|
||||
* @returns A new atom.
|
||||
*/
|
||||
function atom<State>(state: State, options?: AtomOptions<State>): Atom<State>;
|
||||
|
||||
// Overload for no arguments
|
||||
function atom<State>(state?: State, options?: AtomOptions<State>): Atom<State | undefined>;
|
||||
|
||||
/**
|
||||
* Creates a read-only atom that derives its state from one or more atoms.
|
||||
* Used to avoid unnecessary recomputations if multiple listeners depend on
|
||||
* the same atoms.
|
||||
*
|
||||
* @param callback The function that produces the state.
|
||||
* @param options Optional configuration.
|
||||
* @returns A new read-only atom.
|
||||
*/
|
||||
function computed<State>(callback: Selector<State>, options?: AtomOptions<State>): Selector<State>;
|
||||
|
||||
/**
|
||||
* Subscribes to changes in the given atom or selector. The callback is
|
||||
* called with the current state and the previous state immediately after a
|
||||
* change occurs.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @param listener The function to call when the state changes.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function subscribe<State>(callback: Selector<State>, listener: (state: State, prev: State) => void): Cleanup;
|
||||
|
||||
/**
|
||||
* Runs the given callback immediately and whenever any atom it depends on
|
||||
* changes. Returns a cleanup function that unsubscribes the callback.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function effect(callback: (cleanup: Cleanup) => Cleanup | void): Cleanup;
|
||||
|
||||
/**
|
||||
* Returns the result of the function without subscribing to changes. If a
|
||||
* non-function value is provided, it is returned as is.
|
||||
*
|
||||
* @param callback The atom or selector to get the state of.
|
||||
* @param args Arguments to pass to the function.
|
||||
* @returns The current state.
|
||||
*/
|
||||
function peek<State, Args extends unknown[]>(callback: State | ((...args: Args) => State), ...args: Args): State;
|
||||
|
||||
/**
|
||||
* Returns whether the given value is an atom.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is an atom, otherwise `false`.
|
||||
*/
|
||||
function isAtom(value: unknown): value is Atom<any>;
|
||||
|
||||
/**
|
||||
* Runs the given function and schedules listeners to be notified only once
|
||||
* after the function has completed. Useful for batching multiple changes.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
*/
|
||||
function batch(callback: () => void): void;
|
||||
|
||||
/**
|
||||
* Captures all atoms that are read during the function call and returns them
|
||||
* along with the result of the function. Useful for tracking dependencies.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
* @returns A tuple containing the captured atoms and the result of the function.
|
||||
*/
|
||||
function capture<State>(callback: Selector<State>): LuaTuple<[dependencies: Set<Atom<unknown>>, state: State]>;
|
||||
|
||||
/**
|
||||
* Notifies all subscribers of the given atom that the state has changed.
|
||||
* Mainly for running effects, since subscriptions check for equality.
|
||||
*
|
||||
* @param atom The atom to notify.
|
||||
*/
|
||||
function notify<State>(atom: Atom<State>): void;
|
||||
|
||||
/**
|
||||
* Creates an instance of `factory` for each item in the atom's state, and
|
||||
* cleans up the instance when the item is removed. Returns a cleanup function
|
||||
* that unsubscribes all instances.
|
||||
*
|
||||
* @param callback The atom or selector to observe.
|
||||
* @param factory The function that tracks the lifecycle of each item.
|
||||
* @returns A function that unsubscribes all instances.
|
||||
*/
|
||||
function observe<Item>(
|
||||
callback: Selector<readonly Item[]>,
|
||||
factory: (item: Item, index: number) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
function observe<Key, Item>(
|
||||
callback: Selector<Map<Key, Item> | ReadonlyMap<Key, Item>>,
|
||||
factory: (item: Item, key: Key) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
function observe<Key extends string | number | symbol, Item>(
|
||||
callback: Selector<{ readonly [P in Key]: Item }>,
|
||||
factory: (item: Item, key: Key) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
/**
|
||||
* Maps each entry in the atom's state to a new key-value pair. If the `mapper`
|
||||
* function returns `undefined`, the entry is omitted from the resulting map.
|
||||
* When the atom changes, the `mapper` is called for each entry in the state
|
||||
* to compute the new state.
|
||||
*
|
||||
* @param callback The atom or selector to map.
|
||||
* @param mapper The function that maps each entry.
|
||||
* @returns A new atom with the mapped state.
|
||||
*/
|
||||
function mapped<V0, K1, V1>(
|
||||
callback: Selector<readonly V0[]>,
|
||||
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
|
||||
function mapped<V0, V1>(
|
||||
callback: Selector<readonly V0[]>,
|
||||
mapper: (value: V0, index: number) => V1,
|
||||
): Selector<readonly V1[]>;
|
||||
|
||||
function mapped<K0, V0, K1 = K0, V1 = V0>(
|
||||
callback: Selector<Map<K0, V0> | ReadonlyMap<K0, V0>>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
|
||||
function mapped<K0 extends string | number | symbol, V0, K1 = K0, V1 = V0>(
|
||||
callback: Selector<{ readonly [P in K0]: V0 }>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
export enum ReactiveFlags {
|
||||
None = 0b000000,
|
||||
Mutable = 0b000001,
|
||||
Watching = 0b000010,
|
||||
RecursedCheck = 0b000100,
|
||||
Recursed = 0b001000,
|
||||
Dirty = 0b010000,
|
||||
Pending = 0b100000,
|
||||
}
|
||||
|
||||
export interface ReactiveNode {
|
||||
deps?: Link;
|
||||
depsTail?: Link;
|
||||
subs?: Link;
|
||||
subsTail?: Link;
|
||||
flags: ReactiveFlags;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
version: number;
|
||||
dep: ReactiveNode;
|
||||
sub: ReactiveNode;
|
||||
prevSub?: Link;
|
||||
nextSub?: Link;
|
||||
prevDep?: Link;
|
||||
nextDep?: Link;
|
||||
}
|
||||
|
||||
export interface Atom<T> {
|
||||
(update: Update<T>): T;
|
||||
(): T;
|
||||
}
|
||||
|
||||
export type Getter<T> = () => T;
|
||||
|
||||
export type Setter<T> = (update: Update<T>) => T;
|
||||
|
||||
export type Update<T> = ((currentValue: T) => T) | T;
|
||||
|
||||
export type Equals<T> = (current: T, incoming: T) => boolean;
|
||||
|
||||
/**
|
||||
* Global flags that modify the behavior of Charm's reactive system.
|
||||
*/
|
||||
export const flags: {
|
||||
/**
|
||||
* Enforces synchronous, non-yielding behavior in signals and effects.
|
||||
* Also enables state validation in Charm Sync to catch sync errors early.
|
||||
* Enabled if the optimization level is below 2, which is true in studio.
|
||||
*/
|
||||
strict: boolean;
|
||||
/**
|
||||
* Enforces immutability of tables by deep-freezing table values. Enabled
|
||||
* if the optimization level is below 2, which is true in studio.
|
||||
*/
|
||||
frozen: boolean;
|
||||
/**
|
||||
* Whether parent effects should track inner effects and clean them up
|
||||
* when the parent effect re-runs. This is usually desirable, but can be
|
||||
* disabled to revert to the old behavior. Defaults to `true`.
|
||||
*/
|
||||
trackInnerEffects: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a reactive signal that stores a value and notifies subscribers when
|
||||
* the value changes. The signal provides both a getter and a setter.
|
||||
*
|
||||
* @param initialValue The initial value of the signal.
|
||||
* @param equals A comparator function to determine if the signal's value has changed.
|
||||
* @returns A tuple containing the getter and setter functions for the signal.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
|
||||
*/
|
||||
export function signal<T>(initialValue: T, equals?: Equals<T>): LuaTuple<[getter: Getter<T>, setter: Setter<T>]>;
|
||||
export function signal<T>(): LuaTuple<[getter: Getter<T | undefined>, setter: Setter<T | undefined>]>;
|
||||
|
||||
/**
|
||||
* Creates a reactive atom that acts as both a signal getter and setter.
|
||||
* Calling the atom with an argument sets its value, while calling it with no
|
||||
* arguments returns its current value.
|
||||
*
|
||||
* @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`.
|
||||
* @param equals A comparator function to determine if the atom's value has changed.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
|
||||
*/
|
||||
export function atom<T>(initialValue: T, equals?: Equals<T>): Atom<T>;
|
||||
export function atom<T>(): Atom<T | undefined>;
|
||||
|
||||
/**
|
||||
* Creates a new read-only signal that is computed based on the values of
|
||||
* other signals. The computed signal's value is automatically updated when a
|
||||
* change is detected in any of its dependencies.
|
||||
*
|
||||
* @param getter A function that produces the next value.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
|
||||
*/
|
||||
export function computed<T>(getter: (previousValue?: T) => T): Getter<T>;
|
||||
|
||||
/**
|
||||
* Creates an effect that runs a callback in response to signal state changes.
|
||||
* An effect tracks which signals are accessed during its execution, and runs
|
||||
* the callback when those signals change.
|
||||
*
|
||||
* The effect callback may return a cleanup function, which gets called once,
|
||||
* either before the effect re-runs or when it is disposed.
|
||||
*
|
||||
* If the effect is called within another effect, it will be disposed when the
|
||||
* parent effect re-runs.
|
||||
*
|
||||
* @param fn The function to run when dependencies change.
|
||||
* @returns A function for disposing the effect.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
|
||||
*/
|
||||
export function effect(fn: () => Cleanup | void): Cleanup;
|
||||
|
||||
/**
|
||||
* Creates an effect scope that can capture reactive effects and computed
|
||||
* signals created within it so that they can be disposed together.
|
||||
*
|
||||
* @param fn A function that may create reactive effects.
|
||||
* @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
|
||||
* @returns A function for disposing all effects created within the scope.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
|
||||
*/
|
||||
export function effectScope(fn: () => Cleanup | void, detached?: boolean): Cleanup;
|
||||
|
||||
/**
|
||||
* Allows you to manually trigger updates for downstream dependencies when
|
||||
* you've directly mutated a signal's value without using the signal setter.
|
||||
*
|
||||
* @param fn The dependency or a function that calls multiple dependencies.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
|
||||
*/
|
||||
export function trigger(fn: () => void): void;
|
||||
|
||||
/**
|
||||
* Binds the cleanup function to the current active effect or effect scope. If
|
||||
* there is no active effect, a warning is logged, unless `failSilently` is
|
||||
* set to `true`.
|
||||
*
|
||||
* @param fn The cleanup function to register.
|
||||
* @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
|
||||
*/
|
||||
export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
|
||||
|
||||
/**
|
||||
* Runs the function without subscribing to signal updates and prevents the
|
||||
* current effect or scope from tracking inner effects created within the
|
||||
* function.
|
||||
*
|
||||
* To avoid tracking signals while still allowing inner effects to be tracked
|
||||
* by parent effects/scopes, use `effectScope()` instead.
|
||||
*
|
||||
* @param fn A function that may read signals or create effects.
|
||||
* @param args Arguments to pass to the function.
|
||||
* @returns The return value of the function.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
|
||||
*/
|
||||
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
|
||||
|
||||
/**
|
||||
* Combines multiple updates into a single "commit" that runs effects and
|
||||
* computed signals after the provided function finishes running.
|
||||
*
|
||||
* @param fn A function that performs multiple updates.
|
||||
* @param args Arguments to pass to the function.
|
||||
* @returns The return value of the function.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
|
||||
*/
|
||||
export function batch<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
|
||||
|
||||
/**
|
||||
* Creates an effect that runs the callback one immediately, and then again
|
||||
* when the value returned by the getter changes. The callback receives the new
|
||||
* value and the previous value as arguments.
|
||||
*
|
||||
* @param getter A function that returns the value to watch.
|
||||
* @param callback The function to run when the value changes.
|
||||
* @returns A function for disposing the effect.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
||||
*/
|
||||
export function listen<T>(getter: Getter<T>, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
|
||||
|
||||
/**
|
||||
* Creates an effect that only runs the callback when the value returned by the
|
||||
* getter changes. The callback receives the new value and the previous value
|
||||
* as arguments.
|
||||
*
|
||||
* @param getter A function that returns the value to watch.
|
||||
* @param callback The function to run when the value changes.
|
||||
* @returns A function for disposing the effect.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
||||
*/
|
||||
export function subscribe<T>(getter: Getter<T>, callback: (value: T, previousValue: T) => void): Cleanup;
|
||||
|
||||
/**
|
||||
* Creates a new read-only signal that is computed by mapping over the entries
|
||||
* of the source table. If the key is omitted from the transform function's
|
||||
* return value, the original key is preserved.
|
||||
*
|
||||
* This function is optimized to minimize unnecessary updates by only calling
|
||||
* the transform function for entries that have changed since the last run.
|
||||
*
|
||||
* @param getter A signal or function that returns a table
|
||||
* @param transform Function that maps each entry from the source to a new entry in the mapped table
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform
|
||||
*/
|
||||
export function mapped<VI, KI, VO, KO = KI>(
|
||||
getter: Getter<Map<KI, VI> | ReadonlyMap<KI, VI>>,
|
||||
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
): Getter<ReadonlyMap<KO, VO>>;
|
||||
// Overload for arrays
|
||||
export function mapped<VI, VO, K extends Key = number>(
|
||||
getter: Getter<readonly VI[]>,
|
||||
transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
|
||||
): Getter<K extends number ? readonly VO[] : { readonly [P in K]: VO }>;
|
||||
// Overload for objects
|
||||
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
|
||||
getter: Getter<{ readonly [K in KI]: VI }>,
|
||||
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
): Getter<{ readonly [K in KO]: VO }>;
|
||||
|
||||
/**
|
||||
* Calls the callback function for each unique key in the table returned by the
|
||||
* getter. The callback is called once for each key, and again when a new key
|
||||
* is added.
|
||||
*
|
||||
* The callback may return a cleanup function, which will be called when the
|
||||
* key is removed or when the entire callback is disposed.
|
||||
*
|
||||
* Effects created within the callback are cleaned up when the item is removed
|
||||
* from the table or when the callback is disposed.
|
||||
*
|
||||
* @param getter A function that returns the table to observe.
|
||||
* @param callback A function that is called for each key in the table.
|
||||
* @returns A function for disposing the observer.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
||||
*/
|
||||
export function observe<V, K = number>(
|
||||
getter: Getter<Map<K, V> | ReadonlyMap<K, V> | readonly V[]>,
|
||||
callback: (value: V, key: K) => Cleanup | void,
|
||||
): Cleanup;
|
||||
// Overload for objects
|
||||
export function observe<V, K extends Key>(
|
||||
getter: Getter<{ readonly [K in Key]: V }>,
|
||||
callback: (value: V, key: K) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
/**
|
||||
* Returns the currently active subscriber (effect or computed node).
|
||||
*
|
||||
* @return Current active subscriber or `nil`
|
||||
*/
|
||||
export function getActiveSub(): ReactiveNode | undefined;
|
||||
|
||||
/**
|
||||
* Sets the currently active subscriber (effect or computed node) and returns
|
||||
* the previous subscriber.
|
||||
*
|
||||
* @param sub New subscriber to set as active
|
||||
* @return Previous active subscriber
|
||||
*/
|
||||
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
|
||||
|
||||
/**
|
||||
* Starts a batch operation, incrementing the batch depth counter. Effects
|
||||
* will not run until the outermost batch ends.
|
||||
*/
|
||||
export function startBatch(): void;
|
||||
|
||||
/**
|
||||
* Ends a batch operation, decrementing the batch depth counter. If this is
|
||||
* the outermost batch, queued effects are flushed and run.
|
||||
*/
|
||||
export function endBatch(): void;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,71 +0,0 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
local store = require(script.Parent.store)
|
||||
local subscribe = require(script.Parent.subscribe)
|
||||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type Selector<T> = types.Selector<T>
|
||||
|
||||
type Map =
|
||||
(<K0, V0, K1, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Selector<{ [K1]: V1 }>)
|
||||
& (<K0, V0, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Selector<{ [K0]: V1 }>)
|
||||
& (<K0, V0, K1, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Selector<{ [K1]: V1 }>)
|
||||
|
||||
--[=[
|
||||
Maps each entry in the atom's state to a new key-value pair. If the `mapper`
|
||||
function returns `undefined`, the entry is omitted from the resulting map.
|
||||
When the atom changes, the `mapper` is called for each entry in the state
|
||||
to compute the new state.
|
||||
|
||||
@param callback The atom or selector to map.
|
||||
@param mapper The function that maps each entry.
|
||||
@return A new atom with the mapped state.
|
||||
]=]
|
||||
local function mapped<K0, V0, K1, V1>(callback: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Selector<{ [K1]: V1 }>
|
||||
local mappedAtom = atom({})
|
||||
local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" })
|
||||
local prevMappedItems: { [K1]: V1 } = {}
|
||||
local unsubscribe
|
||||
|
||||
local function listener(items: { [K0]: V0 })
|
||||
local mappedAtom = mappedAtomRef.current
|
||||
|
||||
if not mappedAtom then
|
||||
return unsubscribe()
|
||||
end
|
||||
|
||||
local mappedItems = table.clone(mappedAtom())
|
||||
local mappedKeys = {}
|
||||
|
||||
-- TODO: Only call mapper if the item has changed.
|
||||
for key, item in next, items do
|
||||
local newItem, newKey = mapper(item, key)
|
||||
if newKey == nil then
|
||||
newKey = key :: any
|
||||
end
|
||||
if mappedItems[newKey :: K1] ~= newItem then
|
||||
mappedItems[newKey :: K1] = newItem :: V1
|
||||
else
|
||||
mappedKeys[newKey] = key
|
||||
end
|
||||
end
|
||||
|
||||
for key in next, prevMappedItems do
|
||||
if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then
|
||||
mappedItems[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
prevMappedItems = mappedItems
|
||||
mappedAtom(mappedItems)
|
||||
end
|
||||
|
||||
unsubscribe = subscribe(callback, listener)
|
||||
|
||||
store.peek(function(): ()
|
||||
listener(callback())
|
||||
end)
|
||||
|
||||
return mappedAtom
|
||||
end
|
||||
|
||||
return mapped :: Map
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
local store = require(script.Parent.store)
|
||||
local subscribe = require(script.Parent.subscribe)
|
||||
local types = require(script.Parent.types)
|
||||
type Selector<T> = types.Selector<T>
|
||||
|
||||
local function noop() end
|
||||
|
||||
--[=[
|
||||
Creates an instance of `factory` for each item in the atom's state, and
|
||||
cleans up the instance when the item is removed. Returns a cleanup function
|
||||
that unsubscribes all instances.
|
||||
|
||||
@param callback The atom or selector to observe.
|
||||
@param factory The function that tracks the lifecycle of each item.
|
||||
@return A function that unsubscribes all instances.
|
||||
]=]
|
||||
local function observe<K, V>(callback: Selector<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> ()
|
||||
local connections: { [K]: () -> () } = {}
|
||||
|
||||
local function listener(state: { [K]: V })
|
||||
for key, disconnect in next, connections do
|
||||
if state[key] == nil then
|
||||
connections[key] = nil
|
||||
disconnect()
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in next, state do
|
||||
if not connections[key] then
|
||||
connections[key] = factory(value, key) or noop
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local unsubscribe = subscribe(callback, listener)
|
||||
|
||||
store.peek(function(): ()
|
||||
listener(callback())
|
||||
end)
|
||||
|
||||
return function()
|
||||
unsubscribe()
|
||||
for _, disconnect in next, connections do
|
||||
disconnect()
|
||||
end
|
||||
table.clear(connections)
|
||||
end
|
||||
end
|
||||
|
||||
return observe
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type Set<T> = types.Set<T>
|
||||
type WeakMap<K, V> = types.WeakMap<K, V>
|
||||
|
||||
local __DEV__ = _G.__DEV__
|
||||
|
||||
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local batched: Set<() -> ()> = {}
|
||||
local batching = false
|
||||
|
||||
local capturing = {
|
||||
stack = {} :: { Set<Atom<any>> },
|
||||
index = 0,
|
||||
}
|
||||
|
||||
--[=[
|
||||
Calls the given function and returns the result. If the function yields or
|
||||
throws an error, the thread is closed and an error is thrown. Regardless of
|
||||
the outcome, the `finally` function is called to clean up any resources.
|
||||
|
||||
@param callback The function to run.
|
||||
@param finally Cleanup logic to run before error handling.
|
||||
@param ... Arguments to pass to the callback.
|
||||
@return The result of the callback.
|
||||
]=]
|
||||
local function try<T, U...>(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T
|
||||
if __DEV__ then
|
||||
local thread = coroutine.create(callback)
|
||||
local success, result = coroutine.resume(thread, ...)
|
||||
|
||||
if finally then
|
||||
finally()
|
||||
end
|
||||
|
||||
if coroutine.status(thread) == "suspended" then
|
||||
local source, line, name = debug.info(callback, "sln")
|
||||
|
||||
coroutine.close(thread)
|
||||
|
||||
error(
|
||||
"Yielding is not allowed in atom functions. Consider wrapping this code in a Promise or task.defer instead."
|
||||
.. `\nFunction defined at: {source}:{line}`
|
||||
.. if name == "" then "" else ` function {name}`
|
||||
)
|
||||
elseif not success then
|
||||
local source, line, name = debug.info(callback, "sln")
|
||||
|
||||
error(
|
||||
"An error occurred while running an atom function"
|
||||
.. `\nFunction defined at: {source}:{line}`
|
||||
.. (if name == "" then "" else ` function {name}`)
|
||||
.. `\nError: {result}`
|
||||
)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
if not finally then
|
||||
return callback(...)
|
||||
end
|
||||
|
||||
local success, result = pcall(callback, ...)
|
||||
|
||||
finally()
|
||||
assert(success, result)
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--[=[
|
||||
Returns whether the given value is an atom.
|
||||
|
||||
@param value The value to check.
|
||||
@return `true` if the value is an atom, otherwise `false`.
|
||||
]=]
|
||||
local function isAtom(value: any): boolean
|
||||
return not not (value and listeners[value])
|
||||
end
|
||||
|
||||
--[=[
|
||||
Notifies all subscribers of the given atom that the state has changed.
|
||||
|
||||
@param atom The atom to notify.
|
||||
]=]
|
||||
local function notify(atom: Atom<any>)
|
||||
if batching then
|
||||
for listener in next, listeners[atom] do
|
||||
batched[listener] = true
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
for listener in next, table.clone(listeners[atom]) do
|
||||
try(listener)
|
||||
end
|
||||
end
|
||||
|
||||
--[=[
|
||||
Returns the result of the function without subscribing to changes. If a
|
||||
non-function value is provided, it is returned as is.
|
||||
|
||||
@param callback The atom or selector to get the state of.
|
||||
@param args Arguments to pass to the function.
|
||||
@return The current state.
|
||||
]=]
|
||||
local function peek<T, U...>(callback: ((U...) -> T) | T, ...: U...): T
|
||||
if type(callback) ~= "function" then
|
||||
return callback
|
||||
end
|
||||
|
||||
if capturing.index == 0 then
|
||||
return callback(...)
|
||||
end
|
||||
|
||||
capturing.index += 1
|
||||
capturing.stack[capturing.index] = {}
|
||||
|
||||
local result = try(callback, function()
|
||||
capturing.stack[capturing.index] = nil
|
||||
capturing.index -= 1
|
||||
end, ...)
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--[=[
|
||||
Captures all atoms that are read during the function call and returns them
|
||||
along with the result of the function. Useful for tracking dependencies.
|
||||
|
||||
@param callback The function to run.
|
||||
@return A tuple containing the captured atoms and the result of the function.
|
||||
]=]
|
||||
local function capture<T, U...>(callback: (U...) -> T, ...: U...): (Set<Atom<any>>, T)
|
||||
-- If the callback is an atom, return it immediately
|
||||
if listeners[callback :: any] then
|
||||
return { [callback :: any] = true }, peek(callback)
|
||||
end
|
||||
|
||||
local dependencies: Set<Atom<any>> = {}
|
||||
|
||||
capturing.index += 1
|
||||
capturing.stack[capturing.index] = dependencies
|
||||
|
||||
local result = try(callback, function()
|
||||
capturing.stack[capturing.index] = nil
|
||||
capturing.index -= 1
|
||||
end, ...)
|
||||
|
||||
return dependencies, result
|
||||
end
|
||||
|
||||
--[=[
|
||||
Runs the given function and schedules listeners to be notified only once
|
||||
after the function has completed. Useful for batching multiple changes.
|
||||
|
||||
@param callback The function to run.
|
||||
]=]
|
||||
local function batch(callback: () -> ())
|
||||
if batching then
|
||||
return callback()
|
||||
end
|
||||
|
||||
batching = true
|
||||
|
||||
try(callback, function()
|
||||
batching = false
|
||||
end)
|
||||
|
||||
for listener in next, batched do
|
||||
try(listener)
|
||||
end
|
||||
|
||||
table.clear(batched)
|
||||
end
|
||||
|
||||
--[=[
|
||||
Subscribes the listener to the changes of the given atoms.
|
||||
|
||||
@param atoms The atoms to listen to.
|
||||
@param listener The function to call when the atoms change.
|
||||
@param ref Optionally bind the lifetime of the listener to a value.
|
||||
]=]
|
||||
local function connect(atoms: Set<Atom<any>>, listener: () -> (), ref: unknown?)
|
||||
for atom in next, atoms do
|
||||
listeners[atom][listener] = ref or true
|
||||
end
|
||||
end
|
||||
|
||||
--[=[
|
||||
Unsubscribes the listener from every atom it was connected to.
|
||||
|
||||
@param atoms The atoms to stop listening to.
|
||||
@param listener The function to stop calling when the atoms change.
|
||||
]=]
|
||||
local function disconnect(atoms: Set<Atom<any>>, listener: () -> ())
|
||||
for atom in next, atoms do
|
||||
listeners[atom][listener] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
listeners = listeners,
|
||||
capturing = capturing,
|
||||
isAtom = isAtom,
|
||||
notify = notify,
|
||||
capture = capture,
|
||||
batch = batch,
|
||||
peek = peek,
|
||||
connect = connect,
|
||||
disconnect = disconnect,
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type Selector<T> = types.Selector<T>
|
||||
|
||||
--[=[
|
||||
Subscribes to changes in the given atom or selector. The callback is
|
||||
called with the current state and the previous state immediately after a
|
||||
change occurs.
|
||||
|
||||
@param callback The atom or selector to subscribe to.
|
||||
@param listener The function to call when the state changes.
|
||||
@return A function that unsubscribes the callback.
|
||||
]=]
|
||||
local function subscribe<T>(callback: Selector<T>, listener: (state: T, prev: T) -> ()): () -> ()
|
||||
local dependencies, state = store.capture(callback)
|
||||
local disconnected = false
|
||||
|
||||
local function handler()
|
||||
local prevState = state
|
||||
|
||||
store.disconnect(dependencies, handler)
|
||||
dependencies, state = store.capture(callback)
|
||||
|
||||
if not disconnected then
|
||||
store.connect(dependencies, handler)
|
||||
end
|
||||
|
||||
if state ~= prevState then
|
||||
listener(state, prevState)
|
||||
end
|
||||
end
|
||||
|
||||
store.connect(dependencies, handler)
|
||||
|
||||
return function()
|
||||
if not disconnected then
|
||||
disconnected = true
|
||||
store.disconnect(dependencies, handler)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return subscribe
|
||||
237
packages/charm/src/system.luau
Normal file
237
packages/charm/src/system.luau
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
--[[
|
||||
Reactive system adapted from alien-signals at
|
||||
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts
|
||||
]]
|
||||
|
||||
export type ReactiveNode = {
|
||||
deps: Link?,
|
||||
depsTail: Link?,
|
||||
subs: Link?,
|
||||
subsTail: Link?,
|
||||
flags: ReactiveFlags,
|
||||
}
|
||||
|
||||
export type Link = {
|
||||
version: number,
|
||||
dep: ReactiveNode,
|
||||
sub: ReactiveNode,
|
||||
prevSub: Link?,
|
||||
nextSub: Link?,
|
||||
prevDep: Link?,
|
||||
nextDep: Link?,
|
||||
}
|
||||
|
||||
type Stack<T> = {
|
||||
value: T,
|
||||
prev: Stack<T>?,
|
||||
}
|
||||
|
||||
export type ReactiveFlags = number
|
||||
|
||||
local NONE = 0b000000
|
||||
local MUTABLE = 0b000001
|
||||
local WATCHING = 0b000010
|
||||
local RECURSED_CHECK = 0b000100
|
||||
local RECURSED = 0b001000
|
||||
local DIRTY = 0b010000
|
||||
local PENDING = 0b100000
|
||||
|
||||
local ReactiveFlags = table.freeze({
|
||||
None = NONE,
|
||||
Mutable = MUTABLE,
|
||||
Watching = WATCHING,
|
||||
RecursedCheck = RECURSED_CHECK,
|
||||
Recursed = RECURSED,
|
||||
Dirty = DIRTY,
|
||||
Pending = PENDING,
|
||||
})
|
||||
|
||||
local update: (sub: ReactiveNode) -> boolean
|
||||
local notify: (sub: ReactiveNode) -> ()
|
||||
local unwatched: (sub: ReactiveNode) -> ()
|
||||
|
||||
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
|
||||
local link = sub.depsTail
|
||||
while link do
|
||||
if link == checkLink then
|
||||
return true
|
||||
end
|
||||
link = link.prevDep
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
|
||||
local prevDep = sub.depsTail
|
||||
if prevDep and prevDep.dep == dep then
|
||||
return
|
||||
end
|
||||
local nextDep = if prevDep then prevDep.nextDep else sub.deps
|
||||
if nextDep and nextDep.dep == dep then
|
||||
nextDep.version = version
|
||||
sub.depsTail = nextDep
|
||||
return
|
||||
end
|
||||
local prevSub = dep.subsTail
|
||||
if prevSub and prevSub.version == version and prevSub.sub == sub then
|
||||
return
|
||||
end
|
||||
local newLink: Link = {
|
||||
version = version,
|
||||
dep = dep,
|
||||
sub = sub,
|
||||
prevDep = prevDep,
|
||||
nextDep = nextDep,
|
||||
prevSub = prevSub,
|
||||
}
|
||||
sub.depsTail = newLink
|
||||
dep.subsTail = newLink
|
||||
if nextDep then
|
||||
nextDep.prevDep = newLink
|
||||
end
|
||||
if prevDep then
|
||||
prevDep.nextDep = newLink
|
||||
else
|
||||
sub.deps = newLink
|
||||
end
|
||||
if prevSub then
|
||||
prevSub.nextSub = newLink
|
||||
else
|
||||
dep.subs = newLink
|
||||
end
|
||||
end
|
||||
|
||||
local function unlink(link: Link, sub: ReactiveNode?): Link?
|
||||
sub = sub or link.sub
|
||||
local dep = link.dep
|
||||
local prevDep = link.prevDep
|
||||
local nextDep = link.nextDep
|
||||
local nextSub = link.nextSub
|
||||
local prevSub = link.prevSub
|
||||
if nextDep then
|
||||
nextDep.prevDep = prevDep
|
||||
else
|
||||
sub.depsTail = prevDep
|
||||
end
|
||||
if prevDep then
|
||||
prevDep.nextDep = nextDep
|
||||
else
|
||||
sub.deps = nextDep
|
||||
end
|
||||
if nextSub then
|
||||
nextSub.prevSub = prevSub
|
||||
else
|
||||
dep.subsTail = prevSub
|
||||
end
|
||||
if prevSub then
|
||||
prevSub.nextSub = nextSub
|
||||
else
|
||||
dep.subs = nextSub
|
||||
if not nextSub then
|
||||
unwatched(dep)
|
||||
end
|
||||
end
|
||||
return nextDep
|
||||
end
|
||||
|
||||
local function propagate(link: Link)
|
||||
repeat
|
||||
local sub = link.sub
|
||||
local flags = sub.flags
|
||||
|
||||
if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
|
||||
sub.flags = bit32.bor(flags, PENDING)
|
||||
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
|
||||
flags = NONE
|
||||
elseif bit32.band(flags, RECURSED_CHECK) == 0 then
|
||||
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
|
||||
elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then
|
||||
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
|
||||
flags = bit32.band(flags, MUTABLE)
|
||||
else
|
||||
flags = NONE
|
||||
end
|
||||
|
||||
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
|
||||
notify(sub)
|
||||
end
|
||||
|
||||
if bit32.btest(flags, MUTABLE) then
|
||||
local subSubs = sub.subs
|
||||
if subSubs then
|
||||
propagate(subSubs)
|
||||
end
|
||||
end
|
||||
|
||||
link = link.nextSub :: Link
|
||||
until not link
|
||||
end
|
||||
|
||||
local function shallowPropagate(link: Link)
|
||||
repeat
|
||||
local sub = link.sub
|
||||
local flags = sub.flags
|
||||
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
|
||||
sub.flags = bit32.bor(flags, DIRTY)
|
||||
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
|
||||
notify(sub)
|
||||
end
|
||||
end
|
||||
link = link.nextSub :: Link
|
||||
until not link
|
||||
end
|
||||
|
||||
local function checkDirty(link: Link, sub: ReactiveNode): boolean
|
||||
repeat
|
||||
local dep = link.dep
|
||||
local depFlags = dep.flags
|
||||
|
||||
if bit32.btest(sub.flags, DIRTY) then
|
||||
return true
|
||||
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
|
||||
if update(dep) then
|
||||
local subs = dep.subs :: Link
|
||||
if subs.nextSub then
|
||||
shallowPropagate(subs)
|
||||
end
|
||||
return true
|
||||
end
|
||||
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
|
||||
if checkDirty(dep.deps :: Link, dep) then
|
||||
if update(dep) then
|
||||
local subs = dep.subs :: Link
|
||||
if subs.nextSub then
|
||||
shallowPropagate(subs)
|
||||
end
|
||||
return true
|
||||
end
|
||||
else
|
||||
dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
|
||||
end
|
||||
end
|
||||
|
||||
link = link.nextDep :: Link
|
||||
until not link
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function createReactiveSystem(
|
||||
updateFn: (sub: ReactiveNode) -> boolean,
|
||||
notifyFn: (sub: ReactiveNode) -> (),
|
||||
unwatchedFn: (sub: ReactiveNode) -> ()
|
||||
)
|
||||
update = updateFn
|
||||
notify = notifyFn
|
||||
unwatched = unwatchedFn
|
||||
end
|
||||
|
||||
return {
|
||||
ReactiveFlags = ReactiveFlags,
|
||||
link = link,
|
||||
unlink = unlink,
|
||||
propagate = propagate,
|
||||
checkDirty = checkDirty,
|
||||
shallowPropagate = shallowPropagate,
|
||||
createReactiveSystem = createReactiveSystem,
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
export type Set<T> = { [T]: true }
|
||||
|
||||
export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" }))
|
||||
|
||||
--[=[
|
||||
A primitive state container that can be read from and written to. When the
|
||||
state changes, all subscribers are notified.
|
||||
|
||||
@param state The next state or a function that produces the next state.
|
||||
@return The current state, if no arguments are provided.
|
||||
]=]
|
||||
export type Atom<T> = (state: (T | (T) -> T)?) -> T
|
||||
|
||||
--[=[
|
||||
A function that depends on one or more atoms and produces a state. Can be
|
||||
used to derive state from atoms.
|
||||
|
||||
@template State The type of the state.
|
||||
@return The current state.
|
||||
]=]
|
||||
export type Selector<T> = () -> T
|
||||
|
||||
--[=[
|
||||
Optional configuration for creating an atom.
|
||||
]=]
|
||||
export type AtomOptions<T> = {
|
||||
--[=[
|
||||
A function that determines whether the state has changed. By default,
|
||||
a strict equality check (`===`) is used.
|
||||
]=]
|
||||
equals: (prev: T, next: T) -> boolean,
|
||||
}
|
||||
|
||||
--[=[
|
||||
A payload that can be sent from the server to the client to synchronize
|
||||
state between the two.
|
||||
]=]
|
||||
export type SyncPayload = {
|
||||
type: "patch" | "init",
|
||||
data: { [any]: any },
|
||||
}
|
||||
|
||||
return nil
|
||||
220
packages/charm/test/computed.test.luau
Normal file
220
packages/charm/test/computed.test.luau
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local signal = Charm.signal
|
||||
|
||||
test("should correctly propagate changes through computed core", function()
|
||||
local src, setSrc = signal(0)
|
||||
local c1 = computed(function()
|
||||
return src() % 2
|
||||
end)
|
||||
local c2 = computed(function()
|
||||
return c1()
|
||||
end)
|
||||
local c3 = computed(function()
|
||||
return c2()
|
||||
end)
|
||||
|
||||
c3()
|
||||
setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
|
||||
c2() -- c1 -> none, c2 -> none
|
||||
setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
|
||||
|
||||
assert(c3() == 1, "bad final value")
|
||||
end)
|
||||
|
||||
test("should propagate updated source value through chained computations", function()
|
||||
local src, setSrc = signal(0)
|
||||
local a = computed(function()
|
||||
return src()
|
||||
end)
|
||||
local b = computed(function()
|
||||
return a() % 2
|
||||
end)
|
||||
local c = computed(function()
|
||||
return src()
|
||||
end)
|
||||
local d = computed(function()
|
||||
return b() + c()
|
||||
end)
|
||||
|
||||
assert(d() == 0, "bad initial value")
|
||||
setSrc(2)
|
||||
assert(d() == 2, "bad updated value")
|
||||
end)
|
||||
|
||||
test("should handle flags are indirectly updated during checkDirty", function()
|
||||
local a, setA = signal(false)
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
b()
|
||||
return 0
|
||||
end)
|
||||
local d = computed(function()
|
||||
c()
|
||||
return b()
|
||||
end)
|
||||
|
||||
assert(d() == false, "bad initial value")
|
||||
setA(true)
|
||||
assert(d() == true, "bad updated value")
|
||||
end)
|
||||
|
||||
test("should not update if the signal value is reverted", function()
|
||||
local times = 0
|
||||
|
||||
local src, setSrc = signal(0)
|
||||
local c1 = computed(function()
|
||||
times += 1
|
||||
return src()
|
||||
end)
|
||||
c1()
|
||||
assert(times == 1, "computed did not run")
|
||||
setSrc(1)
|
||||
setSrc(0)
|
||||
c1()
|
||||
assert(times == 1, "computed ran when it should not")
|
||||
end)
|
||||
|
||||
test("should receive previous value", function()
|
||||
local calls = {}
|
||||
local src, setSrc = signal(0)
|
||||
local c1 = computed(function(current: number?): number
|
||||
local incoming = src()
|
||||
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, c1())
|
||||
end)
|
||||
|
||||
setSrc(2)
|
||||
setSrc(3)
|
||||
assert(c1() == 2, "should skip odd value")
|
||||
|
||||
setSrc(4)
|
||||
setSrc(5)
|
||||
assert(c1() == 4, "should skip odd value")
|
||||
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
|
||||
end)
|
||||
|
||||
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L875
|
||||
test("should chained computeds keep reactivity when computed effect happens", function()
|
||||
local v = atom("Hello")
|
||||
local c = computed(function()
|
||||
v(v() .. " World")
|
||||
return v()
|
||||
end)
|
||||
local d = computed(function()
|
||||
return c()
|
||||
end)
|
||||
local e = computed(function()
|
||||
return d()
|
||||
end)
|
||||
local res = computed(function()
|
||||
return d() .. " | " .. e()
|
||||
end)
|
||||
|
||||
assert(res() == "Hello World | Hello World", res())
|
||||
|
||||
v(v() .. " World")
|
||||
assert(res() == "Hello World World World World | Hello World World World World", res())
|
||||
end)
|
||||
|
||||
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L580
|
||||
test("should not trigger effect scheduler by recursive computed effect", function()
|
||||
local v = atom("Hello")
|
||||
local c = computed(function()
|
||||
v(v() .. " World")
|
||||
return v()
|
||||
end)
|
||||
local comp = computed(function()
|
||||
return c()
|
||||
end)
|
||||
|
||||
assert(comp() == "Hello World", comp())
|
||||
|
||||
v(v() .. " World")
|
||||
assert(comp() == "Hello World World World World", comp())
|
||||
end)
|
||||
|
||||
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528
|
||||
test("should not override queried MaybeDirty result", function()
|
||||
local v1 = atom(nil :: any)
|
||||
local v2 = atom(false)
|
||||
local c1 = computed(function()
|
||||
local c = v1()
|
||||
if not v1() then
|
||||
c = { v = atom(0) }
|
||||
v1(c)
|
||||
end
|
||||
return c.v()
|
||||
end)
|
||||
local c2 = computed(function()
|
||||
if not v2() then
|
||||
return "no"
|
||||
end
|
||||
return if c1() then "yes" else "no"
|
||||
end)
|
||||
local c3 = computed(function()
|
||||
return c2()
|
||||
end)
|
||||
|
||||
c3()
|
||||
v2(true)
|
||||
|
||||
c3()
|
||||
v1().v(999)
|
||||
|
||||
assert(c3() == "yes", "should return correct value after update")
|
||||
end)
|
||||
|
||||
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L503
|
||||
test("should trigger effect even computed already dirty", function()
|
||||
local calls: { string } = {}
|
||||
local v = atom(0)
|
||||
local c1 = computed(function()
|
||||
if v() == 0 then
|
||||
v(1)
|
||||
end
|
||||
return "foo"
|
||||
end)
|
||||
local c2 = computed(function()
|
||||
return v() .. c1()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, c2())
|
||||
end)
|
||||
assert(table.concat(calls, ",") == "0foo", table.concat(calls, ","))
|
||||
assert(v() == 1, `expected 1 for v(), got {v()}`)
|
||||
v(2)
|
||||
assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ","))
|
||||
assert(v() == 2, `expected 2 for v(), got {v()}`)
|
||||
end)
|
||||
|
||||
test("should handle inner effect clearing outer computed's deps on rerun", function()
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect: () -> ()
|
||||
local c = computed(function()
|
||||
src()
|
||||
effect(function()
|
||||
return function()
|
||||
stopEffect()
|
||||
end
|
||||
end)
|
||||
effect(function() end)
|
||||
return 0
|
||||
end)
|
||||
stopEffect = effect(function()
|
||||
c()
|
||||
end)
|
||||
setSrc(true)
|
||||
end)
|
||||
|
||||
return {}
|
||||
576
packages/charm/test/effect.test.luau
Normal file
576
packages/charm/test/effect.test.luau
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local endBatch = Charm.endBatch
|
||||
local onCleanup = Charm.onCleanup
|
||||
local setActiveSub = Charm.setActiveSub
|
||||
local signal = Charm.signal
|
||||
local startBatch = Charm.startBatch
|
||||
|
||||
test("should clear subscriptions when untracked by all subscribers", function()
|
||||
local bRunTimes = 0
|
||||
|
||||
local a, setA = signal(1)
|
||||
local b = computed(function()
|
||||
bRunTimes += 1
|
||||
return a() * 2
|
||||
end)
|
||||
local stopEffect = effect(function()
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(bRunTimes == 1, "should run initially")
|
||||
setA(2)
|
||||
assert(bRunTimes == 2, "should run after change")
|
||||
stopEffect()
|
||||
setA(3)
|
||||
assert(bRunTimes == 2, "should not run after stop")
|
||||
end)
|
||||
|
||||
test("should not run untracked inner effect", function()
|
||||
local a, setA = signal(3)
|
||||
local b = computed(function()
|
||||
return a() > 0
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
if b() then
|
||||
effect(function()
|
||||
if a() == 0 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
setA(2)
|
||||
setA(1)
|
||||
setA(0)
|
||||
end)
|
||||
|
||||
test("should run outer effect first", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(1)
|
||||
|
||||
effect(function()
|
||||
if a() > 0 then
|
||||
effect(function()
|
||||
b()
|
||||
if a() == 0 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
startBatch()
|
||||
setB(0)
|
||||
setA(0)
|
||||
endBatch()
|
||||
end)
|
||||
|
||||
test("should not trigger inner effect when resolve maybe dirty", function()
|
||||
local a, setA = signal(0)
|
||||
local b = computed(function()
|
||||
return a() % 2
|
||||
end)
|
||||
|
||||
local innerTriggerTimes = 0
|
||||
|
||||
effect(function()
|
||||
effect(function()
|
||||
b()
|
||||
innerTriggerTimes += 1
|
||||
if innerTriggerTimes >= 2 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
setA(2)
|
||||
end)
|
||||
|
||||
test("should notify inner effects in the same order as non-inner effects", function()
|
||||
local getA, setA = signal(0)
|
||||
local getB, setB = signal(0)
|
||||
local c = computed(function()
|
||||
return getA() - getB()
|
||||
end)
|
||||
local order1 = {}
|
||||
local order2 = {}
|
||||
local order3 = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order1, "effect1")
|
||||
getA()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order1, "effect2")
|
||||
getA()
|
||||
getB()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
c()
|
||||
effect(function()
|
||||
table.insert(order2, "effect1")
|
||||
getA()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order2, "effect2")
|
||||
getA()
|
||||
getB()
|
||||
end)
|
||||
end)
|
||||
|
||||
effectScope(function()
|
||||
effect(function()
|
||||
table.insert(order3, "effect1")
|
||||
getA()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order3, "effect2")
|
||||
getA()
|
||||
getB()
|
||||
end)
|
||||
end)
|
||||
|
||||
table.clear(order1)
|
||||
table.clear(order2)
|
||||
table.clear(order3)
|
||||
|
||||
startBatch()
|
||||
setB(1)
|
||||
setA(1)
|
||||
endBatch()
|
||||
|
||||
assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1")
|
||||
assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2")
|
||||
assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3")
|
||||
end)
|
||||
|
||||
test("should custom effect support batch", function()
|
||||
local function batchEffect(fn: () -> ())
|
||||
return effect(function()
|
||||
startBatch()
|
||||
local success, result: unknown = pcall(fn)
|
||||
endBatch()
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local logs: { string } = {}
|
||||
local a = signal(0)
|
||||
local b, setB = signal(0)
|
||||
|
||||
local aa = computed(function()
|
||||
table.insert(logs, "aa-0")
|
||||
if a() == 0 then
|
||||
setB(1)
|
||||
end
|
||||
table.insert(logs, "aa-1")
|
||||
return nil
|
||||
end)
|
||||
|
||||
local bb = computed(function()
|
||||
table.insert(logs, "bb")
|
||||
return b()
|
||||
end)
|
||||
|
||||
batchEffect(function()
|
||||
bb()
|
||||
end)
|
||||
batchEffect(function()
|
||||
aa()
|
||||
end)
|
||||
|
||||
assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`)
|
||||
end)
|
||||
|
||||
test("should duplicate subscribers do not affect the notify order", function()
|
||||
local src1, setSrc1 = signal(0)
|
||||
local src2, setSrc2 = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order, "a")
|
||||
local currentSub = setActiveSub(nil)
|
||||
local isOne = src2() == 1
|
||||
setActiveSub(currentSub)
|
||||
if isOne then
|
||||
src1()
|
||||
end
|
||||
src2()
|
||||
src1()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order, "b")
|
||||
src1()
|
||||
end)
|
||||
setSrc2(1) -- src1.subs: a -> b -> a
|
||||
|
||||
table.clear(order)
|
||||
setSrc1(src1() + 1)
|
||||
|
||||
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should handle side effect with inner effects", function()
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
effect(function()
|
||||
a()
|
||||
table.insert(order, "a")
|
||||
end)
|
||||
effect(function()
|
||||
b()
|
||||
table.insert(order, "b")
|
||||
end)
|
||||
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
setB(1)
|
||||
setA(1)
|
||||
end)
|
||||
|
||||
-- DEVIATION: This assertion was moved outside of the effect so that it
|
||||
-- runs after the batch updates have been processed
|
||||
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should handle flags are indirectly updated during checkDirty", function()
|
||||
local a, setA = signal(false)
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
b()
|
||||
return 0
|
||||
end)
|
||||
local d = computed(function()
|
||||
c()
|
||||
return b()
|
||||
end)
|
||||
|
||||
local triggers = 0
|
||||
|
||||
effect(function()
|
||||
d()
|
||||
triggers += 1
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
setA(true)
|
||||
assert(triggers == 2, "did not update indirectly")
|
||||
end)
|
||||
|
||||
test("should not execute skipped effects from previous failed flush when updating unrelated signal", function()
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local c, setC = signal(0)
|
||||
local d = computed(function()
|
||||
c()
|
||||
return 0
|
||||
end)
|
||||
|
||||
local effect3Executed = false
|
||||
|
||||
effect(function()
|
||||
a()
|
||||
end)
|
||||
effect(function()
|
||||
if a() == 2 then
|
||||
error("Error in effect 2")
|
||||
end
|
||||
end)
|
||||
effect(function()
|
||||
a()
|
||||
d()
|
||||
effect3Executed = true
|
||||
end)
|
||||
effect(function()
|
||||
b()
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
|
||||
effect3Executed = false
|
||||
local success, err = pcall(setA, 2)
|
||||
assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`)
|
||||
|
||||
assert(not effect3Executed, "effect 3 should not have executed after failed flush")
|
||||
setB(1)
|
||||
assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b")
|
||||
setC(1)
|
||||
assert(effect3Executed, "effect 3 should have executed after updating signal c")
|
||||
end)
|
||||
|
||||
test("should allow finite recursion", function()
|
||||
local src, setSrc = signal(0)
|
||||
local order = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order, src())
|
||||
setSrc(math.min(src() + 1, 5))
|
||||
end)
|
||||
assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should run cleanups in sequence", function()
|
||||
local order = {}
|
||||
|
||||
local a, setA = signal(false)
|
||||
local stopEffect = effect(function()
|
||||
a()
|
||||
onCleanup(function()
|
||||
table.insert(order, "a")
|
||||
end)
|
||||
onCleanup(function()
|
||||
table.insert(order, "b")
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(table.concat(order) == "", "should not cleanup immediately")
|
||||
|
||||
setA(true)
|
||||
assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
stopEffect()
|
||||
assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
setA(false)
|
||||
assert(table.concat(order) == "", "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should run inner effect cleanup", function()
|
||||
local cleanups = 0
|
||||
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect = effect(function()
|
||||
src()
|
||||
effect(function()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
effect(function()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
setSrc(true)
|
||||
assert(cleanups == 2, "should cleanup after outer update")
|
||||
stopEffect()
|
||||
assert(cleanups == 4, "should cleanup after outer dispose")
|
||||
setSrc(false)
|
||||
assert(cleanups == 4, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should run cleanups outside any evaluation context", function()
|
||||
local calls = 0
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local c = computed(function()
|
||||
if a() == 0 then
|
||||
effect(function()
|
||||
onCleanup(function()
|
||||
b()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
return a()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
calls += 1
|
||||
c()
|
||||
end)
|
||||
assert(calls == 1, "should run once")
|
||||
|
||||
calls = 0
|
||||
setA(1)
|
||||
assert(calls == 1, "should run once after a update")
|
||||
|
||||
calls = 0
|
||||
setB(1)
|
||||
assert(calls == 0, "should not run after b update")
|
||||
end)
|
||||
|
||||
test("should not retrigger the effect if the cleanup modifies one of the dependencies", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, a())
|
||||
return function()
|
||||
setA(2)
|
||||
end
|
||||
end)
|
||||
assert(#calls == 1, "should have run once initially")
|
||||
table.clear(calls)
|
||||
|
||||
setA(1)
|
||||
assert(#calls == 1, "should run once after an update")
|
||||
assert(calls[1] == 2, `bad call: {calls[1]}`)
|
||||
end)
|
||||
|
||||
test("should run the cleanup if the effect disposes itself", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = effect(function()
|
||||
if a() > 0 then
|
||||
dispose()
|
||||
return function()
|
||||
calls += 1
|
||||
end
|
||||
end
|
||||
end)
|
||||
assert(calls == 0, "should not have called cleanup initially")
|
||||
setA(1)
|
||||
assert(calls == 1, "should have called cleanup once after first update")
|
||||
setA(2)
|
||||
assert(calls == 1, "should not call cleanup again after second update")
|
||||
end)
|
||||
|
||||
test("should not run the effect if the cleanup function disposes it", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = effect(function()
|
||||
a()
|
||||
calls += 1
|
||||
return function()
|
||||
dispose()
|
||||
end
|
||||
end)
|
||||
assert(calls == 1, "should have run effect once initially")
|
||||
setA(1)
|
||||
assert(calls == 1, "should not run effect again after update")
|
||||
end)
|
||||
|
||||
test("should reset the cleanup if the effect throws", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = 0
|
||||
|
||||
effect(function()
|
||||
if a() == 0 then
|
||||
return function()
|
||||
calls += 1
|
||||
end
|
||||
else
|
||||
error("hello")
|
||||
end
|
||||
end)
|
||||
assert(calls == 0, "should not have called cleanup initially")
|
||||
local success, err = pcall(setA, 1)
|
||||
assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`)
|
||||
assert(calls == 1, "should have called cleanup once after throwing")
|
||||
setA(0)
|
||||
assert(calls == 1, "should not call cleanup again after resetting value")
|
||||
end)
|
||||
|
||||
test("recursive effects should run and cleanup in order", function()
|
||||
local a, setA = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
local value = a()
|
||||
table.insert(order, `+{value}`)
|
||||
if value < 3 then
|
||||
setA(value + 1)
|
||||
end
|
||||
return function()
|
||||
table.insert(order, `-{value}`)
|
||||
end
|
||||
end)
|
||||
assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`)
|
||||
table.clear(order)
|
||||
setA(100)
|
||||
assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should effects dispose in order", function()
|
||||
local order: { number } = {}
|
||||
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect = effect(function()
|
||||
src()
|
||||
for i = 1, 3 do
|
||||
effect(function()
|
||||
return function()
|
||||
table.insert(order, i)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(#order == 0, "should not cleanup immediately")
|
||||
setSrc(true)
|
||||
assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`)
|
||||
table.clear(order)
|
||||
stopEffect()
|
||||
assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`)
|
||||
table.clear(order)
|
||||
setSrc(false)
|
||||
assert(#order == 0, "should not cleanup after dispose")
|
||||
end)
|
||||
|
||||
test("should handle inner effect clearing outer effect's deps on rerun", function()
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect: () -> ()
|
||||
stopEffect = effect(function()
|
||||
src()
|
||||
effect(function()
|
||||
return function()
|
||||
stopEffect()
|
||||
end
|
||||
end)
|
||||
effect(function() end)
|
||||
end)
|
||||
setSrc(true)
|
||||
end)
|
||||
|
||||
test.skip("should handle inner effect cleanup disposing outer effect", function()
|
||||
local order: { number } = {}
|
||||
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect: () -> ()
|
||||
stopEffect = effect(function()
|
||||
src()
|
||||
table.insert(order, 0)
|
||||
for i = 1, 4 do
|
||||
effect(function()
|
||||
return function()
|
||||
table.insert(order, i)
|
||||
stopEffect()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
assert(table.concat(order) == "0", "should not cleanup immediately")
|
||||
|
||||
table.clear(order)
|
||||
setSrc(true)
|
||||
assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
setSrc(false)
|
||||
assert(#order == 0, "should not cleanup after dispose")
|
||||
end)
|
||||
|
||||
return {}
|
||||
95
packages/charm/test/effectScope.test.luau
Normal file
95
packages/charm/test/effectScope.test.luau
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local signal = Charm.signal
|
||||
local onCleanup = Charm.onCleanup
|
||||
|
||||
test("should not trigger after stop", function()
|
||||
local count, setCount = signal(1)
|
||||
|
||||
local triggers = 0
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
effect(function()
|
||||
triggers += 1
|
||||
count()
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
|
||||
setCount(2)
|
||||
assert(triggers == 2, "did not run after update")
|
||||
end)
|
||||
|
||||
setCount(3)
|
||||
assert(triggers == 3, "did not run after update outside scope")
|
||||
stopScope()
|
||||
setCount(4)
|
||||
assert(triggers == 3, "ran after stop")
|
||||
end)
|
||||
|
||||
test("should run indirect scope cleanup", function()
|
||||
local cleanups = 0
|
||||
local cleanup: () -> ()
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
cleanup = effectScope(function()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
stopScope()
|
||||
assert(cleanups == 1, "should cleanup from parent dispose")
|
||||
cleanup()
|
||||
assert(cleanups == 1, "should skip redundant dispose")
|
||||
end)
|
||||
|
||||
test("should run indirect effect cleanup", function()
|
||||
local cleanups = 0
|
||||
local a, setA = signal(false)
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
effect(function()
|
||||
a()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
setA(true)
|
||||
assert(cleanups == 1, "should cleanup once")
|
||||
stopScope()
|
||||
assert(cleanups == 2, "should cleanup after outer dispose")
|
||||
setA(false)
|
||||
assert(cleanups == 2, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should dispose inner effects if created in an effect", function()
|
||||
local source, setSource = signal(1)
|
||||
local triggers = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
effect(function()
|
||||
dispose = effectScope(function()
|
||||
effect(function()
|
||||
source()
|
||||
triggers += 1
|
||||
end)
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
end)
|
||||
|
||||
setSource(2)
|
||||
assert(triggers == 2, "did not run after update")
|
||||
dispose()
|
||||
setSource(3)
|
||||
assert(triggers == 2, "ran after dispose")
|
||||
end)
|
||||
|
||||
return {}
|
||||
200
packages/charm/test/errors.test.luau
Normal file
200
packages/charm/test/errors.test.luau
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
--!optimize 1
|
||||
|
||||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test("should disallow yielding", function()
|
||||
local success = pcall(function()
|
||||
Charm.effectScope(function()
|
||||
coroutine.yield()
|
||||
end)
|
||||
end)
|
||||
assert(not success, "should error on yield")
|
||||
end)
|
||||
|
||||
test.describe("traceback", function()
|
||||
local DEFAULT_ERROR = "Test error"
|
||||
|
||||
local function fail(message: string?)
|
||||
error(message or DEFAULT_ERROR, 0)
|
||||
end
|
||||
|
||||
local function indirectError(message: string?)
|
||||
fail(message)
|
||||
end
|
||||
|
||||
local function assertStackTrace(err: unknown, expectation: string?)
|
||||
expectation = expectation or DEFAULT_ERROR
|
||||
assert(type(err) == "string", "Expected error to be a string")
|
||||
assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`)
|
||||
assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`)
|
||||
assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`)
|
||||
end
|
||||
|
||||
test("signal update", function()
|
||||
local _, err = pcall(function()
|
||||
local _, setSrc = Charm.signal(0)
|
||||
setSrc(function()
|
||||
indirectError()
|
||||
return 1
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("atom update", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.atom(0)
|
||||
src(function()
|
||||
indirectError()
|
||||
return 1
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("computed", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.computed(function()
|
||||
indirectError()
|
||||
end)
|
||||
src()
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("effect init", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.effect(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("effect run", function()
|
||||
local _, err = pcall(function()
|
||||
local src, setSrc = Charm.signal(0)
|
||||
Charm.effect(function()
|
||||
if src() == 1 then
|
||||
indirectError()
|
||||
end
|
||||
end)
|
||||
setSrc(1)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("effect cleanups", function()
|
||||
local _, err = pcall(function()
|
||||
local dispose = Charm.effect(function()
|
||||
Charm.onCleanup(function()
|
||||
indirectError("onCleanup")
|
||||
end)
|
||||
return function()
|
||||
indirectError("returned cleanup")
|
||||
end
|
||||
end)
|
||||
dispose()
|
||||
end)
|
||||
assertStackTrace(err, "onCleanup")
|
||||
assertStackTrace(err, "returned cleanup")
|
||||
end)
|
||||
|
||||
test("trigger", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.trigger(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("untracked", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.effectScope(function()
|
||||
Charm.untracked(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("batch", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.batch(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("subscribe getter", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.subscribe(function()
|
||||
indirectError()
|
||||
end, function() end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("subscribe callback", function()
|
||||
local _, err = pcall(function()
|
||||
local src, setSrc = Charm.signal(0)
|
||||
Charm.subscribe(src, function()
|
||||
indirectError()
|
||||
end)
|
||||
setSrc(1)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("listen getter", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.listen(function()
|
||||
indirectError()
|
||||
end, function() end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("listen callback", function()
|
||||
local _, err = pcall(function()
|
||||
local src, setSrc = Charm.signal(0)
|
||||
Charm.listen(src, function()
|
||||
indirectError()
|
||||
end)
|
||||
setSrc(1)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("mapped getter", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.mapped(function()
|
||||
indirectError()
|
||||
return { 1 }
|
||||
end, function()
|
||||
return 1
|
||||
end)
|
||||
src()
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("mapped transform", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.mapped(function()
|
||||
return { 1 }
|
||||
end, function()
|
||||
indirectError()
|
||||
return 1
|
||||
end)
|
||||
src()
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
return {}
|
||||
65
packages/charm/test/gc.test.luau
Normal file
65
packages/charm/test/gc.test.luau
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
|
||||
local function weakRef<T>(value: T): { value: T? }
|
||||
return setmetatable({ value = value }, { __mode = "v" }) :: any
|
||||
end
|
||||
|
||||
local function gc()
|
||||
for _ = 1, 2000 do
|
||||
local _ = table.create(100, 0)
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
In alien-signals, a computed value unlinks from dependencies when its last
|
||||
parent scope is disposed. However, calling a computed value outside of a
|
||||
scope still links it to dependencies, which act as strong references.
|
||||
Computed values called in this way won't GC until unlinked from a scope.
|
||||
|
||||
This function releases a computed value by linking it to a scope and then
|
||||
immediately closing it, causing alien-signals to unlink the computed value
|
||||
from its dependencies and allow it to be garbage collected.
|
||||
]]
|
||||
local function unlink(getter: () -> unknown)
|
||||
effectScope(function()
|
||||
getter()
|
||||
end)()
|
||||
end
|
||||
|
||||
test("should release computed cache", function()
|
||||
local srcRef = weakRef({})
|
||||
local src, setSrc = signal(srcRef.value)
|
||||
local c = computed(function()
|
||||
return src()
|
||||
end)
|
||||
|
||||
c() -- cache src value
|
||||
setSrc(nil) -- release value
|
||||
unlink(c) -- release computed
|
||||
|
||||
gc()
|
||||
assert(srcRef.value == nil, "should release computed cache")
|
||||
end)
|
||||
|
||||
test("should not release effect for atom", function()
|
||||
local effectCalls = 0
|
||||
local src, setSrc = signal(0)
|
||||
|
||||
effect(function()
|
||||
effectCalls += 1
|
||||
src()
|
||||
end)
|
||||
assert(effectCalls == 1, "should call effect once")
|
||||
|
||||
gc()
|
||||
setSrc(src() + 1)
|
||||
assert(effectCalls == 2, "should call effect again")
|
||||
end)
|
||||
|
||||
return {}
|
||||
55
packages/charm/test/init.luau
Normal file
55
packages/charm/test/init.luau
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
local test = require("../../tests/test")
|
||||
|
||||
test.describe("computed", function()
|
||||
require("@self/computed.test")
|
||||
end)
|
||||
|
||||
test.describe("effect", function()
|
||||
require("@self/effect.test")
|
||||
end)
|
||||
|
||||
test.describe("effectScope", function()
|
||||
require("@self/effectScope.test")
|
||||
end)
|
||||
|
||||
test.describe("error", function()
|
||||
require("@self/errors.test")
|
||||
end)
|
||||
|
||||
test.describe("gc", function()
|
||||
require("@self/gc.test")
|
||||
end)
|
||||
|
||||
test.describe("listen", function()
|
||||
require("@self/listen.test")
|
||||
end)
|
||||
|
||||
test.describe("mapped", function()
|
||||
require("@self/mapped.test")
|
||||
end)
|
||||
|
||||
test.describe("observe", function()
|
||||
require("@self/observe.test")
|
||||
end)
|
||||
|
||||
test.describe("signal", function()
|
||||
require("@self/signal.test")
|
||||
end)
|
||||
|
||||
test.describe("subscribe", function()
|
||||
require("@self/subscribe.test")
|
||||
end)
|
||||
|
||||
test.describe("topology", function()
|
||||
require("@self/topology.test")
|
||||
end)
|
||||
|
||||
test.describe("trigger", function()
|
||||
require("@self/trigger.test")
|
||||
end)
|
||||
|
||||
test.describe("untracked", function()
|
||||
require("@self/untracked.test")
|
||||
end)
|
||||
|
||||
return {}
|
||||
77
packages/charm/test/listen.test.luau
Normal file
77
packages/charm/test/listen.test.luau
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local listen = Charm.listen
|
||||
|
||||
test("should listen to selector", function()
|
||||
local source, setSource = signal(0)
|
||||
local current, previous
|
||||
|
||||
local unsub = listen(function()
|
||||
return source() + 1
|
||||
end, function(a, b)
|
||||
current = a
|
||||
previous = b
|
||||
end)
|
||||
|
||||
assert(current == 1 and previous == nil, "should run initially")
|
||||
setSource(0)
|
||||
assert(current == 1 and previous == nil, "should skip redundant update")
|
||||
setSource(1)
|
||||
assert(current == 2 and previous == 1, "should run with new value")
|
||||
unsub()
|
||||
setSource(2)
|
||||
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||
end)
|
||||
|
||||
test("should run in untracked", function()
|
||||
local runs = 0
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
|
||||
listen(a, function()
|
||||
runs += 1
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(runs == 1, "should run once on init")
|
||||
setA(1)
|
||||
assert(runs == 2, "should run once on change")
|
||||
setB(1)
|
||||
assert(runs == 2, "should not run on b change")
|
||||
setA(2)
|
||||
assert(runs == 3, "should run on a change")
|
||||
end)
|
||||
|
||||
test("should run if first value is nil", function()
|
||||
local runs = 0
|
||||
local source, setSource = signal(nil :: number?)
|
||||
|
||||
listen(source, function()
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
assert(runs == 1, "should run once on init")
|
||||
setSource(1)
|
||||
assert(runs == 2, "should run once on change")
|
||||
end)
|
||||
|
||||
test("should skip redundant updates", function()
|
||||
local order = {}
|
||||
local a, setA = signal(0)
|
||||
|
||||
listen(function(): "even" | "odd"
|
||||
return if a() % 2 == 0 then "even" else "odd"
|
||||
end, function(value)
|
||||
table.insert(order, value)
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
setA(3)
|
||||
setA(4)
|
||||
setA(6)
|
||||
assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
128
packages/charm/test/mapped.test.luau
Normal file
128
packages/charm/test/mapped.test.luau
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local mapped = Charm.mapped
|
||||
|
||||
test("should map state", function()
|
||||
local src, setSrc = signal({ 1, 2, 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
return value + 1
|
||||
end)
|
||||
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
setSrc({ 4, 5, 6 })
|
||||
assert(table.concat(map()) == "567", table.concat(map()))
|
||||
end)
|
||||
|
||||
test("should not map unchanged values", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ 1, 2, 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
calls += 1
|
||||
return value + 1
|
||||
end)
|
||||
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
assert(calls == 3, "should map 3 values")
|
||||
|
||||
setSrc({ 1, 3, 3 })
|
||||
assert(table.concat(map()) == "244", table.concat(map()))
|
||||
assert(calls == 4, "should map 1 change")
|
||||
|
||||
setSrc({})
|
||||
assert(#map() == 0, "should be empty")
|
||||
assert(calls == 4, "should not map for removed values")
|
||||
end)
|
||||
|
||||
test("should not update if no values change", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ 1, 2, 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
calls += 1
|
||||
return value + 1
|
||||
end)
|
||||
|
||||
local prev = map()
|
||||
assert(calls == 3, "should map 3 values")
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
|
||||
setSrc({ 4, 5, 6, 7 })
|
||||
setSrc({ 1, 2, 3 })
|
||||
map()
|
||||
assert(calls == 3, "should not map unchanged values")
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
assert(prev == map(), "should not create new table")
|
||||
end)
|
||||
|
||||
test("should filter values", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
calls += 1
|
||||
return if value < 0 then nil else value
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(calls == 3, "should map 3 values")
|
||||
assert(state.a and state.b and state.c, "should map all values")
|
||||
|
||||
calls = 0
|
||||
setSrc({ a = -1, b = 2, c = 3, d = 4 })
|
||||
state = map()
|
||||
assert(calls == 2, "should map 2 values")
|
||||
assert(state.a == nil, "should remove a")
|
||||
assert(state.b and state.c, "should keep b and c")
|
||||
assert(state.d == 4, "should add d")
|
||||
end)
|
||||
|
||||
test("should map keys", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
|
||||
local map = mapped(src, function(value: number, key: string)
|
||||
calls += 1
|
||||
return if value < 0 then nil else value, string.upper(key)
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(calls == 3, "should map 3 values")
|
||||
assert(not (state.a and state.b and state.c), "should discard old keys")
|
||||
assert(state.A == 1, "should map a")
|
||||
assert(state.B == 2, "should map b")
|
||||
assert(state.C == 3, "should map c")
|
||||
|
||||
calls = 0
|
||||
setSrc({ a = -1, b = 2, c = 3, d = 4 })
|
||||
state = map()
|
||||
assert(calls == 2, "should map 2 values")
|
||||
assert(state.A == nil and state.a == nil, "should remove a")
|
||||
assert(state.B and state.C, "should keep b and c")
|
||||
assert(state.D == 4, "should add d")
|
||||
end)
|
||||
|
||||
test("should remove old key if mapper returns a new key", function()
|
||||
local src, setSrc = signal({ 1 })
|
||||
local map = mapped(src, function(value: number, index: number)
|
||||
return true, `{index}={value}`
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(state["1=1"], "should have mapped first element to key 1=1")
|
||||
|
||||
setSrc({ 2 })
|
||||
state = map()
|
||||
assert(state["1=1"] == nil, "should have removed old key 1=1")
|
||||
assert(state["1=2"], "should have remapped element to key 1=2")
|
||||
|
||||
setSrc({ 3, 1 })
|
||||
state = map()
|
||||
assert(state["1=2"] == nil, "should have removed old key 1=2")
|
||||
assert(state["1=3"], "should have remapped element to key 1=3")
|
||||
assert(state["2=1"], "should have mapped second element to key 2=1")
|
||||
end)
|
||||
|
||||
return {}
|
||||
198
packages/charm/test/observe.test.luau
Normal file
198
packages/charm/test/observe.test.luau
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local observe = Charm.observe
|
||||
|
||||
test("should observe items", function()
|
||||
local items, setItems = signal({ 0, 0, 0 })
|
||||
local updates = {}
|
||||
local cleanups = {}
|
||||
|
||||
observe(items, function(_, key: number)
|
||||
local get = computed(function()
|
||||
return items()[key]
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(updates, get())
|
||||
end)
|
||||
|
||||
return function()
|
||||
table.insert(cleanups, key)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
|
||||
|
||||
table.clear(updates)
|
||||
setItems({ 1, 2, 3 })
|
||||
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
|
||||
|
||||
table.clear(updates)
|
||||
setItems({ 1, 2, nil :: any, 4 })
|
||||
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
|
||||
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
|
||||
end)
|
||||
|
||||
test("should dispose scopes", function()
|
||||
local items, setItems = signal({ 1, 2, 3 })
|
||||
local a, setA = signal(0)
|
||||
local runs = {}
|
||||
local disposes = {}
|
||||
|
||||
local dispose = observe(items, function(item)
|
||||
effect(function()
|
||||
table.insert(runs, item)
|
||||
a()
|
||||
end)
|
||||
effectScope(function()
|
||||
return function()
|
||||
table.insert(disposes, item)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
table.clear(runs)
|
||||
setItems({ 1, 2 })
|
||||
setA(1)
|
||||
assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
|
||||
assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`)
|
||||
|
||||
table.clear(runs)
|
||||
table.clear(disposes)
|
||||
dispose()
|
||||
setA(2)
|
||||
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
|
||||
assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`)
|
||||
end)
|
||||
|
||||
test("should queue addition and removal", function()
|
||||
local items, setItems = signal({ 1 })
|
||||
local edits = {}
|
||||
|
||||
observe(items, function(item)
|
||||
if item == 1 then
|
||||
setItems({ 1, 2, 3, 4 })
|
||||
elseif item == 4 then
|
||||
setItems({ 1 })
|
||||
end
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should handle recursive additions and removals", function()
|
||||
local items, setItems = signal({ 1 })
|
||||
local edits: { string } = {}
|
||||
|
||||
observe(items, function(item)
|
||||
if item < 5 then
|
||||
setItems(function(state: { number })
|
||||
state = table.clone(state)
|
||||
table.insert(state, item + 1)
|
||||
return state
|
||||
end)
|
||||
else
|
||||
setItems({})
|
||||
end
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should handle self removal", function()
|
||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||
local edits: { string } = {}
|
||||
|
||||
observe(items, function(item, index)
|
||||
setItems(function(state: { number })
|
||||
state = table.clone(state)
|
||||
state[index] = nil
|
||||
return state
|
||||
end)
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should allow disposal during addition", function()
|
||||
local items, setItems = signal({})
|
||||
local edits = {}
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = observe(items, function(item)
|
||||
if item == 1 then
|
||||
dispose()
|
||||
end
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
setItems({ 1, 2, 3, 4 })
|
||||
assert(table.concat(edits) == "+1-1", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should allow disposal during removal", function()
|
||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||
local edits = {}
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = observe(items, function(item)
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
dispose()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
setItems({ 1, 2, 3, nil :: any, 5 })
|
||||
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should not hang on error after recursive update", function()
|
||||
local items, setItems = signal({})
|
||||
|
||||
observe(items, function()
|
||||
setItems({ {} })
|
||||
error("test error")
|
||||
end)
|
||||
|
||||
local success, err = pcall(function()
|
||||
setItems({ {} })
|
||||
end)
|
||||
assert(not success, "should error")
|
||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
||||
end)
|
||||
|
||||
test("should not hang on error after recursive init", function()
|
||||
local items, setItems = signal({ {} })
|
||||
|
||||
local success, err = pcall(function()
|
||||
observe(items, function()
|
||||
setItems({ {} })
|
||||
error("test error")
|
||||
end)
|
||||
end)
|
||||
assert(not success, "should error")
|
||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
67
packages/charm/test/signal.test.luau
Normal file
67
packages/charm/test/signal.test.luau
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local effect = Charm.effect
|
||||
|
||||
test("should accept equality function", function()
|
||||
local calls = {}
|
||||
local src, setSrc = signal(0, function(current: number, incoming: number)
|
||||
return current == incoming or incoming % 2 == 1
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, src())
|
||||
end)
|
||||
|
||||
setSrc(2)
|
||||
setSrc(3)
|
||||
assert(src() == 2, "should skip odd value")
|
||||
|
||||
setSrc(4)
|
||||
setSrc(5)
|
||||
assert(src() == 4, "should skip odd value")
|
||||
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
|
||||
end)
|
||||
|
||||
test("should enforce immutability", function()
|
||||
local src, setSrc = signal({ nested = {} })
|
||||
assert(not pcall(function()
|
||||
src().nested.a = 1
|
||||
end), "should not allow mutation of initial value")
|
||||
|
||||
setSrc({ nested = { a = 1 } })
|
||||
assert(not pcall(function()
|
||||
src().nested.a = 2
|
||||
end), "should not allow mutation of new value")
|
||||
end)
|
||||
|
||||
test("should not freeze values with metatables", function()
|
||||
local src = signal({ object = setmetatable({}, {}) })
|
||||
assert(
|
||||
pcall(function()
|
||||
src().object.a = 2
|
||||
end),
|
||||
"should allow mutation of values with metatables"
|
||||
)
|
||||
end)
|
||||
|
||||
test("should setter return same value as getter even if equal", function()
|
||||
local src, setSrc = signal(0, function()
|
||||
return true
|
||||
end)
|
||||
assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`)
|
||||
end)
|
||||
|
||||
test("should setter return latest value", function()
|
||||
local src, setSrc = signal(0)
|
||||
effect(function()
|
||||
if src() == 1 then
|
||||
setSrc(2)
|
||||
end
|
||||
end)
|
||||
assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`)
|
||||
assert(src() == 2, `src() expected 2, got {src()}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
91
packages/charm/test/subscribe.test.luau
Normal file
91
packages/charm/test/subscribe.test.luau
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local computed = Charm.computed
|
||||
local subscribe = Charm.subscribe
|
||||
|
||||
test("should subscribe to selector", function()
|
||||
local source, setSource = signal(0)
|
||||
local current, previous
|
||||
|
||||
local unsub = subscribe(function()
|
||||
return source() + 1
|
||||
end, function(a, b)
|
||||
current = a
|
||||
previous = b
|
||||
end)
|
||||
|
||||
assert(not current and not previous, "should not run initially")
|
||||
setSource(0)
|
||||
assert(not current and not previous, "should skip redundant update")
|
||||
setSource(1)
|
||||
assert(current == 2 and previous == 1, "should run with new value")
|
||||
unsub()
|
||||
setSource(2)
|
||||
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||
end)
|
||||
|
||||
test("should run in untracked", function()
|
||||
local runs = 0
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
|
||||
subscribe(a, function()
|
||||
runs += 1
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(runs == 0, "should not run once on init")
|
||||
setA(1)
|
||||
assert(runs == 1, "should run once on change")
|
||||
setB(1)
|
||||
assert(runs == 1, "should not run on b change")
|
||||
setA(2)
|
||||
assert(runs == 2, "should run on a change")
|
||||
end)
|
||||
|
||||
test("should ensure correct execution order", function()
|
||||
local order: { number } = {}
|
||||
local n1, setN1 = signal(0)
|
||||
local n2, setN2 = signal(0)
|
||||
local sum = computed(function()
|
||||
return n1() + n2()
|
||||
end)
|
||||
|
||||
subscribe(n1, function()
|
||||
table.insert(order, 1)
|
||||
setN2(n2() + 1)
|
||||
end)
|
||||
|
||||
subscribe(sum, function()
|
||||
table.insert(order, 2)
|
||||
end)
|
||||
|
||||
subscribe(n1, function()
|
||||
table.insert(order, 3)
|
||||
end)
|
||||
|
||||
setN1(n1() + 1)
|
||||
|
||||
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should skip redundant updates", function()
|
||||
local order: { "even" | "odd" } = {}
|
||||
local a, setA = signal(0)
|
||||
|
||||
subscribe(function(): "even" | "odd"
|
||||
return if a() % 2 == 0 then "even" else "odd"
|
||||
end, function(value)
|
||||
table.insert(order, value)
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
setA(3)
|
||||
setA(4)
|
||||
setA(6)
|
||||
assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
484
packages/charm/test/topology.test.luau
Normal file
484
packages/charm/test/topology.test.luau
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
|
||||
--[[
|
||||
Tests adopted with thanks from preact-core implementation at
|
||||
https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022-present Preact Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
]]
|
||||
|
||||
test.describe("graph updates", function()
|
||||
test("should drop A->B->A updates", function()
|
||||
-- A
|
||||
-- / |
|
||||
-- B | <- Looks like a flag doesn't it? :D
|
||||
-- \ |
|
||||
-- C
|
||||
-- |
|
||||
-- D
|
||||
local a = atom(2)
|
||||
|
||||
local b = computed(function()
|
||||
return a() - 1
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a() + b()
|
||||
end)
|
||||
|
||||
local computeCalls = 0
|
||||
local compute = function()
|
||||
computeCalls += 1
|
||||
return "d: " .. c()
|
||||
end
|
||||
local d = computed(compute)
|
||||
|
||||
-- Trigger read
|
||||
assert(d() == "d: 3", "d should be 3")
|
||||
assert(computeCalls == 1, "compute should be called once")
|
||||
computeCalls = 0
|
||||
|
||||
a(4)
|
||||
d()
|
||||
assert(computeCalls == 1, "compute should drop update")
|
||||
end)
|
||||
|
||||
test("should only update every signal once (diamond graph)", function()
|
||||
-- In this scenario "D" should only update once when "A" receives
|
||||
-- an update. This is sometimes referred to as the "diamond" scenario.
|
||||
-- A
|
||||
-- / \
|
||||
-- B C
|
||||
-- \ /
|
||||
-- D
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return b() .. " " .. c()
|
||||
end
|
||||
local d = computed(spy)
|
||||
|
||||
assert(d() == "a a", "d should be 'a a'")
|
||||
assert(spyCalls == 1, "compute should be called once")
|
||||
|
||||
a("aa")
|
||||
assert(d() == "aa aa", "d should be 'aa aa'")
|
||||
assert(spyCalls == 2, "compute should be called only twice")
|
||||
end)
|
||||
|
||||
test("should only update every signal once (diamond graph + tail)", function()
|
||||
-- "E" will be likely updated twice if our mark+sweep logic is buggy.
|
||||
-- A
|
||||
-- / \
|
||||
-- B C
|
||||
-- \ /
|
||||
-- D
|
||||
-- |
|
||||
-- E
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local d = computed(function()
|
||||
return b() .. " " .. c()
|
||||
end)
|
||||
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return d()
|
||||
end
|
||||
local e = computed(spy)
|
||||
|
||||
assert(e() == "a a", "e should be 'a a'")
|
||||
assert(spyCalls == 1, "compute should be called once")
|
||||
|
||||
a("aa")
|
||||
assert(e() == "aa aa", "e should be 'aa aa'")
|
||||
assert(spyCalls == 2, "compute should be called only twice")
|
||||
end)
|
||||
|
||||
test("should bail out if result is the same", function()
|
||||
-- Bail out if value of "B" never changes
|
||||
-- A->B->C
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
a()
|
||||
return "foo"
|
||||
end)
|
||||
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return b()
|
||||
end
|
||||
local c = computed(spy)
|
||||
|
||||
assert(c() == "foo", "c should be 'foo'")
|
||||
assert(spyCalls == 1, "compute should be called once")
|
||||
|
||||
a("aa")
|
||||
assert(c() == "foo", "c should be 'foo'")
|
||||
assert(spyCalls == 1, "compute should bail out")
|
||||
end)
|
||||
|
||||
test("should only update every signal once (jagged diamond graph + tails)", function()
|
||||
-- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy.
|
||||
-- A
|
||||
-- / \
|
||||
-- B C
|
||||
-- | |
|
||||
-- | D
|
||||
-- \ /
|
||||
-- E
|
||||
-- / \
|
||||
-- F G
|
||||
local a = atom("a")
|
||||
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local d = computed(function()
|
||||
return c()
|
||||
end)
|
||||
|
||||
local eSpyCalls = 0
|
||||
local eSpyTime = 0
|
||||
local eSpy = function()
|
||||
eSpyCalls += 1
|
||||
eSpyTime = os.clock()
|
||||
return b() .. " " .. d()
|
||||
end
|
||||
local e = computed(eSpy)
|
||||
|
||||
local fSpyCalls = 0
|
||||
local fSpyTime = 0
|
||||
local fSpy = function()
|
||||
fSpyCalls += 1
|
||||
fSpyTime = os.clock()
|
||||
return e()
|
||||
end
|
||||
local f = computed(fSpy)
|
||||
local gSpyCalls = 0
|
||||
local gSpyTime = 0
|
||||
local gSpy = function()
|
||||
gSpyCalls += 1
|
||||
gSpyTime = os.clock()
|
||||
return e()
|
||||
end
|
||||
local g = computed(gSpy)
|
||||
|
||||
assert(f() == "a a", "f should be 'a a'")
|
||||
assert(fSpyCalls == 1, "f should be called once")
|
||||
|
||||
assert(g() == "a a", "g should be 'a a'")
|
||||
assert(gSpyCalls == 1, "g should be called once")
|
||||
|
||||
eSpyCalls, eSpyTime = 0, 0
|
||||
fSpyCalls, fSpyTime = 0, 0
|
||||
gSpyCalls, gSpyTime = 0, 0
|
||||
|
||||
a("b")
|
||||
|
||||
assert(e() == "b b", "e should be 'b b'")
|
||||
assert(eSpyCalls == 1, "e should run once after update")
|
||||
|
||||
assert(f() == "b b", "f should be 'b b'")
|
||||
assert(fSpyCalls == 1, "f should run once after update")
|
||||
|
||||
assert(g() == "b b", "g should be 'b b'")
|
||||
assert(gSpyCalls == 1, "g should run once after update")
|
||||
|
||||
eSpyCalls, eSpyTime = 0, 0
|
||||
fSpyCalls, fSpyTime = 0, 0
|
||||
gSpyCalls, gSpyTime = 0, 0
|
||||
|
||||
a("c")
|
||||
|
||||
assert(e() == "c c", "e should be 'c c'")
|
||||
assert(eSpyCalls == 1, "e should run once after update")
|
||||
|
||||
assert(f() == "c c", "f should be 'c c'")
|
||||
assert(fSpyCalls == 1, "f should run once after update")
|
||||
|
||||
assert(g() == "c c", "g should be 'c c'")
|
||||
assert(gSpyCalls == 1, "g should run once after update")
|
||||
|
||||
-- top to bottom
|
||||
assert(eSpyTime < fSpyTime, "e should be called before f")
|
||||
-- left to right
|
||||
assert(eSpyTime < gSpyTime, "e should be called before g")
|
||||
end)
|
||||
|
||||
test("should only subscribe to core listened to", function()
|
||||
-- *A
|
||||
-- / \
|
||||
-- *B C <- we don't listen to C
|
||||
local a = atom("a")
|
||||
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return a()
|
||||
end
|
||||
computed(spy)
|
||||
|
||||
assert(b() == "a", "b should be 'a'")
|
||||
assert(spyCalls == 0, "compute should not run")
|
||||
|
||||
a("aa")
|
||||
assert(b() == "aa", "b should be 'aa'")
|
||||
assert(spyCalls == 0, "compute should not run")
|
||||
end)
|
||||
|
||||
test("should only subscribe to core listened to II", function()
|
||||
-- Here both "B" and "C" are active in the beginning, but
|
||||
-- "B" becomes inactive later. At that point it should
|
||||
-- not receive any updates anymore.
|
||||
-- *A
|
||||
-- / \
|
||||
-- *B D <- we don't listen to C
|
||||
-- |
|
||||
-- *C
|
||||
local a = atom("a")
|
||||
local spyBCalls = 0
|
||||
local spyB = function()
|
||||
spyBCalls += 1
|
||||
return a()
|
||||
end
|
||||
local b = computed(spyB)
|
||||
|
||||
local spyCCalls = 0
|
||||
local spyC = function()
|
||||
spyCCalls += 1
|
||||
return b()
|
||||
end
|
||||
local c = computed(spyC)
|
||||
|
||||
local d = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local result = ""
|
||||
local unsub = effect(function()
|
||||
result = c()
|
||||
end)
|
||||
|
||||
assert(result == "a", "result should be 'a'")
|
||||
assert(d() == "a", "d should be 'a'")
|
||||
|
||||
spyBCalls = 0
|
||||
spyCCalls = 0
|
||||
unsub()
|
||||
|
||||
a("aa")
|
||||
|
||||
assert(spyBCalls == 0, "b should not run")
|
||||
assert(spyCCalls == 0, "c should not run")
|
||||
assert(d() == "aa", "d should be 'aa'")
|
||||
end)
|
||||
|
||||
test("should ensure subs update even if one dep unmarks it", function()
|
||||
-- In this scenario "C" always returns the same value. When "A"
|
||||
-- changes, "B" will update, then "C" at which point its update
|
||||
-- to "D" will be unmarked. But "D" must still update because
|
||||
-- "B" marked it. If "D" isn't updated, then we have a bug.
|
||||
-- A
|
||||
-- / \
|
||||
-- B *C <- returns same value every time
|
||||
-- \ /
|
||||
-- D
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
a()
|
||||
return "c"
|
||||
end)
|
||||
local spyResult: string?
|
||||
local spy = function()
|
||||
spyResult = b() .. " " .. c()
|
||||
return spyResult
|
||||
end
|
||||
local d = computed(spy)
|
||||
|
||||
assert(d() == "a c", "d should be 'a c'")
|
||||
spyResult = nil
|
||||
|
||||
a("aa")
|
||||
d()
|
||||
assert(spyResult == "aa c", "d should have returned 'aa c'")
|
||||
end)
|
||||
|
||||
test("should ensure subs update even if two deps unmark it", function()
|
||||
-- In this scenario both "C" and "D" always return the same
|
||||
-- value. But "E" must still update because "A" marked it.
|
||||
-- If "E" isn't updated, then we have a bug.
|
||||
-- A
|
||||
-- / | \
|
||||
-- B *C *D
|
||||
-- \ | /
|
||||
-- E
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
a()
|
||||
return "c"
|
||||
end)
|
||||
local d = computed(function()
|
||||
a()
|
||||
return "d"
|
||||
end)
|
||||
local spyResult: string?
|
||||
local spy = function()
|
||||
spyResult = b() .. " " .. c() .. " " .. d()
|
||||
return spyResult
|
||||
end
|
||||
local e = computed(spy)
|
||||
|
||||
assert(e() == "a c d", "e should be 'a c d'")
|
||||
spyResult = nil
|
||||
|
||||
a("aa")
|
||||
e()
|
||||
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
|
||||
end)
|
||||
|
||||
test("should support lazy branches", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return if a() > 0 then a() else b()
|
||||
end)
|
||||
|
||||
assert(c() == 0, "c should be 0")
|
||||
a(1)
|
||||
assert(c() == 1, "c should be 1")
|
||||
|
||||
a(0)
|
||||
assert(c() == 0, "c should be 0 again")
|
||||
end)
|
||||
|
||||
test("should not update a sub if all deps unmark it", function()
|
||||
-- In this scenario "B" and "C" always return the same value. When "A"
|
||||
-- changes, "D" should not update.
|
||||
-- A
|
||||
-- / \
|
||||
-- *B *C
|
||||
-- \ /
|
||||
-- D
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
a()
|
||||
return "b"
|
||||
end)
|
||||
local c = computed(function()
|
||||
a()
|
||||
return "c"
|
||||
end)
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return b() .. " " .. c()
|
||||
end
|
||||
local d = computed(spy)
|
||||
|
||||
assert(d() == "b c", "d should be 'b c'")
|
||||
spyCalls = 0
|
||||
|
||||
a("aa")
|
||||
assert(spyCalls == 0, "d should not run")
|
||||
end)
|
||||
end)
|
||||
|
||||
test.describe("error handling", function()
|
||||
test("should keep graph consistent on errors during activation", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function(): nil
|
||||
error("fail")
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local success, result = pcall(b)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
||||
|
||||
a(1)
|
||||
assert(c() == 1, "c should recover and return 1")
|
||||
end)
|
||||
|
||||
test("should keep graph consistent on errors in computeds", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function(): number
|
||||
if a() == 1 then
|
||||
error("fail")
|
||||
end
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return b()
|
||||
end)
|
||||
|
||||
assert(c() == 0, "c should be 0")
|
||||
|
||||
a(1)
|
||||
local success, result = pcall(b)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
||||
|
||||
a(2)
|
||||
assert(c() == 2, "c should recover and return 2")
|
||||
end)
|
||||
end)
|
||||
|
||||
return {}
|
||||
111
packages/charm/test/trigger.test.luau
Normal file
111
packages/charm/test/trigger.test.luau
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local listen = Charm.listen
|
||||
local signal = Charm.signal
|
||||
local subscribe = Charm.subscribe
|
||||
local trigger = Charm.trigger
|
||||
|
||||
test.beforeEach(function()
|
||||
Charm.flags.frozen = false
|
||||
end)
|
||||
|
||||
test.afterEach(function()
|
||||
Charm.flags.frozen = true
|
||||
end)
|
||||
|
||||
test("should not throw when triggering with no dependencies", function()
|
||||
trigger(function() end)
|
||||
end)
|
||||
|
||||
test("should trigger updates for dependent computed signals", function()
|
||||
local arr = signal({} :: { number })
|
||||
local length = computed(function()
|
||||
return #arr()
|
||||
end)
|
||||
|
||||
assert(length() == 0, "initial length should be 0")
|
||||
table.insert(arr(), 1)
|
||||
trigger(arr)
|
||||
assert(length() == 1, "length should be 1")
|
||||
end)
|
||||
|
||||
test("should trigger updates for the second source signal", function()
|
||||
local src1 = signal({} :: { number })
|
||||
local src2 = signal({} :: { number })
|
||||
local length = computed(function()
|
||||
return #src2()
|
||||
end)
|
||||
|
||||
assert(length() == 0, "initial length should be 0")
|
||||
table.insert(src2(), 1)
|
||||
trigger(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
assert(length() == 1, "length should be 1")
|
||||
end)
|
||||
|
||||
test("should trigger effect once", function()
|
||||
local src1 = signal({} :: { number })
|
||||
local src2 = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
effect(function()
|
||||
triggers += 1
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
assert(triggers == 1, "effect should run once initially")
|
||||
|
||||
trigger(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
assert(triggers == 2, "effect should run twice")
|
||||
end)
|
||||
|
||||
test("should not notify the trigger function sub", function()
|
||||
local src1 = signal({} :: { number })
|
||||
local src2 = computed(function()
|
||||
return src1()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
trigger(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
end)
|
||||
|
||||
test("should trigger updates for subscribe", function()
|
||||
local src = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
subscribe(src, function()
|
||||
triggers += 1
|
||||
end)
|
||||
|
||||
trigger(src)
|
||||
assert(triggers == 1, "subscribe callback should run after trigger")
|
||||
end)
|
||||
|
||||
test("should trigger updates for listen", function()
|
||||
local src = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
listen(src, function()
|
||||
triggers += 1
|
||||
end)
|
||||
assert(triggers == 1, "listen callback should run once initially")
|
||||
|
||||
trigger(src)
|
||||
assert(triggers == 2, "listen callback should run after trigger")
|
||||
end)
|
||||
|
||||
return {}
|
||||
176
packages/charm/test/untracked.test.luau
Normal file
176
packages/charm/test/untracked.test.luau
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local untracked = Charm.untracked
|
||||
|
||||
test("should run detached from effect", function()
|
||||
local a, setA = signal(1)
|
||||
local effects = 0
|
||||
local innerEffects = {}
|
||||
|
||||
local stopEffect = effect(function()
|
||||
a()
|
||||
untracked(function()
|
||||
local stopInnerEffect = effect(function()
|
||||
effects += 1
|
||||
return function()
|
||||
effects -= 1
|
||||
end
|
||||
end)
|
||||
table.insert(innerEffects, stopInnerEffect)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(effects == 1, "should have one effect")
|
||||
setA(2)
|
||||
assert(effects == 2, "should have two effects")
|
||||
stopEffect()
|
||||
assert(effects == 2, "should still have two effects")
|
||||
for _, stopInnerEffect in innerEffects do
|
||||
stopInnerEffect()
|
||||
end
|
||||
assert(effects == 0, "should have zero effects")
|
||||
end)
|
||||
|
||||
test("should run detached from effect scope", function()
|
||||
local effects = 0
|
||||
local stopEffect: () -> ()
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
untracked(function()
|
||||
stopEffect = effect(function()
|
||||
effects += 1
|
||||
return function()
|
||||
effects -= 1
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(effects == 1, "should have one effect")
|
||||
stopScope()
|
||||
assert(effects == 1, "should still have one effect")
|
||||
stopEffect()
|
||||
assert(effects == 0, "should have zero effects")
|
||||
end)
|
||||
|
||||
test("should block tracking inside effects", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(2)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
untracked(function()
|
||||
spyCalls += 1
|
||||
return a() + b()
|
||||
end)
|
||||
end)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
|
||||
setA(10)
|
||||
setB(20)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
end)
|
||||
|
||||
test("should block tracking even when run inside effect run inside untracked", function()
|
||||
local s, setS = signal(1)
|
||||
local spyCalls = 0
|
||||
|
||||
untracked(function()
|
||||
effect(function()
|
||||
untracked(function()
|
||||
spyCalls += 1
|
||||
return s()
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
|
||||
setS(2)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
end)
|
||||
|
||||
test("should not cause signal assignments to throw", function()
|
||||
local a, setA = signal(1)
|
||||
local aChangedTime, setAChangedTime = signal(0)
|
||||
|
||||
local dispose = effect(function()
|
||||
a()
|
||||
untracked(function()
|
||||
setAChangedTime(aChangedTime() + 1)
|
||||
end)
|
||||
end)
|
||||
|
||||
setA(2)
|
||||
assert(aChangedTime() == 2, "aChangedTime should be 2")
|
||||
setA(3)
|
||||
assert(aChangedTime() == 3, "aChangedTime should be 3")
|
||||
|
||||
dispose()
|
||||
end)
|
||||
|
||||
test("should block tracking inside computed signals", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(2)
|
||||
local spyCalls = 0
|
||||
local c = computed(function()
|
||||
return untracked(function()
|
||||
spyCalls += 1
|
||||
return a() + b()
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(spyCalls == 0, "should not be called yet")
|
||||
assert(c() == 3, "c should be 3")
|
||||
setA(10)
|
||||
c()
|
||||
setB(20)
|
||||
c()
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
assert(c() == 3, "c should be 3")
|
||||
end)
|
||||
|
||||
test("should track outside untracked", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(2)
|
||||
local c, setC = signal(3)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
a()
|
||||
untracked(c)
|
||||
b()
|
||||
spyCalls += 1
|
||||
end)
|
||||
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
setA(10)
|
||||
assert(spyCalls == 2, "should be called twice")
|
||||
setB(20)
|
||||
assert(spyCalls == 3, "should be called three times")
|
||||
setC(30)
|
||||
assert(spyCalls == 3, "should not run on c change")
|
||||
end)
|
||||
|
||||
test("should stack untracked", function()
|
||||
local a, setA = signal(1)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
untracked(function()
|
||||
untracked(a)
|
||||
return a()
|
||||
end)
|
||||
spyCalls += 1
|
||||
end)
|
||||
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
setA(2)
|
||||
assert(spyCalls == 1, "should not be called twice")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
[package]
|
||||
name = "littensy/charm"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0-rc.6"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
5
packages/deep-charm/Charm.luau
Normal file
5
packages/deep-charm/Charm.luau
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
if not script then
|
||||
return require("../charm/src")
|
||||
end
|
||||
|
||||
return require(script.Parent.Parent.charm.src)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "ReactCharm",
|
||||
"name": "deep-charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
27
packages/deep-charm/package.json
Normal file
27
packages/deep-charm/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@rbxts/deep-charm",
|
||||
"version": "0.1.0-rc.1",
|
||||
"description": "Use Charm with plain Luau tables",
|
||||
"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 . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rbxts/charm": "workspace:^"
|
||||
}
|
||||
}
|
||||
53
packages/deep-charm/src/index.d.ts
vendored
Normal file
53
packages/deep-charm/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
export type ReactiveProxy<T extends object = object> = T & {
|
||||
__track: () => void;
|
||||
__target: T;
|
||||
__signals: Map<unknown, () => unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a deep reactive value that tracks properties when they're accessed.
|
||||
* Returns a proxy object that subscribes to changes on any nested property
|
||||
* you read, and a function for mutating the original table.
|
||||
*
|
||||
* @example
|
||||
* ```luau
|
||||
* local users, updateUsers = reactive({
|
||||
* user = { name = "John", surname = "Doe" },
|
||||
* })
|
||||
*
|
||||
* effect(function()
|
||||
* print(users.user and `{users.user.name} {users.user.surname}`)
|
||||
* end)
|
||||
*
|
||||
* updateUsers(function(state)
|
||||
* state.user.name = "Jane"
|
||||
* state.user.surname = "Smith"
|
||||
* end)
|
||||
* ```
|
||||
*
|
||||
* @param initialValue The value to make deeply reactive.
|
||||
* @returns A proxy table and a function to mutate the original table.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||
*/
|
||||
export function reactive<T extends object>(
|
||||
initialValue: T,
|
||||
): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>;
|
||||
|
||||
/**
|
||||
* Determines if the provided value is a reactive proxy created by `reactive()`.
|
||||
*
|
||||
* @param value A value to test.
|
||||
* @returns `true` if the value is a reactive proxy, `false` otherwise.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||
*/
|
||||
export function isReactive(value: unknown): boolean;
|
||||
|
||||
/**
|
||||
* Unwraps a reactive proxy to get the original table. If the provided value
|
||||
* is not a proxy, it is returned as-is.
|
||||
*
|
||||
* @param value A reactive proxy or a non-proxy value.
|
||||
* @returns The unwrapped target value.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||
*/
|
||||
export function toRaw<T>(value: T): T;
|
||||
165
packages/deep-charm/src/init.luau
Normal file
165
packages/deep-charm/src/init.luau
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
local Charm = require("./Charm")
|
||||
|
||||
local computed = Charm.computed
|
||||
local trigger = Charm.trigger
|
||||
local getActiveSub = Charm.getActiveSub
|
||||
|
||||
export type ReactiveProxy<T = any> = typeof(setmetatable(
|
||||
{} :: {
|
||||
__track: () -> (),
|
||||
__target: T,
|
||||
__signals: { [unknown]: () -> unknown },
|
||||
},
|
||||
{} :: { __index: T }
|
||||
))
|
||||
|
||||
local ReactiveProxy = {
|
||||
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
|
||||
}
|
||||
|
||||
--[[
|
||||
Determines if the provided value is a reactive proxy created by `reactive()`.
|
||||
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||
]]
|
||||
local function isReactive(value: any): boolean
|
||||
return type(value) == "table" and getmetatable(value) == ReactiveProxy
|
||||
end
|
||||
|
||||
--[[
|
||||
Unwraps a reactive proxy to get the original table. If the provided value
|
||||
is not a proxy, it is returned as-is.
|
||||
|
||||
@param value A reactive proxy or a non-proxy value
|
||||
@return The source table if `value` is a reactive proxy
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||
]]
|
||||
local function toRaw<T>(value: T): T
|
||||
if isReactive(value) then
|
||||
value = value :: ReactiveProxy<T>
|
||||
value.__track()
|
||||
return toRaw(value.__target)
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
--[[
|
||||
Creates a deep reactive value that tracks properties when they're accessed.
|
||||
Returns a proxy object that subscribes to changes on any nested property
|
||||
you read, and a function for mutating the original table.
|
||||
|
||||
@example
|
||||
```luau
|
||||
local users, updateUsers = reactive({
|
||||
{ name = "John", surname = "Doe" },
|
||||
})
|
||||
|
||||
effect(function()
|
||||
print(`{users[1].name} {users[1].surname}`)
|
||||
end)
|
||||
|
||||
updateUsers(function(state)
|
||||
state[1].name = "Jane"
|
||||
state[1].surname = "Smith"
|
||||
end)
|
||||
```
|
||||
|
||||
@param initialValue Source table to make deeply reactive
|
||||
@return A proxy table that tracks reads to nested properties
|
||||
@return A function for batching mutations to the source table
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||
]]
|
||||
local function reactive<T>(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ())
|
||||
initialValue = toRaw(initialValue)
|
||||
|
||||
local proxy = ReactiveProxy.proxyMap[initialValue]
|
||||
|
||||
if not proxy then
|
||||
local track = computed(function(version: number?)
|
||||
-- Reference the proxy to avoid GCing while an effect is actively
|
||||
-- tracking this reactive object.
|
||||
local _ref = proxy
|
||||
-- Allow updates from the parent to propagate down when the
|
||||
-- target is mutated directly through the setter.
|
||||
if trackParent then
|
||||
trackParent()
|
||||
end
|
||||
return (version or 0) + 1
|
||||
end)
|
||||
|
||||
proxy = setmetatable({
|
||||
__target = initialValue,
|
||||
__signals = setmetatable({}, { __mode = "v" }) :: any,
|
||||
__track = track,
|
||||
}, ReactiveProxy)
|
||||
|
||||
ReactiveProxy.proxyMap[initialValue] = proxy
|
||||
end
|
||||
|
||||
local function setValue(update: ((T) -> ())?)
|
||||
if update then
|
||||
update(proxy.__target)
|
||||
end
|
||||
trigger(proxy.__track)
|
||||
end
|
||||
|
||||
return proxy :: any, setValue
|
||||
end
|
||||
|
||||
--[[
|
||||
Wraps a value in `reactive()` if it is a table; otherwise, returns the
|
||||
value as-is.
|
||||
|
||||
@param value Source value to make deeply reactive
|
||||
@param trackParent Optional tracking function to propagate deep updates
|
||||
@return A reactive proxy if the value is a table
|
||||
]]
|
||||
local function toReactive<T>(value: T, trackParent: (() -> ())?): T
|
||||
return if type(value) == "table" then reactive(value, trackParent) else value
|
||||
end
|
||||
|
||||
function ReactiveProxy.__index(self: ReactiveProxy, key: any): any
|
||||
local track = self.__track
|
||||
local signals = self.__signals
|
||||
local target = self.__target
|
||||
|
||||
if getActiveSub() then
|
||||
if not signals[key] then
|
||||
signals[key] = computed(function()
|
||||
track()
|
||||
return target[key]
|
||||
end)
|
||||
end
|
||||
signals[key]()
|
||||
end
|
||||
|
||||
return toReactive(target[key], track)
|
||||
end
|
||||
|
||||
function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any)
|
||||
value = toRaw(value)
|
||||
if self.__target[key] ~= value then
|
||||
self.__target[key] = value
|
||||
trigger(self.__track)
|
||||
end
|
||||
end
|
||||
|
||||
function ReactiveProxy.__iter(self: ReactiveProxy)
|
||||
self.__track()
|
||||
local function nextReactive(target: any, prevKey: any)
|
||||
local nextKey, nextValue = next(target, prevKey)
|
||||
return nextKey, toReactive(nextValue, self.__track)
|
||||
end
|
||||
return nextReactive, self.__target
|
||||
end
|
||||
|
||||
function ReactiveProxy.__len(self: ReactiveProxy)
|
||||
self.__track()
|
||||
return #self.__target
|
||||
end
|
||||
|
||||
return {
|
||||
reactive = reactive,
|
||||
isReactive = isReactive,
|
||||
toRaw = toRaw,
|
||||
}
|
||||
79
packages/deep-charm/test/gc.test.luau
Normal file
79
packages/deep-charm/test/gc.test.luau
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
local Charm = require("../Charm")
|
||||
local DeepCharm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
type ReactiveProxy<T = any> = DeepCharm.ReactiveProxy<T>
|
||||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local reactive = DeepCharm.reactive
|
||||
|
||||
local function weakRef<T>(value: T): { value: T? }
|
||||
return setmetatable({ value = value }, { __mode = "v" }) :: any
|
||||
end
|
||||
|
||||
local function gc()
|
||||
for _ = 1, 2000 do
|
||||
local _ = table.create(100, 0)
|
||||
end
|
||||
end
|
||||
|
||||
test("should release inner reactive proxy", function()
|
||||
local valueRef = weakRef({})
|
||||
local src: any = reactive({ value = valueRef.value })
|
||||
|
||||
local dispose = effect(function()
|
||||
local _ = src.value -- create proxy
|
||||
end)
|
||||
src.value = nil :: any -- release proxy
|
||||
dispose()
|
||||
|
||||
gc()
|
||||
assert(src.__signals.value == nil, "should release signals")
|
||||
assert(valueRef.value == nil, "should release inner proxy")
|
||||
end)
|
||||
|
||||
test("should release reactive property deps", function()
|
||||
local src: any = reactive({ foo = 1 })
|
||||
|
||||
local c = computed(function()
|
||||
return src.foo
|
||||
end)
|
||||
|
||||
c()
|
||||
assert(src.__signals.foo ~= nil, "should have deps for foo")
|
||||
|
||||
c = nil :: any
|
||||
gc()
|
||||
assert(src.__signals.foo == nil, "should release deps for foo")
|
||||
end)
|
||||
|
||||
test("should not release effect for reactive", function()
|
||||
local src = reactive({ foo = 1 })
|
||||
local calls = 0
|
||||
|
||||
effect(function()
|
||||
calls += 1
|
||||
local _ = src.foo
|
||||
end)
|
||||
assert(calls == 1, "should call effect once")
|
||||
|
||||
gc()
|
||||
src.foo += 1
|
||||
assert(calls == 2, "should call effect again")
|
||||
end)
|
||||
|
||||
test("should not release tracked reactive", function()
|
||||
local src = reactive({ users = {} :: { [string]: {}? } })
|
||||
local user
|
||||
|
||||
effect(function()
|
||||
user = src.users.user
|
||||
end)
|
||||
|
||||
gc()
|
||||
src.users.user = {}
|
||||
assert(user ~= nil, "src.users.user should not be released")
|
||||
end)
|
||||
|
||||
return {}
|
||||
11
packages/deep-charm/test/init.luau
Normal file
11
packages/deep-charm/test/init.luau
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
local test = require("../../tests/test")
|
||||
|
||||
test.describe("reactive", function()
|
||||
require(`@self/reactive.test`)
|
||||
end)
|
||||
|
||||
test.describe("gc", function()
|
||||
require(`@self/gc.test`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
232
packages/deep-charm/test/reactive.test.luau
Normal file
232
packages/deep-charm/test/reactive.test.luau
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
local Charm = require("../Charm")
|
||||
local DeepCharm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local batch = Charm.batch
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local observe = Charm.observe
|
||||
local reactive = DeepCharm.reactive
|
||||
local toRaw = DeepCharm.toRaw
|
||||
|
||||
test("should track nested properties", function()
|
||||
local src, setSrc = reactive({ a = { b = { c = 0 } } })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, `{src.a.b.c}`)
|
||||
end)
|
||||
|
||||
setSrc(function(state)
|
||||
state.a.b.c = 1
|
||||
end)
|
||||
src.a.b.c = 2
|
||||
src.a.b.c = 2
|
||||
setSrc()
|
||||
assert(table.concat(calls) == "012", table.concat(calls))
|
||||
end)
|
||||
|
||||
test("should track object type changes", function()
|
||||
local src = reactive({ a = { b = 0 } } :: { a: any })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, tostring(if type(src.a) == "table" then src.a.b else src.a))
|
||||
end)
|
||||
|
||||
src.a = { b = 0 }
|
||||
src.a = 1
|
||||
src.a = { b = 2 }
|
||||
src.a = nil
|
||||
src.a = 3
|
||||
assert(table.concat(calls) == "0012nil3", table.concat(calls))
|
||||
end)
|
||||
|
||||
test("should unwrap proxy assignments", function()
|
||||
local src = reactive({ a = { x = 0 }, b = { x = 0 } })
|
||||
local runs = 0
|
||||
|
||||
effect(function()
|
||||
local _, _ = src.a.x, src.b.x
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
src.a, src.b = src.a, src.b
|
||||
assert(runs == 1, `bad runs: {runs}`)
|
||||
|
||||
src.a, src.b = src.b, src.a
|
||||
assert(runs == 3, `bad runs: {runs}`)
|
||||
end)
|
||||
|
||||
test("should track iteration", function()
|
||||
local src, setSrc = reactive({} :: { [string]: number })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
for key, value in src do
|
||||
table.insert(calls, `{key}={value};`)
|
||||
end
|
||||
end)
|
||||
|
||||
src.a = 1
|
||||
assert(table.concat(calls) == "a=1;", table.concat(calls))
|
||||
|
||||
table.clear(calls)
|
||||
src.b = 2
|
||||
assert(table.concat(calls) == "a=1;b=2;", table.concat(calls))
|
||||
|
||||
table.clear(calls)
|
||||
src.a = 2
|
||||
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
|
||||
|
||||
table.clear(calls)
|
||||
setSrc()
|
||||
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
|
||||
end)
|
||||
|
||||
test("should track iteration for nested tables", function()
|
||||
local src = reactive({} :: { [string]: { value: number } })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
for key, obj in src do
|
||||
table.insert(calls, `{key}={obj.value};`)
|
||||
end
|
||||
end)
|
||||
|
||||
src.a = { value = 1 }
|
||||
|
||||
table.clear(calls)
|
||||
src.b = { value = 1 }
|
||||
assert(table.concat(calls) == "a=1;b=1;", table.concat(calls))
|
||||
|
||||
table.clear(calls)
|
||||
src.a.value = 2
|
||||
assert(table.concat(calls) == "a=2;b=1;", table.concat(calls))
|
||||
end)
|
||||
|
||||
test("should track array length", function()
|
||||
local src = reactive({ array = {} :: { number } })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, #src.array)
|
||||
end)
|
||||
|
||||
src.array[#src.array + 1] = 1
|
||||
src.array[#src.array + 1] = 2
|
||||
src.array[#src.array + 1] = 3
|
||||
src.array = { 1, 2, 3, 4 }
|
||||
assert(table.concat(calls) == "01234", table.concat(calls))
|
||||
end)
|
||||
|
||||
test("should not track unread properties", function()
|
||||
local src = reactive({ value = { a = 1, b = 2 } })
|
||||
local calls = 0
|
||||
|
||||
effect(function()
|
||||
local _ = src.value.a
|
||||
calls += 1
|
||||
end)
|
||||
assert(calls == 1, `bad calls: {calls}`)
|
||||
|
||||
src.value.b = 3
|
||||
assert(calls == 1, `bad calls: {calls}`)
|
||||
end)
|
||||
|
||||
test("should overwrite with table passed to setter", function()
|
||||
local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number? })
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, `{src.a}{src.b}{src.c}`)
|
||||
end)
|
||||
|
||||
setSrc(function(src)
|
||||
src.a = 2
|
||||
src.b = nil
|
||||
src.c = 4
|
||||
end)
|
||||
assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
|
||||
end)
|
||||
|
||||
test("should unwrap proxy", function()
|
||||
local raw = {}
|
||||
local src = reactive(raw)
|
||||
local runs = 0
|
||||
|
||||
effect(function()
|
||||
local _ = toRaw(src)
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
assert(toRaw(src) == raw, "unwrap did not return the original table")
|
||||
assert(toRaw(raw) == raw, "unwrap modified a non-proxy value")
|
||||
assert(runs == 1, `bad runs: {runs}`)
|
||||
|
||||
src.x = 1
|
||||
assert(runs == 2, `bad runs: {runs}`)
|
||||
end)
|
||||
|
||||
test("should unwrap nested proxy", function()
|
||||
local raw = {}
|
||||
local inner = reactive(raw)
|
||||
local src = reactive(inner)
|
||||
local runs = 0
|
||||
|
||||
effect(function()
|
||||
local _ = toRaw(src)
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
assert(toRaw(src) == raw, "unwrap did not return the original table")
|
||||
assert(runs == 1, `bad runs: {runs}`)
|
||||
|
||||
inner.x = 1
|
||||
assert(runs == 2, `bad runs: {runs}`)
|
||||
|
||||
src.y = 2
|
||||
assert(runs == 3, `bad runs: {runs}`)
|
||||
end)
|
||||
|
||||
test("should observe items", function()
|
||||
local items, updateItems = reactive({ 0, 0, 0 })
|
||||
local updates = {}
|
||||
local cleanups = {}
|
||||
|
||||
observe(function()
|
||||
return toRaw(items)
|
||||
end, function(_, key: number)
|
||||
local get = computed(function()
|
||||
return items[key]
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(updates, get())
|
||||
end)
|
||||
|
||||
return function()
|
||||
table.insert(cleanups, key)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
|
||||
|
||||
table.clear(updates)
|
||||
updateItems(function(state)
|
||||
state[1] = 1
|
||||
state[2] = 2
|
||||
state[3] = 3
|
||||
end)
|
||||
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
|
||||
|
||||
table.clear(updates)
|
||||
batch(function()
|
||||
items[3] = nil
|
||||
items[4] = 4
|
||||
end)
|
||||
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
|
||||
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
9
packages/deep-charm/wally.toml
Normal file
9
packages/deep-charm/wally.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "littensy/deep-charm"
|
||||
version = "0.1.0-rc.1"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
|
||||
|
||||
[dependencies]
|
||||
Charm = "littensy/charm@^0.11.0-rc.6"
|
||||
1
packages/react-charm/Charm.luau
Normal file
1
packages/react-charm/Charm.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return require(script.Parent.Parent.charm.src)
|
||||
1
packages/react-charm/React.luau
Normal file
1
packages/react-charm/React.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)
|
||||
1
packages/react-charm/ReactRoblox.luau
Normal file
1
packages/react-charm/ReactRoblox.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").ReactRoblox)
|
||||
6
packages/react-charm/default.project.json
Normal file
6
packages/react-charm/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "react-charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
30
packages/react-charm/package.json
Normal file
30
packages/react-charm/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@rbxts/react-charm",
|
||||
"version": "0.4.0-rc.4",
|
||||
"description": "React bindings for Charm state management",
|
||||
"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 . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rbxts/charm": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/react": ">=17.0.0"
|
||||
}
|
||||
}
|
||||
31
packages/react-charm/src/index.d.ts
vendored
Normal file
31
packages/react-charm/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Binding } from "@rbxts/react";
|
||||
|
||||
/**
|
||||
* Returns a React state that updates when the getter function updates.
|
||||
* Re-renders the component when the value changes.
|
||||
*
|
||||
* If the `dependencies` array is provided, a new effect is created for the
|
||||
* current `getter` when the dependencies change. Otherwise, the effect is
|
||||
* created only once, when the component is mounted.
|
||||
*
|
||||
* @param getter The signal or selector to watch.
|
||||
* @param dependencies An array of values used to memoize the getter.
|
||||
* @return The current state.
|
||||
*/
|
||||
export function useSignalState<T>(getter: () => T, dependencies?: any[]): T;
|
||||
|
||||
/**
|
||||
* Returns a React binding that updates when the getter function updates.
|
||||
*
|
||||
* If the `dependencies` array is provided, a new effect is created for the
|
||||
* current `getter` when the dependencies change. Otherwise, the effect is
|
||||
* created only once, when the component is mounted.
|
||||
*
|
||||
* @param getter The atom or selector to watch.
|
||||
* @param dependencies An array of values used to memoize the getter.
|
||||
* @return A binding that stores the current state.
|
||||
*/
|
||||
export function useSignalBinding<T>(getter: () => T, dependencies?: any[]): Binding<T>;
|
||||
|
||||
// Aliases
|
||||
export { useSignalState as useAtom };
|
||||
52
packages/react-charm/src/init.luau
Normal file
52
packages/react-charm/src/init.luau
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
local Charm = require("./Charm")
|
||||
local React = require("./React")
|
||||
|
||||
--[[
|
||||
Returns a React state that updates when the getter function updates.
|
||||
Re-renders the component when the value changes.
|
||||
|
||||
If the `dependencies` array is provided, a new effect is created for the
|
||||
current `getter` when the dependencies change. Otherwise, the effect is
|
||||
created only once, when the component is mounted.
|
||||
|
||||
@param getter The signal or selector to watch.
|
||||
@param dependencies An array of values used to memoize the getter.
|
||||
@return The current state.
|
||||
]]
|
||||
local function useSignalState<T>(getter: () -> T, dependencies: {}?): T
|
||||
local value, setValue = React.useState(getter)
|
||||
|
||||
React.useLayoutEffect(function(): () -> ()
|
||||
return Charm.listen(getter, setValue :: (T, T?) -> ())
|
||||
end, dependencies :: { any } or {})
|
||||
|
||||
return value
|
||||
end
|
||||
|
||||
--[[
|
||||
Returns a React binding that updates when the getter function updates.
|
||||
|
||||
If the `dependencies` array is provided, a new effect is created for the
|
||||
current `getter` when the dependencies change. Otherwise, the effect is
|
||||
created only once, when the component is mounted.
|
||||
|
||||
@param getter The atom or selector to watch.
|
||||
@param dependencies An array of values used to memoize the getter.
|
||||
@return A binding that stores the current state.
|
||||
]]
|
||||
local function useSignalBinding<T>(getter: () -> T, dependencies: {}?)
|
||||
local initialValue = React.useMemo(getter, {})
|
||||
local binding, setBinding = React.useBinding(initialValue)
|
||||
|
||||
React.useLayoutEffect(function(): () -> ()
|
||||
return Charm.listen(getter, setBinding :: (T, T?) -> ())
|
||||
end, dependencies :: { any } or {})
|
||||
|
||||
return binding
|
||||
end
|
||||
|
||||
return table.freeze({
|
||||
useSignalState = useSignalState,
|
||||
useSignalBinding = useSignalBinding,
|
||||
useAtom = useSignalState,
|
||||
})
|
||||
39
packages/react-charm/test/helpers/renderHook.luau
Normal file
39
packages/react-charm/test/helpers/renderHook.luau
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
local React = require("../../React")
|
||||
local ReactRoblox = require("../../ReactRoblox")
|
||||
|
||||
type RenderedHook<Props, Result> = {
|
||||
result: Result,
|
||||
rerender: (Props?) -> (),
|
||||
unmount: () -> (),
|
||||
}
|
||||
|
||||
local function renderHook<Props, Result>(callback: (Props) -> Result, props: Props?): RenderedHook<Props, Result>
|
||||
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
|
||||
local hook = {}
|
||||
|
||||
local function Component(props: Props)
|
||||
hook.result = callback(props)
|
||||
return nil
|
||||
end
|
||||
|
||||
function hook.rerender(newProps: Props?)
|
||||
props = newProps or props
|
||||
ReactRoblox.act(function()
|
||||
root:render(React.createElement(Component, props) :: any)
|
||||
end)
|
||||
end
|
||||
|
||||
function hook.unmount()
|
||||
ReactRoblox.act(function()
|
||||
root:unmount()
|
||||
end)
|
||||
end
|
||||
|
||||
ReactRoblox.act(function()
|
||||
hook.rerender(props)
|
||||
end)
|
||||
|
||||
return hook
|
||||
end
|
||||
|
||||
return renderHook
|
||||
13
packages/react-charm/test/init.luau
Normal file
13
packages/react-charm/test/init.luau
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
local test = require("../../tests/test")
|
||||
|
||||
_G.__ROACT_17_MOCK_SCHEDULER__ = true
|
||||
|
||||
test.describe("useSignalState", function()
|
||||
require("@self/useSignalState.test")
|
||||
end)
|
||||
|
||||
test.describe("useSignalBinding", function()
|
||||
require("@self/useSignalBinding.test")
|
||||
end)
|
||||
|
||||
return {}
|
||||
44
packages/react-charm/test/useSignalBinding.test.luau
Normal file
44
packages/react-charm/test/useSignalBinding.test.luau
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
local Charm = require("../Charm")
|
||||
local ReactCharm = require("../src")
|
||||
local renderHook = require("./helpers/renderHook")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test("tracks signal state", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalBinding(getState)
|
||||
end)
|
||||
assert(hook.result:getValue() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result:getValue() == 1, "should return 1")
|
||||
end)
|
||||
|
||||
test("accepts a selector", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalBinding(function()
|
||||
return 2 * getState()
|
||||
end)
|
||||
end)
|
||||
assert(hook.result:getValue() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result:getValue() == 2, "should return 2")
|
||||
end)
|
||||
|
||||
test("tracks dependencies", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalBinding(getState, { getState })
|
||||
end)
|
||||
assert(hook.result:getValue() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result:getValue() == 1, "should return 1")
|
||||
|
||||
getState, setState = Charm.signal(0)
|
||||
hook.rerender()
|
||||
assert(hook.result:getValue() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result:getValue() == 1, "should return 1")
|
||||
end)
|
||||
|
||||
return {}
|
||||
44
packages/react-charm/test/useSignalState.test.luau
Normal file
44
packages/react-charm/test/useSignalState.test.luau
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
local Charm = require("../Charm")
|
||||
local ReactCharm = require("../src")
|
||||
local renderHook = require("./helpers/renderHook")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test("tracks signal state", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalState(getState)
|
||||
end)
|
||||
assert(hook.result == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result == 1, "should return 1")
|
||||
end)
|
||||
|
||||
test("accepts a selector", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalState(function()
|
||||
return 2 * getState()
|
||||
end)
|
||||
end)
|
||||
assert(hook.result == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result == 2, "should return 2")
|
||||
end)
|
||||
|
||||
test("tracks dependencies", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalState(getState, { getState })
|
||||
end)
|
||||
assert(hook.result == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result == 1, "should return 1")
|
||||
|
||||
getState, setState = Charm.signal(0)
|
||||
hook.rerender()
|
||||
assert(hook.result == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result == 1, "should return 1")
|
||||
end)
|
||||
|
||||
return {}
|
||||
17
packages/react-charm/wally.toml
Normal file
17
packages/react-charm/wally.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "littensy/react-charm"
|
||||
version = "0.4.0-rc.4"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = [
|
||||
"node_modules",
|
||||
"test",
|
||||
"src/index.d.ts",
|
||||
"package.json",
|
||||
"React.luau",
|
||||
"Charm.luau",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
Charm = "littensy/charm@^0.11.0-rc.6"
|
||||
React = "jsdotlua/react@^17"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "@rbxts/react-charm",
|
||||
"version": "0.3.0",
|
||||
"description": "React bindings for Charm",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/charm": "workspace:*",
|
||||
"@rbxts/react": "*"
|
||||
}
|
||||
}
|
||||
15
packages/react/src/index.d.ts
vendored
15
packages/react/src/index.d.ts
vendored
|
|
@ -1,15 +0,0 @@
|
|||
import { Selector } from "@rbxts/charm";
|
||||
|
||||
/**
|
||||
* A hook that subscribes to changes in the given atom or selector. The
|
||||
* component is re-rendered whenever the state changes.
|
||||
*
|
||||
* If the `dependencies` array is provided, the subscription to the atom or
|
||||
* selector is re-created whenever the dependencies change. Otherwise, the
|
||||
* subscription is created once when the component is mounted.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @param dependencies An array of values that the subscription depends on.
|
||||
* @returns The current state.
|
||||
*/
|
||||
export function useAtom<State>(callback: Selector<State>, dependencies?: unknown[]): State;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
local Charm = require(script.Parent.Charm)
|
||||
local React = if script.Parent:FindFirstChild("react")
|
||||
then require(script.Parent.react) :: never
|
||||
else require(script.Parent.React)
|
||||
|
||||
--[=[
|
||||
A hook that subscribes to changes in the given atom or selector. The
|
||||
component is re-rendered whenever the state changes.
|
||||
|
||||
If the `dependencies` array is provided, the subscription to the atom or
|
||||
selector is re-created whenever the dependencies change. Otherwise, the
|
||||
subscription is created once when the component is mounted.
|
||||
|
||||
@param callback The atom or selector to subscribe to.
|
||||
@param dependencies An array of values that the subscription depends on.
|
||||
@return The current state.
|
||||
]=]
|
||||
local function useAtom<State>(callback: Charm.Selector<State>, dependencies: { any }?): State
|
||||
local state, setState = React.useState(callback)
|
||||
|
||||
React.useEffect(function()
|
||||
setState(callback())
|
||||
return Charm.subscribe(callback, setState)
|
||||
end, dependencies or {})
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
return {
|
||||
useAtom = useAtom,
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "littensy/react-charm"
|
||||
version = "0.3.0"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
|
||||
[dependencies]
|
||||
charm = "littensy/charm@^0.10"
|
||||
React = "jsdotlua/react@^17"
|
||||
1
packages/vide-charm/Charm.luau
Normal file
1
packages/vide-charm/Charm.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return require(script.Parent.Parent.charm.src)
|
||||
1
packages/vide-charm/Vide.luau
Normal file
1
packages/vide-charm/Vide.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return require(script.Parent.Parent.vide.src)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "VideCharm",
|
||||
"name": "vide-charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
30
packages/vide-charm/package.json
Normal file
30
packages/vide-charm/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@rbxts/vide-charm",
|
||||
"version": "0.4.0-rc.4",
|
||||
"description": "Vide bindings for Charm state management",
|
||||
"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 . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rbxts/charm": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/vide": "*"
|
||||
}
|
||||
}
|
||||
11
packages/vide-charm/src/index.d.ts
vendored
Normal file
11
packages/vide-charm/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Returns a reactive source that subscribes to the current value of the given
|
||||
* signal or getter function.
|
||||
*
|
||||
* @param getter The getter function to subscribe to.
|
||||
* @return The reactive source.
|
||||
*/
|
||||
export function useSignalState<T>(getter: () => T, dependencies?: any[]): () => T;
|
||||
|
||||
// Aliases
|
||||
export { useSignalState as useAtom };
|
||||
22
packages/vide-charm/src/init.luau
Normal file
22
packages/vide-charm/src/init.luau
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
local Charm = require("./Charm")
|
||||
local Vide = require("./Vide")
|
||||
|
||||
--[[
|
||||
Returns a reactive source that subscribes to the current value of the given
|
||||
signal or getter function.
|
||||
|
||||
@param getter The getter function to subscribe to.
|
||||
@return The reactive source.
|
||||
]]
|
||||
local function useSignalState<State>(getter: () -> State): () -> State
|
||||
local state = Vide.source(getter())
|
||||
|
||||
Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ()))
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
return {
|
||||
useSignalState = useSignalState,
|
||||
useAtom = useSignalState,
|
||||
}
|
||||
7
packages/vide-charm/test/init.luau
Normal file
7
packages/vide-charm/test/init.luau
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
local test = require("../../tests/test")
|
||||
|
||||
test.describe("useSignalState", function()
|
||||
require("@self/useSignalState.test")
|
||||
end)
|
||||
|
||||
return {}
|
||||
59
packages/vide-charm/test/useSignalState.test.luau
Normal file
59
packages/vide-charm/test/useSignalState.test.luau
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
local Charm = require("../Charm")
|
||||
local Vide = require("../Vide")
|
||||
local VideCharm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test("tracks signal state", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local source: () -> number
|
||||
local cleanup = Vide.root(function()
|
||||
source = VideCharm.useSignalState(getState)
|
||||
end)
|
||||
assert(source() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(source() == 1, "should return 1")
|
||||
cleanup()
|
||||
end)
|
||||
|
||||
test("accepts a selector", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local source: () -> number
|
||||
local cleanup = Vide.root(function()
|
||||
source = VideCharm.useSignalState(function()
|
||||
return 2 * getState()
|
||||
end)
|
||||
end)
|
||||
assert(source() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(source() == 2, "should return 2")
|
||||
cleanup()
|
||||
end)
|
||||
|
||||
test("updates source in untracked", function()
|
||||
local getOuter, setOuter = Charm.signal(nil :: any)
|
||||
local getInner, setInner = Charm.signal(0)
|
||||
local spyCalls = 0
|
||||
local cleanup = Vide.root(function()
|
||||
return Vide.show(VideCharm.useSignalState(getOuter), function()
|
||||
-- Charm effects created in Vide scopes triggered by useSignalState might
|
||||
-- incorrectly get treated as an inner effect in useSignalState. This test
|
||||
-- ensures that doesn't happen.
|
||||
Vide.cleanup(Charm.effect(function()
|
||||
getInner()
|
||||
spyCalls += 1
|
||||
end))
|
||||
return nil
|
||||
end)
|
||||
end)
|
||||
assert(spyCalls == 0, "should not call spy initially")
|
||||
setOuter(true)
|
||||
assert(spyCalls == 1, "should call spy when visible")
|
||||
setInner(1)
|
||||
assert(spyCalls == 2, "spy should run when counter updates")
|
||||
setOuter({}) -- set to a truthy value to trigger an outer effect re-run
|
||||
setInner(2)
|
||||
assert(spyCalls == 3, "spy should still run when counter updates")
|
||||
cleanup()
|
||||
end)
|
||||
|
||||
return {}
|
||||
17
packages/vide-charm/wally.toml
Normal file
17
packages/vide-charm/wally.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "littensy/vide-charm"
|
||||
version = "0.4.0-rc.5"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = [
|
||||
"node_modules",
|
||||
"test",
|
||||
"src/index.d.ts",
|
||||
"package.json",
|
||||
"Vide.luau",
|
||||
"Charm.luau",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
Charm = "littensy/charm@^0.11.0-rc.6"
|
||||
Vide = "centau/vide@^0.4"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "@rbxts/vide-charm",
|
||||
"version": "0.3.0",
|
||||
"description": "Vide bindings for Charm",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/charm": "workspace:*",
|
||||
"@rbxts/vide": "*"
|
||||
}
|
||||
}
|
||||
9
packages/vide/src/index.d.ts
vendored
9
packages/vide/src/index.d.ts
vendored
|
|
@ -1,9 +0,0 @@
|
|||
import { Selector } from "@rbxts/charm";
|
||||
|
||||
/**
|
||||
* Subscribes to the state of an atom and returns a Vide source.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @returns The reactive source.
|
||||
*/
|
||||
export function useAtom<T>(callback: Selector<T>): () => T;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
local Charm = require(script.Parent.Charm)
|
||||
local Vide = if script.Parent:FindFirstChild("vide")
|
||||
then require(script.Parent.vide.src) :: never
|
||||
else require(script.Parent.Vide)
|
||||
|
||||
--[=[
|
||||
Subscribes to the state of an atom and returns a Vide source.
|
||||
|
||||
@param callback The atom or selector to subscribe to.
|
||||
@return The reactive source.
|
||||
]=]
|
||||
local function useAtom<State>(callback: () -> State): () -> State
|
||||
local state = Vide.source(callback())
|
||||
local unsubscribe = Charm.subscribe(callback, function(value)
|
||||
task.spawn(state :: (State) -> State, value)
|
||||
end)
|
||||
|
||||
Vide.cleanup(unsubscribe)
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
return {
|
||||
useAtom = useAtom,
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "littensy/vide-charm"
|
||||
version = "0.3.0"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
|
||||
[dependencies]
|
||||
charm = "littensy/charm@^0.10"
|
||||
Vide = "centau/vide@^0.3"
|
||||
Loading…
Add table
Add a link
Reference in a new issue