From 861ee5acd7796d37cd5de26ab7e2633bfd3b28cb Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 3 May 2025 18:24:39 -0700 Subject: [PATCH] feat: use alien-signals --- .github/workflows/ci.yml | 13 +- .github/workflows/release.yml | 4 +- .gitignore | 1 + .luaurc | 5 +- bin/check.sh | 29 +- bin/test.sh | 1 + bin/testez.d.luau | 24 - modules/wally.toml | 1 - package.json | 2 +- packages/charm-sync/src/client.luau | 2 +- packages/charm/src/atom.luau | 76 +-- packages/charm/src/batched.luau | 16 + packages/charm/src/computed.luau | 36 +- packages/charm/src/effect.luau | 61 +-- packages/charm/src/effectScope.luau | 13 + packages/charm/src/index.d.ts | 116 ++--- packages/charm/src/init.luau | 30 +- packages/charm/src/lib.luau | 28 + packages/charm/src/listen.luau | 40 ++ packages/charm/src/mapped.luau | 174 ++++--- packages/charm/src/observe.luau | 56 +- packages/charm/src/signals.luau | 400 ++++++++++++++ packages/charm/src/spawn.luau | 36 ++ packages/charm/src/store.luau | 214 -------- packages/charm/src/subscribe.luau | 58 +-- packages/charm/src/system.luau | 357 +++++++++++++ packages/charm/src/types.luau | 44 +- packages/charm/src/untracked.luau | 18 + packages/react/src/index.d.ts | 4 +- packages/react/src/init.luau | 13 +- packages/vide/src/index.d.ts | 4 +- packages/vide/src/init.luau | 16 +- rokit.toml | 7 +- selene.toml | 2 +- selene_defs.yml | 11 + stylua.toml | 3 + testez-companion.toml | 1 - testez.yml | 53 -- .../{atom.bench.luau => effect.bench.luau} | 18 +- tests/benchmarks/propagate.bench.luau | 38 ++ tests/benchmarks/propagate.luau | 61 +++ tests/charm-sync/client.spec.luau | 121 ----- tests/charm-sync/flatten.spec.luau | 24 - tests/charm-sync/patch.spec.luau | 121 ----- tests/charm-sync/server.spec.luau | 97 ---- tests/charm-sync/validate.spec.luau | 33 -- tests/charm/atom.spec.luau | 62 --- tests/charm/batch.spec.luau | 64 --- tests/charm/capture.spec.luau | 64 --- tests/charm/computed.spec.luau | 105 ---- tests/charm/effect.spec.luau | 111 ---- tests/charm/mapped.spec.luau | 97 ---- tests/charm/observe.spec.luau | 35 -- tests/charm/peek.spec.luau | 26 - tests/charm/subscribe.spec.luau | 101 ---- tests/init.server.luau | 13 - tests/init.spec.luau | 11 - tests/react/spec.luau | 13 + tests/react/useAtom.spec.luau | 72 +-- tests/signals/computed.spec.luau | 82 +++ tests/signals/effect.spec.luau | 337 ++++++++++++ tests/signals/effectScope.spec.luau | 66 +++ tests/signals/issue_48.spec.luau | 113 ++++ tests/signals/spec.luau | 18 + tests/signals/topology.spec.luau | 493 ++++++++++++++++++ tests/signals/untrack.spec.luau | 23 + tests/spec.server.luau | 20 + tests/suite.luau | 64 +++ tests/sync/client.spec.luau | 125 +++++ tests/sync/flatten.spec.luau | 28 + tests/sync/patch.spec.luau | 130 +++++ tests/sync/server.spec.luau | 100 ++++ tests/sync/spec.luau | 17 + tests/sync/validate.spec.luau | 35 ++ tests/vide/spec.luau | 13 + tests/vide/useAtom.spec.luau | 54 +- 76 files changed, 3112 insertions(+), 1862 deletions(-) create mode 100644 bin/test.sh delete mode 100644 bin/testez.d.luau create mode 100644 packages/charm/src/batched.luau create mode 100644 packages/charm/src/effectScope.luau create mode 100644 packages/charm/src/lib.luau create mode 100644 packages/charm/src/listen.luau create mode 100644 packages/charm/src/signals.luau create mode 100644 packages/charm/src/spawn.luau delete mode 100644 packages/charm/src/store.luau create mode 100644 packages/charm/src/system.luau create mode 100644 packages/charm/src/untracked.luau create mode 100644 selene_defs.yml delete mode 100644 testez-companion.toml delete mode 100644 testez.yml rename tests/benchmarks/{atom.bench.luau => effect.bench.luau} (66%) create mode 100644 tests/benchmarks/propagate.bench.luau create mode 100644 tests/benchmarks/propagate.luau delete mode 100644 tests/charm-sync/client.spec.luau delete mode 100644 tests/charm-sync/flatten.spec.luau delete mode 100644 tests/charm-sync/patch.spec.luau delete mode 100644 tests/charm-sync/server.spec.luau delete mode 100644 tests/charm-sync/validate.spec.luau delete mode 100644 tests/charm/atom.spec.luau delete mode 100644 tests/charm/batch.spec.luau delete mode 100644 tests/charm/capture.spec.luau delete mode 100644 tests/charm/computed.spec.luau delete mode 100644 tests/charm/effect.spec.luau delete mode 100644 tests/charm/mapped.spec.luau delete mode 100644 tests/charm/observe.spec.luau delete mode 100644 tests/charm/peek.spec.luau delete mode 100644 tests/charm/subscribe.spec.luau delete mode 100644 tests/init.server.luau delete mode 100644 tests/init.spec.luau create mode 100644 tests/react/spec.luau create mode 100644 tests/signals/computed.spec.luau create mode 100644 tests/signals/effect.spec.luau create mode 100644 tests/signals/effectScope.spec.luau create mode 100644 tests/signals/issue_48.spec.luau create mode 100644 tests/signals/spec.luau create mode 100644 tests/signals/topology.spec.luau create mode 100644 tests/signals/untrack.spec.luau create mode 100644 tests/spec.server.luau create mode 100644 tests/suite.luau create mode 100644 tests/sync/client.spec.luau create mode 100644 tests/sync/flatten.spec.luau create mode 100644 tests/sync/patch.spec.luau create mode 100644 tests/sync/server.spec.luau create mode 100644 tests/sync/spec.luau create mode 100644 tests/sync/validate.spec.luau create mode 100644 tests/vide/spec.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4369391..142e5dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07b75ad..cd05b2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index f3a27c4..5701a11 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ *.tgz *.rbxm .DS_Store +roblox.d.luau diff --git a/.luaurc b/.luaurc index e2b625c..4c8043d 100644 --- a/.luaurc +++ b/.luaurc @@ -1,3 +1,6 @@ { - "languageMode": "strict" + "languageMode": "strict", + "lint": { + "LocalShadow": false + } } diff --git a/bin/check.sh b/bin/check.sh index 0e948f8..31abbe9 100644 --- a/bin/check.sh +++ b/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 \ + --flag:LuauFixIndexerSubtypingOrdering=true \ + --sourcemap=sourcemap.json \ + --ignore="**/_Index/**" \ + packages tests - 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 +selene packages tests - selene $1 - stylua --check $1 -} +stylua --check packages tests eslint packages -for i in packages/*/src; do - check $i -done - -check tests - rm bin/roblox.d.luau diff --git a/bin/test.sh b/bin/test.sh new file mode 100644 index 0000000..e1b757a --- /dev/null +++ b/bin/test.sh @@ -0,0 +1 @@ +lute tests/spec.server.luau diff --git a/bin/testez.d.luau b/bin/testez.d.luau deleted file mode 100644 index 28bef92..0000000 --- a/bin/testez.d.luau +++ /dev/null @@ -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: () -> ()): () diff --git a/modules/wally.toml b/modules/wally.toml index 096ab3f..0c18cdc 100644 --- a/modules/wally.toml +++ b/modules/wally.toml @@ -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" diff --git a/package.json b/package.json index 73e2375..3fbcdf4 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,5 @@ "roblox-ts": "^3.0.0", "typescript": "~5.5.4" }, - "packageManager": "pnpm@9.15.4" + "packageManager": "pnpm@10.10.0" } diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 8f3cfb1..b09b870 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -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 diff --git a/packages/charm/src/atom.luau b/packages/charm/src/atom.luau index f10dbd6..5cb0417 100644 --- a/packages/charm/src/atom.luau +++ b/packages/charm/src/atom.luau @@ -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 = types.Atom type AtomOptions = types.AtomOptions ---[=[ - Creates a new atom with the given state. - - @param state The initial state. - @param options Optional configuration. - @return A new atom. -]=] -local function atom(state: T, options: AtomOptions?): Atom - local equals = options and options.equals +local signal = signals.signal +local pauseTracking = signals.pauseTracking +local resumeTracking = signals.resumeTracking - local function atom(...) - if select("#", ...) == 0 then - local index = store.capturing.index - - if index > 0 then - store.capturing.stack[index][atom] = true - end - - return state - end - - local nextState = store.peek(..., state) - - if state ~= nextState and not (equals and equals(state, nextState)) then - state = nextState - store.notify(atom) - end - - return state +local function atomOper(source: (...T) -> T, equals: ((T, T) -> boolean)?, ...: ((T) -> T) | T): T + if select("#", ...) == 0 then + return source() end - store.listeners[atom] = setmetatable({}, { __mode = "v" }) + local transform = (...) + local currentState = (source :: any).value + local nextState: T - return atom + -- 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 + +local function atom(state: T, options: AtomOptions?): Atom + local source = signal(state) + local equals = options and options.equals + + return function(...) + return atomOper(source, equals, ...) + end end return atom diff --git a/packages/charm/src/batched.luau b/packages/charm/src/batched.luau new file mode 100644 index 0000000..4eaa86b --- /dev/null +++ b/packages/charm/src/batched.luau @@ -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 diff --git a/packages/charm/src/computed.luau b/packages/charm/src/computed.luau index e786c45..70b9536 100644 --- a/packages/charm/src/computed.luau +++ b/packages/charm/src/computed.luau @@ -1,37 +1,13 @@ -local atom = require(script.Parent.atom) -local store = require(script.Parent.store) -local types = require(script.Parent.types) -type AtomOptions = types.AtomOptions -type Selector = types.Selector +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(callback: Selector, options: AtomOptions?): Selector - local dependencies, state = store.capture(callback) - local computedAtom = atom(state, options) - local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" }) +local function computed(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) - end + return function() + return getter() end - - store.connect(dependencies, listener, computedAtom) - - return computedAtom end return computed diff --git a/packages/charm/src/effect.luau b/packages/charm/src/effect.luau index 23f3294..274edb4 100644 --- a/packages/charm/src/effect.luau +++ b/packages/charm/src/effect.luau @@ -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. - - @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 effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> () + local disposed = false + local dispose: () -> () - 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 - end + dispose = effectScope(function() + effectImpl(function() + return callback(disposeSafe) + end) + end) - disconnected = true - store.disconnect(dependencies, listener) - - if cleanup then - cleanup() - end + if disposed then + dispose() end - dependencies, cleanup = store.capture(callback, disconnect) - - if not disconnected then - store.connect(dependencies, listener) - end - - return disconnect + return disposeSafe end -return effect :: (callback: ((cleanup: Cleanup) -> ()) | ((cleanup: Cleanup) -> Cleanup?)) -> Cleanup +return effect diff --git a/packages/charm/src/effectScope.luau b/packages/charm/src/effectScope.luau new file mode 100644 index 0000000..92147a1 --- /dev/null +++ b/packages/charm/src/effectScope.luau @@ -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 diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index da1c5fa..6244d62 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -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 extends Selector { + interface Atom { + (): State; (nextState: State | ((prevState: State) => State)): State; } @@ -23,19 +24,10 @@ declare namespace Charm { */ type Selector = () => 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` instead. - * @returns The current state. - */ - type Molecule = Selector; - /** * Infers the type of the state produced by the given function. */ - type StateOf = T extends Selector ? State : never; + type StateOf = T extends () => infer State ? State : never; interface AtomOptions { /** @@ -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(callback: Selector, options?: AtomOptions): Selector; + function computed(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(callback: Selector, listener: (state: State, prev: State) => void): Cleanup; + function subscribe(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( + 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(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; + 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(callback: Selector): LuaTuple<[dependencies: Set>, 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(atom: Atom): void; + function untracked(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( - callback: Selector, - factory: (item: Item, index: number) => Cleanup | void, - ): Cleanup; + callback: () => readonly Item[], + factory: (item: Item, index: number) => Dispose | void, + ): Dispose; function observe( - callback: Selector | ReadonlyMap>, - factory: (item: Item, key: Key) => Cleanup | void, - ): Cleanup; + callback: () => Map | ReadonlyMap, + factory: (item: Item, key: Key) => Dispose | void, + ): Dispose; function observe( - 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( - callback: Selector, + callback: () => readonly V0[], mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>, - ): Selector>; + ): () => ReadonlyMap; function mapped( - callback: Selector, + callback: () => readonly V0[], mapper: (value: V0, index: number) => V1, - ): Selector; + ): () => readonly V1[]; function mapped( - callback: Selector | ReadonlyMap>, + callback: () => Map | ReadonlyMap, mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, - ): Selector>; + ): () => ReadonlyMap; function mapped( - 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; } diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 4bc1a8c..ef19757 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -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 = types.Atom -export type Selector = types.Selector -export type Molecule = types.Selector -export type SyncPayload = types.SyncPayload +export type Atom = lib.Atom +export type AtomOptions = lib.AtomOptions +export type Selector = lib.Selector -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 diff --git a/packages/charm/src/lib.luau b/packages/charm/src/lib.luau new file mode 100644 index 0000000..7ab5101 --- /dev/null +++ b/packages/charm/src/lib.luau @@ -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 = types.Atom +export type AtomOptions = types.AtomOptions +export type Selector = types.Selector + +return { + atom = atom, + batched = batched, + computed = computed, + effect = effect, + effectScope = effectScope, + listen = listen, + mapped = mapped, + observe = observe, + subscribe = subscribe, + untracked = untracked, +} diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau new file mode 100644 index 0000000..56c3deb --- /dev/null +++ b/packages/charm/src/listen.luau @@ -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(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 diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index 555aa3f..641615a 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -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 = types.Atom -type Selector = types.Selector +local computed = require("./computed") +local signals = require("./signals") -type Map = - ((fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Selector<{ [K1]: V1 }>) - & ((fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Selector<{ [K0]: 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. - - @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(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 +type MappedKey = { + key: KO, + value: VO, +} - local function listener(items: { [K0]: V0 }) - local mappedAtom = mappedAtomRef.current +local function mapped(selector: () -> { [KI]: VI }, transform: (item: VI, key: KI) -> (VO, KO?)): () -> { [KO]: VO } + local mappedKeys: { [KI]: MappedKey } = {} + local previousItems: { [KI]: VI }? - if not mappedAtom then - return unsubscribe() + local function getTransform(value: VI, key: KI): (VO, KO?) + pauseTracking() + local success, valueOut, keyOut = pcall(transform, value, key) + resumeTracking() + + 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 - end - if mappedItems[newKey :: K1] ~= newItem then - mappedItems[newKey :: K1] = newItem :: V1 - else - mappedKeys[newKey] = key - end - end - - for key in next, prevMappedItems do - if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then - mappedItems[key] = nil - end - end - - prevMappedItems = mappedItems - mappedAtom(mappedItems) + return valueOut, keyOut end - unsubscribe = subscribe(callback, listener) + return computed(function(previousMappedItems) + local currentItems = selector() - store.peek(function(): () - listener(callback()) + -- 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 + + 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 + + previousItems = currentItems + + return mappedItems end) - - return mappedAtom end -return mapped :: Map +return mapped diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index 120ae3b..eaa2522 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -1,49 +1,37 @@ -local store = require(script.Parent.store) -local subscribe = require(script.Parent.subscribe) -local types = require(script.Parent.types) -type Selector = types.Selector +local listen = require("./listen") -local function noop() end +local function observe(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. - - @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(callback: Selector<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> () - local connections: { [K]: () -> () } = {} + 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 - 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 diff --git a/packages/charm/src/signals.luau b/packages/charm/src/signals.luau new file mode 100644 index 0000000..d6520ea --- /dev/null +++ b/packages/charm/src/signals.luau @@ -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 = ReactiveNode & { + value: T?, + getter: (previousValue: T?) -> T, +} + +type Signal = 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(this: Signal, ...: 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(this: Computed): 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(initialValue: T): ((T) -> ()) & (() -> T) + local this: Signal = { + previousValue = initialValue, + value = initialValue, + flags = MUTABLE, + } + return setmetatable(this, signalMetatable) :: any +end + +local function computed(getter: (previousValue: T?) -> T): () -> T + local this: Computed = { + 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, +} diff --git a/packages/charm/src/spawn.luau b/packages/charm/src/spawn.luau new file mode 100644 index 0000000..985dc07 --- /dev/null +++ b/packages/charm/src/spawn.luau @@ -0,0 +1,36 @@ +local freeThread: thread? + +local function run(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(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 diff --git a/packages/charm/src/store.luau b/packages/charm/src/store.luau deleted file mode 100644 index fbffa90..0000000 --- a/packages/charm/src/store.luau +++ /dev/null @@ -1,214 +0,0 @@ -local types = require(script.Parent.types) -type Atom = types.Atom -type Set = types.Set -type WeakMap = types.WeakMap - -local __DEV__ = _G.__DEV__ - -local listeners: WeakMap, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" }) - -local batched: Set<() -> ()> = {} -local batching = false - -local capturing = { - stack = {} :: { Set> }, - 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(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) - 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(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(callback: (U...) -> T, ...: U...): (Set>, 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> = {} - - 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>, 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>, 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, -} diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index 0286d68..9797d55 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -1,42 +1,36 @@ -local store = require(script.Parent.store) -local types = require(script.Parent.types) -type Selector = types.Selector +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. - - @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(callback: Selector, listener: (state: T, prev: T) -> ()): () -> () - local dependencies, state = store.capture(callback) - local disconnected = false +local effect = signals.effect +local pauseTracking = signals.pauseTracking +local resumeTracking = signals.resumeTracking - local function handler() - local prevState = state +local function subscribe(selector: () -> T, listener: (T, T) -> ()): () -> () + local current: T + local mount = true + local dispose: () -> () - store.disconnect(dependencies, handler) - dependencies, state = store.capture(callback) + dispose = effectScope(function() + effect(function() + local value = selector() - if not disconnected then - store.connect(dependencies, handler) - end + if mount then + current = value + mount = false + else + local previous = current + current = value - if state ~= prevState then - listener(state, prevState) - end - end - - store.connect(dependencies, handler) + pauseTracking() + spawn(listener, value, previous) + resumeTracking() + end + end) + end) return function() - if not disconnected then - disconnected = true - store.disconnect(dependencies, handler) - end + dispose() end end diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau new file mode 100644 index 0000000..79d3a48 --- /dev/null +++ b/packages/charm/src/system.luau @@ -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 = { + value: T, + prev: Stack?, +} + +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? + + 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? + 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).value + stack = (stack :: Stack).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, +} diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau index fd3ba6b..68edaea 100644 --- a/packages/charm/src/types.luau +++ b/packages/charm/src/types.luau @@ -1,43 +1,9 @@ -export type Set = { [T]: true } +export type Selector = () -> T -export type WeakMap = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" })) +export type Atom = ((((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 = (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 - ---[=[ - Optional configuration for creating an atom. -]=] -export type AtomOptions = { - --[=[ - 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 = { + 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 {} diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau new file mode 100644 index 0000000..d494345 --- /dev/null +++ b/packages/charm/src/untracked.luau @@ -0,0 +1,18 @@ +local signals = require("./signals") + +local pauseTracking = signals.pauseTracking +local resumeTracking = signals.resumeTracking + +local function untracked(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 diff --git a/packages/react/src/index.d.ts b/packages/react/src/index.d.ts index e3eb917..d35c092 100644 --- a/packages/react/src/index.d.ts +++ b/packages/react/src/index.d.ts @@ -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(callback: Selector, dependencies?: unknown[]): State; +export function useAtom(callback: () => State, dependencies?: unknown[]): State; diff --git a/packages/react/src/init.luau b/packages/react/src/init.luau index e200551..dda3c33 100644 --- a/packages/react/src/init.luau +++ b/packages/react/src/init.luau @@ -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(callback: Charm.Selector, 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 diff --git a/packages/vide/src/index.d.ts b/packages/vide/src/index.d.ts index 21ace65..2d51266 100644 --- a/packages/vide/src/index.d.ts +++ b/packages/vide/src/index.d.ts @@ -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(callback: Selector): () => T; +export function useAtom(callback: () => T): () => T; diff --git a/packages/vide/src/init.luau b/packages/vide/src/init.luau index df962b2..6590ef7 100644 --- a/packages/vide/src/init.luau +++ b/packages/vide/src/init.luau @@ -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(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 diff --git a/rokit.toml b/rokit.toml index 02a00e8..f6f9bb3 100644 --- a/rokit.toml +++ b/rokit.toml @@ -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" diff --git a/selene.toml b/selene.toml index 56479a7..1dab6d8 100644 --- a/selene.toml +++ b/selene.toml @@ -1,4 +1,4 @@ -std = "roblox+testez" +std = "selene_defs" [lints] global_usage = "allow" diff --git a/selene_defs.yml b/selene_defs.yml new file mode 100644 index 0000000..0b25bb6 --- /dev/null +++ b/selene_defs.yml @@ -0,0 +1,11 @@ +base: roblox +name: selene_defs +globals: + require: + args: + - type: string + assert: + args: + - type: any + - type: any + required: false diff --git a/stylua.toml b/stylua.toml index caaa48d..26bd9ac 100644 --- a/stylua.toml +++ b/stylua.toml @@ -1 +1,4 @@ syntax = "Luau" + +[sort_requires] +enabled = true diff --git a/testez-companion.toml b/testez-companion.toml deleted file mode 100644 index 204e049..0000000 --- a/testez-companion.toml +++ /dev/null @@ -1 +0,0 @@ -roots = ["ServerScriptService/Tests"] diff --git a/testez.yml b/testez.yml deleted file mode 100644 index 08be74e..0000000 --- a/testez.yml +++ /dev/null @@ -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 diff --git a/tests/benchmarks/atom.bench.luau b/tests/benchmarks/effect.bench.luau similarity index 66% rename from tests/benchmarks/atom.bench.luau rename to tests/benchmarks/effect.bench.luau index 3bae3ca..fcbc85e 100644 --- a/tests/benchmarks/atom.bench.luau +++ b/tests/benchmarks/effect.bench.luau @@ -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 diff --git a/tests/benchmarks/propagate.bench.luau b/tests/benchmarks/propagate.bench.luau new file mode 100644 index 0000000..7f66876 --- /dev/null +++ b/tests/benchmarks/propagate.bench.luau @@ -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, + computed: (() -> 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, + }, +} diff --git a/tests/benchmarks/propagate.luau b/tests/benchmarks/propagate.luau new file mode 100644 index 0000000..d05bc12 --- /dev/null +++ b/tests/benchmarks/propagate.luau @@ -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) diff --git a/tests/charm-sync/client.spec.luau b/tests/charm-sync/client.spec.luau deleted file mode 100644 index 2d2de15..0000000 --- a/tests/charm-sync/client.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm-sync/flatten.spec.luau b/tests/charm-sync/flatten.spec.luau deleted file mode 100644 index c274d05..0000000 --- a/tests/charm-sync/flatten.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm-sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau deleted file mode 100644 index 6e850b2..0000000 --- a/tests/charm-sync/patch.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm-sync/server.spec.luau b/tests/charm-sync/server.spec.luau deleted file mode 100644 index 5fc33f9..0000000 --- a/tests/charm-sync/server.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm-sync/validate.spec.luau b/tests/charm-sync/validate.spec.luau deleted file mode 100644 index 0a378d8..0000000 --- a/tests/charm-sync/validate.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm/atom.spec.luau b/tests/charm/atom.spec.luau deleted file mode 100644 index 0872037..0000000 --- a/tests/charm/atom.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm/batch.spec.luau b/tests/charm/batch.spec.luau deleted file mode 100644 index 375f515..0000000 --- a/tests/charm/batch.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm/capture.spec.luau b/tests/charm/capture.spec.luau deleted file mode 100644 index 80669a5..0000000 --- a/tests/charm/capture.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm/computed.spec.luau b/tests/charm/computed.spec.luau deleted file mode 100644 index d71d46d..0000000 --- a/tests/charm/computed.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm/effect.spec.luau b/tests/charm/effect.spec.luau deleted file mode 100644 index 1e834d0..0000000 --- a/tests/charm/effect.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm/mapped.spec.luau b/tests/charm/mapped.spec.luau deleted file mode 100644 index ea3d406..0000000 --- a/tests/charm/mapped.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm/observe.spec.luau b/tests/charm/observe.spec.luau deleted file mode 100644 index 314366c..0000000 --- a/tests/charm/observe.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm/peek.spec.luau b/tests/charm/peek.spec.luau deleted file mode 100644 index 7b72b86..0000000 --- a/tests/charm/peek.spec.luau +++ /dev/null @@ -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 diff --git a/tests/charm/subscribe.spec.luau b/tests/charm/subscribe.spec.luau deleted file mode 100644 index a732be9..0000000 --- a/tests/charm/subscribe.spec.luau +++ /dev/null @@ -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 diff --git a/tests/init.server.luau b/tests/init.server.luau deleted file mode 100644 index 518d08b..0000000 --- a/tests/init.server.luau +++ /dev/null @@ -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) diff --git a/tests/init.spec.luau b/tests/init.spec.luau deleted file mode 100644 index aed8099..0000000 --- a/tests/init.spec.luau +++ /dev/null @@ -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 diff --git a/tests/react/spec.luau b/tests/react/spec.luau new file mode 100644 index 0000000..7fcdaf0 --- /dev/null +++ b/tests/react/spec.luau @@ -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 {} diff --git a/tests/react/useAtom.spec.luau b/tests/react/useAtom.spec.luau index 5476f28..4e725e5 100644 --- a/tests/react/useAtom.spec.luau +++ b/tests/react/useAtom.spec.luau @@ -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 state = atom(0) - local hook = renderHook(function() - return useAtom(state) - end) - expect(hook.result).to.equal(0) - state(1) - expect(hook.result).to.equal(1) - end) +local atom = Charm.atom +local useAtom = ReactCharm.useAtom +local test = suite.test - it("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) - state(1) - expect(hook.result).to.equal(2) +test("tracks atom state", function() + local state = atom(0) + local hook = renderHook(function() + return useAtom(state) end) + assert(hook.result == 0) + state(1) + assert(hook.result == 1) +end) - it("tracks dependencies", function() - local state = atom(0) - local hook = renderHook(function() - return useAtom(state, { state }) +test("accepts a selector", function() + local state = atom(0) + local hook = renderHook(function() + return useAtom(function() + return 2 * state() end) - expect(hook.result).to.equal(0) - state(1) - expect(hook.result).to.equal(1) - state = atom(0) - hook.rerender() - expect(hook.result).to.equal(0) - state(1) - expect(hook.result).to.equal(1) end) -end + assert(hook.result == 0) + state(1) + assert(hook.result == 2) +end) + +test("tracks dependencies", function() + local state = atom(0) + local hook = renderHook(function() + return useAtom(state, { state }) + end) + assert(hook.result == 0) + state(1) + assert(hook.result == 1) + state = atom(0) + hook.rerender() + assert(hook.result == 0) + state(1) + assert(hook.result == 1) +end) diff --git a/tests/signals/computed.spec.luau b/tests/signals/computed.spec.luau new file mode 100644 index 0000000..9cff908 --- /dev/null +++ b/tests/signals/computed.spec.luau @@ -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 {} diff --git a/tests/signals/effect.spec.luau b/tests/signals/effect.spec.luau new file mode 100644 index 0000000..dd8d9df --- /dev/null +++ b/tests/signals/effect.spec.luau @@ -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 {} diff --git a/tests/signals/effectScope.spec.luau b/tests/signals/effectScope.spec.luau new file mode 100644 index 0000000..d958993 --- /dev/null +++ b/tests/signals/effectScope.spec.luau @@ -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 {} diff --git a/tests/signals/issue_48.spec.luau b/tests/signals/issue_48.spec.luau new file mode 100644 index 0000000..8899f97 --- /dev/null +++ b/tests/signals/issue_48.spec.luau @@ -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 = { + fireImmediately: boolean?, + equals: ((a: T, b: T?) -> boolean)?, + onError: (error: unknown) -> ()?, + scheduler: (fn: () -> ()) -> ()?, + once: boolean?, +} + +local function untracked(callback: () -> T): T + pauseTracking() + local success, result = pcall(callback) + resumeTracking() + if not success then + error(result) + end + return result +end + +local function reaction( + dataFn: () -> T, + effectFn: (newValue: T, oldValue: T?) -> (), + options: ReactionOptions? +): () -> () + local options: ReactionOptions = 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 {} diff --git a/tests/signals/spec.luau b/tests/signals/spec.luau new file mode 100644 index 0000000..4853275 --- /dev/null +++ b/tests/signals/spec.luau @@ -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 {} diff --git a/tests/signals/topology.spec.luau b/tests/signals/topology.spec.luau new file mode 100644 index 0000000..d61f92b --- /dev/null +++ b/tests/signals/topology.spec.luau @@ -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 {} diff --git a/tests/signals/untrack.spec.luau b/tests/signals/untrack.spec.luau new file mode 100644 index 0000000..c35c1f1 --- /dev/null +++ b/tests/signals/untrack.spec.luau @@ -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 {} diff --git a/tests/spec.server.luau b/tests/spec.server.luau new file mode 100644 index 0000000..109d845 --- /dev/null +++ b/tests/spec.server.luau @@ -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() diff --git a/tests/suite.luau b/tests/suite.luau new file mode 100644 index 0000000..71429c4 --- /dev/null +++ b/tests/suite.luau @@ -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, +} diff --git a/tests/sync/client.spec.luau b/tests/sync/client.spec.luau new file mode 100644 index 0000000..a4c9352 --- /dev/null +++ b/tests/sync/client.spec.luau @@ -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 {} diff --git a/tests/sync/flatten.spec.luau b/tests/sync/flatten.spec.luau new file mode 100644 index 0000000..94533db --- /dev/null +++ b/tests/sync/flatten.spec.luau @@ -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 {} diff --git a/tests/sync/patch.spec.luau b/tests/sync/patch.spec.luau new file mode 100644 index 0000000..56205c2 --- /dev/null +++ b/tests/sync/patch.spec.luau @@ -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 {} diff --git a/tests/sync/server.spec.luau b/tests/sync/server.spec.luau new file mode 100644 index 0000000..6a7aefa --- /dev/null +++ b/tests/sync/server.spec.luau @@ -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) diff --git a/tests/sync/spec.luau b/tests/sync/spec.luau new file mode 100644 index 0000000..b53aefc --- /dev/null +++ b/tests/sync/spec.luau @@ -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 {} diff --git a/tests/sync/validate.spec.luau b/tests/sync/validate.spec.luau new file mode 100644 index 0000000..067dc30 --- /dev/null +++ b/tests/sync/validate.spec.luau @@ -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) diff --git a/tests/vide/spec.luau b/tests/vide/spec.luau new file mode 100644 index 0000000..7fcdaf0 --- /dev/null +++ b/tests/vide/spec.luau @@ -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 {} diff --git a/tests/vide/useAtom.spec.luau b/tests/vide/useAtom.spec.luau index cc4dd40..9572f33 100644 --- a/tests/vide/useAtom.spec.luau +++ b/tests/vide/useAtom.spec.luau @@ -1,36 +1,40 @@ 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 suite = require(ServerScriptService.Tests.suite) + +local atom = Charm.atom +local root = Vide.root local useAtom = VideCharm.useAtom +local test = suite.test -return function() - it("tracks atom state", function() - local state = atom(0) - local source - local cleanup = root(function() - source = useAtom(state) - end) - expect(source()).to.equal(0) - state(1) - expect(source()).to.equal(1) - cleanup() +test("tracks atom state", function() + local state = atom(0) + local source + local cleanup = root(function() + source = useAtom(state) end) + assert(source() == 0) + state(1) + assert(source() == 1) + cleanup() +end) - it("accepts a selector", function() - local state = atom(0) - local source - local cleanup = root(function() - source = useAtom(function() - return 2 * state() - end) +test("accepts a selector", function() + local state = atom(0) + local source + local cleanup = root(function() + source = useAtom(function() + return 2 * state() end) - expect(source()).to.equal(0) - state(1) - expect(source()).to.equal(2) - cleanup() end) -end + assert(source() == 0) + state(1) + assert(source() == 2) + cleanup() +end) + +return {}