mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: use alien-signals
This commit is contained in:
parent
d7a1f62c3c
commit
861ee5acd7
76 changed files with 3112 additions and 1862 deletions
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
|
|
@ -1,13 +1,6 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
|
@ -17,9 +10,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: true
|
||||
|
||||
- name: Install Rokit
|
||||
uses: CompeyDev/setup-rokit@v0.1.2
|
||||
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- name: Install Wally dependencies
|
||||
run: pnpm wally
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -10,9 +10,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install Rokit
|
||||
uses: CompeyDev/setup-rokit@v0.1.2
|
||||
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- name: Build models
|
||||
run: pnpm build
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,3 +11,4 @@
|
|||
*.tgz
|
||||
*.rbxm
|
||||
.DS_Store
|
||||
roblox.d.luau
|
||||
|
|
|
|||
5
.luaurc
5
.luaurc
|
|
@ -1,3 +1,6 @@
|
|||
{
|
||||
"languageMode": "strict"
|
||||
"languageMode": "strict",
|
||||
"lint": {
|
||||
"LocalShadow": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
bin/check.sh
21
bin/check.sh
|
|
@ -1,29 +1,18 @@
|
|||
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua
|
||||
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
|
||||
|
||||
rojo sourcemap -o sourcemap.json
|
||||
|
||||
check() {
|
||||
echo "Checking $1"
|
||||
|
||||
luau-lsp analyze \
|
||||
--defs=bin/roblox.d.luau \
|
||||
--defs=bin/testez.d.luau \
|
||||
--flag:LuauFixIndexerSubtypingOrdering=true \
|
||||
--flag:LuauInstantiateInSubtyping=true \
|
||||
--sourcemap=sourcemap.json \
|
||||
--ignore="**/_Index/**" \
|
||||
$1
|
||||
packages tests
|
||||
|
||||
selene $1
|
||||
stylua --check $1
|
||||
}
|
||||
selene packages tests
|
||||
|
||||
stylua --check packages tests
|
||||
|
||||
eslint packages
|
||||
|
||||
for i in packages/*/src; do
|
||||
check $i
|
||||
done
|
||||
|
||||
check tests
|
||||
|
||||
rm bin/roblox.d.luau
|
||||
|
|
|
|||
1
bin/test.sh
Normal file
1
bin/test.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
lute tests/spec.server.luau
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
declare function afterAll(callback: () -> ()): ()
|
||||
declare function afterEach(callback: () -> ()): ()
|
||||
|
||||
declare function beforeAll(callback: () -> ()): ()
|
||||
declare function beforeEach(callback: () -> ()): ()
|
||||
|
||||
declare function describe(phrase: string, callback: () -> ()): ()
|
||||
declare function describeFOCUS(phrase: string, callback: () -> ()): ()
|
||||
declare function fdescribe(phrase: string, callback: () -> ()): ()
|
||||
declare function describeSKIP(phrase: string, callback: () -> ()): ()
|
||||
declare function xdescribe(phrase: string, callback: () -> ()): ()
|
||||
|
||||
declare function expect(value: any): any
|
||||
|
||||
declare function FIXME(optionalMessage: string?): ()
|
||||
declare function FOCUS(): ()
|
||||
declare function SKIP(): ()
|
||||
|
||||
declare function it(phrase: string, callback: () -> ()): ()
|
||||
declare function itFOCUS(phrase: string, callback: () -> ()): ()
|
||||
declare function fit(phrase: string, callback: () -> ()): ()
|
||||
declare function itSKIP(phrase: string, callback: () -> ()): ()
|
||||
declare function xit(phrase: string, callback: () -> ()): ()
|
||||
declare function itFIXME(phrase: string, callback: () -> ()): ()
|
||||
|
|
@ -7,5 +7,4 @@ realm = "shared"
|
|||
[dev-dependencies]
|
||||
React = "jsdotlua/react@17.2.0"
|
||||
ReactRoblox = "jsdotlua/react-roblox@17.2.0"
|
||||
TestEZ = "roblox/testez@0.4.1"
|
||||
Vide = "centau/vide@0.3.1"
|
||||
|
|
|
|||
|
|
@ -20,5 +20,5 @@
|
|||
"roblox-ts": "^3.0.0",
|
||||
"typescript": "~5.5.4"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4"
|
||||
"packageManager": "pnpm@10.10.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ local function client(options: ClientOptions): ClientSyncer
|
|||
for index = 1, select("#", ...) do
|
||||
local payload: SyncPayload = select(index, ...)
|
||||
|
||||
Charm.batch(function()
|
||||
Charm.batched(function()
|
||||
if payload.type == "init" then
|
||||
hydrate(payload.data)
|
||||
elseif not ignoreUnhydrated or hydrated then
|
||||
|
|
|
|||
|
|
@ -1,42 +1,54 @@
|
|||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
local signals = require("./signals")
|
||||
local types = require("./types")
|
||||
|
||||
type Atom<T> = types.Atom<T>
|
||||
type AtomOptions<T> = types.AtomOptions<T>
|
||||
|
||||
--[=[
|
||||
Creates a new atom with the given state.
|
||||
local signal = signals.signal
|
||||
local pauseTracking = signals.pauseTracking
|
||||
local resumeTracking = signals.resumeTracking
|
||||
|
||||
local function atomOper<T>(source: (...T) -> T, equals: ((T, T) -> boolean)?, ...: ((T) -> T) | T): T
|
||||
if select("#", ...) == 0 then
|
||||
return source()
|
||||
end
|
||||
|
||||
local transform = (...)
|
||||
local currentState = (source :: any).value
|
||||
local nextState: T
|
||||
|
||||
-- If the transform is a function, call it with the current state and pause
|
||||
-- tracking to avoid adding signals called by the transform.
|
||||
if type(transform) == "function" then
|
||||
pauseTracking()
|
||||
local success, result = pcall(transform, currentState)
|
||||
resumeTracking()
|
||||
|
||||
if not success then
|
||||
error(result, 2)
|
||||
end
|
||||
|
||||
nextState = result
|
||||
else
|
||||
nextState = transform
|
||||
end
|
||||
|
||||
if equals and equals(currentState, nextState) then
|
||||
return nextState
|
||||
end
|
||||
|
||||
source(nextState)
|
||||
|
||||
return nextState
|
||||
end
|
||||
|
||||
@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 source = signal(state)
|
||||
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
|
||||
return function(...)
|
||||
return atomOper(source, equals, ...)
|
||||
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
|
||||
|
|
|
|||
16
packages/charm/src/batched.luau
Normal file
16
packages/charm/src/batched.luau
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
local signals = require("./signals")
|
||||
|
||||
local startBatch = signals.startBatch
|
||||
local endBatch = signals.endBatch
|
||||
|
||||
local function batched(callback: () -> ())
|
||||
startBatch()
|
||||
local success, result: any = pcall(callback)
|
||||
endBatch()
|
||||
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
end
|
||||
|
||||
return batched
|
||||
|
|
@ -1,37 +1,13 @@
|
|||
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>
|
||||
local signals = require("./signals")
|
||||
|
||||
--[=[
|
||||
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.
|
||||
local computedImpl = signals.computed
|
||||
|
||||
@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 computed<T>(selector: (previous: T?) -> T): () -> T
|
||||
local getter = computedImpl(selector)
|
||||
|
||||
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)
|
||||
return function()
|
||||
return getter()
|
||||
end
|
||||
end
|
||||
|
||||
store.connect(dependencies, listener, computedAtom)
|
||||
|
||||
return computedAtom
|
||||
end
|
||||
|
||||
return computed
|
||||
|
|
|
|||
|
|
@ -1,53 +1,30 @@
|
|||
local store = require(script.Parent.store)
|
||||
local effectScope = require("./effectScope")
|
||||
local signals = require("./signals")
|
||||
|
||||
type Cleanup = () -> ()
|
||||
local effectImpl = signals.effect
|
||||
|
||||
--[=[
|
||||
Runs the given callback immediately and whenever any atom it depends on
|
||||
changes. Returns a cleanup function that unsubscribes the callback.
|
||||
local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> ()
|
||||
local disposed = false
|
||||
local dispose: () -> ()
|
||||
|
||||
@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)
|
||||
local function disposeSafe()
|
||||
disposed = true
|
||||
if dispose then
|
||||
dispose()
|
||||
end
|
||||
end
|
||||
|
||||
function disconnect()
|
||||
if disconnected then
|
||||
return
|
||||
dispose = effectScope(function()
|
||||
effectImpl(function()
|
||||
return callback(disposeSafe)
|
||||
end)
|
||||
end)
|
||||
|
||||
if disposed then
|
||||
dispose()
|
||||
end
|
||||
|
||||
disconnected = true
|
||||
store.disconnect(dependencies, listener)
|
||||
|
||||
if cleanup then
|
||||
cleanup()
|
||||
end
|
||||
return disposeSafe
|
||||
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
|
||||
return effect
|
||||
|
|
|
|||
13
packages/charm/src/effectScope.luau
Normal file
13
packages/charm/src/effectScope.luau
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
local signals = require("./signals")
|
||||
|
||||
local effectScopeImpl = signals.effectScope
|
||||
|
||||
local function effectScope(callback: () -> ...() -> ()?): () -> ()
|
||||
local dispose = effectScopeImpl(callback)
|
||||
|
||||
return function()
|
||||
dispose()
|
||||
end
|
||||
end
|
||||
|
||||
return effectScope
|
||||
116
packages/charm/src/index.d.ts
vendored
116
packages/charm/src/index.d.ts
vendored
|
|
@ -1,7 +1,7 @@
|
|||
export = Charm;
|
||||
export as namespace Charm;
|
||||
|
||||
type Cleanup = () => void;
|
||||
type Dispose = () => void;
|
||||
|
||||
declare namespace Charm {
|
||||
/**
|
||||
|
|
@ -11,7 +11,8 @@ declare namespace Charm {
|
|||
* @param state The next state or a function that produces the next state.
|
||||
* @returns The current state.
|
||||
*/
|
||||
interface Atom<State> extends Selector<State> {
|
||||
interface Atom<State> {
|
||||
(): State;
|
||||
(nextState: State | ((prevState: State) => State)): State;
|
||||
}
|
||||
|
||||
|
|
@ -23,19 +24,10 @@ declare namespace Charm {
|
|||
*/
|
||||
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;
|
||||
type StateOf<T> = T extends () => infer State ? State : never;
|
||||
|
||||
interface AtomOptions<State> {
|
||||
/**
|
||||
|
|
@ -63,21 +55,34 @@ declare namespace Charm {
|
|||
* 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>;
|
||||
function computed<State>(callback: (prevResult?: State) => State): () => 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.
|
||||
* called with the current state and the previous state only 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;
|
||||
function subscribe<State>(callback: () => State, listener: (state: State, prevState: State) => void): Dispose;
|
||||
|
||||
/**
|
||||
* Subscribes to changes in the given atom or selector. The callback is
|
||||
* called with the current state and the previous state immediately after
|
||||
* subscribing, and again when 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 listen<State>(
|
||||
callback: () => State,
|
||||
listener: (state: State, prevState: State, dispose: Dispose) => void,
|
||||
): Dispose;
|
||||
|
||||
/**
|
||||
* Runs the given callback immediately and whenever any atom it depends on
|
||||
|
|
@ -86,25 +91,7 @@ declare namespace Charm {
|
|||
* @param callback The function to run.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function effect(callback: () => 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>;
|
||||
function effect(callback: (dispose: Dispose) => Dispose | void): Dispose;
|
||||
|
||||
/**
|
||||
* Runs the given function and schedules listeners to be notified only once
|
||||
|
|
@ -112,24 +99,16 @@ declare namespace Charm {
|
|||
*
|
||||
* @param callback The function to run.
|
||||
*/
|
||||
function batch(callback: () => void): void;
|
||||
function batched(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.
|
||||
* Returns the result of the function without subscribing to changes.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
* @returns A tuple containing the captured atoms and the result of the function.
|
||||
* @param callback The atom or selector to get the state of.
|
||||
* @param args Arguments to pass to the function.
|
||||
* @returns The current state.
|
||||
*/
|
||||
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;
|
||||
function untracked<State, Args extends unknown[]>(callback: (...args: Args) => State, ...args: Args): State;
|
||||
|
||||
/**
|
||||
* Creates an instance of `factory` for each item in the atom's state, and
|
||||
|
|
@ -141,47 +120,46 @@ declare namespace Charm {
|
|||
* @returns A function that unsubscribes all instances.
|
||||
*/
|
||||
function observe<Item>(
|
||||
callback: Selector<readonly Item[]>,
|
||||
factory: (item: Item, index: number) => Cleanup | void,
|
||||
): Cleanup;
|
||||
callback: () => readonly Item[],
|
||||
factory: (item: Item, index: number) => Dispose | void,
|
||||
): Dispose;
|
||||
|
||||
function observe<Key, Item>(
|
||||
callback: Selector<Map<Key, Item> | ReadonlyMap<Key, Item>>,
|
||||
factory: (item: Item, key: Key) => Cleanup | void,
|
||||
): Cleanup;
|
||||
callback: () => Map<Key, Item> | ReadonlyMap<Key, Item>,
|
||||
factory: (item: Item, key: Key) => Dispose | void,
|
||||
): Dispose;
|
||||
|
||||
function observe<Key extends string | number | symbol, Item>(
|
||||
callback: Selector<{ readonly [P in Key]: Item }>,
|
||||
factory: (item: Item, key: Key) => Cleanup | void,
|
||||
): Cleanup;
|
||||
callback: () => { readonly [P in Key]: Item },
|
||||
factory: (item: Item, key: Key) => Dispose | void,
|
||||
): Dispose;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* When the atom changes, the `mapper` only runs for the changed entries.
|
||||
*
|
||||
* @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[]>,
|
||||
callback: () => readonly V0[],
|
||||
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
): () => ReadonlyMap<K1, V1>;
|
||||
|
||||
function mapped<V0, V1>(
|
||||
callback: Selector<readonly V0[]>,
|
||||
callback: () => readonly V0[],
|
||||
mapper: (value: V0, index: number) => V1,
|
||||
): Selector<readonly V1[]>;
|
||||
): () => readonly V1[];
|
||||
|
||||
function mapped<K0, V0, K1 = K0, V1 = V0>(
|
||||
callback: Selector<Map<K0, V0> | ReadonlyMap<K0, V0>>,
|
||||
callback: () => Map<K0, V0> | ReadonlyMap<K0, V0>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
): () => ReadonlyMap<K1, V1>;
|
||||
|
||||
function mapped<K0 extends string | number | symbol, V0, K1 = K0, V1 = V0>(
|
||||
callback: Selector<{ readonly [P in K0]: V0 }>,
|
||||
callback: () => { readonly [P in K0]: V0 },
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
): () => ReadonlyMap<K1, V1>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,7 @@
|
|||
local atom = require(script.atom)
|
||||
local computed = require(script.computed)
|
||||
local effect = require(script.effect)
|
||||
local mapped = require(script.mapped)
|
||||
local observe = require(script.observe)
|
||||
local store = require(script.store)
|
||||
local subscribe = require(script.subscribe)
|
||||
local types = require(script.types)
|
||||
local lib = require(script.lib)
|
||||
|
||||
export type Atom<State> = types.Atom<State>
|
||||
export type Selector<State> = types.Selector<State>
|
||||
export type Molecule<State> = types.Selector<State>
|
||||
export type SyncPayload = types.SyncPayload
|
||||
export type Atom<T = any> = lib.Atom<T>
|
||||
export type AtomOptions<T = any> = lib.AtomOptions<T>
|
||||
export type Selector<T = any> = lib.Selector<T>
|
||||
|
||||
return {
|
||||
atom = atom,
|
||||
computed = computed,
|
||||
effect = effect,
|
||||
mapped = mapped,
|
||||
observe = observe,
|
||||
subscribe = subscribe,
|
||||
batch = store.batch,
|
||||
capture = store.capture,
|
||||
isAtom = store.isAtom,
|
||||
notify = store.notify,
|
||||
peek = store.peek,
|
||||
}
|
||||
return lib
|
||||
|
|
|
|||
28
packages/charm/src/lib.luau
Normal file
28
packages/charm/src/lib.luau
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
local atom = require("./atom")
|
||||
local batched = require("./batched")
|
||||
local computed = require("./computed")
|
||||
local effect = require("./effect")
|
||||
local effectScope = require("./effectScope")
|
||||
local listen = require("./listen")
|
||||
local mapped = require("./mapped")
|
||||
local observe = require("./observe")
|
||||
local subscribe = require("./subscribe")
|
||||
local types = require("./types")
|
||||
local untracked = require("./untracked")
|
||||
|
||||
export type Atom<T = any> = types.Atom<T>
|
||||
export type AtomOptions<T = any> = types.AtomOptions<T>
|
||||
export type Selector<T = any> = types.Selector<T>
|
||||
|
||||
return {
|
||||
atom = atom,
|
||||
batched = batched,
|
||||
computed = computed,
|
||||
effect = effect,
|
||||
effectScope = effectScope,
|
||||
listen = listen,
|
||||
mapped = mapped,
|
||||
observe = observe,
|
||||
subscribe = subscribe,
|
||||
untracked = untracked,
|
||||
}
|
||||
40
packages/charm/src/listen.luau
Normal file
40
packages/charm/src/listen.luau
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
local effectScope = require("./effectScope")
|
||||
local signals = require("./signals")
|
||||
local spawn = require("./spawn")
|
||||
|
||||
local effect = signals.effect
|
||||
local pauseTracking = signals.pauseTracking
|
||||
local resumeTracking = signals.resumeTracking
|
||||
|
||||
local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> ()
|
||||
local disposed = false
|
||||
local current: T?
|
||||
local dispose: () -> ()
|
||||
|
||||
local function disposeSafe()
|
||||
disposed = true
|
||||
if dispose then
|
||||
dispose()
|
||||
end
|
||||
end
|
||||
|
||||
dispose = effectScope(function()
|
||||
effect(function()
|
||||
local value = selector()
|
||||
local previous = current
|
||||
current = value
|
||||
|
||||
pauseTracking()
|
||||
spawn(listener, value, previous, disposeSafe)
|
||||
resumeTracking()
|
||||
end)
|
||||
end)
|
||||
|
||||
if disposed then
|
||||
dispose()
|
||||
end
|
||||
|
||||
return disposeSafe
|
||||
end
|
||||
|
||||
return listen
|
||||
|
|
@ -1,71 +1,127 @@
|
|||
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>
|
||||
local computed = require("./computed")
|
||||
local signals = require("./signals")
|
||||
|
||||
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 }>)
|
||||
local pauseTracking = signals.pauseTracking
|
||||
local resumeTracking = signals.resumeTracking
|
||||
|
||||
--[=[
|
||||
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.
|
||||
type MappedKey<VI, KO, VO> = {
|
||||
key: KO,
|
||||
value: VO,
|
||||
}
|
||||
|
||||
@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 mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, transform: (item: VI, key: KI) -> (VO, KO?)): () -> { [KO]: VO }
|
||||
local mappedKeys: { [KI]: MappedKey<VI, KO, VO> } = {}
|
||||
local previousItems: { [KI]: VI }?
|
||||
|
||||
local function listener(items: { [K0]: V0 })
|
||||
local mappedAtom = mappedAtomRef.current
|
||||
local function getTransform(value: VI, key: KI): (VO, KO?)
|
||||
pauseTracking()
|
||||
local success, valueOut, keyOut = pcall(transform, value, key)
|
||||
resumeTracking()
|
||||
|
||||
if not mappedAtom then
|
||||
return unsubscribe()
|
||||
if not success then
|
||||
error(`observe() transform function failed: {valueOut}`)
|
||||
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
|
||||
return valueOut, keyOut
|
||||
end
|
||||
if mappedItems[newKey :: K1] ~= newItem then
|
||||
mappedItems[newKey :: K1] = newItem :: V1
|
||||
else
|
||||
mappedKeys[newKey] = key
|
||||
|
||||
return computed(function(previousMappedItems)
|
||||
local currentItems = selector()
|
||||
|
||||
-- If this is the initial run, create a map of the items and store the
|
||||
-- updated keys and values.
|
||||
if not previousItems or not previousMappedItems then
|
||||
local mappedItems: { [KO]: VO } = {}
|
||||
previousItems = currentItems
|
||||
|
||||
for key, value in currentItems do
|
||||
local valueOut, keyOut = getTransform(value, key)
|
||||
local keyOut = if keyOut ~= nil then keyOut else key :: never
|
||||
|
||||
if valueOut ~= nil then
|
||||
mappedKeys[key] = { key = keyOut, value = valueOut }
|
||||
mappedItems[keyOut] = valueOut
|
||||
end
|
||||
end
|
||||
|
||||
for key in next, prevMappedItems do
|
||||
if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then
|
||||
mappedItems[key] = nil
|
||||
return mappedItems
|
||||
end
|
||||
|
||||
local mappedItems = table.clone(previousMappedItems)
|
||||
|
||||
-- Update and remove mapped keys if the source values changed
|
||||
for key, oldValue in previousItems do
|
||||
local currentValue = currentItems[key]
|
||||
local mappedKey = mappedKeys[key]
|
||||
|
||||
-- Remove the key if the source value changed from non-nil to nil
|
||||
if currentValue == nil then
|
||||
mappedKeys[key] = nil
|
||||
if mappedKey then
|
||||
mappedItems[mappedKey.key] = nil
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
-- Do not call transform if the value has not changed
|
||||
if currentValue == oldValue then
|
||||
continue
|
||||
end
|
||||
|
||||
local valueOut, keyOut = getTransform(currentValue, key)
|
||||
local keyOut = if keyOut ~= nil then keyOut else key :: never
|
||||
|
||||
-- If there is no previous mappedKey, then this item was filtered
|
||||
-- out to optimize memory. We need to check if the value is nil to
|
||||
-- determine if we should add it back to the list.
|
||||
if not mappedKey then
|
||||
if valueOut ~= nil then
|
||||
mappedKeys[key] = { key = keyOut, value = valueOut }
|
||||
mappedItems[keyOut] = valueOut
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
-- If the mapped value is nil, then delete mappedKey to save memory.
|
||||
-- This is treated the same as setting mappedKey.value to nil.
|
||||
if valueOut == nil then
|
||||
mappedKeys[key] = nil
|
||||
mappedItems[mappedKey.key] = nil
|
||||
continue
|
||||
end
|
||||
|
||||
-- If the key changed since the last transform call, remove
|
||||
-- the old key and update mappedKey.
|
||||
if keyOut ~= mappedKey.key then
|
||||
mappedItems[mappedKey.key] = nil
|
||||
mappedKey.key = keyOut
|
||||
end
|
||||
|
||||
mappedKey.value = valueOut
|
||||
mappedItems[keyOut] = valueOut
|
||||
end
|
||||
|
||||
-- Add new keys if the value was not present in the previous state
|
||||
for key, currentValue in currentItems do
|
||||
local oldValue = previousItems[key]
|
||||
|
||||
if oldValue ~= nil then
|
||||
continue
|
||||
end
|
||||
|
||||
local valueOut, keyOut = getTransform(currentValue, key)
|
||||
local keyOut = if keyOut ~= nil then keyOut else key :: never
|
||||
|
||||
if valueOut ~= nil then
|
||||
mappedKeys[key] = { key = keyOut, value = valueOut }
|
||||
mappedItems[keyOut] = valueOut
|
||||
end
|
||||
end
|
||||
|
||||
prevMappedItems = mappedItems
|
||||
mappedAtom(mappedItems)
|
||||
end
|
||||
previousItems = currentItems
|
||||
|
||||
unsubscribe = subscribe(callback, listener)
|
||||
|
||||
store.peek(function(): ()
|
||||
listener(callback())
|
||||
return mappedItems
|
||||
end)
|
||||
|
||||
return mappedAtom
|
||||
end
|
||||
|
||||
return mapped :: Map
|
||||
return mapped
|
||||
|
|
|
|||
|
|
@ -1,49 +1,37 @@
|
|||
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 listen = require("./listen")
|
||||
|
||||
local function noop() end
|
||||
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> ()
|
||||
local cleanups: { [K]: () -> ()? } = {}
|
||||
|
||||
--[=[
|
||||
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.
|
||||
local unsub = listen(selector, function(currentItems, previousItems)
|
||||
if not previousItems then
|
||||
for key, value in currentItems do
|
||||
cleanups[key] = observer(value, key)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@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()
|
||||
for key in previousItems do
|
||||
local cleanup = cleanups[key]
|
||||
if currentItems[key] == nil and cleanup then
|
||||
cleanups[key] = nil
|
||||
cleanup()
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in next, state do
|
||||
if not connections[key] then
|
||||
connections[key] = factory(value, key) or noop
|
||||
for key, value in currentItems do
|
||||
if previousItems[key] == nil then
|
||||
cleanups[key] = observer(value, key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local unsubscribe = subscribe(callback, listener)
|
||||
|
||||
store.peek(function(): ()
|
||||
listener(callback())
|
||||
end)
|
||||
|
||||
return function()
|
||||
unsubscribe()
|
||||
for _, disconnect in next, connections do
|
||||
disconnect()
|
||||
unsub()
|
||||
for _, cleanup in cleanups do
|
||||
(cleanup :: () -> ())()
|
||||
end
|
||||
table.clear(connections)
|
||||
table.clear(cleanups)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
400
packages/charm/src/signals.luau
Normal file
400
packages/charm/src/signals.luau
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
--[=[
|
||||
alien-signals 2.0.1 implementation with effect cleanups, adopted from
|
||||
https://github.com/Nicell/alien-signals-luau
|
||||
https://github.com/stackblitz/alien-signals
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Johnson Chu, Nicholas Winans
|
||||
|
||||
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.
|
||||
]=]
|
||||
|
||||
local system = require("./system")
|
||||
|
||||
export type ReactiveFlags = system.ReactiveFlags
|
||||
export type ReactiveNode = system.ReactiveNode
|
||||
export type Link = system.Link
|
||||
|
||||
local ReactiveFlags = system.ReactiveFlags
|
||||
local createReactiveSystem = system.createReactiveSystem
|
||||
|
||||
local NONE = 0b0000000
|
||||
local MUTABLE = 0b0000001
|
||||
local WATCHING = 0b0000010
|
||||
local DIRTY = 0b0010000
|
||||
local PENDING = 0b0100000
|
||||
local QUEUED = 0b1000000
|
||||
|
||||
type EffectScope = ReactiveNode & {
|
||||
cleanup: (() -> ())?,
|
||||
}
|
||||
|
||||
type Effect = ReactiveNode & {
|
||||
fn: () -> (() -> ())?,
|
||||
cleanup: (() -> ())?,
|
||||
}
|
||||
|
||||
type Computed<T = any> = ReactiveNode & {
|
||||
value: T?,
|
||||
getter: (previousValue: T?) -> T,
|
||||
}
|
||||
|
||||
type Signal<T = any> = ReactiveNode & {
|
||||
previousValue: T,
|
||||
value: T,
|
||||
}
|
||||
|
||||
local pauseStack: { ReactiveNode? } = {}
|
||||
local pauseStackCount = 0
|
||||
local queuedEffects: { Effect | EffectScope } = {}
|
||||
|
||||
local link: (dep: ReactiveNode, sub: ReactiveNode) -> ()
|
||||
local unlink: (link: Link, sub: ReactiveNode?) -> Link?
|
||||
local propagate: (link: Link) -> ()
|
||||
local checkDirty: (link: Link, sub: ReactiveNode) -> boolean
|
||||
local endTracking: (sub: ReactiveNode) -> ()
|
||||
local startTracking: (sub: ReactiveNode) -> ()
|
||||
local shallowPropagate: (link: Link) -> ()
|
||||
|
||||
local batchDepth = 0
|
||||
|
||||
local notifyIndex = 0
|
||||
local queuedEffectsLength = 0
|
||||
local activeSub: ReactiveNode?
|
||||
local activeScope: EffectScope?
|
||||
|
||||
local function getCurrentSub(): ReactiveNode?
|
||||
return activeSub
|
||||
end
|
||||
|
||||
local function setCurrentSub(sub: ReactiveNode?)
|
||||
local prevSub = activeSub
|
||||
activeSub = sub
|
||||
return prevSub
|
||||
end
|
||||
|
||||
local function getCurrentScope(): EffectScope?
|
||||
return activeScope
|
||||
end
|
||||
|
||||
local function setCurrentScope(scope: EffectScope?)
|
||||
local prevScope = activeScope
|
||||
activeScope = scope
|
||||
return prevScope
|
||||
end
|
||||
|
||||
local function pauseTracking()
|
||||
pauseStackCount += 1
|
||||
pauseStack[pauseStackCount] = activeSub
|
||||
activeSub = nil
|
||||
end
|
||||
|
||||
local function resumeTracking()
|
||||
activeSub = pauseStack[pauseStackCount]
|
||||
pauseStack[pauseStackCount] = nil
|
||||
pauseStackCount -= 1
|
||||
end
|
||||
|
||||
local function cleanup(e: ReactiveNode)
|
||||
local e = e :: EffectScope
|
||||
local cleanup = e.cleanup
|
||||
if cleanup then
|
||||
e.cleanup = nil
|
||||
local prev = setCurrentSub(nil)
|
||||
local success, result: unknown = pcall(cleanup)
|
||||
setCurrentSub(prev)
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function run(e: EffectScope, flags: ReactiveFlags): ()
|
||||
if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(e.deps :: Link, e)) then
|
||||
cleanup(e)
|
||||
local prev = setCurrentSub(e)
|
||||
startTracking(e)
|
||||
local success, result = pcall((e :: Effect).fn)
|
||||
setCurrentSub(prev)
|
||||
endTracking(e)
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
e.cleanup = result
|
||||
return
|
||||
elseif bit32.band(flags, PENDING) ~= 0 then
|
||||
e.flags = bit32.band(flags, bit32.bnot(PENDING))
|
||||
end
|
||||
local link = e.deps
|
||||
while link do
|
||||
local dep = link.dep
|
||||
local depFlags = dep.flags
|
||||
if bit32.band(depFlags, QUEUED) ~= 0 then
|
||||
depFlags = bit32.band(depFlags, bit32.bnot(QUEUED))
|
||||
dep.flags = depFlags
|
||||
run(dep :: EffectScope, depFlags)
|
||||
end
|
||||
link = link.nextDep
|
||||
end
|
||||
end
|
||||
|
||||
local function flush(): ()
|
||||
while notifyIndex < queuedEffectsLength do
|
||||
notifyIndex += 1
|
||||
local effect = queuedEffects[notifyIndex] :: EffectScope
|
||||
queuedEffects[notifyIndex] = nil
|
||||
effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED))
|
||||
run(effect, effect.flags)
|
||||
end
|
||||
notifyIndex = 0
|
||||
queuedEffectsLength = 0
|
||||
end
|
||||
|
||||
local function getBatchDepth(): number
|
||||
return batchDepth
|
||||
end
|
||||
|
||||
local function startBatch()
|
||||
batchDepth += 1
|
||||
end
|
||||
|
||||
local function endBatch()
|
||||
batchDepth -= 1
|
||||
if batchDepth == 0 then
|
||||
flush()
|
||||
end
|
||||
end
|
||||
|
||||
local function updateComputed(c: Computed): boolean
|
||||
local prevSub = setCurrentSub(c)
|
||||
startTracking(c)
|
||||
local oldValue = c.value
|
||||
local success, result = pcall(c.getter, oldValue)
|
||||
setCurrentSub(prevSub)
|
||||
endTracking(c)
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
if oldValue ~= result then
|
||||
c.value = result
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function updateSignal(s: Signal, value: any): boolean
|
||||
s.flags = MUTABLE
|
||||
if s.previousValue ~= value then
|
||||
s.previousValue = value
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function signalOper<T>(this: Signal<T>, ...: T): T?
|
||||
if select("#", ...) > 0 then
|
||||
local newValue = (...)
|
||||
if this.value ~= newValue then
|
||||
this.value = newValue
|
||||
this.flags = bit32.bor(MUTABLE, DIRTY)
|
||||
local subs = this.subs
|
||||
if subs then
|
||||
propagate(subs)
|
||||
if batchDepth == 0 then
|
||||
flush()
|
||||
end
|
||||
end
|
||||
end
|
||||
return
|
||||
else
|
||||
local value = this.value
|
||||
if bit32.band(this.flags, DIRTY) ~= 0 then
|
||||
if updateSignal(this, value) then
|
||||
local subs = this.subs
|
||||
if subs then
|
||||
shallowPropagate(subs)
|
||||
end
|
||||
end
|
||||
end
|
||||
if activeSub then
|
||||
link(this, activeSub)
|
||||
end
|
||||
return value
|
||||
end
|
||||
end
|
||||
|
||||
local function computedOper<T>(this: Computed<T>): T
|
||||
local flags = this.flags
|
||||
if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this)) then
|
||||
if updateComputed(this) then
|
||||
local subs = this.subs
|
||||
if subs then
|
||||
shallowPropagate(subs)
|
||||
end
|
||||
end
|
||||
elseif bit32.band(flags, PENDING) ~= 0 then
|
||||
this.flags = bit32.band(flags, bit32.bnot(PENDING))
|
||||
end
|
||||
if activeSub then
|
||||
link(this, activeSub)
|
||||
elseif activeScope then
|
||||
link(this, activeScope)
|
||||
end
|
||||
return this.value :: T
|
||||
end
|
||||
|
||||
local function effectOper(this: EffectScope): ()
|
||||
local dep = this.deps
|
||||
while dep do
|
||||
dep = unlink(dep, this)
|
||||
end
|
||||
local sub = this.subs
|
||||
while sub do
|
||||
unlink(sub)
|
||||
sub = this.subs
|
||||
end
|
||||
this.flags = NONE
|
||||
cleanup(this)
|
||||
end
|
||||
|
||||
local signalMetatable = { __call = signalOper }
|
||||
local computedMetatable = { __call = computedOper }
|
||||
local effectMetatable = { __call = effectOper }
|
||||
|
||||
local function signal<T>(initialValue: T): ((T) -> ()) & (() -> T)
|
||||
local this: Signal<T> = {
|
||||
previousValue = initialValue,
|
||||
value = initialValue,
|
||||
flags = MUTABLE,
|
||||
}
|
||||
return setmetatable(this, signalMetatable) :: any
|
||||
end
|
||||
|
||||
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
|
||||
local this: Computed<T> = {
|
||||
flags = bit32.bor(MUTABLE, DIRTY),
|
||||
getter = getter,
|
||||
}
|
||||
return setmetatable(this, computedMetatable) :: any
|
||||
end
|
||||
|
||||
local function effect(fn: () -> ...() -> ()?): () -> ()
|
||||
local e: Effect = {
|
||||
fn = fn,
|
||||
flags = WATCHING,
|
||||
}
|
||||
if activeSub then
|
||||
link(e, activeSub)
|
||||
elseif activeScope then
|
||||
link(e, activeScope)
|
||||
end
|
||||
local prev = setCurrentSub(e)
|
||||
local success, result = pcall(fn)
|
||||
setCurrentSub(prev)
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
e.cleanup = result
|
||||
return setmetatable(e, effectMetatable) :: any
|
||||
end
|
||||
|
||||
local function effectScope(fn: () -> ...() -> ()?): () -> ()
|
||||
local e: EffectScope = {
|
||||
flags = NONE,
|
||||
}
|
||||
if activeScope then
|
||||
link(e, activeScope)
|
||||
end
|
||||
local prev = setCurrentSub(e)
|
||||
local success, result = pcall(fn)
|
||||
setCurrentSub(prev)
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
e.cleanup = result
|
||||
return setmetatable(e, effectMetatable) :: any
|
||||
end
|
||||
|
||||
local function notify(e: ReactiveNode)
|
||||
local flags = e.flags
|
||||
if bit32.band(flags, QUEUED) == 0 then
|
||||
e.flags = bit32.bor(flags, QUEUED)
|
||||
local subs = e.subs
|
||||
if subs then
|
||||
notify(subs.sub :: Effect | EffectScope)
|
||||
else
|
||||
queuedEffectsLength += 1
|
||||
queuedEffects[queuedEffectsLength] = e :: Effect | EffectScope
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function update(signal: ReactiveNode): boolean
|
||||
if (signal :: Computed).getter then
|
||||
return updateComputed(signal :: Computed)
|
||||
else
|
||||
return updateSignal(signal :: Signal, (signal :: Signal).value)
|
||||
end
|
||||
end
|
||||
|
||||
local function unwatched(signal: ReactiveNode)
|
||||
local toRemove = signal.deps
|
||||
if toRemove then
|
||||
repeat
|
||||
toRemove = unlink(toRemove, signal)
|
||||
until not toRemove
|
||||
signal.flags = bit32.bor(signal.flags, DIRTY)
|
||||
end
|
||||
cleanup(signal)
|
||||
end
|
||||
|
||||
local reactiveSystem = createReactiveSystem({
|
||||
update = update,
|
||||
notify = notify,
|
||||
unwatched = unwatched,
|
||||
})
|
||||
|
||||
link = reactiveSystem.link
|
||||
unlink = reactiveSystem.unlink
|
||||
propagate = reactiveSystem.propagate
|
||||
checkDirty = reactiveSystem.checkDirty
|
||||
endTracking = reactiveSystem.endTracking
|
||||
startTracking = reactiveSystem.startTracking
|
||||
shallowPropagate = reactiveSystem.shallowPropagate
|
||||
|
||||
return {
|
||||
ReactiveFlags = ReactiveFlags,
|
||||
createReactiveSystem = createReactiveSystem,
|
||||
getCurrentSub = getCurrentSub,
|
||||
setCurrentSub = setCurrentSub,
|
||||
getCurrentScope = getCurrentScope,
|
||||
setCurrentScope = setCurrentScope,
|
||||
getBatchDepth = getBatchDepth,
|
||||
startBatch = startBatch,
|
||||
endBatch = endBatch,
|
||||
pauseTracking = pauseTracking,
|
||||
resumeTracking = resumeTracking,
|
||||
signal = signal,
|
||||
computed = computed,
|
||||
effect = effect,
|
||||
effectScope = effectScope,
|
||||
}
|
||||
36
packages/charm/src/spawn.luau
Normal file
36
packages/charm/src/spawn.luau
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
local freeThread: thread?
|
||||
|
||||
local function run<T...>(thread: thread, fn: (T...) -> (), ...: T...): boolean
|
||||
fn(...)
|
||||
if freeThread then
|
||||
return true
|
||||
else
|
||||
freeThread = thread
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function runner()
|
||||
local quit = false
|
||||
repeat
|
||||
quit = run(coroutine.yield())
|
||||
until quit
|
||||
end
|
||||
|
||||
local function spawn<T...>(callback: (T...) -> (), ...: T...)
|
||||
local thread
|
||||
|
||||
if freeThread then
|
||||
thread = freeThread
|
||||
freeThread = nil
|
||||
elseif task then
|
||||
thread = task.spawn(runner)
|
||||
else
|
||||
thread = coroutine.create(runner)
|
||||
coroutine.resume(thread)
|
||||
end
|
||||
|
||||
coroutine.resume(thread, thread, callback, ...)
|
||||
end
|
||||
|
||||
return spawn
|
||||
|
|
@ -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,42 +1,36 @@
|
|||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type Selector<T> = types.Selector<T>
|
||||
local effectScope = require("./effectScope")
|
||||
local signals = require("./signals")
|
||||
local spawn = require("./spawn")
|
||||
|
||||
--[=[
|
||||
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.
|
||||
local effect = signals.effect
|
||||
local pauseTracking = signals.pauseTracking
|
||||
local resumeTracking = signals.resumeTracking
|
||||
|
||||
@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 subscribe<T>(selector: () -> T, listener: (T, T) -> ()): () -> ()
|
||||
local current: T
|
||||
local mount = true
|
||||
local dispose: () -> ()
|
||||
|
||||
local function handler()
|
||||
local prevState = state
|
||||
dispose = effectScope(function()
|
||||
effect(function()
|
||||
local value = selector()
|
||||
|
||||
store.disconnect(dependencies, handler)
|
||||
dependencies, state = store.capture(callback)
|
||||
if mount then
|
||||
current = value
|
||||
mount = false
|
||||
else
|
||||
local previous = current
|
||||
current = value
|
||||
|
||||
if not disconnected then
|
||||
store.connect(dependencies, handler)
|
||||
pauseTracking()
|
||||
spawn(listener, value, previous)
|
||||
resumeTracking()
|
||||
end
|
||||
|
||||
if state ~= prevState then
|
||||
listener(state, prevState)
|
||||
end
|
||||
end
|
||||
|
||||
store.connect(dependencies, handler)
|
||||
end)
|
||||
end)
|
||||
|
||||
return function()
|
||||
if not disconnected then
|
||||
disconnected = true
|
||||
store.disconnect(dependencies, handler)
|
||||
end
|
||||
dispose()
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
357
packages/charm/src/system.luau
Normal file
357
packages/charm/src/system.luau
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
--[=[
|
||||
alien-signals 2.0.1 implementation adopted from
|
||||
https://github.com/Nicell/alien-signals-luau
|
||||
https://github.com/stackblitz/alien-signals
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Johnson Chu, Nicholas Winans
|
||||
|
||||
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.
|
||||
]=]
|
||||
|
||||
export type ReactiveNode = {
|
||||
deps: Link?,
|
||||
depsTail: Link?,
|
||||
subs: Link?,
|
||||
subsTail: Link?,
|
||||
flags: ReactiveFlags,
|
||||
}
|
||||
|
||||
export type Link = {
|
||||
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 = 0b0000000
|
||||
local MUTABLE = 0b0000001
|
||||
local WATCHING = 0b0000010
|
||||
local RECURSED_CHECK = 0b0000100
|
||||
local RECURSED = 0b0001000
|
||||
local DIRTY = 0b0010000
|
||||
local PENDING = 0b0100000
|
||||
|
||||
local ReactiveFlags = {
|
||||
None = NONE,
|
||||
Mutable = MUTABLE,
|
||||
Watching = WATCHING,
|
||||
RecursedCheck = RECURSED_CHECK,
|
||||
Recursed = RECURSED,
|
||||
Dirty = DIRTY,
|
||||
Pending = PENDING,
|
||||
}
|
||||
|
||||
local function createReactiveSystem(props: {
|
||||
update: (sub: ReactiveNode) -> boolean,
|
||||
notify: (sub: ReactiveNode) -> (),
|
||||
unwatched: (sub: ReactiveNode) -> (),
|
||||
})
|
||||
local update = props.update
|
||||
local notify = props.notify
|
||||
local unwatched = props.unwatched
|
||||
|
||||
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
|
||||
local depsTail = sub.depsTail
|
||||
if depsTail then
|
||||
local link = sub.deps :: Link
|
||||
repeat
|
||||
if link == checkLink then
|
||||
return true
|
||||
end
|
||||
if link == depsTail then
|
||||
break
|
||||
end
|
||||
link = link.nextDep :: Link
|
||||
until not link
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function link(dep: ReactiveNode, sub: ReactiveNode): ()
|
||||
local prevDep = sub.depsTail
|
||||
if prevDep and prevDep.dep == dep then
|
||||
return
|
||||
end
|
||||
local nextDep: Link?
|
||||
local recursedCheck = bit32.band(sub.flags, RECURSED_CHECK) ~= 0
|
||||
if recursedCheck then
|
||||
nextDep = if prevDep then prevDep.nextDep else sub.deps
|
||||
if nextDep and nextDep.dep == dep then
|
||||
sub.depsTail = nextDep
|
||||
return
|
||||
end
|
||||
end
|
||||
local prevSub = dep.subsTail
|
||||
if prevSub and prevSub.sub == sub and (not recursedCheck or isValidLink(prevSub, sub)) then
|
||||
return
|
||||
end
|
||||
local newLink: Link = {
|
||||
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?
|
||||
local 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): ()
|
||||
local next = link.nextSub
|
||||
local stack: Stack<Link?>?
|
||||
|
||||
repeat
|
||||
local sub = link.sub
|
||||
|
||||
local flags = sub.flags
|
||||
|
||||
if bit32.band(flags, bit32.bor(MUTABLE, WATCHING)) ~= 0 then
|
||||
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
|
||||
-- selene: allow(if_same_then_else)
|
||||
flags = NONE
|
||||
end
|
||||
|
||||
if bit32.band(flags, WATCHING) ~= 0 then
|
||||
notify(sub)
|
||||
end
|
||||
|
||||
if bit32.band(flags, MUTABLE) ~= 0 then
|
||||
local subSubs = sub.subs
|
||||
if subSubs then
|
||||
link = subSubs
|
||||
if subSubs.nextSub then
|
||||
stack = { value = next, prev = stack }
|
||||
next = link.nextSub
|
||||
end
|
||||
continue
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
link = next :: Link
|
||||
|
||||
if link then
|
||||
next = link.nextSub
|
||||
continue
|
||||
end
|
||||
|
||||
local continueTop = false
|
||||
|
||||
while stack do
|
||||
link = stack.value :: Link
|
||||
stack = stack.prev
|
||||
if link then
|
||||
next = link.nextSub
|
||||
continueTop = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if continueTop then
|
||||
continue
|
||||
end
|
||||
|
||||
break
|
||||
until false
|
||||
end
|
||||
|
||||
local function startTracking(sub: ReactiveNode): ()
|
||||
sub.depsTail = nil
|
||||
sub.flags = bit32.bor(bit32.band(sub.flags, bit32.bnot(bit32.bor(RECURSED, DIRTY, PENDING))), RECURSED_CHECK)
|
||||
end
|
||||
|
||||
local function endTracking(sub: ReactiveNode): ()
|
||||
local depsTail = sub.depsTail
|
||||
local toRemove = if depsTail then depsTail.nextDep else sub.deps
|
||||
while toRemove do
|
||||
toRemove = unlink(toRemove, sub)
|
||||
end
|
||||
sub.flags = bit32.band(sub.flags, bit32.bnot(RECURSED_CHECK))
|
||||
end
|
||||
|
||||
local function shallowPropagate(link: Link): ()
|
||||
repeat
|
||||
local sub = link.sub
|
||||
local subFlags = sub.flags
|
||||
if bit32.band(subFlags, bit32.bor(PENDING, DIRTY)) == PENDING then
|
||||
sub.flags = bit32.bor(subFlags, DIRTY)
|
||||
if bit32.band(subFlags, WATCHING) ~= 0 then
|
||||
notify(sub)
|
||||
end
|
||||
end
|
||||
link = link.nextSub :: Link
|
||||
until not link
|
||||
end
|
||||
|
||||
local function checkDirty(link: Link, sub: ReactiveNode): boolean
|
||||
local stack: Stack<Link>?
|
||||
local checkDepth = 0
|
||||
|
||||
repeat
|
||||
local dep = link.dep
|
||||
local depFlags = dep.flags
|
||||
|
||||
local dirty = false
|
||||
|
||||
if bit32.band(sub.flags, DIRTY) ~= 0 then
|
||||
dirty = 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
|
||||
dirty = true
|
||||
end
|
||||
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
|
||||
if link.nextSub or link.prevSub then
|
||||
stack = { value = link, prev = stack }
|
||||
end
|
||||
link = dep.deps :: Link
|
||||
sub = dep
|
||||
checkDepth += 1
|
||||
continue
|
||||
end
|
||||
|
||||
if not dirty and link.nextDep then
|
||||
link = link.nextDep
|
||||
continue
|
||||
end
|
||||
|
||||
local continueTop = false
|
||||
|
||||
while checkDepth ~= 0 do
|
||||
checkDepth -= 1
|
||||
local firstSub = sub.subs :: Link
|
||||
local hasMultipleSubs = firstSub.nextSub ~= nil
|
||||
if hasMultipleSubs then
|
||||
link = (stack :: Stack<Link>).value
|
||||
stack = (stack :: Stack<Link>).prev
|
||||
else
|
||||
link = firstSub
|
||||
end
|
||||
if dirty then
|
||||
if update(sub) then
|
||||
if hasMultipleSubs then
|
||||
shallowPropagate(firstSub)
|
||||
end
|
||||
sub = link.sub
|
||||
continue
|
||||
end
|
||||
else
|
||||
sub.flags = bit32.band(sub.flags, bit32.bnot(PENDING))
|
||||
end
|
||||
sub = link.sub
|
||||
if link.nextDep then
|
||||
link = link.nextDep
|
||||
continueTop = true
|
||||
break
|
||||
end
|
||||
dirty = false
|
||||
end
|
||||
|
||||
if continueTop then
|
||||
continue
|
||||
end
|
||||
|
||||
return dirty
|
||||
until false
|
||||
end
|
||||
|
||||
return {
|
||||
link = link,
|
||||
unlink = unlink,
|
||||
propagate = propagate,
|
||||
checkDirty = checkDirty,
|
||||
endTracking = endTracking,
|
||||
startTracking = startTracking,
|
||||
shallowPropagate = shallowPropagate,
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
ReactiveFlags = ReactiveFlags,
|
||||
createReactiveSystem = createReactiveSystem,
|
||||
}
|
||||
|
|
@ -1,43 +1,9 @@
|
|||
export type Set<T> = { [T]: true }
|
||||
export type Selector<T = any> = () -> T
|
||||
|
||||
export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" }))
|
||||
export type Atom<T = any> = ((((T) -> T) | T) -> T) & (() -> T)
|
||||
|
||||
--[=[
|
||||
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,
|
||||
export type AtomOptions<T = any> = {
|
||||
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
|
||||
return {}
|
||||
|
|
|
|||
18
packages/charm/src/untracked.luau
Normal file
18
packages/charm/src/untracked.luau
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
local signals = require("./signals")
|
||||
|
||||
local pauseTracking = signals.pauseTracking
|
||||
local resumeTracking = signals.resumeTracking
|
||||
|
||||
local function untracked<T, Args...>(callback: ((Args...) -> T) | (Args...) -> (), ...: Args...): T
|
||||
pauseTracking()
|
||||
local success, result = pcall(callback, ...)
|
||||
resumeTracking()
|
||||
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
return untracked
|
||||
4
packages/react/src/index.d.ts
vendored
4
packages/react/src/index.d.ts
vendored
|
|
@ -1,5 +1,3 @@
|
|||
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.
|
||||
|
|
@ -12,4 +10,4 @@ import { Selector } from "@rbxts/charm";
|
|||
* @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;
|
||||
export function useAtom<State>(callback: () => State, dependencies?: unknown[]): State;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
local Charm = require(script.Parent.Charm)
|
||||
local React = if script.Parent:FindFirstChild("react")
|
||||
then require(script.Parent.react) :: never
|
||||
then require((script :: any).Parent.react) :: never
|
||||
else require(script.Parent.React)
|
||||
|
||||
local listen = Charm.listen
|
||||
local useEffect = React.useEffect
|
||||
local useState = React.useState
|
||||
|
||||
--[=[
|
||||
A hook that subscribes to changes in the given atom or selector. The
|
||||
component is re-rendered whenever the state changes.
|
||||
|
|
@ -16,11 +20,10 @@ local React = if script.Parent:FindFirstChild("react")
|
|||
@return The current state.
|
||||
]=]
|
||||
local function useAtom<State>(callback: Charm.Selector<State>, dependencies: { any }?): State
|
||||
local state, setState = React.useState(callback)
|
||||
local state, setState = useState(callback)
|
||||
|
||||
React.useEffect(function()
|
||||
setState(callback())
|
||||
return Charm.subscribe(callback, setState)
|
||||
useEffect(function()
|
||||
return listen(callback, setState)
|
||||
end, dependencies or {})
|
||||
|
||||
return state
|
||||
|
|
|
|||
4
packages/vide/src/index.d.ts
vendored
4
packages/vide/src/index.d.ts
vendored
|
|
@ -1,9 +1,7 @@
|
|||
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;
|
||||
export function useAtom<T>(callback: () => T): () => T;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
local Charm = require(script.Parent.Charm)
|
||||
local Vide = if script.Parent:FindFirstChild("vide")
|
||||
then require(script.Parent.vide.src) :: never
|
||||
then require((script :: any).Parent.vide.src) :: never
|
||||
else require(script.Parent.Vide)
|
||||
|
||||
local subscribe = Charm.subscribe
|
||||
local source = Vide.source
|
||||
local cleanup = Vide.cleanup
|
||||
|
||||
--[=[
|
||||
Subscribes to the state of an atom and returns a Vide source.
|
||||
|
||||
|
|
@ -10,13 +14,9 @@ local Vide = if script.Parent:FindFirstChild("vide")
|
|||
@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)
|
||||
|
||||
local state = source(callback())
|
||||
local dispose = subscribe(callback, state)
|
||||
cleanup(dispose)
|
||||
return state
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@
|
|||
|
||||
[tools]
|
||||
wally = "UpliftGames/wally@0.3.2"
|
||||
rojo = "rojo-rbx/rojo@7.4.4"
|
||||
rojo = "rojo-rbx/rojo@7.5.1"
|
||||
wally-package-types = "JohnnyMorganz/wally-package-types@1.4.2"
|
||||
stylua = "johnnymorganz/stylua@2.0.2"
|
||||
stylua = "johnnymorganz/stylua@2.1.0"
|
||||
selene = "Kampfkarren/selene@0.28.0"
|
||||
luau-lsp = "johnnymorganz/luau-lsp@1.38.1"
|
||||
luau-lsp = "johnnymorganz/luau-lsp@1.44.1"
|
||||
lute = "luau-lang/lute@0.1.0-nightly.20250503"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
std = "roblox+testez"
|
||||
std = "selene_defs"
|
||||
|
||||
[lints]
|
||||
global_usage = "allow"
|
||||
|
|
|
|||
11
selene_defs.yml
Normal file
11
selene_defs.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
base: roblox
|
||||
name: selene_defs
|
||||
globals:
|
||||
require:
|
||||
args:
|
||||
- type: string
|
||||
assert:
|
||||
args:
|
||||
- type: any
|
||||
- type: any
|
||||
required: false
|
||||
|
|
@ -1 +1,4 @@
|
|||
syntax = "Luau"
|
||||
|
||||
[sort_requires]
|
||||
enabled = true
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
roots = ["ServerScriptService/Tests"]
|
||||
53
testez.yml
53
testez.yml
|
|
@ -1,53 +0,0 @@
|
|||
---
|
||||
globals:
|
||||
FIXME:
|
||||
args:
|
||||
- required: false
|
||||
type: string
|
||||
FOCUS:
|
||||
args: []
|
||||
SKIP:
|
||||
args: []
|
||||
afterAll:
|
||||
args:
|
||||
- type: function
|
||||
afterEach:
|
||||
args:
|
||||
- type: function
|
||||
beforeAll:
|
||||
args:
|
||||
- type: function
|
||||
beforeEach:
|
||||
args:
|
||||
- type: function
|
||||
describe:
|
||||
args:
|
||||
- type: string
|
||||
- type: function
|
||||
describeFOCUS:
|
||||
args:
|
||||
- type: string
|
||||
- type: function
|
||||
describeSKIP:
|
||||
args:
|
||||
- type: string
|
||||
- type: function
|
||||
expect:
|
||||
args:
|
||||
- type: any
|
||||
it:
|
||||
args:
|
||||
- type: string
|
||||
- type: function
|
||||
itFIXME:
|
||||
args:
|
||||
- type: string
|
||||
- type: function
|
||||
itFOCUS:
|
||||
args:
|
||||
- type: string
|
||||
- type: function
|
||||
itSKIP:
|
||||
args:
|
||||
- type: string
|
||||
- type: function
|
||||
|
|
@ -1,15 +1,25 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local atom = Charm.atom
|
||||
|
||||
local cleanup: () -> ()
|
||||
|
||||
return {
|
||||
ParameterGenerator = function() end,
|
||||
AfterEach = function()
|
||||
cleanup()
|
||||
end,
|
||||
Functions = {
|
||||
atom = function()
|
||||
local state = atom(0)
|
||||
|
||||
cleanup = effect(function()
|
||||
state()
|
||||
end)
|
||||
|
||||
for _ = 1, 500 do
|
||||
state(math.random(1, 2))
|
||||
end
|
||||
|
|
@ -18,10 +28,14 @@ return {
|
|||
computed = function()
|
||||
local state = atom(0)
|
||||
|
||||
local _double = computed(function()
|
||||
local double = computed(function()
|
||||
return 2 * state()
|
||||
end)
|
||||
|
||||
cleanup = effect(function()
|
||||
double()
|
||||
end)
|
||||
|
||||
for _ = 1, 500 do
|
||||
state(math.random(1, 2))
|
||||
end
|
||||
38
tests/benchmarks/propagate.bench.luau
Normal file
38
tests/benchmarks/propagate.bench.luau
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local signals = require(ReplicatedStorage.DevPackages.Charm.signals)
|
||||
|
||||
local function propagate(
|
||||
signal: <T>(T) -> (T?) -> T,
|
||||
computed: <T>(() -> T) -> () -> T,
|
||||
effect: (() -> ()) -> (),
|
||||
w: number,
|
||||
h: number
|
||||
)
|
||||
local src = signal(1)
|
||||
for _ = 1, w do
|
||||
local last = src
|
||||
for _ = 1, h do
|
||||
local prev = last
|
||||
last = computed(function()
|
||||
return prev() + 1
|
||||
end)
|
||||
end
|
||||
effect(function()
|
||||
last()
|
||||
end)
|
||||
end
|
||||
src(src() + 1)
|
||||
end
|
||||
|
||||
return {
|
||||
Functions = {
|
||||
charm = function()
|
||||
propagate(Charm.atom, Charm.computed, Charm.effect, 10, 10)
|
||||
end,
|
||||
alien_signals = function()
|
||||
propagate(signals.signal, signals.computed, signals.effect, 10, 10)
|
||||
end,
|
||||
},
|
||||
}
|
||||
61
tests/benchmarks/propagate.luau
Normal file
61
tests/benchmarks/propagate.luau
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
local Charm = require("../../packages/charm/src/lib")
|
||||
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
|
||||
local signals = require("../../packages/charm/src/signals")
|
||||
|
||||
local signal = signals.signal
|
||||
local computedSignal = signals.computed
|
||||
local effectSignal = signals.effect
|
||||
|
||||
local w = 10
|
||||
local h = 10
|
||||
|
||||
local function bench(name: string, test: () -> ())
|
||||
local start = os.clock()
|
||||
local iter = 0
|
||||
for _ = 1, 15000 do
|
||||
iter += 1
|
||||
test()
|
||||
end
|
||||
local endTime = os.clock()
|
||||
local elapsed = endTime - start
|
||||
print(`{name}: {elapsed / iter * 1000 * 1000} us/iter`)
|
||||
end
|
||||
|
||||
bench(`charm {w} * {h}`, function()
|
||||
local src = atom(1)
|
||||
for _ = 1, w do
|
||||
local last = src
|
||||
for _ = 1, h do
|
||||
local prev = last
|
||||
last = computed(function()
|
||||
return prev() + 1
|
||||
end)
|
||||
end
|
||||
effect(function()
|
||||
last()
|
||||
end)
|
||||
end
|
||||
src(src() + 1)
|
||||
end)
|
||||
|
||||
bench(`alien-signals {w} * {h}`, function()
|
||||
local src = signal(1)
|
||||
for _ = 1, w do
|
||||
local last = src
|
||||
for _ = 1, h do
|
||||
local prev = last
|
||||
last = computedSignal(function()
|
||||
return prev() + 1
|
||||
end)
|
||||
end
|
||||
effectSignal(function()
|
||||
last()
|
||||
return nil
|
||||
end)
|
||||
end
|
||||
src(src() + 1)
|
||||
end)
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local subscribe = Charm.subscribe
|
||||
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
|
||||
local client = CharmSync.client
|
||||
|
||||
return function()
|
||||
it("syncs state payload", function()
|
||||
local a = atom(1)
|
||||
local client = client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = 2 },
|
||||
})
|
||||
|
||||
expect(a()).to.equal(2)
|
||||
end)
|
||||
|
||||
it("syncs state patch", function()
|
||||
local a = atom({ b = 1, c = 2 })
|
||||
local previous = a()
|
||||
local client = client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a() },
|
||||
})
|
||||
|
||||
expect(a()).to.equal(previous)
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = { b = 3 } },
|
||||
})
|
||||
|
||||
expect(a()).to.never.equal(previous)
|
||||
expect(a().b).to.equal(3)
|
||||
expect(a().c).to.equal(2)
|
||||
end)
|
||||
|
||||
it("fixes sparse array patches", function()
|
||||
local a = atom({ 1 })
|
||||
local client = client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
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:sync({
|
||||
type = "patch",
|
||||
data = { a = { ["2"] = 2 } },
|
||||
})
|
||||
|
||||
expect(a()["2" :: any]).to.never.be.ok()
|
||||
expect(a()[2]).to.equal(2)
|
||||
end)
|
||||
|
||||
it("applies multiple patches", function()
|
||||
local a = atom(1)
|
||||
local client = client({ atoms = { a = a } })
|
||||
local calls = {}
|
||||
|
||||
subscribe(a, function(value)
|
||||
table.insert(calls, value)
|
||||
end)
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a() },
|
||||
})
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = 2 },
|
||||
}, {
|
||||
type = "patch",
|
||||
data = { a = 3 },
|
||||
})
|
||||
|
||||
expect(a()).to.equal(3)
|
||||
expect(calls[1]).to.equal(2)
|
||||
expect(calls[2]).to.equal(3)
|
||||
end)
|
||||
|
||||
it("batches patches", function()
|
||||
local a = atom(0)
|
||||
local b = atom(0)
|
||||
local client = client({ atoms = { a = a, b = b } })
|
||||
local states = {}
|
||||
|
||||
subscribe(a, function()
|
||||
states.b = b()
|
||||
end)
|
||||
|
||||
subscribe(b, function()
|
||||
states.a = a()
|
||||
end)
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a(), b = b() },
|
||||
})
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = 1, b = 2 },
|
||||
})
|
||||
|
||||
expect(a()).to.equal(1)
|
||||
expect(b()).to.equal(2)
|
||||
expect(states.a).to.equal(1)
|
||||
expect(states.b).to.equal(2)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
|
||||
local flatten = CharmSync.flatten
|
||||
|
||||
return function()
|
||||
it("flattens atoms", function()
|
||||
local input = {
|
||||
a = atom(0),
|
||||
b = { c = atom(0) },
|
||||
d = { e = { f = atom(0) } },
|
||||
}
|
||||
local result = flatten(input)
|
||||
|
||||
expect(result.a).to.equal(input.a)
|
||||
expect(result["b/c"]).to.equal(input.b.c)
|
||||
expect(result["d/e/f"]).to.equal(input.d.e.f)
|
||||
|
||||
expect(result.b).to.never.be.ok()
|
||||
expect(result.d).to.never.be.ok()
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
local HttpService = game:GetService("HttpService")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
|
||||
|
||||
return function()
|
||||
local function serdes(value: { [any]: any })
|
||||
return HttpService:JSONDecode(HttpService:JSONEncode(value))
|
||||
end
|
||||
|
||||
it("returns a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local nextState = { foo = "bar", baz = "quux" }
|
||||
local patches = patch.diff(prevState, nextState)
|
||||
|
||||
expect(patches).to.be.ok()
|
||||
expect(patches.baz).to.equal("quux")
|
||||
expect(patches.foo).to.never.be.ok()
|
||||
end)
|
||||
|
||||
it("applies a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local patches = { baz = "quux", qaz = "qux" }
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
expect(applied).to.be.ok()
|
||||
expect(applied.baz).to.equal("quux")
|
||||
expect(applied.foo).to.equal("bar")
|
||||
expect(applied.qaz).to.equal("qux")
|
||||
end)
|
||||
|
||||
it("allows nested patches", function()
|
||||
local prevState = { foo = { bar = "baz" } }
|
||||
local nextState = { foo = { bar = "qux" } }
|
||||
local patches = patch.diff(prevState, nextState)
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
expect(applied.foo.bar).to.equal("qux")
|
||||
end)
|
||||
|
||||
it("preserves array density when applying", function()
|
||||
local prevState = { "a", "b", "c", "d" }
|
||||
local nextState = { ["2"] = "B", ["4"] = "D" }
|
||||
local applied = patch.apply(prevState, nextState)
|
||||
|
||||
expect(applied[1]).to.equal("a")
|
||||
expect(applied[2]).to.equal("B")
|
||||
expect(applied[3]).to.equal("c")
|
||||
expect(applied[4]).to.equal("D")
|
||||
|
||||
expect(applied["2"]).to.never.be.ok()
|
||||
expect(applied["4"]).to.never.be.ok()
|
||||
end)
|
||||
|
||||
it("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 patches = serdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
for index in next, prevState do
|
||||
expect(applied[index]).to.equal(nextState[index])
|
||||
end
|
||||
|
||||
for index in next, applied do
|
||||
expect(applied[index]).to.equal(nextState[index])
|
||||
end
|
||||
end)
|
||||
|
||||
it("preserves large gaps between keys", function()
|
||||
local prevState = table.create(10000, "foo")
|
||||
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
|
||||
|
||||
local start = os.clock()
|
||||
|
||||
local patches = serdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
|
||||
|
||||
for index in next, prevState do
|
||||
expect(applied[index]).to.equal(nextState[index])
|
||||
end
|
||||
end)
|
||||
|
||||
it("cannot preserve sparse arrays", function()
|
||||
local prevState = {}
|
||||
local nextState = { [2] = "foo" }
|
||||
|
||||
local patches = serdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
-- Not enough information is available to determine whether 'patches'
|
||||
-- should keep the string keys added during remote event serialization.
|
||||
expect(applied["2"]).to.be.ok()
|
||||
expect(applied[2]).to.never.be.ok()
|
||||
end)
|
||||
|
||||
it("optionally fixes sparse arrays", function()
|
||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do
|
||||
expect(index).to.be.a("string")
|
||||
end
|
||||
|
||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
|
||||
expect(index).to.be.a("number")
|
||||
end
|
||||
end)
|
||||
|
||||
it("optionally validates state", function()
|
||||
expect(function()
|
||||
patch.diff({}, { [0.1] = true }, true)
|
||||
end).to.throw()
|
||||
|
||||
expect(function()
|
||||
patch.diff({}, { [0.1] = true }, false)
|
||||
end).to.never.throw()
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
|
||||
type SyncPayload = CharmSync.SyncPayload
|
||||
local server = CharmSync.server
|
||||
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
|
||||
|
||||
return function()
|
||||
local target: Player = {} :: any
|
||||
|
||||
it("sends state to new players", function()
|
||||
local a = atom(1)
|
||||
local server = server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: SyncPayload
|
||||
|
||||
local disconnect = server:connect(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
server:hydrate(target)
|
||||
|
||||
expect(player).to.equal(target)
|
||||
expect(payload.type).to.equal("init")
|
||||
expect(payload.data.a).to.equal(1)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
|
||||
it("sends state patches", function()
|
||||
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
|
||||
local server = server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: SyncPayload
|
||||
|
||||
local disconnect = server:connect(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
a({ b = 1, c = 5 })
|
||||
server:_sendPatch(target)
|
||||
|
||||
expect(player).to.equal(target)
|
||||
expect(payload.type).to.equal("patch")
|
||||
expect(payload.data.a.b).to.never.be.ok()
|
||||
expect(payload.data.a.c).to.equal(5)
|
||||
|
||||
a({ b = 1, d = 1 })
|
||||
server:_sendPatch(target)
|
||||
|
||||
expect(payload.data.a.b).to.never.be.ok()
|
||||
expect(patch.isNone(payload.data.a.c)).to.equal(true)
|
||||
expect(payload.data.a.d).to.equal(1)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
|
||||
it("preserves history", function()
|
||||
local a = atom(1)
|
||||
local b = atom(1)
|
||||
local payloads: { SyncPayload }
|
||||
|
||||
local server = server({
|
||||
atoms = { a = a, b = b },
|
||||
interval = -1,
|
||||
preserveHistory = true,
|
||||
})
|
||||
|
||||
local disconnect = server:connect(function(_, ...)
|
||||
payloads = { ... }
|
||||
end)
|
||||
|
||||
a(2)
|
||||
b(2)
|
||||
server:_sendPatch(target)
|
||||
|
||||
expect(#payloads).to.equal(1)
|
||||
expect(payloads[1].type).to.equal("patch")
|
||||
expect(payloads[1].data.a).to.equal(2)
|
||||
expect(payloads[1].data.b).to.equal(2)
|
||||
|
||||
a(3)
|
||||
a(4)
|
||||
b(3)
|
||||
server:_sendPatch(target)
|
||||
|
||||
expect(#payloads).to.equal(2)
|
||||
expect(payloads[1].type).to.equal("patch")
|
||||
expect(payloads[1].data.a).to.equal(3)
|
||||
expect(payloads[1].data.b).to.never.be.ok()
|
||||
expect(payloads[2].type).to.equal("patch")
|
||||
expect(payloads[2].data.a).to.equal(4)
|
||||
expect(payloads[2].data.b).to.equal(3)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate)
|
||||
|
||||
return function()
|
||||
it("validates inputs", function()
|
||||
local cases: { [string]: { value: any, expected: any } } = {
|
||||
array = { value = { 1, 2, 3 }, expected = true },
|
||||
array_sparse = { value = { 1, nil :: any, 3 }, expected = true },
|
||||
array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false },
|
||||
|
||||
map_decimal_key = { value = { [1.5] = 0 }, expected = false },
|
||||
map_infinity_key = { value = { [math.huge] = true }, expected = false },
|
||||
map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false },
|
||||
|
||||
nested_atom = { value = atom(0), expected = false },
|
||||
}
|
||||
|
||||
for key, case in next, cases do
|
||||
local success, result = pcall(function()
|
||||
validate(case.value, key)
|
||||
end)
|
||||
|
||||
if case.expected and not success then
|
||||
error(`Expected {key} to be valid, threw {result}`)
|
||||
elseif not case.expected and success then
|
||||
error(`Expected {key} to be invalid, but it was valid`)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
return function()
|
||||
it("updates state", function()
|
||||
local source = atom(1)
|
||||
source(2)
|
||||
expect(source()).to.equal(2)
|
||||
source(function(x)
|
||||
return x + 1
|
||||
end)
|
||||
expect(source()).to.equal(3)
|
||||
end)
|
||||
|
||||
it("calls listeners", function()
|
||||
local source = atom(1)
|
||||
local value
|
||||
local function listener()
|
||||
value = source()
|
||||
end
|
||||
store.listeners[source][listener] = true
|
||||
source(2)
|
||||
expect(value).to.equal(2)
|
||||
end)
|
||||
|
||||
it("skips pending listeners", function()
|
||||
local source = atom(1)
|
||||
local outer = 0
|
||||
local inner = 0
|
||||
local function listener()
|
||||
outer += 1
|
||||
local function listener()
|
||||
inner += 1
|
||||
end
|
||||
store.listeners[source][listener] = true
|
||||
end
|
||||
store.listeners[source][listener] = true
|
||||
source(2)
|
||||
expect(outer).to.equal(1)
|
||||
expect(inner).to.equal(0)
|
||||
source(3)
|
||||
expect(outer).to.equal(2)
|
||||
expect(inner).to.equal(1)
|
||||
end)
|
||||
|
||||
it("takes a custom comparator", function()
|
||||
local max = atom(1, {
|
||||
equals = function(prev, next)
|
||||
return next < prev
|
||||
end,
|
||||
})
|
||||
max(2)
|
||||
expect(max()).to.equal(2)
|
||||
max(1)
|
||||
expect(max()).to.equal(2)
|
||||
max(3)
|
||||
expect(max()).to.equal(3)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local subscribe = Charm.subscribe
|
||||
local effect = Charm.effect
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
return function()
|
||||
it("batches updates", function()
|
||||
local source = atom(1)
|
||||
local value = 0
|
||||
subscribe(source, function()
|
||||
value += 1
|
||||
end)
|
||||
store.batch(function()
|
||||
source(2)
|
||||
source(3)
|
||||
source(4)
|
||||
end)
|
||||
expect(value).to.equal(1)
|
||||
end)
|
||||
|
||||
it("handles duplicate listeners", function()
|
||||
local a = atom(1)
|
||||
local b = atom(1)
|
||||
local value = 0
|
||||
effect(function()
|
||||
value += 1
|
||||
a()
|
||||
b()
|
||||
end)
|
||||
value = 0 -- effect() will run once initially
|
||||
store.batch(function()
|
||||
a(2)
|
||||
b(2)
|
||||
a(3)
|
||||
end)
|
||||
expect(value).to.equal(1)
|
||||
end)
|
||||
|
||||
it("batches computed atoms", function()
|
||||
local source = atom(1)
|
||||
local updates = 0
|
||||
local computations = 0
|
||||
local double = computed(function()
|
||||
computations += 1
|
||||
return source() * 2
|
||||
end)
|
||||
computations = 0 -- computed atoms will run once initially
|
||||
subscribe(double, function()
|
||||
updates += 1
|
||||
end)
|
||||
store.batch(function()
|
||||
source(2)
|
||||
source(3)
|
||||
source(4)
|
||||
end)
|
||||
expect(updates).to.equal(1)
|
||||
expect(computations).to.equal(1)
|
||||
expect(double()).to.equal(8)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local subscribe = Charm.subscribe
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
return function()
|
||||
it("gracefully handles yielding", function()
|
||||
expect(function()
|
||||
store.capture(function()
|
||||
task.wait(10)
|
||||
return 1
|
||||
end)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("captures atoms from a function", function()
|
||||
local a, b, c = atom(1), atom(1), atom(2)
|
||||
local deps, result = store.capture(function()
|
||||
return a() + b()
|
||||
end)
|
||||
expect(deps[a]).to.be.ok()
|
||||
expect(deps[b]).to.be.ok()
|
||||
expect(deps[c]).to.never.be.ok()
|
||||
expect(result).to.equal(2)
|
||||
end)
|
||||
|
||||
it("captures an atom by itself", function()
|
||||
local a = atom(1)
|
||||
local deps, result = store.capture(a)
|
||||
expect(deps[a]).to.be.ok()
|
||||
expect(result).to.equal(1)
|
||||
end)
|
||||
|
||||
it("skips atoms in inner capture calls", function()
|
||||
local a = atom(1)
|
||||
local b = atom(1)
|
||||
local inner
|
||||
local outer = store.capture(function()
|
||||
inner = store.capture(function()
|
||||
return b()
|
||||
end)
|
||||
return a()
|
||||
end)
|
||||
|
||||
assert(outer[a], "outer did not capture a")
|
||||
assert(outer[b] == nil, "outer captured b")
|
||||
assert(inner[a] == nil, "inner captured a")
|
||||
assert(inner[b], "inner did not capture b")
|
||||
end)
|
||||
|
||||
it("should not capture notified dependents", function()
|
||||
local state = atom(1)
|
||||
|
||||
subscribe(state, function() end)
|
||||
|
||||
local deps = store.capture(function()
|
||||
return state(2)
|
||||
end)
|
||||
|
||||
expect(deps[state]).to.never.be.ok()
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
local collect = require(script.Parent.Parent.utils.collect)
|
||||
local count = require(script.Parent.Parent.utils.count)
|
||||
|
||||
return function()
|
||||
it("memoizes the result", function()
|
||||
local calls = 0
|
||||
local a = atom(1)
|
||||
local b = atom(2)
|
||||
local double = computed(function()
|
||||
calls += 1
|
||||
return a() * b()
|
||||
end)
|
||||
expect(calls).to.equal(1)
|
||||
a(1)
|
||||
expect(calls).to.equal(1)
|
||||
a(2)
|
||||
expect(calls).to.equal(2)
|
||||
b(3)
|
||||
expect(calls).to.equal(3)
|
||||
double()
|
||||
expect(calls).to.equal(3)
|
||||
end)
|
||||
|
||||
it("can be nested", function()
|
||||
local source = atom(1)
|
||||
local double = computed(function()
|
||||
return source() * 2
|
||||
end)
|
||||
local quadruple = computed(function()
|
||||
return double() * 2
|
||||
end)
|
||||
expect(quadruple()).to.equal(4)
|
||||
source(2)
|
||||
expect(quadruple()).to.equal(8)
|
||||
end)
|
||||
|
||||
it("collects computed atoms", function()
|
||||
local source = atom(0)
|
||||
|
||||
computed(function()
|
||||
return source()
|
||||
end)
|
||||
|
||||
collect()
|
||||
|
||||
assert(count(store.listeners[source]) == 0, "computed atom did not gc")
|
||||
end)
|
||||
|
||||
it("collects nested computed atoms", function()
|
||||
local source = atom(0)
|
||||
|
||||
do
|
||||
-- Declare this in another scope to ensure no references exist at the
|
||||
-- time of garbage collection.
|
||||
local double = computed(function()
|
||||
return source() * 2
|
||||
end)
|
||||
|
||||
computed(function()
|
||||
return double() * 2
|
||||
end)
|
||||
end
|
||||
|
||||
collect()
|
||||
|
||||
assert(count(store.listeners[source]) == 0, "nested computed atom did not gc")
|
||||
end)
|
||||
|
||||
it("tracks conditional dependencies", function()
|
||||
local condition = atom(false)
|
||||
local a = atom(0)
|
||||
local b = atom(1)
|
||||
local reruns = 0
|
||||
local current
|
||||
|
||||
-- assign variable to avoid gc
|
||||
local _ = computed(function()
|
||||
reruns += 1
|
||||
current = if condition() then a() else b()
|
||||
return current
|
||||
end)
|
||||
|
||||
expect(reruns).to.equal(1)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(true)
|
||||
expect(reruns).to.equal(2)
|
||||
expect(current).to.equal(0)
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
expect(reruns).to.equal(3)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(false)
|
||||
expect(reruns).to.equal(4)
|
||||
expect(current).to.equal(2)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local effect = Charm.effect
|
||||
|
||||
return function()
|
||||
it("runs on state change", function()
|
||||
local a = atom(0)
|
||||
local b = atom(0)
|
||||
local current
|
||||
local unsubscribe = effect(function()
|
||||
current = a() + b()
|
||||
end)
|
||||
|
||||
expect(current).to.equal(0)
|
||||
|
||||
for index = 1, 3 do
|
||||
a(index)
|
||||
expect(current).to.equal(index)
|
||||
end
|
||||
|
||||
for index = 1, 3 do
|
||||
b(index)
|
||||
expect(current).to.equal(a() + index)
|
||||
end
|
||||
|
||||
unsubscribe()
|
||||
a(4)
|
||||
expect(current).to.equal(6)
|
||||
end)
|
||||
|
||||
it("accepts a cleanup function", function()
|
||||
local a = atom(0)
|
||||
local cleanups = 0
|
||||
local unsubscribe = effect(function()
|
||||
a()
|
||||
return function()
|
||||
cleanups += 1
|
||||
end
|
||||
end)
|
||||
|
||||
expect(cleanups).to.equal(0)
|
||||
a(1)
|
||||
expect(cleanups).to.equal(1)
|
||||
unsubscribe()
|
||||
expect(cleanups).to.equal(2)
|
||||
end)
|
||||
|
||||
it("tracks conditional dependencies", function()
|
||||
local condition = atom(false)
|
||||
local a = atom(0)
|
||||
local b = atom(1)
|
||||
local reruns = 0
|
||||
local current
|
||||
|
||||
effect(function()
|
||||
reruns += 1
|
||||
current = if condition() then a() else b()
|
||||
end)
|
||||
|
||||
expect(reruns).to.equal(1)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(true)
|
||||
expect(reruns).to.equal(2)
|
||||
expect(current).to.equal(0)
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
expect(reruns).to.equal(3)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(false)
|
||||
expect(reruns).to.equal(4)
|
||||
expect(current).to.equal(2)
|
||||
end)
|
||||
|
||||
it("allows self-cleanup", function()
|
||||
local a = atom(1)
|
||||
local cleanedUp = false
|
||||
local cleanup
|
||||
|
||||
cleanup = effect(function()
|
||||
if cleanup then
|
||||
assert(not cleanedUp, "cleanup ran twice")
|
||||
cleanup()
|
||||
cleanedUp = true
|
||||
end
|
||||
a()
|
||||
end)
|
||||
|
||||
a(2)
|
||||
a(3)
|
||||
end)
|
||||
|
||||
it("allows immediate self-cleanup", function()
|
||||
local a = atom(1)
|
||||
local cleanedUp = false
|
||||
|
||||
effect(function(cleanup)
|
||||
assert(not cleanedUp, "cleanup ran twice")
|
||||
cleanup()
|
||||
cleanedUp = true
|
||||
a()
|
||||
end)
|
||||
|
||||
a(2)
|
||||
a(3)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local mapped = Charm.mapped
|
||||
local observe = Charm.observe
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
return function()
|
||||
it("returns mapped atoms", function()
|
||||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local mappedAtom = mapped(itemsAtom, function(value, key)
|
||||
return string.upper(value), key
|
||||
end)
|
||||
local updates = 0
|
||||
|
||||
store.listeners[mappedAtom][function()
|
||||
updates += 1
|
||||
end] = true
|
||||
|
||||
for key = 1, 3 do
|
||||
local item = mappedAtom()[key]
|
||||
expect(item).to.equal(string.upper(itemsAtom()[key]))
|
||||
end
|
||||
|
||||
itemsAtom({ "d", "e", "f" })
|
||||
expect(updates).to.equal(1)
|
||||
|
||||
for key = 1, 3 do
|
||||
local item = mappedAtom()[key]
|
||||
expect(item).to.equal(string.upper(itemsAtom()[key]))
|
||||
end
|
||||
|
||||
itemsAtom({ [1] = "d", [3] = "f" })
|
||||
expect(mappedAtom()[2]).to.never.be.ok()
|
||||
expect(updates).to.equal(2)
|
||||
end)
|
||||
|
||||
it("uses key extractor", function()
|
||||
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
|
||||
local mappedAtom = mapped(itemsAtom, function(item)
|
||||
return item, item.key
|
||||
end)
|
||||
|
||||
for key = 1, 3 do
|
||||
local item = mappedAtom()[key]
|
||||
expect(item).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
local previous = mappedAtom()
|
||||
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
||||
|
||||
expect(mappedAtom()[1]).to.equal(previous[1])
|
||||
expect(mappedAtom()[1]).to.equal(itemsAtom()[1])
|
||||
expect(mappedAtom()[2]).to.never.be.ok()
|
||||
expect(mappedAtom()[3]).to.equal(previous[3])
|
||||
expect(mappedAtom()[3]).to.equal(itemsAtom()[2])
|
||||
end)
|
||||
|
||||
it("allows observe()", function()
|
||||
local itemsAtom = atom({
|
||||
{ key = 1, value = "a" },
|
||||
{ key = 2, value = "b" },
|
||||
{ key = 3, value = "c" },
|
||||
})
|
||||
|
||||
local mappedAtom = mapped(itemsAtom, function(item)
|
||||
return item, item.key
|
||||
end)
|
||||
|
||||
local added = 0
|
||||
local deleted = 0
|
||||
|
||||
observe(mappedAtom, function(item, key)
|
||||
expect(item).to.equal(itemsAtom()[key])
|
||||
added += 1
|
||||
|
||||
return function()
|
||||
expect(mappedAtom()[key]).to.never.be.ok()
|
||||
deleted += 1
|
||||
end
|
||||
end)
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(deleted).to.equal(0)
|
||||
|
||||
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(deleted).to.equal(1)
|
||||
|
||||
itemsAtom({})
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(deleted).to.equal(3)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local observe = Charm.observe
|
||||
|
||||
return function()
|
||||
it("observes lifetime", function()
|
||||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local state = {}
|
||||
|
||||
local cleanup = observe(itemsAtom, function(item, key)
|
||||
state[key] = item
|
||||
return function()
|
||||
state[key] = nil
|
||||
end
|
||||
end)
|
||||
|
||||
for key = 1, 3 do
|
||||
expect(state[key]).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
itemsAtom({ [4] = "d", [5] = "e", [6] = "f" })
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
cleanup()
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local capture = Charm.capture
|
||||
local peek = Charm.peek
|
||||
|
||||
return function()
|
||||
it("calls an atom without capturing it", function()
|
||||
local a, b, c = atom(1), atom(1), atom(1)
|
||||
local deps, result = capture(function()
|
||||
return a() + peek(b) + c()
|
||||
end)
|
||||
expect(deps[a]).to.be.ok()
|
||||
expect(deps[b]).to.never.be.ok()
|
||||
expect(deps[c]).to.be.ok()
|
||||
expect(result).to.equal(3)
|
||||
end)
|
||||
|
||||
it("passes through arguments", function()
|
||||
expect(peek(1)).to.equal(1)
|
||||
expect(peek(function()
|
||||
return 1
|
||||
end)).to.equal(1)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local subscribe = Charm.subscribe
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
local collect = require(script.Parent.Parent.utils.collect)
|
||||
local count = require(script.Parent.Parent.utils.count)
|
||||
|
||||
return function()
|
||||
it("runs on state change", function()
|
||||
local current, previous
|
||||
local source = atom(0)
|
||||
local unsubscribe = subscribe(source, function(...)
|
||||
current, previous = ...
|
||||
end)
|
||||
|
||||
for index = 1, 3 do
|
||||
source(index)
|
||||
expect(current).to.equal(index)
|
||||
expect(previous).to.equal(index - 1)
|
||||
end
|
||||
|
||||
unsubscribe()
|
||||
source(4)
|
||||
|
||||
expect(current).to.equal(3)
|
||||
expect(previous).to.equal(2)
|
||||
end)
|
||||
|
||||
it("prevents garbage collect", function()
|
||||
local source = atom(1)
|
||||
local updates = 0
|
||||
|
||||
-- Create a new scope to encapsulate variables with a limited lifetime, like
|
||||
-- 'unsubscribe' and 'computed', to ensure they are garbage collected.
|
||||
--
|
||||
-- The idea here is to test whether subscribe() prevents every computed
|
||||
-- atom in a chain from being garbage collected. If any are GCed, then
|
||||
-- the subscription will stop updating and the test will fail.
|
||||
do
|
||||
local unsubscribe
|
||||
|
||||
do
|
||||
local double = computed(function()
|
||||
return source() * 2
|
||||
end)
|
||||
|
||||
unsubscribe = subscribe(
|
||||
computed(function()
|
||||
return double() * 2
|
||||
end),
|
||||
function()
|
||||
updates += 1
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
collect()
|
||||
source(source() + 1)
|
||||
|
||||
assert(updates == 1, "subscription stopped updating")
|
||||
assert(count(store.listeners[source]) == 1, "computed atom was garbage collected")
|
||||
|
||||
unsubscribe()
|
||||
end
|
||||
|
||||
collect()
|
||||
|
||||
assert(count(store.listeners[source]) == 0, "computed atom did not collect")
|
||||
end)
|
||||
|
||||
it("tracks conditional dependencies", function()
|
||||
local condition = atom(false)
|
||||
local a = atom(0)
|
||||
local b = atom(1)
|
||||
local reruns = 0
|
||||
local current
|
||||
|
||||
subscribe(function()
|
||||
return if condition() then a() else b()
|
||||
end, function(state)
|
||||
reruns += 1
|
||||
current = state
|
||||
end)
|
||||
|
||||
condition(true)
|
||||
expect(reruns).to.equal(1)
|
||||
expect(current).to.equal(0)
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
expect(reruns).to.equal(2)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(false)
|
||||
expect(reruns).to.equal(3)
|
||||
expect(current).to.equal(2)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
--!nocheck
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
|
||||
|
||||
-- Start Charm in debug mode
|
||||
_G.__DEV__ = true
|
||||
require(ReplicatedStorage.DevPackages.Charm)
|
||||
require(ReplicatedStorage.DevPackages.CharmSync)
|
||||
_G.__DEV__ = false
|
||||
|
||||
TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter)
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
return function()
|
||||
afterEach(function()
|
||||
table.clear(store.listeners :: any)
|
||||
table.clear(store.capturing.stack)
|
||||
store.capturing.index = 0
|
||||
end)
|
||||
end
|
||||
13
tests/react/spec.luau
Normal file
13
tests/react/spec.luau
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
local suite = require("../suite")
|
||||
|
||||
local describe = suite.describe
|
||||
|
||||
local function test(name: string)
|
||||
describe(name, function()
|
||||
return require(`./{name}.spec`) :: any
|
||||
end)
|
||||
end
|
||||
|
||||
test("useAtom")
|
||||
|
||||
return {}
|
||||
|
|
@ -1,46 +1,48 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local ServerScriptService = game:GetService("ServerScriptService")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm)
|
||||
local useAtom = ReactCharm.useAtom
|
||||
local renderHook = require(script.Parent.Parent.utils.renderHook)
|
||||
local suite = require(ServerScriptService.Tests.suite)
|
||||
|
||||
return function()
|
||||
it("tracks atom state", function()
|
||||
local atom = Charm.atom
|
||||
local useAtom = ReactCharm.useAtom
|
||||
local test = suite.test
|
||||
|
||||
test("tracks atom state", function()
|
||||
local state = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtom(state)
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
assert(hook.result == 0)
|
||||
state(1)
|
||||
expect(hook.result).to.equal(1)
|
||||
assert(hook.result == 1)
|
||||
end)
|
||||
|
||||
it("accepts a selector", function()
|
||||
test("accepts a selector", function()
|
||||
local state = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtom(function()
|
||||
return 2 * state()
|
||||
end)
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
assert(hook.result == 0)
|
||||
state(1)
|
||||
expect(hook.result).to.equal(2)
|
||||
assert(hook.result == 2)
|
||||
end)
|
||||
|
||||
it("tracks dependencies", function()
|
||||
test("tracks dependencies", function()
|
||||
local state = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtom(state, { state })
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
assert(hook.result == 0)
|
||||
state(1)
|
||||
expect(hook.result).to.equal(1)
|
||||
assert(hook.result == 1)
|
||||
state = atom(0)
|
||||
hook.rerender()
|
||||
expect(hook.result).to.equal(0)
|
||||
assert(hook.result == 0)
|
||||
state(1)
|
||||
expect(hook.result).to.equal(1)
|
||||
assert(hook.result == 1)
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
82
tests/signals/computed.spec.luau
Normal file
82
tests/signals/computed.spec.luau
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
local test = require("../suite").test
|
||||
local signals = require("../../packages/charm/src/signals")
|
||||
|
||||
local computed = signals.computed
|
||||
local signal = signals.signal
|
||||
|
||||
test("should correctly propagate changes through computed signals", function()
|
||||
local src = 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()
|
||||
src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
|
||||
c2() -- c1 -> none, c2 -> none
|
||||
src(3) -- c1 -> dirty, c2 -> toCheckDirty
|
||||
|
||||
assert(c3() == 1, "bad final value")
|
||||
end)
|
||||
|
||||
test("should propagate updated source value through chained computations", function()
|
||||
local src = 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")
|
||||
src(2)
|
||||
assert(d() == 2, "bad updated value")
|
||||
end)
|
||||
|
||||
test("should handle flags are indirectly updated during checkDirty", function()
|
||||
local a = 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")
|
||||
a(true)
|
||||
assert(d() == true, "bad updated value")
|
||||
end)
|
||||
|
||||
test("should not update if the signal value is reverted", function()
|
||||
local times = 0
|
||||
|
||||
local src = signal(0)
|
||||
local c1 = computed(function()
|
||||
times += 1
|
||||
return src()
|
||||
end)
|
||||
c1()
|
||||
assert(times == 1, "computed did not run")
|
||||
src(1)
|
||||
src(0)
|
||||
c1()
|
||||
assert(times == 1, "computed ran when it should not")
|
||||
end)
|
||||
|
||||
return {}
|
||||
337
tests/signals/effect.spec.luau
Normal file
337
tests/signals/effect.spec.luau
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
local test = require("../suite").test
|
||||
local signals = require("../../packages/charm/src/signals")
|
||||
|
||||
local computed = signals.computed
|
||||
local effect = signals.effect
|
||||
local effectScope = signals.effectScope
|
||||
local endBatch = signals.endBatch
|
||||
local pauseTracking = signals.pauseTracking
|
||||
local resumeTracking = signals.resumeTracking
|
||||
local signal = signals.signal
|
||||
local startBatch = signals.startBatch
|
||||
|
||||
test("should clear subscriptions when untracked by all subscribers", function()
|
||||
local bRunTimes = 0
|
||||
|
||||
local a = signal(1)
|
||||
local b = computed(function()
|
||||
bRunTimes += 1
|
||||
return a() * 2
|
||||
end)
|
||||
local stopEffect = effect(function()
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(bRunTimes == 1, "should run initially")
|
||||
a(2)
|
||||
assert(bRunTimes == 2, "should run after change")
|
||||
stopEffect()
|
||||
a(3)
|
||||
assert(bRunTimes == 2, "should not run after stop")
|
||||
end)
|
||||
|
||||
test("should not run untracked inner effect", function()
|
||||
local a = 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)
|
||||
|
||||
a(2)
|
||||
a(1)
|
||||
a(0)
|
||||
end)
|
||||
|
||||
test("should run outer effect first", function()
|
||||
local a = signal(1)
|
||||
local b = signal(1)
|
||||
|
||||
effect(function()
|
||||
if a() > 0 then
|
||||
effect(function()
|
||||
b()
|
||||
if a() == 0 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
startBatch()
|
||||
b(0)
|
||||
a(0)
|
||||
endBatch()
|
||||
end)
|
||||
|
||||
test("should not trigger inner effect when resolve maybe dirty", function()
|
||||
local a = 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)
|
||||
|
||||
a(2)
|
||||
end)
|
||||
|
||||
test("should trigger inner effects in sequence", function()
|
||||
local a = signal(0)
|
||||
local b = signal(0)
|
||||
local c = computed(function()
|
||||
return a() - b()
|
||||
end)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
c()
|
||||
|
||||
effect(function()
|
||||
table.insert(order, "first inner")
|
||||
a()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(order, "last inner")
|
||||
a()
|
||||
b()
|
||||
end)
|
||||
end)
|
||||
|
||||
table.clear(order)
|
||||
|
||||
startBatch()
|
||||
b(1)
|
||||
a(1)
|
||||
endBatch()
|
||||
|
||||
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
|
||||
end)
|
||||
|
||||
test("should trigger inner effects in sequence in effect scope", function()
|
||||
local a = signal(0)
|
||||
local b = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effectScope(function()
|
||||
effect(function()
|
||||
table.insert(order, "first inner")
|
||||
a()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(order, "last inner")
|
||||
a()
|
||||
b()
|
||||
end)
|
||||
end)
|
||||
|
||||
table.clear(order)
|
||||
|
||||
startBatch()
|
||||
b(1)
|
||||
a(1)
|
||||
endBatch()
|
||||
|
||||
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
|
||||
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 = signal(0)
|
||||
|
||||
local aa = computed(function()
|
||||
table.insert(logs, "aa-0")
|
||||
if a() == 0 then
|
||||
b(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 = signal(0)
|
||||
local src2 = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order, "a")
|
||||
pauseTracking()
|
||||
local isOne = src2() == 1
|
||||
resumeTracking()
|
||||
if isOne then
|
||||
src1()
|
||||
end
|
||||
src2()
|
||||
src1()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order, "b")
|
||||
src1()
|
||||
end)
|
||||
src2(1) -- src1.subs: a -> b -> a
|
||||
|
||||
table.clear(order)
|
||||
src1(src1() + 1)
|
||||
|
||||
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should handle side effect with inner effects", function()
|
||||
local a = signal(0)
|
||||
local b = 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)
|
||||
b(1)
|
||||
a(1)
|
||||
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
|
||||
end)
|
||||
end)
|
||||
|
||||
test("should handle flags are indirectly updated during checkDirty", function()
|
||||
local a = 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")
|
||||
a(true)
|
||||
assert(triggers == 2, "did not update indirectly")
|
||||
end)
|
||||
|
||||
test("should run effect cleanup", function()
|
||||
local cleanups = 0
|
||||
|
||||
local a = signal(false)
|
||||
local cleanup = effect(function()
|
||||
a()
|
||||
return function()
|
||||
cleanups += 1
|
||||
end
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
a(true)
|
||||
assert(cleanups == 1, "should cleanup once")
|
||||
cleanup()
|
||||
assert(cleanups == 2, "should cleanup after effect stop")
|
||||
a(false)
|
||||
assert(cleanups == 2, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should run inner effect cleanup in scope", function()
|
||||
local cleanups = 0
|
||||
|
||||
local a = signal(false)
|
||||
local stopScope = effectScope(function()
|
||||
effect(function()
|
||||
a()
|
||||
return function()
|
||||
cleanups += 1
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
a(true)
|
||||
assert(cleanups == 1, "should cleanup once")
|
||||
stopScope()
|
||||
assert(cleanups == 2, "should cleanup after outer dispose")
|
||||
a(false)
|
||||
assert(cleanups == 2, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should run inner effect cleanup", function()
|
||||
local cleanups = 0
|
||||
|
||||
local a = signal(false)
|
||||
local stopScope = effect(function()
|
||||
a()
|
||||
effect(function()
|
||||
return function()
|
||||
cleanups += 1
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
a(true)
|
||||
assert(cleanups == 1, "should cleanup after outer update")
|
||||
stopScope()
|
||||
assert(cleanups == 2, "should cleanup after outer dispose")
|
||||
a(false)
|
||||
assert(cleanups == 2, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
return {}
|
||||
66
tests/signals/effectScope.spec.luau
Normal file
66
tests/signals/effectScope.spec.luau
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
local test = require("../suite").test
|
||||
local signals = require("../../packages/charm/src/signals")
|
||||
|
||||
local effect = signals.effect
|
||||
local effectScope = signals.effectScope
|
||||
local signal = signals.signal
|
||||
|
||||
test("should not trigger after stop", function()
|
||||
local count = signal(1)
|
||||
|
||||
local triggers = 0
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
effect(function()
|
||||
triggers += 1
|
||||
count()
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
|
||||
count(2)
|
||||
assert(triggers == 2, "did not run after update")
|
||||
end)
|
||||
|
||||
count(3)
|
||||
assert(triggers == 3, "did not run after update outside scope")
|
||||
stopScope()
|
||||
count(4)
|
||||
assert(triggers == 3, "ran after stop")
|
||||
end)
|
||||
|
||||
test("should run effect cleanup", function()
|
||||
local cleanups = 0
|
||||
|
||||
local cleanup = effectScope(function()
|
||||
return function()
|
||||
cleanups += 1
|
||||
end
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
cleanup()
|
||||
assert(cleanups == 1, "should cleanup after effect stop")
|
||||
cleanup()
|
||||
assert(cleanups == 1, "should skip redundant cleanup")
|
||||
end)
|
||||
|
||||
test("should not run indirect effect cleanup", function()
|
||||
local cleanups = 0
|
||||
|
||||
local cleanup
|
||||
local stopScope = effectScope(function()
|
||||
cleanup = effectScope(function()
|
||||
return function()
|
||||
cleanups += 1
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
stopScope()
|
||||
assert(cleanups == 0, "should be detached from parent dispose")
|
||||
cleanup()
|
||||
assert(cleanups == 1, "should cleanup after inner dispose")
|
||||
end)
|
||||
|
||||
return {}
|
||||
113
tests/signals/issue_48.spec.luau
Normal file
113
tests/signals/issue_48.spec.luau
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
local test = require("../suite").test
|
||||
local signals = require("../../packages/charm/src/signals")
|
||||
|
||||
local computed = signals.computed
|
||||
local effect = signals.effect
|
||||
local pauseTracking = signals.pauseTracking
|
||||
local resumeTracking = signals.resumeTracking
|
||||
local signal = signals.signal
|
||||
|
||||
type ReactionOptions<T = unknown> = {
|
||||
fireImmediately: boolean?,
|
||||
equals: ((a: T, b: T?) -> boolean)?,
|
||||
onError: (error: unknown) -> ()?,
|
||||
scheduler: (fn: () -> ()) -> ()?,
|
||||
once: boolean?,
|
||||
}
|
||||
|
||||
local function untracked<T>(callback: () -> T): T
|
||||
pauseTracking()
|
||||
local success, result = pcall(callback)
|
||||
resumeTracking()
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function reaction<T>(
|
||||
dataFn: () -> T,
|
||||
effectFn: (newValue: T, oldValue: T?) -> (),
|
||||
options: ReactionOptions<T>?
|
||||
): () -> ()
|
||||
local options: ReactionOptions<T> = options or {}
|
||||
local scheduler: (() -> ()) -> () = options.scheduler or function(fn)
|
||||
return fn()
|
||||
end
|
||||
local equals: (T, T?) -> boolean = options.equals or function(a, b)
|
||||
return a == b
|
||||
end
|
||||
local onError = options.onError
|
||||
local once = options.once or false
|
||||
local fireImmediately = options.fireImmediately or false
|
||||
|
||||
local prevValue: T?
|
||||
local version = 0
|
||||
|
||||
local tracked = computed(function()
|
||||
local success, result = pcall(dataFn)
|
||||
if not success then
|
||||
untracked(function()
|
||||
if onError then
|
||||
onError(result)
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
return prevValue
|
||||
end
|
||||
return result
|
||||
end)
|
||||
|
||||
local dispose
|
||||
dispose = effect(function()
|
||||
local current = tracked()
|
||||
if not fireImmediately and version == 0 then
|
||||
prevValue = current
|
||||
end
|
||||
version += 1
|
||||
if equals(current :: T, prevValue) then
|
||||
return
|
||||
end
|
||||
local oldValue = prevValue
|
||||
prevValue = current
|
||||
untracked(function()
|
||||
scheduler(function()
|
||||
local success = pcall(effectFn, current :: T, oldValue)
|
||||
if not success and onError then
|
||||
onError(error)
|
||||
end
|
||||
if once then
|
||||
if (fireImmediately and version > 1) or (not fireImmediately and version > 0) then
|
||||
dispose()
|
||||
end
|
||||
end
|
||||
end)
|
||||
return nil
|
||||
end)
|
||||
end)
|
||||
|
||||
return dispose
|
||||
end
|
||||
|
||||
test("#48", function()
|
||||
local source = signal(0)
|
||||
local disposeInner: () -> ()
|
||||
|
||||
reaction(function()
|
||||
return source()
|
||||
end, function(val)
|
||||
if val == 1 then
|
||||
disposeInner = reaction(function()
|
||||
return source()
|
||||
end, function() end)
|
||||
elseif val == 2 then
|
||||
disposeInner()
|
||||
end
|
||||
end)
|
||||
|
||||
source(1)
|
||||
source(2)
|
||||
source(3)
|
||||
end)
|
||||
|
||||
return {}
|
||||
18
tests/signals/spec.luau
Normal file
18
tests/signals/spec.luau
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
local suite = require("../suite")
|
||||
|
||||
local describe = suite.describe
|
||||
|
||||
local function test(name: string)
|
||||
describe(name, function()
|
||||
return require(`./{name}.spec`) :: any
|
||||
end)
|
||||
end
|
||||
|
||||
test("computed")
|
||||
test("effect")
|
||||
test("effectScope")
|
||||
test("issue_48")
|
||||
test("topology")
|
||||
test("untrack")
|
||||
|
||||
return {}
|
||||
493
tests/signals/topology.spec.luau
Normal file
493
tests/signals/topology.spec.luau
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
local signals = require("../../packages/charm/src/signals")
|
||||
local suite = require("../suite")
|
||||
|
||||
local test = suite.test
|
||||
local describe = suite.describe
|
||||
|
||||
local computed = signals.computed
|
||||
local effect = signals.effect
|
||||
local signal = signals.signal
|
||||
|
||||
--[[
|
||||
Tests adopted with thanks from preact-signals 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
|
||||
]]
|
||||
|
||||
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 = signal(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 = signal("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 = signal("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 = signal("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 = signal("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 signals listened to", function()
|
||||
-- *A
|
||||
-- / \
|
||||
-- *B C <- we don't listen to C
|
||||
local a = signal("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 signals 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 = signal("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 = signal("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 = signal("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 = signal(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 = signal("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)
|
||||
|
||||
describe("error handling", function()
|
||||
test("should keep graph consistent on errors during activation", function()
|
||||
local a = signal(0)
|
||||
local b = computed(function()
|
||||
error("fail")
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local success, result = pcall(function()
|
||||
b()
|
||||
end)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(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 = signal(0)
|
||||
local b = computed(function()
|
||||
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(function()
|
||||
b()
|
||||
end)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(result, "fail"), "should throw fail")
|
||||
|
||||
a(2)
|
||||
assert(c() == 2, "c should recover and return 2")
|
||||
end)
|
||||
end)
|
||||
|
||||
return {}
|
||||
23
tests/signals/untrack.spec.luau
Normal file
23
tests/signals/untrack.spec.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
local test = require("../suite").test
|
||||
local signals = require("../../packages/charm/src/signals")
|
||||
|
||||
local computed = signals.computed
|
||||
local pauseTracking = signals.pauseTracking
|
||||
local resumeTracking = signals.resumeTracking
|
||||
local signal = signals.signal
|
||||
|
||||
test("should pause tracking", function()
|
||||
local src = signal(0)
|
||||
local c = computed(function()
|
||||
pauseTracking()
|
||||
local value = src()
|
||||
resumeTracking()
|
||||
return value
|
||||
end)
|
||||
assert(c() == 0, "should not track src")
|
||||
|
||||
src(1)
|
||||
assert(c() == 0, "should not track src")
|
||||
end)
|
||||
|
||||
return {}
|
||||
20
tests/spec.server.luau
Normal file
20
tests/spec.server.luau
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
local suite = require("./suite")
|
||||
|
||||
local describe = suite.describe
|
||||
local finish = suite.finish
|
||||
|
||||
local function test(name: string)
|
||||
describe(name, function()
|
||||
return require(`./{name}/spec`) :: any
|
||||
end)
|
||||
end
|
||||
|
||||
test("signals")
|
||||
|
||||
if game then
|
||||
test("sync")
|
||||
test("react")
|
||||
test("vide")
|
||||
end
|
||||
|
||||
finish()
|
||||
64
tests/suite.luau
Normal file
64
tests/suite.luau
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
local results: { string } = {}
|
||||
local stack: { string } = {}
|
||||
local errors: { string } = {}
|
||||
local tests = 0
|
||||
|
||||
local function log(result: string, index: number?)
|
||||
local index = index or #results + 1
|
||||
local indent = string.rep(" ", #stack - 1)
|
||||
|
||||
results[index] = indent .. result
|
||||
end
|
||||
|
||||
local function test(name: string, callback: () -> ())
|
||||
tests += 1
|
||||
table.insert(stack, name)
|
||||
|
||||
local success = xpcall(callback, function(err)
|
||||
local path = table.concat(stack, " > ")
|
||||
table.insert(errors, `\n🔴 {path}:\n{err}`)
|
||||
end)
|
||||
|
||||
if not success then
|
||||
log(`🔴 {name}`)
|
||||
end
|
||||
|
||||
table.remove(stack)
|
||||
end
|
||||
|
||||
local function describe(name: string, callback: () -> ())
|
||||
table.insert(stack, name)
|
||||
table.insert(results, name)
|
||||
|
||||
local index = #results
|
||||
local errorCount = #errors
|
||||
callback()
|
||||
|
||||
if #errors > errorCount then
|
||||
log(`🔴 {name}`, index)
|
||||
else
|
||||
log(`🟢 {name}`, index)
|
||||
end
|
||||
|
||||
table.remove(stack)
|
||||
end
|
||||
|
||||
local function finish()
|
||||
print(`\n🔵 {tests - #errors} / {tests} tests passed\n`)
|
||||
|
||||
for _, result in results do
|
||||
print(result)
|
||||
end
|
||||
|
||||
print()
|
||||
|
||||
if #errors > 0 then
|
||||
error(`{#errors} test(s) failed:\n{table.concat(errors, "\n")}\n`)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
test = test,
|
||||
describe = describe,
|
||||
finish = finish,
|
||||
}
|
||||
125
tests/sync/client.spec.luau
Normal file
125
tests/sync/client.spec.luau
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local ServerScriptService = game:GetService("ServerScriptService")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
|
||||
local suite = require(ServerScriptService.Tests.suite)
|
||||
|
||||
local atom = Charm.atom
|
||||
local subscribe = Charm.subscribe
|
||||
local client = CharmSync.client
|
||||
local test = suite.test
|
||||
|
||||
test("should sync state payload", function()
|
||||
local a = atom(1)
|
||||
local client = client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = 2 },
|
||||
})
|
||||
|
||||
assert(a() == 2)
|
||||
end)
|
||||
|
||||
test("should sync state patch", function()
|
||||
local a = atom({ b = 1, c = 2 })
|
||||
local previous = a()
|
||||
local client = client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a() },
|
||||
})
|
||||
|
||||
assert(a() == previous)
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = { b = 3 } },
|
||||
})
|
||||
|
||||
assert(a() ~= previous)
|
||||
assert(a().b == 3)
|
||||
assert(a().c == 2)
|
||||
end)
|
||||
|
||||
test("should fix sparse array patches", function()
|
||||
local a = atom({ 1 })
|
||||
local client = client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
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:sync({
|
||||
type = "patch",
|
||||
data = { a = { ["2"] = 2 } },
|
||||
})
|
||||
|
||||
assert(a()["2" :: any] == nil)
|
||||
assert(a()[2] == 2)
|
||||
end)
|
||||
|
||||
test("should apply multiple patches", function()
|
||||
local a = atom(1)
|
||||
local client = client({ atoms = { a = a } })
|
||||
local calls = {}
|
||||
|
||||
subscribe(a, function(value)
|
||||
table.insert(calls, value)
|
||||
end)
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a() },
|
||||
})
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = 2 },
|
||||
}, {
|
||||
type = "patch",
|
||||
data = { a = 3 },
|
||||
})
|
||||
|
||||
assert(a() == 3)
|
||||
assert(calls[1] == 2)
|
||||
assert(calls[2] == 3)
|
||||
end)
|
||||
|
||||
test("should batch patches", function()
|
||||
local a = atom(0)
|
||||
local b = atom(0)
|
||||
local client = client({ atoms = { a = a, b = b } })
|
||||
local states = {}
|
||||
|
||||
subscribe(a, function()
|
||||
states.b = b()
|
||||
end)
|
||||
|
||||
subscribe(b, function()
|
||||
states.a = a()
|
||||
end)
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a(), b = b() },
|
||||
})
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = 1, b = 2 },
|
||||
})
|
||||
|
||||
assert(a() == 1)
|
||||
assert(b() == 2)
|
||||
assert(states.a == 1)
|
||||
assert(states.b == 2)
|
||||
end)
|
||||
|
||||
return {}
|
||||
28
tests/sync/flatten.spec.luau
Normal file
28
tests/sync/flatten.spec.luau
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local ServerScriptService = game:GetService("ServerScriptService")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
|
||||
local suite = require(ServerScriptService.Tests.suite)
|
||||
|
||||
local atom = Charm.atom
|
||||
local flatten = CharmSync.flatten
|
||||
local test = suite.test
|
||||
|
||||
test("should flatten atoms", function()
|
||||
local input = {
|
||||
a = atom(0),
|
||||
b = { c = atom(0) },
|
||||
d = { e = { f = atom(0) } },
|
||||
}
|
||||
local result = flatten(input)
|
||||
|
||||
assert(result.a == input.a)
|
||||
assert(result["b/c"] == input.b.c)
|
||||
assert(result["d/e/f"] == input.d.e.f)
|
||||
|
||||
assert(result.b == nil)
|
||||
assert(result.d == nil)
|
||||
end)
|
||||
|
||||
return {}
|
||||
130
tests/sync/patch.spec.luau
Normal file
130
tests/sync/patch.spec.luau
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
local HttpService = game:GetService("HttpService")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local ServerScriptService = game:GetService("ServerScriptService")
|
||||
|
||||
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
|
||||
local suite = require(ServerScriptService.Tests.suite)
|
||||
|
||||
local test = suite.test
|
||||
|
||||
local function serdes(value: { [any]: any })
|
||||
return HttpService:JSONDecode(HttpService:JSONEncode(value))
|
||||
end
|
||||
|
||||
test("returns a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local nextState = { foo = "bar", baz = "quux" }
|
||||
local patches = patch.diff(prevState, nextState)
|
||||
|
||||
assert(patches ~= nil)
|
||||
assert(patches.baz == "quux")
|
||||
assert(patches.foo == nil)
|
||||
end)
|
||||
|
||||
test("applies a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local patches = { baz = "quux", qaz = "qux" }
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
assert(applied ~= nil)
|
||||
assert(applied.baz == "quux")
|
||||
assert(applied.foo == "bar")
|
||||
assert(applied.qaz == "qux")
|
||||
end)
|
||||
|
||||
test("allows nested patches", function()
|
||||
local prevState = { foo = { bar = "baz" } }
|
||||
local nextState = { foo = { bar = "qux" } }
|
||||
local patches = patch.diff(prevState, nextState)
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
assert(applied.foo.bar == "qux")
|
||||
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")
|
||||
assert(applied[2] == "B")
|
||||
assert(applied[3] == "c")
|
||||
assert(applied[4] == "D")
|
||||
|
||||
assert(applied["2"] == nil)
|
||||
assert(applied["4"] == nil)
|
||||
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 patches = serdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
for index in next, prevState do
|
||||
assert(applied[index] == nextState[index])
|
||||
end
|
||||
|
||||
for index in next, applied do
|
||||
assert(applied[index] == nextState[index])
|
||||
end
|
||||
end)
|
||||
|
||||
test("preserves large gaps between keys", function()
|
||||
local prevState = table.create(10000, "foo")
|
||||
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
|
||||
|
||||
local start = os.clock()
|
||||
|
||||
local patches = serdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
|
||||
|
||||
for index in next, prevState do
|
||||
assert(applied[index] == nextState[index])
|
||||
end
|
||||
end)
|
||||
|
||||
test("cannot preserve sparse arrays", function()
|
||||
local prevState = {}
|
||||
local nextState = { [2] = "foo" }
|
||||
|
||||
local patches = serdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
-- Not enough information is available to determine whether 'patches'
|
||||
-- should keep the string keys added during remote event serialization.
|
||||
assert(applied["2"] ~= nil)
|
||||
assert(applied[2] == nil)
|
||||
end)
|
||||
|
||||
test("optionally fixes sparse arrays", function()
|
||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do
|
||||
assert(type(index) == "string")
|
||||
end
|
||||
|
||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
|
||||
assert(type(index) == "number")
|
||||
end
|
||||
end)
|
||||
|
||||
test("optionally validates state", function()
|
||||
local success = pcall(function()
|
||||
patch.diff({}, { [0.1] = true }, true)
|
||||
end)
|
||||
|
||||
assert(not success)
|
||||
|
||||
success = pcall(function()
|
||||
patch.diff({}, { [0.1] = true }, false)
|
||||
end)
|
||||
|
||||
assert(success)
|
||||
assert(success)
|
||||
end)
|
||||
|
||||
return {}
|
||||
100
tests/sync/server.spec.luau
Normal file
100
tests/sync/server.spec.luau
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local ServerScriptService = game:GetService("ServerScriptService")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
|
||||
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
|
||||
local suite = require(ServerScriptService.Tests.suite)
|
||||
|
||||
type SyncPayload = CharmSync.SyncPayload
|
||||
|
||||
local atom = Charm.atom
|
||||
local server = CharmSync.server
|
||||
local test = suite.test
|
||||
|
||||
local target: Player = {} :: any
|
||||
|
||||
test("sends state to new players", function()
|
||||
local a = atom(1)
|
||||
local server = server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: SyncPayload
|
||||
|
||||
local disconnect = server:connect(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
server:hydrate(target)
|
||||
|
||||
assert(player == target)
|
||||
assert(payload.type == "init")
|
||||
assert(payload.data.a == 1)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
|
||||
test("sends state patches", function()
|
||||
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
|
||||
local server = server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: SyncPayload
|
||||
|
||||
local disconnect = server:connect(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
a({ b = 1, c = 5 })
|
||||
server:_sendPatch(target)
|
||||
|
||||
assert(player == target)
|
||||
assert(payload.type == "patch")
|
||||
assert(payload.data.a.b == nil)
|
||||
assert(payload.data.a.c == 5)
|
||||
|
||||
a({ b = 1, d = 1 })
|
||||
server:_sendPatch(target)
|
||||
|
||||
assert(payload.data.a.b == nil)
|
||||
assert(patch.isNone(payload.data.a.c) == true)
|
||||
assert(payload.data.a.d == 1)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
|
||||
test("preserves history", function()
|
||||
local a = atom(1)
|
||||
local b = atom(1)
|
||||
local payloads: { SyncPayload }
|
||||
|
||||
local server = server({
|
||||
atoms = { a = a, b = b },
|
||||
interval = -1,
|
||||
preserveHistory = true,
|
||||
})
|
||||
|
||||
local disconnect = server:connect(function(_, ...)
|
||||
payloads = { ... }
|
||||
end)
|
||||
|
||||
a(2)
|
||||
b(2)
|
||||
server:_sendPatch(target)
|
||||
|
||||
assert(#payloads == 1)
|
||||
assert(payloads[1].type == "patch")
|
||||
assert(payloads[1].data.a == 2)
|
||||
assert(payloads[1].data.b == 2)
|
||||
|
||||
a(3)
|
||||
a(4)
|
||||
b(3)
|
||||
server:_sendPatch(target)
|
||||
|
||||
assert(#payloads == 2)
|
||||
assert(payloads[1].type == "patch")
|
||||
assert(payloads[1].data.a == 3)
|
||||
assert(payloads[1].data.b == nil)
|
||||
assert(payloads[2].type == "patch")
|
||||
assert(payloads[2].data.a == 4)
|
||||
assert(payloads[2].data.b == 3)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
17
tests/sync/spec.luau
Normal file
17
tests/sync/spec.luau
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
local suite = require("../suite")
|
||||
|
||||
local describe = suite.describe
|
||||
|
||||
local function test(name: string)
|
||||
describe(name, function()
|
||||
return require(`./{name}.spec`) :: any
|
||||
end)
|
||||
end
|
||||
|
||||
test("client")
|
||||
test("server")
|
||||
test("flatten")
|
||||
test("patch")
|
||||
test("validate")
|
||||
|
||||
return {}
|
||||
35
tests/sync/validate.spec.luau
Normal file
35
tests/sync/validate.spec.luau
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local ServerScriptService = game:GetService("ServerScriptService")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local suite = require(ServerScriptService.Tests.suite)
|
||||
local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate)
|
||||
|
||||
local atom = Charm.atom
|
||||
local test = suite.test
|
||||
|
||||
test("validates inputs", function()
|
||||
local cases: { [string]: { value: any, expected: any } } = {
|
||||
array = { value = { 1, 2, 3 }, expected = true },
|
||||
array_sparse = { value = { 1, nil :: any, 3 }, expected = true },
|
||||
array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false },
|
||||
|
||||
map_decimal_key = { value = { [1.5] = 0 }, expected = false },
|
||||
map_infinity_key = { value = { [math.huge] = true }, expected = false },
|
||||
map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false },
|
||||
|
||||
nested_atom = { value = atom(0), expected = false },
|
||||
}
|
||||
|
||||
for key, case in next, cases do
|
||||
local success, result = pcall(function()
|
||||
validate(case.value, key)
|
||||
end)
|
||||
|
||||
if case.expected and not success then
|
||||
error(`Expected {key} to be valid, threw {result}`)
|
||||
elseif not case.expected and success then
|
||||
error(`Expected {key} to be invalid, but it was valid`)
|
||||
end
|
||||
end
|
||||
end)
|
||||
13
tests/vide/spec.luau
Normal file
13
tests/vide/spec.luau
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
local suite = require("../suite")
|
||||
|
||||
local describe = suite.describe
|
||||
|
||||
local function test(name: string)
|
||||
describe(name, function()
|
||||
return require(`./{name}.spec`) :: any
|
||||
end)
|
||||
end
|
||||
|
||||
test("useAtom")
|
||||
|
||||
return {}
|
||||
|
|
@ -1,26 +1,29 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local ServerScriptService = game:GetService("ServerScriptService")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local Vide = require(ReplicatedStorage.DevPackages.Vide)
|
||||
local root = Vide.root
|
||||
local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm)
|
||||
local useAtom = VideCharm.useAtom
|
||||
local suite = require(ServerScriptService.Tests.suite)
|
||||
|
||||
return function()
|
||||
it("tracks atom state", function()
|
||||
local atom = Charm.atom
|
||||
local root = Vide.root
|
||||
local useAtom = VideCharm.useAtom
|
||||
local test = suite.test
|
||||
|
||||
test("tracks atom state", function()
|
||||
local state = atom(0)
|
||||
local source
|
||||
local cleanup = root(function()
|
||||
source = useAtom(state)
|
||||
end)
|
||||
expect(source()).to.equal(0)
|
||||
assert(source() == 0)
|
||||
state(1)
|
||||
expect(source()).to.equal(1)
|
||||
assert(source() == 1)
|
||||
cleanup()
|
||||
end)
|
||||
|
||||
it("accepts a selector", function()
|
||||
test("accepts a selector", function()
|
||||
local state = atom(0)
|
||||
local source
|
||||
local cleanup = root(function()
|
||||
|
|
@ -28,9 +31,10 @@ return function()
|
|||
return 2 * state()
|
||||
end)
|
||||
end)
|
||||
expect(source()).to.equal(0)
|
||||
assert(source() == 0)
|
||||
state(1)
|
||||
expect(source()).to.equal(2)
|
||||
assert(source() == 2)
|
||||
cleanup()
|
||||
end)
|
||||
end
|
||||
|
||||
return {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue