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 001/160] 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 {} From e34b0c9acac835fe5abe1b40b7a2ad24de38b1a3 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 3 May 2025 20:30:10 -0700 Subject: [PATCH 002/160] refactor: remove pnpm, fix tests --- .eslintrc | 26 - .gitattributes | 1 - .github/workflows/ci.yml | 14 +- .github/workflows/release.yml | 3 +- .gitignore | 7 +- .prettierrc | 6 - README.md | 106 +- bin/build.sh | 4 +- bin/check.sh | 4 +- bin/install.sh | 4 +- default.project.json | 24 +- modules/wally.lock | 103 - modules/wally.toml | 10 - package.json | 24 - packages/charm-sync/package.json | 16 - packages/charm-sync/src/client.luau | 1 + packages/charm-sync/src/flatten.luau | 1 + packages/charm-sync/src/index.d.ts | 214 --- packages/charm-sync/src/init.luau | 3 + packages/charm-sync/src/server.luau | 1 + packages/charm-sync/src/types.luau | 1 + packages/charm-sync/wally.toml | 2 +- packages/charm/package.json | 13 - packages/charm/src/index.d.ts | 165 -- packages/charm/src/lib.luau | 2 + .../default.project.json | 0 packages/{react => react-charm}/src/init.luau | 2 +- packages/{react => react-charm}/wally.toml | 2 +- packages/react/package.json | 17 - packages/react/src/index.d.ts | 13 - .../{vide => vide-charm}/default.project.json | 0 packages/{vide => vide-charm}/src/init.luau | 0 packages/{vide => vide-charm}/wally.toml | 2 +- packages/vide/package.json | 17 - packages/vide/src/index.d.ts | 7 - pesde.lock | 195 ++ pesde.toml | 17 + pnpm-lock.yaml | 1656 ----------------- pnpm-workspace.yaml | 2 - rokit.toml | 2 +- tests/benchmarks/effect.bench.luau | 2 +- tests/benchmarks/patch.bench.luau | 4 +- tests/benchmarks/propagate.bench.luau | 5 +- tests/benchmarks/propagate.luau | 10 +- tests/{sync => charm-sync}/client.spec.luau | 4 +- tests/{sync => charm-sync}/flatten.spec.luau | 4 +- tests/{sync => charm-sync}/patch.spec.luau | 7 +- tests/{sync => charm-sync}/server.spec.luau | 8 +- tests/{sync => charm-sync}/spec.luau | 0 tests/{sync => charm-sync}/validate.spec.luau | 7 +- tests/charm.luau | 3 + tests/{react => react-charm}/spec.luau | 0 .../{react => react-charm}/useAtom.spec.luau | 8 +- tests/signals/computed.spec.luau | 2 +- tests/signals/effect.spec.luau | 2 +- tests/signals/effectScope.spec.luau | 2 +- tests/signals/issue_48.spec.luau | 2 +- tests/signals/topology.spec.luau | 2 +- tests/signals/untrack.spec.luau | 2 +- tests/spec.server.luau | 7 +- tests/suite.luau | 6 +- tests/utils/renderHook.luau | 4 +- tests/{vide => vide-charm}/spec.luau | 0 tests/{vide => vide-charm}/useAtom.spec.luau | 6 +- tsconfig.json | 27 - 65 files changed, 353 insertions(+), 2458 deletions(-) delete mode 100644 .eslintrc delete mode 100644 .gitattributes delete mode 100644 .prettierrc delete mode 100644 modules/wally.lock delete mode 100644 modules/wally.toml delete mode 100644 package.json delete mode 100644 packages/charm-sync/package.json delete mode 100644 packages/charm-sync/src/index.d.ts delete mode 100644 packages/charm/package.json delete mode 100644 packages/charm/src/index.d.ts rename packages/{react => react-charm}/default.project.json (100%) rename packages/{react => react-charm}/src/init.luau (91%) rename packages/{react => react-charm}/wally.toml (85%) delete mode 100644 packages/react/package.json delete mode 100644 packages/react/src/index.d.ts rename packages/{vide => vide-charm}/default.project.json (100%) rename packages/{vide => vide-charm}/src/init.luau (100%) rename packages/{vide => vide-charm}/wally.toml (84%) delete mode 100644 packages/vide/package.json delete mode 100644 packages/vide/src/index.d.ts create mode 100644 pesde.lock create mode 100644 pesde.toml delete mode 100644 pnpm-lock.yaml delete mode 100644 pnpm-workspace.yaml rename tests/{sync => charm-sync}/client.spec.luau (94%) rename tests/{sync => charm-sync}/flatten.spec.luau (82%) rename tests/{sync => charm-sync}/patch.spec.luau (94%) rename tests/{sync => charm-sync}/server.spec.luau (91%) rename tests/{sync => charm-sync}/spec.luau (100%) rename tests/{sync => charm-sync}/validate.spec.luau (87%) create mode 100644 tests/charm.luau rename tests/{react => react-charm}/spec.luau (100%) rename tests/{react => react-charm}/useAtom.spec.luau (83%) rename tests/{vide => vide-charm}/spec.luau (100%) rename tests/{vide => vide-charm}/useAtom.spec.luau (80%) delete mode 100644 tsconfig.json diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 8f628fe..0000000 --- a/.eslintrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "jsx": true, - "useJSXTextNode": true, - "ecmaVersion": 2018, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "plugins": [ - "@typescript-eslint", - "roblox-ts", - "prettier" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:roblox-ts/recommended", - "plugin:prettier/recommended" - ], - "rules": { - "prettier/prettier": "warn", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unsafe-declaration-merging": "off" - } -} diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 6313b56..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 142e5dc..35c0019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,16 +7,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - run_install: true - uses: CompeyDev/setup-rokit@v0.1.2 - - name: Install Wally dependencies - run: pnpm wally + - name: Install dependencies + run: sh bin/install.sh - name: Check code quality - run: pnpm check + run: sh bin/check.sh + + - name: Run tests + run: sh bin/test.sh - name: Build models - run: pnpm build + run: sh bin/build.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd05b2e..5bdee21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,11 +9,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - uses: CompeyDev/setup-rokit@v0.1.2 - name: Build models - run: pnpm build + run: sh bin/build.sh - name: Upload release assets uses: AButler/upload-release-assets@v3.0 diff --git a/.gitignore b/.gitignore index 5701a11..f1dcc5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,9 @@ /.vscode -/node_modules /build -/modules/DevPackages -/packages/**/node_modules +/roblox_packages /packages/**/wally.lock -/packages/**/README.md -/packages/**/LICENSE.md /sourcemap.json *.tsbuildinfo -*.tgz *.rbxm .DS_Store roblox.d.luau diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 537ac33..0000000 --- a/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "printWidth": 120, - "tabWidth": 4, - "trailingComma": "all", - "useTabs": true -} diff --git a/README.md b/README.md index 8ea132b..f832d4d 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,13 @@ See an example of Charm's features in [this example repository](https://github.c ## 🍀 Features -- ⚛️ **Manage state with _atoms_.** Decompose state into independent containers called _atoms_, as opposed to combining them into a single store. +- ⚛️ **Manage state with _atoms_.** Decompose state into independent containers called _atoms_, as opposed to combining them into a single store. -- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. +- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. -- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. +- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. -- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. +- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. --- @@ -59,12 +59,12 @@ Charm provides a debug mode to help you identify potential bugs in your project. Enabling `__DEV__` adds a few helpful features: -- Better error handling for selectors, subscriptions, and batched functions: +- Better error handling for selectors, subscriptions, and batched functions: - - Errors provide the function's name and line number. - - Yielding in certain functions will throw an error. + - Errors provide the function's name and line number. + - Yielding in certain functions will throw an error. -- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. +- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead. @@ -85,11 +85,11 @@ local todosAtom: Atom<{ string }> = atom({}) #### Parameters -- `state`: The value to assign to the atom initially. +- `state`: The value to assign to the atom initially. -- **optional** `options`: An object that configures the behavior of this atom. +- **optional** `options`: An object that configures the behavior of this atom. - - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. + - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. #### Returns @@ -144,9 +144,9 @@ nameAtom("Jane") --> "JANE" #### Parameters -- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. +- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. -- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. +- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. #### Returns @@ -173,7 +173,7 @@ Because `effect` implicitly tracks all atoms read within the callback, it might #### Parameters -- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. +- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. #### Returns @@ -181,14 +181,14 @@ Because `effect` implicitly tracks all atoms read within the callback, it might #### Caveats -- **If your effect should disconnect itself, use the `cleanup` argument.** Because effects run immediately, your effect may run before a `cleanup` function is returned. To disconnect an effect from the inside, use the argument passed to your effect instead: - ```lua - effect(function(cleanup) - if condition() then - cleanup() - end - end) - ``` +- **If your effect should disconnect itself, use the `cleanup` argument.** Because effects run immediately, your effect may run before a `cleanup` function is returned. To disconnect an effect from the inside, use the argument passed to your effect instead: + ```lua + effect(function(cleanup) + if condition() then + cleanup() + end + end) + ``` --- @@ -213,9 +213,9 @@ This function is also useful for optimizing `effect` calls that depend on multip #### Parameters -- `callback`: A function that returns a new value depending on one or more atoms. +- `callback`: A function that returns a new value depending on one or more atoms. -- **optional** [`options`](#parameters): An object that configures the behavior of this atom. +- **optional** [`options`](#parameters): An object that configures the behavior of this atom. #### Returns @@ -243,9 +243,9 @@ end) #### Parameters -- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. +- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. -- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. +- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. #### Returns @@ -266,13 +266,13 @@ end) #### Parameters -- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. +- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. -- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: +- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: - 1. Return a single value to map the table's original key to a new value. - 2. Return two values, the first being the value and the second being the key, to update both keys and values. - 3. Return `nil` for the value to remove the key from the resulting table. + 1. Return a single value to map the table's original key to a new value. + 2. Return two values, the first being the value and the second being the key, to update both keys and values. + 3. Return `nil` for the value to remove the key from the resulting table. #### Returns @@ -296,9 +296,9 @@ end) #### Parameters -- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. +- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. -- **optional** `...args`: Additional arguments to pass to the value if it is a function. +- **optional** `...args`: Additional arguments to pass to the value if it is a function. #### Returns @@ -322,7 +322,7 @@ end) #### Parameters -- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. +- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. #### Returns @@ -372,9 +372,9 @@ end, { props.filter }) #### Parameters -- `callback`: An atom or selector function that depends on an atom. +- `callback`: An atom or selector function that depends on an atom. -- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. +- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. #### Returns @@ -416,7 +416,7 @@ end #### Parameters -- `callback`: An atom or selector function that depends on an atom. +- `callback`: An atom or selector function that depends on an atom. #### Returns @@ -475,15 +475,15 @@ end) #### Parameters -- `options`: An object to configure sync behavior. +- `options`: An object to configure sync behavior. - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. + - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. - - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. + - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. - - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. + - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. - - **optional** `autoSerialize`: Whether to apply validation and workarounds to certain [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#table-indexing). Defaults to `true`, but you should set it to `false` if you serialize remote arguments (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). + - **optional** `autoSerialize`: Whether to apply validation and workarounds to certain [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#table-indexing). Defaults to `true`, but you should set it to `false` if you serialize remote arguments (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). > [!NOTE] > Charm sends table updates in the form of partial tables, so arrays will contain `nil` values, which has undefined behavior in remotes without serialization. @@ -494,17 +494,17 @@ end) `server` returns an object with the following methods: -- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. +- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. -- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. +- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. #### Caveats -- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. [Read more about argument limitations in remotes.](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) +- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. [Read more about argument limitations in remotes.](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) -- **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. +- **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. -- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `autoSerialize` accordingly! +- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `autoSerialize` accordingly! --- @@ -531,21 +531,21 @@ remotes.requestState:fire() #### Parameters -- `options`: An object to configure sync behavior. +- `options`: An object to configure sync behavior. - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. + - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. - - **optional** `ignoreUnhydrated`: Whether to ignore state updates before setting the initial state. Defaults to `true`. + - **optional** `ignoreUnhydrated`: Whether to ignore state updates before setting the initial state. Defaults to `true`. #### Returns `client` returns an object with the following methods: -- `syncer:sync(...payloads)` applies a state update from the server. +- `syncer:sync(...payloads)` applies a state update from the server. #### Caveats -- **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above. +- **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above. --- @@ -572,7 +572,7 @@ end) #### Parameters -- `value`: Any value. If the value is `None`, `isNone` will return `true`. +- `value`: Any value. If the value is `None`, `isNone` will return `true`. #### Returns diff --git a/bin/build.sh b/bin/build.sh index 547e4e7..be6fdf7 100644 --- a/bin/build.sh +++ b/bin/build.sh @@ -1,5 +1,5 @@ mkdir build rojo build packages/charm -o build/charm.rbxm rojo build packages/charm-sync -o build/charm-sync.rbxm -rojo build packages/react -o build/react-charm.rbxm -rojo build packages/vide -o build/vide-charm.rbxm +rojo build packages/react-charm -o build/react-charm.rbxm +rojo build packages/vide-charm -o build/vide-charm.rbxm diff --git a/bin/check.sh b/bin/check.sh index 31abbe9..7aec81b 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -6,13 +6,11 @@ luau-lsp analyze \ --defs=bin/roblox.d.luau \ --flag:LuauFixIndexerSubtypingOrdering=true \ --sourcemap=sourcemap.json \ - --ignore="**/_Index/**" \ + --ignore="roblox_packages/**" \ packages tests selene packages tests stylua --check packages tests -eslint packages - rm bin/roblox.d.luau diff --git a/bin/install.sh b/bin/install.sh index 53ccca2..ba622eb 100644 --- a/bin/install.sh +++ b/bin/install.sh @@ -1,4 +1,2 @@ -cd modules -wally install -cd .. +pesde install rojo sourcemap --output sourcemap.json diff --git a/default.project.json b/default.project.json index a43197e..cbd3b70 100644 --- a/default.project.json +++ b/default.project.json @@ -4,27 +4,17 @@ "$className": "DataModel", "ReplicatedStorage": { "$className": "ReplicatedStorage", - "DevPackages": { - "$path": "modules/DevPackages", - "Charm": { - "$path": "packages/charm" - }, - "CharmSync": { - "$path": "packages/charm-sync" - }, - "ReactCharm": { - "$path": "packages/react" - }, - "VideCharm": { - "$path": "packages/vide" - } + "Packages": { + "$path": "roblox_packages", + "Charm": { "$path": "packages/charm" }, + "CharmSync": { "$path": "packages/charm-sync" }, + "ReactCharm": { "$path": "packages/react-charm" }, + "VideCharm": { "$path": "packages/vide-charm" } } }, "ServerScriptService": { "$className": "ServerScriptService", - "Tests": { - "$path": "tests" - } + "Tests": { "$path": "tests" } } } } diff --git a/modules/wally.lock b/modules/wally.lock deleted file mode 100644 index 7294d77..0000000 --- a/modules/wally.lock +++ /dev/null @@ -1,103 +0,0 @@ -# This file is automatically @generated by Wally. -# It is not intended for manual editing. -registry = "test" - -[[package]] -name = "centau/vide" -version = "0.3.1" -dependencies = [] - -[[package]] -name = "jsdotlua/boolean" -version = "1.2.7" -dependencies = [["number", "jsdotlua/number@1.2.7"]] - -[[package]] -name = "jsdotlua/collections" -version = "1.2.7" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"]] - -[[package]] -name = "jsdotlua/console" -version = "1.2.7" -dependencies = [["collections", "jsdotlua/collections@1.2.7"]] - -[[package]] -name = "jsdotlua/es7-types" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/instance-of" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/luau-polyfill" -version = "1.2.7" -dependencies = [["boolean", "jsdotlua/boolean@1.2.7"], ["collections", "jsdotlua/collections@1.2.7"], ["console", "jsdotlua/console@1.2.7"], ["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"], ["math", "jsdotlua/math@1.2.7"], ["number", "jsdotlua/number@1.2.7"], ["string", "jsdotlua/string@1.2.7"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.7"]] - -[[package]] -name = "jsdotlua/math" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/number" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/promise" -version = "3.5.2" -dependencies = [] - -[[package]] -name = "jsdotlua/react" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/react-reconciler" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/react-roblox" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["react", "jsdotlua/react@17.2.1"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/scheduler" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/shared" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"]] - -[[package]] -name = "jsdotlua/string" -version = "1.2.7" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["number", "jsdotlua/number@1.2.7"]] - -[[package]] -name = "jsdotlua/symbol-luau" -version = "1.0.1" -dependencies = [] - -[[package]] -name = "jsdotlua/timers" -version = "1.2.7" -dependencies = [["collections", "jsdotlua/collections@1.2.7"]] - -[[package]] -name = "littensy/charm" -version = "0.1.0" -dependencies = [["React", "jsdotlua/react@17.2.1"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.1"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]] - -[[package]] -name = "roblox/testez" -version = "0.4.1" -dependencies = [] diff --git a/modules/wally.toml b/modules/wally.toml deleted file mode 100644 index 0c18cdc..0000000 --- a/modules/wally.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "littensy/charm" -version = "0.1.0" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" - -[dev-dependencies] -React = "jsdotlua/react@17.2.0" -ReactRoblox = "jsdotlua/react-roblox@17.2.0" -Vide = "centau/vide@0.3.1" diff --git a/package.json b/package.json deleted file mode 100644 index 3fbcdf4..0000000 --- a/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "private": true, - "scripts": { - "build": "sh bin/build.sh", - "check": "sh bin/check.sh", - "wally": "sh bin/install.sh", - "publish:npm": "pnpm publish -r --access public", - "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" - }, - "devDependencies": { - "@rbxts/compiler-types": "3.0.0-types.0", - "@rbxts/types": "^1.0.811", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.2.1", - "eslint-plugin-roblox-ts": "^0.0.36", - "prettier": "^3.3.3", - "roblox-ts": "^3.0.0", - "typescript": "~5.5.4" - }, - "packageManager": "pnpm@10.10.0" -} diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json deleted file mode 100644 index 5256c2d..0000000 --- a/packages/charm-sync/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@rbxts/charm-sync", - "version": "0.3.0", - "description": "Sync server state with clients using Charm", - "repository": "littensy/charm", - "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", - "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", - "publish:wally": "wally publish" - }, - "peerDependencies": { - "@rbxts/charm": "workspace:*" - } -} diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index b09b870..8dec492 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -1,6 +1,7 @@ local Charm = require(script.Parent.Parent.Charm) local patch = require(script.Parent.patch) local types = require(script.Parent.types) + type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau index 9c8d012..42a93eb 100644 --- a/packages/charm-sync/src/flatten.luau +++ b/packages/charm-sync/src/flatten.luau @@ -1,4 +1,5 @@ local types = require(script.Parent.types) + type AtomMap = types.AtomMap type NestedAtomMap = { diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts deleted file mode 100644 index 52ff658..0000000 --- a/packages/charm-sync/src/index.d.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { Atom, Selector } from "@rbxts/charm"; - -export = CharmSync; -export as namespace CharmSync; - -type Cleanup = () => void; - -type Key = string | number | symbol; - -declare namespace CharmSync { - type AtomMap = Record>; - - type SelectorMap = Record>; - - /** - * @deprecated Use `SelectorMap` instead. - */ - type MoleculeMap = SelectorMap; - - /** - * Infers the type of the return values produced by a map of functions. - */ - type StateOfMap = { - readonly [P in keyof T]: T[P] extends Selector ? State : never; - }; - - /** - * Represents the removal of a value from the state. - */ - interface None { - readonly __none: "__none"; - } - - type MaybeNone = undefined extends T ? None : never; - - /** - * Creates a `ClientSyncer` object that receives patches from the server and - * applies them to the local state. - * - * @client - * @param options The atoms to synchronize with the server. - * @returns A `ClientSyncer` object. - */ - function client(options: ClientOptions): ClientSyncer; - - /** - * Creates a `ServerSyncer` object that sends patches to the client and - * hydrates the client's state. - * - * @server - * @param options The atoms to synchronize with the client. - * @returns A `ServerSyncer` object. - */ - function server( - options: ServerOptions, - ): ServerSyncer; - - /** - * Checks whether a value is `None`. If `true`, the value is scheduled to be - * removed from the state when the patch is applied. - * - * @param value The value to check. - * @returns `true` if the value is `None`, otherwise `false`. - */ - function isNone(value: unknown): value is None; - - interface NestedAtomMap { - readonly [key: string]: NestedAtomMap | Atom; - } - - type AppendPath = undefined extends Path - ? Name - : Name extends string - ? Path extends string - ? `${Path}/${Name}` - : never - : never; - - type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; - - type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; - - type FlattenNestedAtoms = IntersectValues<{ - readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom - ? { readonly [K in AppendPath]: Atoms[Name] } - : Atoms[Name] extends NestedAtomMap - ? FlattenNestedAtoms> - : never; - }>; - - /** - * Flattens a nested atom map into a single object with slash-separated - * keys. Useful for recursively collecting atoms returned by modules. - * - * @param atoms The nested atom map to flatten. - * @return A flattened atom map. - */ - function flatten(atoms: Atoms): FlattenNestedAtoms; - - type DataTypes = { - [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; - }; - - /** - * A type that should not be made partial in patches. - */ - type DataType = DataTypes[keyof DataTypes]; - - /** - * A partial patch that can be applied to the state to update it. Represents - * the difference between the current state and the next state. - * - * If a value was removed, it is replaced with `None`. This can be checked - * using the `sync.isNone` function. - */ - type SyncPatch = MaybeNone | State extends - | ReadonlyMap - | Map - ? ReadonlyMap | None> - : State extends Set | ReadonlySet - ? ReadonlyMap - : State extends readonly (infer T)[] - ? Serialize extends true - ? ReadonlyMap | None> - : readonly (SyncPatch | None | undefined)[] - : State extends DataType - ? State - : State extends object - ? { readonly [P in keyof State]?: SyncPatch } - : State; - - /** - * A payload that can be sent from the server to the client to synchronize - * state between the two. - */ - type SyncPayload = - | { readonly type: "init"; readonly data: StateOfMap } - | { readonly type: "patch"; readonly data: SyncPatch, Serialize> }; - - interface ClientOptions { - /** - * The atoms to synchronize with the server. - */ - atoms: Atoms; - /** - * Whether to ignore patches sent before the client has been hydrated. - * Defaults to `true`. - */ - ignoreUnhydrated?: boolean; - } - - interface ServerOptions { - /** - * The atoms to synchronize with the client. - */ - atoms: Selectors; - /** - * The interval at which to send patches to the client, in seconds. - * Defaults to `0` (patches are sent up to once per frame). Set to a - * negative value to disable automatic syncing. - */ - interval?: number; - /** - * Whether the history of state changes since the client's last update - * should be preserved. This is useful for values that change multiple times - * per frame, where each individual change is important. Defaults to `false`. - * - * If `true`, the broadcaster will send a list of payloads to the client - * instead of a single payload. The client will apply each payload in order - * to reconstruct the state's changes over time. - */ - preserveHistory?: boolean; - /** - * When `true`, Charm will apply validation and serialize unsafe arrays - * to address remote event argument limitations. Defaults to `true`. - * - * This option should be disabled if your network library uses a custom - * serialization method (i.e. Zap, ByteNet) to prevent interference. - */ - autoSerialize?: Serialize; - } - - interface ClientSyncer { - /** - * Applies a patch or initializes the state of the atoms with the given - * payload from the server. - * - * @param payloads The patches or hydration payloads to apply. - */ - sync(...payloads: SyncPayload[]): void; - } - - interface ServerSyncer { - /** - * Sets up a subscription to each atom that schedules a patch to be sent to - * the client whenever the state changes. When a change occurs, the `callback` - * is called with the player and the payloads to send. - * - * Note that a `payload` object should not be mutated. If you need to modify - * a payload, apply the changes to a copy of the object. - * - * @param callback The function to call when the state changes. - * @returns A cleanup function that unsubscribes all listeners. - */ - connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; - /** - * Hydrates the client's state with the server's state. This should be - * called when a player joins the game and requires the server's state. - * - * @param player The player to hydrate. - */ - hydrate(player: Player): void; - } -} diff --git a/packages/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau index 5fc9171..a3e8fb2 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -3,6 +3,7 @@ local flatten = require(script.flatten) local patch = require(script.patch) local server = require(script.server) local types = require(script.types) +local validate = require(script.validate) export type SyncPayload = types.SyncPayload @@ -14,5 +15,7 @@ return { client = client, server = server, flatten = flatten, + validate = validate, + patch = patch, isNone = patch.isNone, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index f2c336e..afb646e 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -4,6 +4,7 @@ local Charm = require(script.Parent.Parent.Charm) local interval = require(script.Parent.interval) local patch = require(script.Parent.patch) local types = require(script.Parent.types) + type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 641f744..7942c80 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -1,4 +1,5 @@ local Charm = require(script.Parent.Parent.Charm) + type Atom = Charm.Atom --[=[ diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index f1a78f0..0507407 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -5,4 +5,4 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -charm = "littensy/charm@^0.10" +Charm = "littensy/charm@^0.10" diff --git a/packages/charm/package.json b/packages/charm/package.json deleted file mode 100644 index ea40fa6..0000000 --- a/packages/charm/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@rbxts/charm", - "version": "0.10.0", - "description": "An atomic state management library for Roblox", - "repository": "littensy/charm", - "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", - "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", - "publish:wally": "wally publish" - } -} diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts deleted file mode 100644 index 6244d62..0000000 --- a/packages/charm/src/index.d.ts +++ /dev/null @@ -1,165 +0,0 @@ -export = Charm; -export as namespace Charm; - -type Dispose = () => void; - -declare namespace Charm { - /** - * A primitive state container that can be read from or written to. When - * the state changes, all subscribers are notified. - * - * @param state The next state or a function that produces the next state. - * @returns The current state. - */ - interface Atom { - (): State; - (nextState: State | ((prevState: State) => State)): State; - } - - /** - * A function that depends on one or more atoms and produces a value. Can be - * used to derive state from atoms. - * - * @returns The current state. - */ - type Selector = () => State; - - /** - * Infers the type of the state produced by the given function. - */ - type StateOf = T extends () => infer State ? State : never; - - interface AtomOptions { - /** - * A function that determines whether the state has changed. By default, - * a strict equality check (`===`) is used. - */ - equals?: (prev: State, next: State) => boolean; - } - - /** - * Creates a new atom with the given state. - * - * @param state The initial state. - * @param options Optional configuration. - * @returns A new atom. - */ - function atom(state: State, options?: AtomOptions): Atom; - - // Overload for no arguments - function atom(state?: State, options?: AtomOptions): Atom; - - /** - * Creates a read-only atom that derives its state from one or more atoms. - * Used to avoid unnecessary recomputations if multiple listeners depend on - * the same atoms. - * - * @param callback The function that produces the state. - * @returns A new read-only atom. - */ - 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 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: () => 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 - * changes. Returns a cleanup function that unsubscribes the callback. - * - * @param callback The function to run. - * @returns A function that unsubscribes the callback. - */ - function effect(callback: (dispose: Dispose) => Dispose | void): Dispose; - - /** - * Runs the given function and schedules listeners to be notified only once - * after the function has completed. Useful for batching multiple changes. - * - * @param callback The function to run. - */ - function batched(callback: () => void): void; - - /** - * Returns the result of the function without subscribing to changes. - * - * @param callback The atom or selector to get the state of. - * @param args Arguments to pass to the function. - * @returns The current state. - */ - function untracked(callback: (...args: Args) => State, ...args: Args): State; - - /** - * Creates an instance of `factory` for each item in the atom's state, and - * cleans up the instance when the item is removed. Returns a cleanup function - * that unsubscribes all instances. - * - * @param callback The atom or selector to observe. - * @param factory The function that tracks the lifecycle of each item. - * @returns A function that unsubscribes all instances. - */ - function observe( - callback: () => readonly Item[], - factory: (item: Item, index: number) => Dispose | void, - ): Dispose; - - function observe( - callback: () => Map | ReadonlyMap, - factory: (item: Item, key: Key) => Dispose | void, - ): Dispose; - - function observe( - 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` 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: () => readonly V0[], - mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>, - ): () => ReadonlyMap; - - function mapped( - callback: () => readonly V0[], - mapper: (value: V0, index: number) => V1, - ): () => readonly V1[]; - - function mapped( - callback: () => Map | ReadonlyMap, - mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, - ): () => ReadonlyMap; - - function mapped( - callback: () => { readonly [P in K0]: V0 }, - mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, - ): () => ReadonlyMap; -} diff --git a/packages/charm/src/lib.luau b/packages/charm/src/lib.luau index 7ab5101..96e1746 100644 --- a/packages/charm/src/lib.luau +++ b/packages/charm/src/lib.luau @@ -6,6 +6,7 @@ local effectScope = require("./effectScope") local listen = require("./listen") local mapped = require("./mapped") local observe = require("./observe") +local signals = require("./signals") local subscribe = require("./subscribe") local types = require("./types") local untracked = require("./untracked") @@ -25,4 +26,5 @@ return { observe = observe, subscribe = subscribe, untracked = untracked, + signals = signals, } diff --git a/packages/react/default.project.json b/packages/react-charm/default.project.json similarity index 100% rename from packages/react/default.project.json rename to packages/react-charm/default.project.json diff --git a/packages/react/src/init.luau b/packages/react-charm/src/init.luau similarity index 91% rename from packages/react/src/init.luau rename to packages/react-charm/src/init.luau index dda3c33..13159bf 100644 --- a/packages/react/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -19,7 +19,7 @@ local useState = React.useState @param dependencies An array of values that the subscription depends on. @return The current state. ]=] -local function useAtom(callback: Charm.Selector, dependencies: { any }?): State +local function useAtom(callback: () -> State, dependencies: { any }?): State local state, setState = useState(callback) useEffect(function() diff --git a/packages/react/wally.toml b/packages/react-charm/wally.toml similarity index 85% rename from packages/react/wally.toml rename to packages/react-charm/wally.toml index 91073ad..6fd29e7 100644 --- a/packages/react/wally.toml +++ b/packages/react-charm/wally.toml @@ -5,5 +5,5 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -charm = "littensy/charm@^0.10" +Charm = "littensy/charm@^0.10" React = "jsdotlua/react@^17" diff --git a/packages/react/package.json b/packages/react/package.json deleted file mode 100644 index df178aa..0000000 --- a/packages/react/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@rbxts/react-charm", - "version": "0.3.0", - "description": "React bindings for Charm", - "repository": "littensy/charm", - "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", - "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", - "publish:wally": "wally publish" - }, - "peerDependencies": { - "@rbxts/charm": "workspace:*", - "@rbxts/react": "*" - } -} diff --git a/packages/react/src/index.d.ts b/packages/react/src/index.d.ts deleted file mode 100644 index d35c092..0000000 --- a/packages/react/src/index.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * A hook that subscribes to changes in the given atom or selector. The - * component is re-rendered whenever the state changes. - * - * If the `dependencies` array is provided, the subscription to the atom or - * selector is re-created whenever the dependencies change. Otherwise, the - * subscription is created once when the component is mounted. - * - * @param callback The atom or selector to subscribe to. - * @param dependencies An array of values that the subscription depends on. - * @returns The current state. - */ -export function useAtom(callback: () => State, dependencies?: unknown[]): State; diff --git a/packages/vide/default.project.json b/packages/vide-charm/default.project.json similarity index 100% rename from packages/vide/default.project.json rename to packages/vide-charm/default.project.json diff --git a/packages/vide/src/init.luau b/packages/vide-charm/src/init.luau similarity index 100% rename from packages/vide/src/init.luau rename to packages/vide-charm/src/init.luau diff --git a/packages/vide/wally.toml b/packages/vide-charm/wally.toml similarity index 84% rename from packages/vide/wally.toml rename to packages/vide-charm/wally.toml index de0f253..ed47be6 100644 --- a/packages/vide/wally.toml +++ b/packages/vide-charm/wally.toml @@ -5,5 +5,5 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -charm = "littensy/charm@^0.10" +Charm = "littensy/charm@^0.10" Vide = "centau/vide@^0.3" diff --git a/packages/vide/package.json b/packages/vide/package.json deleted file mode 100644 index adfcef4..0000000 --- a/packages/vide/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@rbxts/vide-charm", - "version": "0.3.0", - "description": "Vide bindings for Charm", - "repository": "littensy/charm", - "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", - "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", - "publish:wally": "wally publish" - }, - "peerDependencies": { - "@rbxts/charm": "workspace:*", - "@rbxts/vide": "*" - } -} diff --git a/packages/vide/src/index.d.ts b/packages/vide/src/index.d.ts deleted file mode 100644 index 2d51266..0000000 --- a/packages/vide/src/index.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 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: () => T): () => T; diff --git a/pesde.lock b/pesde.lock new file mode 100644 index 0000000..e27fd50 --- /dev/null +++ b/pesde.lock @@ -0,0 +1,195 @@ +# This file is automatically @generated by pesde. +# It is not intended for manual editing. +format = 2 +name = "littensy/charm" +version = "0.1.0" +target = "roblox" + +[graph."wally#centau/vide@0.3.1 roblox"] +direct = ["Vide", { wally = "wally#centau/vide", version = "^0.3.1", index = "default" }, "standard"] + +[graph."wally#centau/vide@0.3.1 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/boolean@1.2.7 roblox".dependencies] +number = ["wally#jsdotlua/number@1.2.7 roblox", "standard"] + +[graph."wally#jsdotlua/boolean@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/boolean@1.2.7 roblox".pkg_ref.dependencies] +number = [{ wally = "wally#jsdotlua/number", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/collections@1.2.7 roblox".dependencies] +es7-types = ["wally#jsdotlua/es7-types@1.2.7 roblox", "standard"] +instance-of = ["wally#jsdotlua/instance-of@1.2.7 roblox", "standard"] + +[graph."wally#jsdotlua/collections@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/collections@1.2.7 roblox".pkg_ref.dependencies] +es7-types = [{ wally = "wally#jsdotlua/es7-types", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +instance-of = [{ wally = "wally#jsdotlua/instance-of", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/console@1.2.7 roblox".dependencies] +collections = ["wally#jsdotlua/collections@1.2.7 roblox", "standard"] + +[graph."wally#jsdotlua/console@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/console@1.2.7 roblox".pkg_ref.dependencies] +collections = [{ wally = "wally#jsdotlua/collections", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/es7-types@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/instance-of@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/luau-polyfill@1.2.7 roblox".dependencies] +boolean = ["wally#jsdotlua/boolean@1.2.7 roblox", "standard"] +collections = ["wally#jsdotlua/collections@1.2.7 roblox", "standard"] +console = ["wally#jsdotlua/console@1.2.7 roblox", "standard"] +es7-types = ["wally#jsdotlua/es7-types@1.2.7 roblox", "standard"] +instance-of = ["wally#jsdotlua/instance-of@1.2.7 roblox", "standard"] +math = ["wally#jsdotlua/math@1.2.7 roblox", "standard"] +number = ["wally#jsdotlua/number@1.2.7 roblox", "standard"] +string = ["wally#jsdotlua/string@1.2.7 roblox", "standard"] +symbol-luau = ["wally#jsdotlua/symbol-luau@1.0.1 roblox", "standard"] +timers = ["wally#jsdotlua/timers@1.2.7 roblox", "standard"] + +[graph."wally#jsdotlua/luau-polyfill@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/luau-polyfill@1.2.7 roblox".pkg_ref.dependencies] +boolean = [{ wally = "wally#jsdotlua/boolean", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +collections = [{ wally = "wally#jsdotlua/collections", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +console = [{ wally = "wally#jsdotlua/console", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +es7-types = [{ wally = "wally#jsdotlua/es7-types", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +instance-of = [{ wally = "wally#jsdotlua/instance-of", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +math = [{ wally = "wally#jsdotlua/math", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +number = [{ wally = "wally#jsdotlua/number", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +string = [{ wally = "wally#jsdotlua/string", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +symbol-luau = [{ wally = "wally#jsdotlua/symbol-luau", version = ">=1.0.0, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +timers = [{ wally = "wally#jsdotlua/timers", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/math@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/number@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/promise@3.5.2 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/react@17.2.1 roblox"] +direct = ["React", { wally = "wally#jsdotlua/react", version = "^17.2.1", index = "default" }, "standard"] + +[graph."wally#jsdotlua/react@17.2.1 roblox".dependencies] +luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] +shared = ["wally#jsdotlua/shared@17.2.1 roblox", "standard"] + +[graph."wally#jsdotlua/react@17.2.1 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/react@17.2.1 roblox".pkg_ref.dependencies] +luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +shared = [{ wally = "wally#jsdotlua/shared", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/react-reconciler@17.2.1 roblox".dependencies] +luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] +promise = ["wally#jsdotlua/promise@3.5.2 roblox", "standard"] +react = ["wally#jsdotlua/react@17.2.1 roblox", "standard"] +scheduler = ["wally#jsdotlua/scheduler@17.2.1 roblox", "standard"] +shared = ["wally#jsdotlua/shared@17.2.1 roblox", "standard"] + +[graph."wally#jsdotlua/react-reconciler@17.2.1 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/react-reconciler@17.2.1 roblox".pkg_ref.dependencies] +luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +promise = [{ wally = "wally#jsdotlua/promise", version = ">=3.5.0, <4.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +react = [{ wally = "wally#jsdotlua/react", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +scheduler = [{ wally = "wally#jsdotlua/scheduler", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +shared = [{ wally = "wally#jsdotlua/shared", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/react-roblox@17.2.1 roblox"] +direct = ["ReactRoblox", { wally = "wally#jsdotlua/react-roblox", version = "^17.2.1", index = "default" }, "standard"] + +[graph."wally#jsdotlua/react-roblox@17.2.1 roblox".dependencies] +luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] +react = ["wally#jsdotlua/react@17.2.1 roblox", "standard"] +react-reconciler = ["wally#jsdotlua/react-reconciler@17.2.1 roblox", "standard"] +scheduler = ["wally#jsdotlua/scheduler@17.2.1 roblox", "standard"] +shared = ["wally#jsdotlua/shared@17.2.1 roblox", "standard"] + +[graph."wally#jsdotlua/react-roblox@17.2.1 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/react-roblox@17.2.1 roblox".pkg_ref.dependencies] +luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +react = [{ wally = "wally#jsdotlua/react", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +react-reconciler = [{ wally = "wally#jsdotlua/react-reconciler", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +scheduler = [{ wally = "wally#jsdotlua/scheduler", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +shared = [{ wally = "wally#jsdotlua/shared", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/scheduler@17.2.1 roblox".dependencies] +luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] +shared = ["wally#jsdotlua/shared@17.2.1 roblox", "standard"] + +[graph."wally#jsdotlua/scheduler@17.2.1 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/scheduler@17.2.1 roblox".pkg_ref.dependencies] +luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +shared = [{ wally = "wally#jsdotlua/shared", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/shared@17.2.1 roblox".dependencies] +luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] + +[graph."wally#jsdotlua/shared@17.2.1 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/shared@17.2.1 roblox".pkg_ref.dependencies] +luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/string@1.2.7 roblox".dependencies] +es7-types = ["wally#jsdotlua/es7-types@1.2.7 roblox", "standard"] +number = ["wally#jsdotlua/number@1.2.7 roblox", "standard"] + +[graph."wally#jsdotlua/string@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/string@1.2.7 roblox".pkg_ref.dependencies] +es7-types = [{ wally = "wally#jsdotlua/es7-types", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] +number = [{ wally = "wally#jsdotlua/number", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] + +[graph."wally#jsdotlua/symbol-luau@1.0.1 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/timers@1.2.7 roblox".dependencies] +collections = ["wally#jsdotlua/collections@1.2.7 roblox", "standard"] + +[graph."wally#jsdotlua/timers@1.2.7 roblox".pkg_ref] +ref_ty = "wally" +index_url = "https://github.com/UpliftGames/wally-index" + +[graph."wally#jsdotlua/timers@1.2.7 roblox".pkg_ref.dependencies] +collections = [{ wally = "wally#jsdotlua/collections", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] diff --git a/pesde.toml b/pesde.toml new file mode 100644 index 0000000..22657f1 --- /dev/null +++ b/pesde.toml @@ -0,0 +1,17 @@ +name = "littensy/charm" +version = "0.1.0" +license = "MIT" + +[target] +environment = "roblox" + +[indices] +default = "https://github.com/pesde-pkg/index" + +[wally_indices] +default = "https://github.com/UpliftGames/wally-index" + +[dependencies] +React = { wally = "jsdotlua/react", version = "^17.2.1" } +ReactRoblox = { wally = "jsdotlua/react-roblox", version = "^17.2.1" } +Vide = { wally = "centau/vide", version = "^0.3.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 90e0e73..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,1656 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - '@rbxts/compiler-types': - specifier: 3.0.0-types.0 - version: 3.0.0-types.0 - '@rbxts/types': - specifier: ^1.0.811 - version: 1.0.811 - '@typescript-eslint/eslint-plugin': - specifier: ^7.18.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/parser': - specifier: ^7.18.0 - version: 7.18.0(eslint@8.57.1)(typescript@5.5.4) - eslint: - specifier: ^8.57.1 - version: 8.57.1 - eslint-config-prettier: - specifier: ^9.1.0 - version: 9.1.0(eslint@8.57.1) - eslint-plugin-prettier: - specifier: ^5.2.1 - version: 5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3) - eslint-plugin-roblox-ts: - specifier: ^0.0.36 - version: 0.0.36(eslint@8.57.1) - prettier: - specifier: ^3.3.3 - version: 3.3.3 - roblox-ts: - specifier: ^3.0.0 - version: 3.0.0 - typescript: - specifier: ~5.5.4 - version: 5.5.4 - - packages/charm: {} - - packages/charm-sync: - dependencies: - '@rbxts/charm': - specifier: workspace:* - version: link:../charm - - packages/react: - dependencies: - '@rbxts/charm': - specifier: workspace:* - version: link:../charm - '@rbxts/react': - specifier: '*' - version: 17.2.3 - - packages/vide: - dependencies: - '@rbxts/charm': - specifier: workspace:* - version: link:../charm - '@rbxts/vide': - specifier: '*' - version: 0.5.3 - -packages: - - '@eslint-community/eslint-utils@4.4.0': - resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.11.1': - resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@pkgr/core@0.1.1': - resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - - '@rbxts/compiler-types@3.0.0-types.0': - resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} - - '@rbxts/react-vendor@17.2.3': - resolution: {integrity: sha512-dWCiVelvTK6h920geaFH/OQ5zoWHyhyqyfH51hKx2/iVZ6OiiyNVJ5UqEIkJMG4HsJ8JnkTORizV3+MtQ41BtA==} - - '@rbxts/react@17.2.3': - resolution: {integrity: sha512-IoOB8e2W8MxT0MKWFf0Ls0vT61ImYL6ctHQgz5+oazma6z8bqFtJjgPxOuy6jmMSuyfBe7rYp/muou0kIjZT+Q==} - - '@rbxts/types@1.0.811': - resolution: {integrity: sha512-MPhs5lxzxvDqie34Y9FzJ1R+k+33jjrkj03agX2y2WzBKyGmO0rD/kPuUaKXQgEiACXuk36C5FxUTYgkKJPbvA==} - - '@rbxts/vide@0.5.3': - resolution: {integrity: sha512-evaZbBRZzaqU8ibddTMaomb30pmW5OhWTT7ba1PLSA/iGBCid0eOF6BX1o0ArKPyMFnNj6pMM2yMTQzL5b9cIA==} - - '@roblox-ts/luau-ast@2.0.0': - resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} - - '@roblox-ts/path-translator@1.1.0': - resolution: {integrity: sha512-D0akTmnNYqBw+ZIek5JxocT3BjmbgGOuOy0x1nIIxHBPNLGCpzseToY8jyYs/0mlvnN2xnSP/k8Tv+jvGOQSwQ==} - - '@roblox-ts/rojo-resolver@1.1.0': - resolution: {integrity: sha512-QmvVryu1EeME+3QUoG5j/gHGJoJUaffCgZ92mhlG7cJSd1uyhgpY4CNWriZAwZJYkTlzd5Htkpn+18yDFbOFXA==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@20.16.11': - resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==} - - '@types/semver@7.5.8': - resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} - - '@typescript-eslint/eslint-plugin@7.18.0': - resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - '@typescript-eslint/parser': ^7.0.0 - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/experimental-utils@5.62.0': - resolution: {integrity: sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@typescript-eslint/parser@7.18.0': - resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/scope-manager@5.62.0': - resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@typescript-eslint/scope-manager@7.18.0': - resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} - engines: {node: ^18.18.0 || >=20.0.0} - - '@typescript-eslint/type-utils@7.18.0': - resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/types@5.62.0': - resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@typescript-eslint/types@7.18.0': - resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} - engines: {node: ^18.18.0 || >=20.0.0} - - '@typescript-eslint/typescript-estree@5.62.0': - resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/typescript-estree@7.18.0': - resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/utils@5.62.0': - resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@typescript-eslint/utils@7.18.0': - resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - eslint: ^8.56.0 - - '@typescript-eslint/visitor-keys@5.62.0': - resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@typescript-eslint/visitor-keys@7.18.0': - resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} - engines: {node: ^18.18.0 || >=20.0.0} - - '@ungap/structured-clone@1.2.0': - resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.12.1: - resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} - engines: {node: '>= 8'} - - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-config-prettier@9.1.0: - resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-plugin-prettier@5.2.1: - resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '*' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true - - eslint-plugin-roblox-ts@0.0.36: - resolution: {integrity: sha512-vlbgfHY1heWSyDunVX3gIOamCy28KqrJlDkmuDOyYVBo8uLqdvbFkbWkdGxn59Vky1m6pFcvZGU6TObfmwm5Pg==} - engines: {node: '>=0.10.0'} - - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. - hasBin: true - - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - - fast-glob@3.3.2: - resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-uri@3.0.2: - resolution: {integrity: sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==} - - fastq@1.17.1: - resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} - - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} - - flatted@3.3.1: - resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} - - fs-extra@11.2.0: - resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} - engines: {node: '>=14.14'} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-core-module@2.15.1: - resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier-linter-helpers@1.0.0: - resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} - engines: {node: '>=6.0.0'} - - prettier@3.3.3: - resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} - engines: {node: '>=14'} - hasBin: true - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve@1.22.8: - resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} - hasBin: true - - reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - - roblox-ts@3.0.0: - resolution: {integrity: sha512-hwAC2frIFlLJOtHd6F+5opMEhBgfAMK9z5l1mP+bykLBbMO5cn1q5lIwhhXbeh9Pq07rlhF8uGHlmeRLPd/3AA==} - hasBin: true - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} - engines: {node: '>=10'} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - synckit@0.9.2: - resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} - engines: {node: ^14.18.0 || >=16.0.0} - - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - ts-api-utils@1.3.0: - resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} - engines: {node: '>=16'} - peerDependencies: - typescript: '>=4.2.0' - - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - - tslib@2.7.0: - resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} - - tsutils@3.21.0: - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - - typescript@5.5.3: - resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} - engines: {node: '>=14.17'} - hasBin: true - - typescript@5.5.4: - resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - -snapshots: - - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': - dependencies: - eslint: 8.57.1 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.11.1': {} - - '@eslint/eslintrc@2.1.4': - dependencies: - ajv: 6.12.6 - debug: 4.3.7 - espree: 9.6.1 - globals: 13.24.0 - ignore: 5.3.2 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@8.57.1': {} - - '@humanwhocodes/config-array@0.13.0': - dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.7 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/object-schema@2.0.3': {} - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.17.1 - - '@pkgr/core@0.1.1': {} - - '@rbxts/compiler-types@3.0.0-types.0': {} - - '@rbxts/react-vendor@17.2.3': {} - - '@rbxts/react@17.2.3': - dependencies: - '@rbxts/react-vendor': 17.2.3 - - '@rbxts/types@1.0.811': {} - - '@rbxts/vide@0.5.3': {} - - '@roblox-ts/luau-ast@2.0.0': {} - - '@roblox-ts/path-translator@1.1.0': - dependencies: - ajv: 8.17.1 - fs-extra: 11.2.0 - - '@roblox-ts/rojo-resolver@1.1.0': - dependencies: - ajv: 8.17.1 - fs-extra: 11.2.0 - - '@types/json-schema@7.0.15': {} - - '@types/node@20.16.11': - dependencies: - undici-types: 6.19.8 - - '@types/semver@7.5.8': {} - - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@eslint-community/regexpp': 4.11.1 - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 - natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) - eslint: 8.57.1 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7 - eslint: 8.57.1 - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@5.62.0': - dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - - '@typescript-eslint/scope-manager@7.18.0': - dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - - '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - debug: 4.3.7 - eslint: 8.57.1 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@5.62.0': {} - - '@typescript-eslint/types@7.18.0': {} - - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.5.4)': - dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.7 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.6.3 - tsutils: 3.21.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.5.4)': - dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.5.8 - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.4) - eslint: 8.57.1 - eslint-scope: 5.1.1 - semver: 7.6.3 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - eslint: 8.57.1 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/visitor-keys@5.62.0': - dependencies: - '@typescript-eslint/types': 5.62.0 - eslint-visitor-keys: 3.4.3 - - '@typescript-eslint/visitor-keys@7.18.0': - dependencies: - '@typescript-eslint/types': 7.18.0 - eslint-visitor-keys: 3.4.3 - - '@ungap/structured-clone@1.2.0': {} - - acorn-jsx@5.3.2(acorn@8.12.1): - dependencies: - acorn: 8.12.1 - - acorn@8.12.1: {} - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.0.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - argparse@2.0.1: {} - - array-union@2.1.0: {} - - balanced-match@1.0.2: {} - - binary-extensions@2.3.0: {} - - brace-expansion@1.1.11: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - callsites@3.1.0: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - concat-map@0.0.1: {} - - cross-spawn@7.0.3: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.3.7: - dependencies: - ms: 2.1.3 - - deep-is@0.1.4: {} - - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - - emoji-regex@8.0.0: {} - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-config-prettier@9.1.0(eslint@8.57.1): - dependencies: - eslint: 8.57.1 - - eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3): - dependencies: - eslint: 8.57.1 - prettier: 3.3.3 - prettier-linter-helpers: 1.0.0 - synckit: 0.9.2 - optionalDependencies: - eslint-config-prettier: 9.1.0(eslint@8.57.1) - - eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1): - dependencies: - '@types/node': 20.16.11 - '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) - typescript: 5.5.4 - transitivePeerDependencies: - - eslint - - supports-color - - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - - eslint-scope@7.2.2: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint@8.57.1: - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@eslint-community/regexpp': 4.11.1 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 - '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.2.0 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.3 - debug: 4.3.7 - doctrine: 3.0.0 - escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 - find-up: 5.0.0 - glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.0 - json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 - transitivePeerDependencies: - - supports-color - - espree@9.6.1: - dependencies: - acorn: 8.12.1 - acorn-jsx: 5.3.2(acorn@8.12.1) - eslint-visitor-keys: 3.4.3 - - esquery@1.6.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@4.3.0: {} - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - fast-deep-equal@3.1.3: {} - - fast-diff@1.3.0: {} - - fast-glob@3.3.2: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-uri@3.0.2: {} - - fastq@1.17.1: - dependencies: - reusify: 1.0.4 - - file-entry-cache@6.0.1: - dependencies: - flat-cache: 3.2.0 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@3.2.0: - dependencies: - flatted: 3.3.1 - keyv: 4.5.4 - rimraf: 3.0.2 - - flatted@3.3.1: {} - - fs-extra@11.2.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.1 - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - get-caller-file@2.0.5: {} - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - globals@13.24.0: - dependencies: - type-fest: 0.20.2 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.2 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - - graceful-fs@4.2.11: {} - - graphemer@1.4.0: {} - - has-flag@4.0.0: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - ignore@5.3.2: {} - - import-fresh@3.3.0: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-core-module@2.15.1: - dependencies: - hasown: 2.0.2 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-path-inside@3.0.3: {} - - isexe@2.0.0: {} - - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - jsonfile@6.1.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - kleur@4.1.5: {} - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.11 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.1 - - ms@2.1.3: {} - - natural-compare@1.4.0: {} - - normalize-path@3.0.0: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - - path-type@4.0.0: {} - - picomatch@2.3.1: {} - - prelude-ls@1.2.1: {} - - prettier-linter-helpers@1.0.0: - dependencies: - fast-diff: 1.3.0 - - prettier@3.3.3: {} - - punycode@2.3.1: {} - - queue-microtask@1.2.3: {} - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.1 - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - resolve-from@4.0.0: {} - - resolve@1.22.8: - dependencies: - is-core-module: 2.15.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.0.4: {} - - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - - roblox-ts@3.0.0: - dependencies: - '@roblox-ts/luau-ast': 2.0.0 - '@roblox-ts/path-translator': 1.1.0 - '@roblox-ts/rojo-resolver': 1.1.0 - chokidar: 3.6.0 - fs-extra: 11.2.0 - kleur: 4.1.5 - resolve: 1.22.8 - typescript: 5.5.3 - yargs: 17.7.2 - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - semver@7.6.3: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - slash@3.0.0: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-json-comments@3.1.1: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - synckit@0.9.2: - dependencies: - '@pkgr/core': 0.1.1 - tslib: 2.7.0 - - text-table@0.2.0: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - ts-api-utils@1.3.0(typescript@5.5.4): - dependencies: - typescript: 5.5.4 - - tslib@1.14.1: {} - - tslib@2.7.0: {} - - tsutils@3.21.0(typescript@5.5.4): - dependencies: - tslib: 1.14.1 - typescript: 5.5.4 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-fest@0.20.2: {} - - typescript@5.5.3: {} - - typescript@5.5.4: {} - - undici-types@6.19.8: {} - - universalify@2.0.1: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - y18n@5.0.8: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 924b55f..0000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - packages/* diff --git a/rokit.toml b/rokit.toml index f6f9bb3..4be8a9f 100644 --- a/rokit.toml +++ b/rokit.toml @@ -6,8 +6,8 @@ [tools] wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.5.1" -wally-package-types = "JohnnyMorganz/wally-package-types@1.4.2" stylua = "johnnymorganz/stylua@2.1.0" selene = "Kampfkarren/selene@0.28.0" luau-lsp = "johnnymorganz/luau-lsp@1.44.1" lute = "luau-lang/lute@0.1.0-nightly.20250503" +pesde = "daimond113/pesde@0.7.0-rc.1+registry.0.2.3-rc.1" diff --git a/tests/benchmarks/effect.bench.luau b/tests/benchmarks/effect.bench.luau index fcbc85e..10d0643 100644 --- a/tests/benchmarks/effect.bench.luau +++ b/tests/benchmarks/effect.bench.luau @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.Packages.Charm) local computed = Charm.computed local effect = Charm.effect diff --git a/tests/benchmarks/patch.bench.luau b/tests/benchmarks/patch.bench.luau index 6ea53f6..e353acd 100644 --- a/tests/benchmarks/patch.bench.luau +++ b/tests/benchmarks/patch.bench.luau @@ -1,6 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) +local CharmSync = require(ReplicatedStorage.Packages.CharmSync) + +local patch = CharmSync.patch local USERS = 1000 diff --git a/tests/benchmarks/propagate.bench.luau b/tests/benchmarks/propagate.bench.luau index 7f66876..b81dec7 100644 --- a/tests/benchmarks/propagate.bench.luau +++ b/tests/benchmarks/propagate.bench.luau @@ -1,7 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local signals = require(ReplicatedStorage.DevPackages.Charm.signals) +local Charm = require(ReplicatedStorage.Packages.Charm) + +local signals = Charm.signals local function propagate( signal: (T) -> (T?) -> T, diff --git a/tests/benchmarks/propagate.luau b/tests/benchmarks/propagate.luau index d05bc12..ee56eeb 100644 --- a/tests/benchmarks/propagate.luau +++ b/tests/benchmarks/propagate.luau @@ -1,14 +1,12 @@ -local Charm = require("../../packages/charm/src/lib") +local Charm = require("../charm") 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 signal = Charm.signals.signal +local computedSignal = Charm.signals.computed +local effectSignal = Charm.signals.effect local w = 10 local h = 10 diff --git a/tests/sync/client.spec.luau b/tests/charm-sync/client.spec.luau similarity index 94% rename from tests/sync/client.spec.luau rename to tests/charm-sync/client.spec.luau index a4c9352..62dd4f6 100644 --- a/tests/sync/client.spec.luau +++ b/tests/charm-sync/client.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +local Charm = require(ReplicatedStorage.Packages.Charm) +local CharmSync = require(ReplicatedStorage.Packages.CharmSync) local suite = require(ServerScriptService.Tests.suite) local atom = Charm.atom diff --git a/tests/sync/flatten.spec.luau b/tests/charm-sync/flatten.spec.luau similarity index 82% rename from tests/sync/flatten.spec.luau rename to tests/charm-sync/flatten.spec.luau index 94533db..727e035 100644 --- a/tests/sync/flatten.spec.luau +++ b/tests/charm-sync/flatten.spec.luau @@ -1,8 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +local Charm = require(ReplicatedStorage.Packages.Charm) +local CharmSync = require(ReplicatedStorage.Packages.CharmSync) local suite = require(ServerScriptService.Tests.suite) local atom = Charm.atom diff --git a/tests/sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau similarity index 94% rename from tests/sync/patch.spec.luau rename to tests/charm-sync/patch.spec.luau index 56205c2..9622b7b 100644 --- a/tests/sync/patch.spec.luau +++ b/tests/charm-sync/patch.spec.luau @@ -1,10 +1,10 @@ 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 CharmSync = require(ReplicatedStorage.Packages.CharmSync) +local suite = require("../suite") +local patch = CharmSync.patch local test = suite.test local function serdes(value: { [any]: any }) @@ -124,7 +124,6 @@ test("optionally validates state", function() end) assert(success) - assert(success) end) return {} diff --git a/tests/sync/server.spec.luau b/tests/charm-sync/server.spec.luau similarity index 91% rename from tests/sync/server.spec.luau rename to tests/charm-sync/server.spec.luau index 6a7aefa..a6457c9 100644 --- a/tests/sync/server.spec.luau +++ b/tests/charm-sync/server.spec.luau @@ -1,15 +1,15 @@ 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 Charm = require(ReplicatedStorage.Packages.Charm) +local CharmSync = require(ReplicatedStorage.Packages.CharmSync) local suite = require(ServerScriptService.Tests.suite) type SyncPayload = CharmSync.SyncPayload local atom = Charm.atom local server = CharmSync.server +local patch = CharmSync.patch local test = suite.test local target: Player = {} :: any @@ -98,3 +98,5 @@ test("preserves history", function() disconnect() end) + +return {} diff --git a/tests/sync/spec.luau b/tests/charm-sync/spec.luau similarity index 100% rename from tests/sync/spec.luau rename to tests/charm-sync/spec.luau diff --git a/tests/sync/validate.spec.luau b/tests/charm-sync/validate.spec.luau similarity index 87% rename from tests/sync/validate.spec.luau rename to tests/charm-sync/validate.spec.luau index 067dc30..87b048a 100644 --- a/tests/sync/validate.spec.luau +++ b/tests/charm-sync/validate.spec.luau @@ -1,11 +1,12 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") -local Charm = require(ReplicatedStorage.DevPackages.Charm) +local Charm = require(ReplicatedStorage.Packages.Charm) +local CharmSync = require(ReplicatedStorage.Packages.CharmSync) local suite = require(ServerScriptService.Tests.suite) -local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate) local atom = Charm.atom +local validate = CharmSync.validate local test = suite.test test("validates inputs", function() @@ -33,3 +34,5 @@ test("validates inputs", function() end end end) + +return {} diff --git a/tests/charm.luau b/tests/charm.luau new file mode 100644 index 0000000..aedbca8 --- /dev/null +++ b/tests/charm.luau @@ -0,0 +1,3 @@ +return if game + then require(game:GetService("ReplicatedStorage").Packages.Charm) + else require("../packages/charm/src/lib") diff --git a/tests/react/spec.luau b/tests/react-charm/spec.luau similarity index 100% rename from tests/react/spec.luau rename to tests/react-charm/spec.luau diff --git a/tests/react/useAtom.spec.luau b/tests/react-charm/useAtom.spec.luau similarity index 83% rename from tests/react/useAtom.spec.luau rename to tests/react-charm/useAtom.spec.luau index 4e725e5..4e01cd0 100644 --- a/tests/react/useAtom.spec.luau +++ b/tests/react-charm/useAtom.spec.luau @@ -1,9 +1,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm) -local renderHook = require(script.Parent.Parent.utils.renderHook) +local Charm = require(ReplicatedStorage.Packages.Charm) +local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm) +local renderHook = require(ServerScriptService.Tests.utils.renderHook) local suite = require(ServerScriptService.Tests.suite) local atom = Charm.atom @@ -46,3 +46,5 @@ test("tracks dependencies", function() state(1) assert(hook.result == 1) end) + +return {} diff --git a/tests/signals/computed.spec.luau b/tests/signals/computed.spec.luau index 9cff908..a1e435b 100644 --- a/tests/signals/computed.spec.luau +++ b/tests/signals/computed.spec.luau @@ -1,5 +1,5 @@ local test = require("../suite").test -local signals = require("../../packages/charm/src/signals") +local signals = require("../charm").signals local computed = signals.computed local signal = signals.signal diff --git a/tests/signals/effect.spec.luau b/tests/signals/effect.spec.luau index dd8d9df..87aeda5 100644 --- a/tests/signals/effect.spec.luau +++ b/tests/signals/effect.spec.luau @@ -1,5 +1,5 @@ local test = require("../suite").test -local signals = require("../../packages/charm/src/signals") +local signals = require("../charm").signals local computed = signals.computed local effect = signals.effect diff --git a/tests/signals/effectScope.spec.luau b/tests/signals/effectScope.spec.luau index d958993..83dc2cd 100644 --- a/tests/signals/effectScope.spec.luau +++ b/tests/signals/effectScope.spec.luau @@ -1,5 +1,5 @@ local test = require("../suite").test -local signals = require("../../packages/charm/src/signals") +local signals = require("../charm").signals local effect = signals.effect local effectScope = signals.effectScope diff --git a/tests/signals/issue_48.spec.luau b/tests/signals/issue_48.spec.luau index 8899f97..3ecf720 100644 --- a/tests/signals/issue_48.spec.luau +++ b/tests/signals/issue_48.spec.luau @@ -1,5 +1,5 @@ local test = require("../suite").test -local signals = require("../../packages/charm/src/signals") +local signals = require("../charm").signals local computed = signals.computed local effect = signals.effect diff --git a/tests/signals/topology.spec.luau b/tests/signals/topology.spec.luau index d61f92b..227ff47 100644 --- a/tests/signals/topology.spec.luau +++ b/tests/signals/topology.spec.luau @@ -1,4 +1,4 @@ -local signals = require("../../packages/charm/src/signals") +local signals = require("../charm").signals local suite = require("../suite") local test = suite.test diff --git a/tests/signals/untrack.spec.luau b/tests/signals/untrack.spec.luau index c35c1f1..8ff46ba 100644 --- a/tests/signals/untrack.spec.luau +++ b/tests/signals/untrack.spec.luau @@ -1,5 +1,5 @@ local test = require("../suite").test -local signals = require("../../packages/charm/src/signals") +local signals = require("../charm").signals local computed = signals.computed local pauseTracking = signals.pauseTracking diff --git a/tests/spec.server.luau b/tests/spec.server.luau index 109d845..efb9162 100644 --- a/tests/spec.server.luau +++ b/tests/spec.server.luau @@ -12,9 +12,10 @@ end test("signals") if game then - test("sync") - test("react") - test("vide") + _G.__DEV__ = true + test("charm-sync") + test("react-charm") + test("vide-charm") end finish() diff --git a/tests/suite.luau b/tests/suite.luau index 71429c4..9ac76f3 100644 --- a/tests/suite.luau +++ b/tests/suite.luau @@ -44,13 +44,13 @@ local function describe(name: string, callback: () -> ()) end local function finish() - print(`\n🔵 {tests - #errors} / {tests} tests passed\n`) + local output = { `\n🔵 {tests - #errors} / {tests} tests passed\n` } for _, result in results do - print(result) + table.insert(output, result) end - print() + print(table.concat(output, "\n") .. "\n") if #errors > 0 then error(`{#errors} test(s) failed:\n{table.concat(errors, "\n")}\n`) diff --git a/tests/utils/renderHook.luau b/tests/utils/renderHook.luau index 0d2d19f..b4f7ac0 100644 --- a/tests/utils/renderHook.luau +++ b/tests/utils/renderHook.luau @@ -2,8 +2,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") _G.__ROACT_17_MOCK_SCHEDULER__ = true -local React = require(ReplicatedStorage.DevPackages.React) -local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox) +local React = require(ReplicatedStorage.Packages.React) +local ReactRoblox = require(ReplicatedStorage.Packages.ReactRoblox) type RenderedHook = { result: Result, diff --git a/tests/vide/spec.luau b/tests/vide-charm/spec.luau similarity index 100% rename from tests/vide/spec.luau rename to tests/vide-charm/spec.luau diff --git a/tests/vide/useAtom.spec.luau b/tests/vide-charm/useAtom.spec.luau similarity index 80% rename from tests/vide/useAtom.spec.luau rename to tests/vide-charm/useAtom.spec.luau index 9572f33..7231b85 100644 --- a/tests/vide/useAtom.spec.luau +++ b/tests/vide-charm/useAtom.spec.luau @@ -1,9 +1,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local Vide = require(ReplicatedStorage.DevPackages.Vide) -local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm) +local Charm = require(ReplicatedStorage.Packages.Charm) +local Vide = require(ReplicatedStorage.Packages.Vide) +local VideCharm = require(ReplicatedStorage.Packages.VideCharm) local suite = require(ServerScriptService.Tests.suite) local atom = Charm.atom diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index b255ab8..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - // required - "allowSyntheticDefaultImports": true, - "downlevelIteration": true, - "jsx": "react", - "jsxFactory": "React.createElement", - "jsxFragmentFactory": "React.Fragment", - "module": "commonjs", - "moduleResolution": "Node", - "noLib": true, - "resolveJsonModule": true, - "experimentalDecorators": true, - "forceConsistentCasingInFileNames": true, - "moduleDetection": "force", - "strict": true, - "target": "ESNext", - "typeRoots": ["node_modules/@rbxts"], - - // configurable - "rootDir": "src", - "outDir": "out", - "incremental": true, - "tsBuildInfoFile": "out/tsconfig.tsbuildinfo", - "declaration": true - } -} From a0fc88ede1d765aed80df82c1c16cc06508d2a7a Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 3 May 2025 21:16:53 -0700 Subject: [PATCH 003/160] fix: `effectScope` should set scope --- packages/charm/src/signals.luau | 4 ++-- tests/signals/effectScope.spec.luau | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/charm/src/signals.luau b/packages/charm/src/signals.luau index d6520ea..0fad3e2 100644 --- a/packages/charm/src/signals.luau +++ b/packages/charm/src/signals.luau @@ -324,9 +324,9 @@ local function effectScope(fn: () -> ...() -> ()?): () -> () if activeScope then link(e, activeScope) end - local prev = setCurrentSub(e) + local prev = setCurrentScope(e) local success, result = pcall(fn) - setCurrentSub(prev) + setCurrentScope(prev) if not success then error(result) end diff --git a/tests/signals/effectScope.spec.luau b/tests/signals/effectScope.spec.luau index 83dc2cd..1ac0317 100644 --- a/tests/signals/effectScope.spec.luau +++ b/tests/signals/effectScope.spec.luau @@ -44,7 +44,7 @@ test("should run effect cleanup", function() assert(cleanups == 1, "should skip redundant cleanup") end) -test("should not run indirect effect cleanup", function() +test("should run indirect effect cleanup", function() local cleanups = 0 local cleanup @@ -58,9 +58,9 @@ test("should not run indirect effect cleanup", function() assert(cleanups == 0, "should not cleanup immediately") stopScope() - assert(cleanups == 0, "should be detached from parent dispose") + assert(cleanups == 1, "should cleanup from parent dispose") cleanup() - assert(cleanups == 1, "should cleanup after inner dispose") + assert(cleanups == 1, "should skip redundant dispose") end) return {} From 209f9b583bb954843cbfa7b62c2c558a4927dbe9 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 3 May 2025 21:17:03 -0700 Subject: [PATCH 004/160] fix: check previous value before calling listener --- packages/charm/src/listen.luau | 8 ++++++++ packages/charm/src/subscribe.luau | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 56c3deb..9de1d9c 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -8,6 +8,7 @@ local resumeTracking = signals.resumeTracking local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () local disposed = false + local mounted = true local current: T? local dispose: () -> () @@ -21,6 +22,13 @@ local function listen(selector: () -> T, listener: (state: T, previous: T?, d dispose = effectScope(function() effect(function() local value = selector() + + if mounted then + mounted = false + elseif value == current then + return + end + local previous = current current = value diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index 9797d55..9d9e1e6 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -8,17 +8,17 @@ local resumeTracking = signals.resumeTracking local function subscribe(selector: () -> T, listener: (T, T) -> ()): () -> () local current: T - local mount = true + local mounted = true local dispose: () -> () dispose = effectScope(function() effect(function() local value = selector() - if mount then + if mounted then current = value - mount = false - else + mounted = false + elseif value ~= current then local previous = current current = value From 8df67d6fff45eaa8de5ad7f463f22f8f80868256 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 5 May 2025 01:30:35 -0700 Subject: [PATCH 005/160] refactor: improve imports --- packages/charm-sync/Charm.luau | 2 ++ packages/charm/src/computed.luau | 13 ------------- packages/charm/src/effect.luau | 7 ++----- packages/charm/src/effectScope.luau | 13 ------------- packages/charm/src/lib.luau | 5 +++-- packages/charm/src/listen.luau | 27 ++++++++++++--------------- packages/charm/src/mapped.luau | 2 +- packages/charm/src/subscribe.luau | 27 ++++++++++++--------------- packages/react-charm/Charm.luau | 2 ++ packages/react-charm/React.luau | 2 ++ packages/react-charm/src/init.luau | 4 +--- packages/vide-charm/Charm.luau | 2 ++ packages/vide-charm/Vide.luau | 2 ++ packages/vide-charm/src/init.luau | 7 ++----- tests/signals/computed.spec.luau | 6 ++++-- tests/signals/effect.spec.luau | 6 ++++-- tests/signals/effectScope.spec.luau | 6 ++++-- tests/signals/issue_48.spec.luau | 6 ++++-- tests/signals/topology.spec.luau | 8 ++++---- tests/signals/untrack.spec.luau | 6 ++++-- 20 files changed, 67 insertions(+), 86 deletions(-) create mode 100644 packages/charm-sync/Charm.luau delete mode 100644 packages/charm/src/computed.luau delete mode 100644 packages/charm/src/effectScope.luau create mode 100644 packages/react-charm/Charm.luau create mode 100644 packages/react-charm/React.luau create mode 100644 packages/vide-charm/Charm.luau create mode 100644 packages/vide-charm/Vide.luau diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau new file mode 100644 index 0000000..9954123 --- /dev/null +++ b/packages/charm-sync/Charm.luau @@ -0,0 +1,2 @@ +--!nocheck +return require(script.Parent.charm.src) diff --git a/packages/charm/src/computed.luau b/packages/charm/src/computed.luau deleted file mode 100644 index 70b9536..0000000 --- a/packages/charm/src/computed.luau +++ /dev/null @@ -1,13 +0,0 @@ -local signals = require("./signals") - -local computedImpl = signals.computed - -local function computed(selector: (previous: T?) -> T): () -> T - local getter = computedImpl(selector) - - return function() - return getter() - end -end - -return computed diff --git a/packages/charm/src/effect.luau b/packages/charm/src/effect.luau index 274edb4..fc4f9bf 100644 --- a/packages/charm/src/effect.luau +++ b/packages/charm/src/effect.luau @@ -1,4 +1,3 @@ -local effectScope = require("./effectScope") local signals = require("./signals") local effectImpl = signals.effect @@ -14,10 +13,8 @@ local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> () end end - dispose = effectScope(function() - effectImpl(function() - return callback(disposeSafe) - end) + dispose = effectImpl(function() + return callback(disposeSafe) end) if disposed then diff --git a/packages/charm/src/effectScope.luau b/packages/charm/src/effectScope.luau deleted file mode 100644 index 92147a1..0000000 --- a/packages/charm/src/effectScope.luau +++ /dev/null @@ -1,13 +0,0 @@ -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/lib.luau b/packages/charm/src/lib.luau index 96e1746..8741d13 100644 --- a/packages/charm/src/lib.luau +++ b/packages/charm/src/lib.luau @@ -1,8 +1,6 @@ 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") @@ -15,6 +13,9 @@ export type Atom = types.Atom export type AtomOptions = types.AtomOptions export type Selector = types.Selector +local computed = signals.computed +local effectScope = signals.effectScope + return { atom = atom, batched = batched, diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 9de1d9c..e16e623 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -1,4 +1,3 @@ -local effectScope = require("./effectScope") local signals = require("./signals") local spawn = require("./spawn") @@ -19,23 +18,21 @@ local function listen(selector: () -> T, listener: (state: T, previous: T?, d end end - dispose = effectScope(function() - effect(function() - local value = selector() + dispose = effect(function() + local value = selector() - if mounted then - mounted = false - elseif value == current then - return - end + if mounted then + mounted = false + elseif value == current then + return + end - local previous = current - current = value + local previous = current + current = value - pauseTracking() - spawn(listener, value, previous, disposeSafe) - resumeTracking() - end) + pauseTracking() + spawn(listener, value, previous, disposeSafe) + resumeTracking() end) if disposed then diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index 641615a..a842b95 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -1,6 +1,6 @@ -local computed = require("./computed") local signals = require("./signals") +local computed = signals.computed local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index 9d9e1e6..745b03a 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -1,4 +1,3 @@ -local effectScope = require("./effectScope") local signals = require("./signals") local spawn = require("./spawn") @@ -11,22 +10,20 @@ local function subscribe(selector: () -> T, listener: (T, T) -> ()): () -> () local mounted = true local dispose: () -> () - dispose = effectScope(function() - effect(function() - local value = selector() + dispose = effect(function() + local value = selector() - if mounted then - current = value - mounted = false - elseif value ~= current then - local previous = current - current = value + if mounted then + current = value + mounted = false + elseif value ~= current then + local previous = current + current = value - pauseTracking() - spawn(listener, value, previous) - resumeTracking() - end - end) + pauseTracking() + spawn(listener, value, previous) + resumeTracking() + end end) return function() diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau new file mode 100644 index 0000000..9954123 --- /dev/null +++ b/packages/react-charm/Charm.luau @@ -0,0 +1,2 @@ +--!nocheck +return require(script.Parent.charm.src) diff --git a/packages/react-charm/React.luau b/packages/react-charm/React.luau new file mode 100644 index 0000000..3e8acbd --- /dev/null +++ b/packages/react-charm/React.luau @@ -0,0 +1,2 @@ +--!nocheck +return require(script.Parent.react) diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 13159bf..9d13132 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -1,7 +1,5 @@ local Charm = require(script.Parent.Charm) -local React = if script.Parent:FindFirstChild("react") - then require((script :: any).Parent.react) :: never - else require(script.Parent.React) +local React = require(script.Parent.React) local listen = Charm.listen local useEffect = React.useEffect diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau new file mode 100644 index 0000000..9954123 --- /dev/null +++ b/packages/vide-charm/Charm.luau @@ -0,0 +1,2 @@ +--!nocheck +return require(script.Parent.charm.src) diff --git a/packages/vide-charm/Vide.luau b/packages/vide-charm/Vide.luau new file mode 100644 index 0000000..0dc1c09 --- /dev/null +++ b/packages/vide-charm/Vide.luau @@ -0,0 +1,2 @@ +--!nocheck +return require(script.Parent.vide.src) diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index 6590ef7..ef7bb25 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -1,7 +1,5 @@ local Charm = require(script.Parent.Charm) -local Vide = if script.Parent:FindFirstChild("vide") - then require((script :: any).Parent.vide.src) :: never - else require(script.Parent.Vide) +local Vide = require(script.Parent.Vide) local subscribe = Charm.subscribe local source = Vide.source @@ -15,8 +13,7 @@ local cleanup = Vide.cleanup ]=] local function useAtom(callback: () -> State): () -> State local state = source(callback()) - local dispose = subscribe(callback, state) - cleanup(dispose) + cleanup(subscribe(callback, state)) return state end diff --git a/tests/signals/computed.spec.luau b/tests/signals/computed.spec.luau index a1e435b..828d9c3 100644 --- a/tests/signals/computed.spec.luau +++ b/tests/signals/computed.spec.luau @@ -1,8 +1,10 @@ -local test = require("../suite").test -local signals = require("../charm").signals +local charm = require("../charm") +local suite = require("../suite") +local signals = charm.signals local computed = signals.computed local signal = signals.signal +local test = suite.test test("should correctly propagate changes through computed signals", function() local src = signal(0) diff --git a/tests/signals/effect.spec.luau b/tests/signals/effect.spec.luau index 87aeda5..a279e47 100644 --- a/tests/signals/effect.spec.luau +++ b/tests/signals/effect.spec.luau @@ -1,6 +1,7 @@ -local test = require("../suite").test -local signals = require("../charm").signals +local charm = require("../charm") +local suite = require("../suite") +local signals = charm.signals local computed = signals.computed local effect = signals.effect local effectScope = signals.effectScope @@ -9,6 +10,7 @@ local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking local signal = signals.signal local startBatch = signals.startBatch +local test = suite.test test("should clear subscriptions when untracked by all subscribers", function() local bRunTimes = 0 diff --git a/tests/signals/effectScope.spec.luau b/tests/signals/effectScope.spec.luau index 1ac0317..5f5ebd3 100644 --- a/tests/signals/effectScope.spec.luau +++ b/tests/signals/effectScope.spec.luau @@ -1,9 +1,11 @@ -local test = require("../suite").test -local signals = require("../charm").signals +local charm = require("../charm") +local suite = require("../suite") +local signals = charm.signals local effect = signals.effect local effectScope = signals.effectScope local signal = signals.signal +local test = suite.test test("should not trigger after stop", function() local count = signal(1) diff --git a/tests/signals/issue_48.spec.luau b/tests/signals/issue_48.spec.luau index 3ecf720..bfb2d86 100644 --- a/tests/signals/issue_48.spec.luau +++ b/tests/signals/issue_48.spec.luau @@ -1,11 +1,13 @@ -local test = require("../suite").test -local signals = require("../charm").signals +local charm = require("../charm") +local suite = require("../suite") +local signals = charm.signals local computed = signals.computed local effect = signals.effect local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking local signal = signals.signal +local test = suite.test type ReactionOptions = { fireImmediately: boolean?, diff --git a/tests/signals/topology.spec.luau b/tests/signals/topology.spec.luau index 227ff47..e111576 100644 --- a/tests/signals/topology.spec.luau +++ b/tests/signals/topology.spec.luau @@ -1,12 +1,12 @@ -local signals = require("../charm").signals +local charm = require("../charm") local suite = require("../suite") -local test = suite.test -local describe = suite.describe - +local signals = charm.signals local computed = signals.computed local effect = signals.effect local signal = signals.signal +local describe = suite.describe +local test = suite.test --[[ Tests adopted with thanks from preact-signals implementation at diff --git a/tests/signals/untrack.spec.luau b/tests/signals/untrack.spec.luau index 8ff46ba..860ea0b 100644 --- a/tests/signals/untrack.spec.luau +++ b/tests/signals/untrack.spec.luau @@ -1,10 +1,12 @@ -local test = require("../suite").test -local signals = require("../charm").signals +local charm = require("../charm") +local suite = require("../suite") +local signals = charm.signals local computed = signals.computed local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking local signal = signals.signal +local test = suite.test test("should pause tracking", function() local src = signal(0) From 3eba56e2982e471e5a3caf375cceacbaa3e4cdaa Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 5 May 2025 01:32:51 -0700 Subject: [PATCH 006/160] refactor: exclude re-exports from wally --- packages/charm-sync/wally.toml | 1 + packages/react-charm/wally.toml | 1 + packages/vide-charm/wally.toml | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 0507407..5832ab5 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -3,6 +3,7 @@ name = "littensy/charm-sync" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["Charm.luau"] [dependencies] Charm = "littensy/charm@^0.10" diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 6fd29e7..513bbd2 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -3,6 +3,7 @@ name = "littensy/react-charm" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["Charm.luau", "React.luau"] [dependencies] Charm = "littensy/charm@^0.10" diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index ed47be6..d344bdc 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -3,6 +3,7 @@ name = "littensy/vide-charm" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["Charm.luau", "Vide.luau"] [dependencies] Charm = "littensy/charm@^0.10" From 8d385356cd03743c71ab6f92fdb3f0d2100c6def Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 5 May 2025 22:05:41 -0700 Subject: [PATCH 007/160] refactor: embrace scope cleanup, restructure code --- .github/workflows/ci.yml | 2 +- .gitignore | 2 +- .luaurc | 4 + bin/build.sh | 8 +- bin/test.sh | 1 - default.project.json | 21 +- packages/charm-sync/Charm.luau | 2 - packages/charm/src/atom.luau | 54 ----- packages/charm/src/effect.luau | 27 --- packages/charm/src/observe.luau | 38 ---- packages/react-charm/Charm.luau | 2 - packages/react-charm/React.luau | 2 - packages/vide-charm/Charm.luau | 2 - packages/vide-charm/Vide.luau | 2 - .../__benchmarks__}/patch.bench.luau | 18 +- .../charm-sync/__tests__}/client.spec.luau | 0 .../charm-sync/__tests__}/flatten.spec.luau | 0 .../charm-sync/__tests__}/patch.spec.luau | 0 .../charm-sync/__tests__}/server.spec.luau | 0 pkgs/charm-sync/__tests__/spec.luau | 19 ++ .../charm-sync/__tests__}/validate.spec.luau | 0 .../charm-sync/default.project.json | 0 {packages => pkgs}/charm-sync/src/client.luau | 0 .../charm-sync/src/flatten.luau | 0 {packages => pkgs}/charm-sync/src/init.luau | 0 .../charm-sync/src/interval.luau | 0 {packages => pkgs}/charm-sync/src/patch.luau | 0 {packages => pkgs}/charm-sync/src/server.luau | 0 {packages => pkgs}/charm-sync/src/types.luau | 0 .../charm-sync/src/validate.luau | 0 {packages => pkgs}/charm-sync/wally.toml | 0 .../charm/__benchmarks__}/effect.bench.luau | 9 +- .../charm/__benchmarks__/propagate.bench.luau | 63 ++++++ .../__tests__}/signals/computed.spec.luau | 25 ++- .../charm/__tests__}/signals/effect.spec.luau | 142 ++++++------- .../__tests__}/signals/effectScope.spec.luau | 59 +++--- .../__tests__}/signals/issue_48.spec.luau | 13 +- pkgs/charm/__tests__/signals/spec.luau | 18 ++ .../__tests__}/signals/topology.spec.luau | 63 +++--- .../__tests__}/signals/untrack.spec.luau | 9 +- pkgs/charm/__tests__/spec.luau | 20 ++ pkgs/charm/__tests__/subscribe.spec.luau | 28 +++ {packages => pkgs}/charm/default.project.json | 0 pkgs/charm/src/atom.luau | 54 +++++ {packages => pkgs}/charm/src/batched.luau | 0 pkgs/charm/src/effect.luau | 27 +++ pkgs/charm/src/effectScope.luau | 15 ++ {packages => pkgs}/charm/src/init.luau | 0 {packages => pkgs}/charm/src/lib.luau | 4 + {packages => pkgs}/charm/src/listen.luau | 33 ++- {packages => pkgs}/charm/src/mapped.luau | 6 +- pkgs/charm/src/observe.luau | 60 ++++++ {packages => pkgs}/charm/src/signals.luau | 196 +++++++++++------- {packages => pkgs}/charm/src/spawn.luau | 0 {packages => pkgs}/charm/src/subscribe.luau | 7 +- {packages => pkgs}/charm/src/system.luau | 0 {packages => pkgs}/charm/src/types.luau | 0 {packages => pkgs}/charm/src/untracked.luau | 0 {packages => pkgs}/charm/wally.toml | 0 .../__tests__/helpers}/renderHook.luau | 0 pkgs/react-charm/__tests__/spec.luau | 15 ++ .../react-charm/__tests__}/useAtom.spec.luau | 2 +- .../react-charm/default.project.json | 0 {packages => pkgs}/react-charm/src/init.luau | 0 {packages => pkgs}/react-charm/wally.toml | 0 pkgs/vide-charm/__tests__/spec.luau | 15 ++ .../vide-charm/__tests__}/useAtom.spec.luau | 0 .../vide-charm/default.project.json | 0 {packages => pkgs}/vide-charm/src/init.luau | 0 {packages => pkgs}/vide-charm/wally.toml | 0 tests/benchmarks/propagate.bench.luau | 39 ---- tests/benchmarks/propagate.luau | 59 ------ tests/charm-sync/spec.luau | 17 -- tests/charm.luau | 3 - tests/init.luau | 14 ++ tests/react-charm/spec.luau | 13 -- tests/signals/spec.luau | 18 -- tests/spec.server.luau | 23 +- tests/utils/collect.luau | 12 -- tests/utils/count.luau | 15 -- tests/vide-charm/spec.luau | 13 -- 81 files changed, 687 insertions(+), 626 deletions(-) delete mode 100644 bin/test.sh delete mode 100644 packages/charm-sync/Charm.luau delete mode 100644 packages/charm/src/atom.luau delete mode 100644 packages/charm/src/effect.luau delete mode 100644 packages/charm/src/observe.luau delete mode 100644 packages/react-charm/Charm.luau delete mode 100644 packages/react-charm/React.luau delete mode 100644 packages/vide-charm/Charm.luau delete mode 100644 packages/vide-charm/Vide.luau rename {tests/benchmarks => pkgs/charm-sync/__benchmarks__}/patch.bench.luau (78%) rename {tests/charm-sync => pkgs/charm-sync/__tests__}/client.spec.luau (100%) rename {tests/charm-sync => pkgs/charm-sync/__tests__}/flatten.spec.luau (100%) rename {tests/charm-sync => pkgs/charm-sync/__tests__}/patch.spec.luau (100%) rename {tests/charm-sync => pkgs/charm-sync/__tests__}/server.spec.luau (100%) create mode 100644 pkgs/charm-sync/__tests__/spec.luau rename {tests/charm-sync => pkgs/charm-sync/__tests__}/validate.spec.luau (100%) rename {packages => pkgs}/charm-sync/default.project.json (100%) rename {packages => pkgs}/charm-sync/src/client.luau (100%) rename {packages => pkgs}/charm-sync/src/flatten.luau (100%) rename {packages => pkgs}/charm-sync/src/init.luau (100%) rename {packages => pkgs}/charm-sync/src/interval.luau (100%) rename {packages => pkgs}/charm-sync/src/patch.luau (100%) rename {packages => pkgs}/charm-sync/src/server.luau (100%) rename {packages => pkgs}/charm-sync/src/types.luau (100%) rename {packages => pkgs}/charm-sync/src/validate.luau (100%) rename {packages => pkgs}/charm-sync/wally.toml (100%) rename {tests/benchmarks => pkgs/charm/__benchmarks__}/effect.bench.luau (81%) create mode 100644 pkgs/charm/__benchmarks__/propagate.bench.luau rename {tests => pkgs/charm/__tests__}/signals/computed.spec.luau (79%) rename {tests => pkgs/charm/__tests__}/signals/effect.spec.luau (75%) rename {tests => pkgs/charm/__tests__}/signals/effectScope.spec.luau (59%) rename {tests => pkgs/charm/__tests__}/signals/issue_48.spec.luau (93%) create mode 100644 pkgs/charm/__tests__/signals/spec.luau rename {tests => pkgs/charm/__tests__}/signals/topology.spec.luau (94%) rename {tests => pkgs/charm/__tests__}/signals/untrack.spec.luau (77%) create mode 100644 pkgs/charm/__tests__/spec.luau create mode 100644 pkgs/charm/__tests__/subscribe.spec.luau rename {packages => pkgs}/charm/default.project.json (100%) create mode 100644 pkgs/charm/src/atom.luau rename {packages => pkgs}/charm/src/batched.luau (100%) create mode 100644 pkgs/charm/src/effect.luau create mode 100644 pkgs/charm/src/effectScope.luau rename {packages => pkgs}/charm/src/init.luau (100%) rename {packages => pkgs}/charm/src/lib.luau (83%) rename {packages => pkgs}/charm/src/listen.luau (52%) rename {packages => pkgs}/charm/src/mapped.luau (96%) create mode 100644 pkgs/charm/src/observe.luau rename {packages => pkgs}/charm/src/signals.luau (75%) rename {packages => pkgs}/charm/src/spawn.luau (100%) rename {packages => pkgs}/charm/src/subscribe.luau (86%) rename {packages => pkgs}/charm/src/system.luau (100%) rename {packages => pkgs}/charm/src/types.luau (100%) rename {packages => pkgs}/charm/src/untracked.luau (100%) rename {packages => pkgs}/charm/wally.toml (100%) rename {tests/utils => pkgs/react-charm/__tests__/helpers}/renderHook.luau (100%) create mode 100644 pkgs/react-charm/__tests__/spec.luau rename {tests/react-charm => pkgs/react-charm/__tests__}/useAtom.spec.luau (94%) rename {packages => pkgs}/react-charm/default.project.json (100%) rename {packages => pkgs}/react-charm/src/init.luau (100%) rename {packages => pkgs}/react-charm/wally.toml (100%) create mode 100644 pkgs/vide-charm/__tests__/spec.luau rename {tests/vide-charm => pkgs/vide-charm/__tests__}/useAtom.spec.luau (100%) rename {packages => pkgs}/vide-charm/default.project.json (100%) rename {packages => pkgs}/vide-charm/src/init.luau (100%) rename {packages => pkgs}/vide-charm/wally.toml (100%) delete mode 100644 tests/benchmarks/propagate.bench.luau delete mode 100644 tests/benchmarks/propagate.luau delete mode 100644 tests/charm-sync/spec.luau delete mode 100644 tests/charm.luau create mode 100644 tests/init.luau delete mode 100644 tests/react-charm/spec.luau delete mode 100644 tests/signals/spec.luau delete mode 100644 tests/utils/collect.luau delete mode 100644 tests/utils/count.luau delete mode 100644 tests/vide-charm/spec.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35c0019..de89233 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: run: sh bin/check.sh - name: Run tests - run: sh bin/test.sh + run: lute tests/init.luau - name: Build models run: sh bin/build.sh diff --git a/.gitignore b/.gitignore index f1dcc5c..ac9c72d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ /.vscode /build /roblox_packages -/packages/**/wally.lock +/pkgs/**/wally.lock /sourcemap.json *.tsbuildinfo *.rbxm diff --git a/.luaurc b/.luaurc index 4c8043d..e9cc27f 100644 --- a/.luaurc +++ b/.luaurc @@ -2,5 +2,9 @@ "languageMode": "strict", "lint": { "LocalShadow": false + }, + "aliases": { + "tests": "tests", + "pkgs": "packages" } } diff --git a/bin/build.sh b/bin/build.sh index be6fdf7..c228f96 100644 --- a/bin/build.sh +++ b/bin/build.sh @@ -1,5 +1,5 @@ mkdir build -rojo build packages/charm -o build/charm.rbxm -rojo build packages/charm-sync -o build/charm-sync.rbxm -rojo build packages/react-charm -o build/react-charm.rbxm -rojo build packages/vide-charm -o build/vide-charm.rbxm +rojo build pkgs/charm -o build/charm.rbxm +rojo build pkgs/charm-sync -o build/charm-sync.rbxm +rojo build pkgs/react-charm -o build/react-charm.rbxm +rojo build pkgs/vide-charm -o build/vide-charm.rbxm diff --git a/bin/test.sh b/bin/test.sh deleted file mode 100644 index e1b757a..0000000 --- a/bin/test.sh +++ /dev/null @@ -1 +0,0 @@ -lute tests/spec.server.luau diff --git a/default.project.json b/default.project.json index cbd3b70..576c28c 100644 --- a/default.project.json +++ b/default.project.json @@ -6,15 +6,26 @@ "$className": "ReplicatedStorage", "Packages": { "$path": "roblox_packages", - "Charm": { "$path": "packages/charm" }, - "CharmSync": { "$path": "packages/charm-sync" }, - "ReactCharm": { "$path": "packages/react-charm" }, - "VideCharm": { "$path": "packages/vide-charm" } + "Charm": { "$path": "pkgs/charm" }, + "CharmSync": { "$path": "pkgs/charm-sync" }, + "ReactCharm": { "$path": "pkgs/react-charm" }, + "VideCharm": { "$path": "pkgs/vide-charm" } } }, "ServerScriptService": { "$className": "ServerScriptService", - "Tests": { "$path": "tests" } + "Tests": { + "$path": "tests", + "Charm": { "$path": "pkgs/charm/__tests__" }, + "CharmSync": { "$path": "pkgs/charm-sync/__tests__" }, + "ReactCharm": { "$path": "pkgs/react-charm/__tests__" }, + "VideCharm": { "$path": "pkgs/vide-charm/__tests__" } + }, + "Benchmarks": { + "$className": "Folder", + "Charm": { "$path": "pkgs/charm/__benchmarks__" }, + "CharmSync": { "$path": "pkgs/charm-sync/__benchmarks__" } + } } } } diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau deleted file mode 100644 index 9954123..0000000 --- a/packages/charm-sync/Charm.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.charm.src) diff --git a/packages/charm/src/atom.luau b/packages/charm/src/atom.luau deleted file mode 100644 index 5cb0417..0000000 --- a/packages/charm/src/atom.luau +++ /dev/null @@ -1,54 +0,0 @@ -local signals = require("./signals") -local types = require("./types") - -type Atom = types.Atom -type AtomOptions = types.AtomOptions - -local signal = signals.signal -local pauseTracking = signals.pauseTracking -local resumeTracking = signals.resumeTracking - -local function atomOper(source: (...T) -> T, equals: ((T, T) -> boolean)?, ...: ((T) -> T) | T): T - if select("#", ...) == 0 then - return source() - end - - local transform = (...) - local currentState = (source :: any).value - local nextState: T - - -- If the transform is a function, call it with the current state and pause - -- tracking to avoid adding signals called by the transform. - if type(transform) == "function" then - pauseTracking() - local success, result = pcall(transform, currentState) - resumeTracking() - - if not success then - error(result, 2) - end - - nextState = result - else - nextState = transform - end - - if equals and equals(currentState, nextState) then - return nextState - end - - source(nextState) - - return nextState -end - -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/effect.luau b/packages/charm/src/effect.luau deleted file mode 100644 index fc4f9bf..0000000 --- a/packages/charm/src/effect.luau +++ /dev/null @@ -1,27 +0,0 @@ -local signals = require("./signals") - -local effectImpl = signals.effect - -local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> () - local disposed = false - local dispose: () -> () - - local function disposeSafe() - disposed = true - if dispose then - dispose() - end - end - - dispose = effectImpl(function() - return callback(disposeSafe) - end) - - if disposed then - dispose() - end - - return disposeSafe -end - -return effect diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau deleted file mode 100644 index eaa2522..0000000 --- a/packages/charm/src/observe.luau +++ /dev/null @@ -1,38 +0,0 @@ -local listen = require("./listen") - -local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> () - local cleanups: { [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 - - 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 currentItems do - if previousItems[key] == nil then - cleanups[key] = observer(value, key) - end - end - end) - - return function() - unsub() - for _, cleanup in cleanups do - (cleanup :: () -> ())() - end - table.clear(cleanups) - end -end - -return observe diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau deleted file mode 100644 index 9954123..0000000 --- a/packages/react-charm/Charm.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.charm.src) diff --git a/packages/react-charm/React.luau b/packages/react-charm/React.luau deleted file mode 100644 index 3e8acbd..0000000 --- a/packages/react-charm/React.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.react) diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau deleted file mode 100644 index 9954123..0000000 --- a/packages/vide-charm/Charm.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.charm.src) diff --git a/packages/vide-charm/Vide.luau b/packages/vide-charm/Vide.luau deleted file mode 100644 index 0dc1c09..0000000 --- a/packages/vide-charm/Vide.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.vide.src) diff --git a/tests/benchmarks/patch.bench.luau b/pkgs/charm-sync/__benchmarks__/patch.bench.luau similarity index 78% rename from tests/benchmarks/patch.bench.luau rename to pkgs/charm-sync/__benchmarks__/patch.bench.luau index e353acd..8d4eeea 100644 --- a/tests/benchmarks/patch.bench.luau +++ b/pkgs/charm-sync/__benchmarks__/patch.bench.luau @@ -24,14 +24,18 @@ local function update(state) state.users = table.clone(state.users) for user = 1, USERS do - if math.random() > 0.5 then - continue - end + local rng = math.random() - state.users[user] = { - coins = math.random(1, 2), - gems = math.random(1, 2), - } + if rng < 1 / 3 then + continue + elseif rng < 2 / 3 then + state.users[user] = nil + else + state.users[user] = { + coins = math.random(1, 2), + gems = math.random(1, 2), + } + end end return state diff --git a/tests/charm-sync/client.spec.luau b/pkgs/charm-sync/__tests__/client.spec.luau similarity index 100% rename from tests/charm-sync/client.spec.luau rename to pkgs/charm-sync/__tests__/client.spec.luau diff --git a/tests/charm-sync/flatten.spec.luau b/pkgs/charm-sync/__tests__/flatten.spec.luau similarity index 100% rename from tests/charm-sync/flatten.spec.luau rename to pkgs/charm-sync/__tests__/flatten.spec.luau diff --git a/tests/charm-sync/patch.spec.luau b/pkgs/charm-sync/__tests__/patch.spec.luau similarity index 100% rename from tests/charm-sync/patch.spec.luau rename to pkgs/charm-sync/__tests__/patch.spec.luau diff --git a/tests/charm-sync/server.spec.luau b/pkgs/charm-sync/__tests__/server.spec.luau similarity index 100% rename from tests/charm-sync/server.spec.luau rename to pkgs/charm-sync/__tests__/server.spec.luau diff --git a/pkgs/charm-sync/__tests__/spec.luau b/pkgs/charm-sync/__tests__/spec.luau new file mode 100644 index 0000000..b386f15 --- /dev/null +++ b/pkgs/charm-sync/__tests__/spec.luau @@ -0,0 +1,19 @@ +local ServerScriptService = game:GetService("ServerScriptService") + +local suite = require(ServerScriptService.Tests.suite) + +local describe = suite.describe + +local function spec(name: string) + describe(name, function() + return require(`./{name}.spec`) :: any + end) +end + +spec("client") +spec("server") +spec("flatten") +spec("patch") +spec("validate") + +return {} diff --git a/tests/charm-sync/validate.spec.luau b/pkgs/charm-sync/__tests__/validate.spec.luau similarity index 100% rename from tests/charm-sync/validate.spec.luau rename to pkgs/charm-sync/__tests__/validate.spec.luau diff --git a/packages/charm-sync/default.project.json b/pkgs/charm-sync/default.project.json similarity index 100% rename from packages/charm-sync/default.project.json rename to pkgs/charm-sync/default.project.json diff --git a/packages/charm-sync/src/client.luau b/pkgs/charm-sync/src/client.luau similarity index 100% rename from packages/charm-sync/src/client.luau rename to pkgs/charm-sync/src/client.luau diff --git a/packages/charm-sync/src/flatten.luau b/pkgs/charm-sync/src/flatten.luau similarity index 100% rename from packages/charm-sync/src/flatten.luau rename to pkgs/charm-sync/src/flatten.luau diff --git a/packages/charm-sync/src/init.luau b/pkgs/charm-sync/src/init.luau similarity index 100% rename from packages/charm-sync/src/init.luau rename to pkgs/charm-sync/src/init.luau diff --git a/packages/charm-sync/src/interval.luau b/pkgs/charm-sync/src/interval.luau similarity index 100% rename from packages/charm-sync/src/interval.luau rename to pkgs/charm-sync/src/interval.luau diff --git a/packages/charm-sync/src/patch.luau b/pkgs/charm-sync/src/patch.luau similarity index 100% rename from packages/charm-sync/src/patch.luau rename to pkgs/charm-sync/src/patch.luau diff --git a/packages/charm-sync/src/server.luau b/pkgs/charm-sync/src/server.luau similarity index 100% rename from packages/charm-sync/src/server.luau rename to pkgs/charm-sync/src/server.luau diff --git a/packages/charm-sync/src/types.luau b/pkgs/charm-sync/src/types.luau similarity index 100% rename from packages/charm-sync/src/types.luau rename to pkgs/charm-sync/src/types.luau diff --git a/packages/charm-sync/src/validate.luau b/pkgs/charm-sync/src/validate.luau similarity index 100% rename from packages/charm-sync/src/validate.luau rename to pkgs/charm-sync/src/validate.luau diff --git a/packages/charm-sync/wally.toml b/pkgs/charm-sync/wally.toml similarity index 100% rename from packages/charm-sync/wally.toml rename to pkgs/charm-sync/wally.toml diff --git a/tests/benchmarks/effect.bench.luau b/pkgs/charm/__benchmarks__/effect.bench.luau similarity index 81% rename from tests/benchmarks/effect.bench.luau rename to pkgs/charm/__benchmarks__/effect.bench.luau index 10d0643..45ab9c7 100644 --- a/tests/benchmarks/effect.bench.luau +++ b/pkgs/charm/__benchmarks__/effect.bench.luau @@ -6,17 +6,12 @@ local computed = Charm.computed local effect = Charm.effect local atom = Charm.atom -local cleanup: () -> () - return { - AfterEach = function() - cleanup() - end, Functions = { atom = function() local state = atom(0) - cleanup = effect(function() + effect(function() state() end) @@ -32,7 +27,7 @@ return { return 2 * state() end) - cleanup = effect(function() + effect(function() double() end) diff --git a/pkgs/charm/__benchmarks__/propagate.bench.luau b/pkgs/charm/__benchmarks__/propagate.bench.luau new file mode 100644 index 0000000..e2b0260 --- /dev/null +++ b/pkgs/charm/__benchmarks__/propagate.bench.luau @@ -0,0 +1,63 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.Packages.Charm) + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local listen = Charm.listen + +local function propagate(w: number, h: number) + 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 + +local function propagateListen(w: number, h: number) + 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 + listen(last, function() end) + end + src(src() + 1) +end + +return { + Functions = { + ["effect 1*100"] = function() + propagate(1, 100) + end, + ["effect 100*1"] = function() + propagate(100, 1) + end, + ["effect 10*10"] = function() + propagate(10, 10) + end, + ["listen 1*100"] = function() + propagateListen(1, 100) + end, + ["listen 100*1"] = function() + propagateListen(100, 1) + end, + ["listen 10*10"] = function() + propagateListen(10, 10) + end, + }, +} diff --git a/tests/signals/computed.spec.luau b/pkgs/charm/__tests__/signals/computed.spec.luau similarity index 79% rename from tests/signals/computed.spec.luau rename to pkgs/charm/__tests__/signals/computed.spec.luau index 828d9c3..3a4721a 100644 --- a/tests/signals/computed.spec.luau +++ b/pkgs/charm/__tests__/signals/computed.spec.luau @@ -1,13 +1,12 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local computed = signals.computed local signal = signals.signal local test = suite.test test("should correctly propagate changes through computed signals", function() - local src = signal(0) + local src, setSrc = signal(0) local c1 = computed(function() return src() % 2 end) @@ -19,15 +18,15 @@ test("should correctly propagate changes through computed signals", function() end) c3() - src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty + setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty c2() -- c1 -> none, c2 -> none - src(3) -- c1 -> dirty, c2 -> toCheckDirty + setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty assert(c3() == 1, "bad final value") end) test("should propagate updated source value through chained computations", function() - local src = signal(0) + local src, setSrc = signal(0) local a = computed(function() return src() end) @@ -42,12 +41,12 @@ test("should propagate updated source value through chained computations", funct end) assert(d() == 0, "bad initial value") - src(2) + setSrc(2) assert(d() == 2, "bad updated value") end) test("should handle flags are indirectly updated during checkDirty", function() - local a = signal(false) + local a, setA = signal(false) local b = computed(function() return a() end) @@ -61,22 +60,22 @@ test("should handle flags are indirectly updated during checkDirty", function() end) assert(d() == false, "bad initial value") - a(true) + setA(true) assert(d() == true, "bad updated value") end) test("should not update if the signal value is reverted", function() local times = 0 - local src = signal(0) + local src, setSrc = signal(0) local c1 = computed(function() times += 1 return src() end) c1() assert(times == 1, "computed did not run") - src(1) - src(0) + setSrc(1) + setSrc(0) c1() assert(times == 1, "computed ran when it should not") end) diff --git a/tests/signals/effect.spec.luau b/pkgs/charm/__tests__/signals/effect.spec.luau similarity index 75% rename from tests/signals/effect.spec.luau rename to pkgs/charm/__tests__/signals/effect.spec.luau index a279e47..7fb2546 100644 --- a/tests/signals/effect.spec.luau +++ b/pkgs/charm/__tests__/signals/effect.spec.luau @@ -1,13 +1,13 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.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 onEffectCleanup = signals.onEffectCleanup local signal = signals.signal local startBatch = signals.startBatch local test = suite.test @@ -15,7 +15,7 @@ local test = suite.test test("should clear subscriptions when untracked by all subscribers", function() local bRunTimes = 0 - local a = signal(1) + local a, setA = signal(1) local b = computed(function() bRunTimes += 1 return a() * 2 @@ -25,15 +25,15 @@ test("should clear subscriptions when untracked by all subscribers", function() end) assert(bRunTimes == 1, "should run initially") - a(2) + setA(2) assert(bRunTimes == 2, "should run after change") stopEffect() - a(3) + setA(3) assert(bRunTimes == 2, "should not run after stop") end) test("should not run untracked inner effect", function() - local a = signal(3) + local a, setA = signal(3) local b = computed(function() return a() > 0 end) @@ -48,14 +48,14 @@ test("should not run untracked inner effect", function() end end) - a(2) - a(1) - a(0) + setA(2) + setA(1) + setA(0) end) test("should run outer effect first", function() - local a = signal(1) - local b = signal(1) + local a, setA = signal(1) + local b, setB = signal(1) effect(function() if a() > 0 then @@ -69,13 +69,13 @@ test("should run outer effect first", function() end) startBatch() - b(0) - a(0) + setB(0) + setA(0) endBatch() end) test("should not trigger inner effect when resolve maybe dirty", function() - local a = signal(0) + local a, setA = signal(0) local b = computed(function() return a() % 2 end) @@ -92,12 +92,12 @@ test("should not trigger inner effect when resolve maybe dirty", function() end) end) - a(2) + setA(2) end) test("should trigger inner effects in sequence", function() - local a = signal(0) - local b = signal(0) + local a, setA = signal(0) + local b, setB = signal(0) local c = computed(function() return a() - b() end) @@ -121,16 +121,16 @@ test("should trigger inner effects in sequence", function() table.clear(order) startBatch() - b(1) - a(1) + setB(1) + setA(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 a, setA = signal(0) + local b, setB = signal(0) local order: { string } = {} effectScope(function() @@ -149,8 +149,8 @@ test("should trigger inner effects in sequence in effect scope", function() table.clear(order) startBatch() - b(1) - a(1) + setB(1) + setA(1) endBatch() assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`) @@ -170,12 +170,12 @@ test("should custom effect support batch", function() local logs: { string } = {} local a = signal(0) - local b = signal(0) + local b, setB = signal(0) local aa = computed(function() table.insert(logs, "aa-0") if a() == 0 then - b(1) + setB(1) end table.insert(logs, "aa-1") return nil @@ -197,8 +197,8 @@ test("should custom effect support batch", function() end) test("should duplicate subscribers do not affect the notify order", function() - local src1 = signal(0) - local src2 = signal(0) + local src1, setSrc1 = signal(0) + local src2, setSrc2 = signal(0) local order: { string } = {} effect(function() @@ -216,17 +216,17 @@ test("should duplicate subscribers do not affect the notify order", function() table.insert(order, "b") src1() end) - src2(1) -- src1.subs: a -> b -> a + setSrc2(1) -- src1.subs: a -> b -> a table.clear(order) - src1(src1() + 1) + setSrc1(src1() + 1) assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) end) test("should handle side effect with inner effects", function() - local a = signal(0) - local b = signal(0) + local a, setA = signal(0) + local b, setB = signal(0) local order: { string } = {} effect(function() @@ -241,14 +241,14 @@ test("should handle side effect with inner effects", function() assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) table.clear(order) - b(1) - a(1) + setB(1) + setA(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 a, setA = signal(false) local b = computed(function() return a() end) @@ -268,71 +268,57 @@ test("should handle flags are indirectly updated during checkDirty", function() triggers += 1 end) assert(triggers == 1, "did not run initially") - a(true) + setA(true) assert(triggers == 2, "did not update indirectly") end) -test("should run effect cleanup", function() - local cleanups = 0 +test("should run cleanups in sequence", function() + local order = {} - local a = signal(false) - local cleanup = effect(function() + local a, setA = signal(false) + local stopEffect = 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 + onEffectCleanup(function() + table.insert(order, "a") + end) + onEffectCleanup(function() + table.insert(order, "b") 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") + assert(table.concat(order) == "", "should not cleanup immediately") + + setA(true) + assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`) + + table.clear(order) + stopEffect() + assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`) + + table.clear(order) + setA(false) + assert(table.concat(order) == "", "should not cleanup after update") end) test("should run inner effect cleanup", function() local cleanups = 0 - local a = signal(false) - local stopScope = effect(function() + local a, setA = signal(false) + local stopEffect = effect(function() a() effect(function() - return function() + onEffectCleanup(function() cleanups += 1 - end + end) end) end) assert(cleanups == 0, "should not cleanup immediately") - a(true) + setA(true) assert(cleanups == 1, "should cleanup after outer update") - stopScope() + stopEffect() assert(cleanups == 2, "should cleanup after outer dispose") - a(false) + setA(false) assert(cleanups == 2, "should not cleanup after update") end) diff --git a/tests/signals/effectScope.spec.luau b/pkgs/charm/__tests__/signals/effectScope.spec.luau similarity index 59% rename from tests/signals/effectScope.spec.luau rename to pkgs/charm/__tests__/signals/effectScope.spec.luau index 5f5ebd3..711f785 100644 --- a/tests/signals/effectScope.spec.luau +++ b/pkgs/charm/__tests__/signals/effectScope.spec.luau @@ -1,14 +1,15 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local effect = signals.effect local effectScope = signals.effectScope local signal = signals.signal +local onEffectCleanup = signals.onEffectCleanup +local onScopeDispose = signals.onScopeDispose local test = suite.test test("should not trigger after stop", function() - local count = signal(1) + local count, setCount = signal(1) local triggers = 0 @@ -19,42 +20,26 @@ test("should not trigger after stop", function() end) assert(triggers == 1, "did not run initially") - count(2) + setCount(2) assert(triggers == 2, "did not run after update") end) - count(3) + setCount(3) assert(triggers == 3, "did not run after update outside scope") stopScope() - count(4) + setCount(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 run indirect effect cleanup", function() +test("should run indirect scope cleanup", function() local cleanups = 0 local cleanup local stopScope = effectScope(function() cleanup = effectScope(function() - return function() + onScopeDispose(function() cleanups += 1 - end + end) end) end) @@ -65,4 +50,26 @@ test("should run indirect effect cleanup", function() assert(cleanups == 1, "should skip redundant dispose") end) +test("should run indirect effect cleanup", function() + local cleanups = 0 + + local a, setA = signal(false) + local stopScope = effectScope(function() + effect(function() + a() + onEffectCleanup(function() + cleanups += 1 + end) + end) + end) + + assert(cleanups == 0, "should not cleanup immediately") + setA(true) + assert(cleanups == 1, "should cleanup once") + stopScope() + assert(cleanups == 2, "should cleanup after outer dispose") + setA(false) + assert(cleanups == 2, "should not cleanup after update") +end) + return {} diff --git a/tests/signals/issue_48.spec.luau b/pkgs/charm/__tests__/signals/issue_48.spec.luau similarity index 93% rename from tests/signals/issue_48.spec.luau rename to pkgs/charm/__tests__/signals/issue_48.spec.luau index bfb2d86..519c0aa 100644 --- a/tests/signals/issue_48.spec.luau +++ b/pkgs/charm/__tests__/signals/issue_48.spec.luau @@ -1,7 +1,6 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local computed = signals.computed local effect = signals.effect local pauseTracking = signals.pauseTracking @@ -92,7 +91,7 @@ local function reaction( end test("#48", function() - local source = signal(0) + local source, setSource = signal(0) local disposeInner: () -> () reaction(function() @@ -107,9 +106,9 @@ test("#48", function() end end) - source(1) - source(2) - source(3) + setSource(1) + setSource(2) + setSource(3) end) return {} diff --git a/pkgs/charm/__tests__/signals/spec.luau b/pkgs/charm/__tests__/signals/spec.luau new file mode 100644 index 0000000..fcc4a57 --- /dev/null +++ b/pkgs/charm/__tests__/signals/spec.luau @@ -0,0 +1,18 @@ +local suite = require("@tests/suite") + +local describe = suite.describe + +local function spec(name: string) + describe(name, function() + return require(`./{name}.spec`) :: any + end) +end + +spec("computed") +spec("effect") +spec("effectScope") +spec("issue_48") +spec("topology") +spec("untrack") + +return {} diff --git a/tests/signals/topology.spec.luau b/pkgs/charm/__tests__/signals/topology.spec.luau similarity index 94% rename from tests/signals/topology.spec.luau rename to pkgs/charm/__tests__/signals/topology.spec.luau index e111576..070b30c 100644 --- a/tests/signals/topology.spec.luau +++ b/pkgs/charm/__tests__/signals/topology.spec.luau @@ -1,7 +1,6 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local computed = signals.computed local effect = signals.effect local signal = signals.signal @@ -44,7 +43,7 @@ describe("graph updates", function() -- C -- | -- D - local a = signal(2) + local a, setA = signal(2) local b = computed(function() return a() - 1 @@ -65,7 +64,7 @@ describe("graph updates", function() assert(computeCalls == 1, "compute should be called once") computeCalls = 0 - a(4) + setA(4) d() assert(computeCalls == 1, "compute should drop update") end) @@ -79,7 +78,7 @@ describe("graph updates", function() -- \ / -- D - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -97,7 +96,7 @@ describe("graph updates", function() assert(d() == "a a", "d should be 'a a'") assert(spyCalls == 1, "compute should be called once") - a("aa") + setA("aa") assert(d() == "aa aa", "d should be 'aa aa'") assert(spyCalls == 2, "compute should be called only twice") end) @@ -112,7 +111,7 @@ describe("graph updates", function() -- | -- E - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -134,7 +133,7 @@ describe("graph updates", function() assert(e() == "a a", "e should be 'a a'") assert(spyCalls == 1, "compute should be called once") - a("aa") + setA("aa") assert(e() == "aa aa", "e should be 'aa aa'") assert(spyCalls == 2, "compute should be called only twice") end) @@ -142,7 +141,7 @@ describe("graph updates", function() 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 a, setA = signal("a") local b = computed(function() a() return "foo" @@ -158,7 +157,7 @@ describe("graph updates", function() assert(c() == "foo", "c should be 'foo'") assert(spyCalls == 1, "compute should be called once") - a("aa") + setA("aa") assert(c() == "foo", "c should be 'foo'") assert(spyCalls == 1, "compute should bail out") end) @@ -174,7 +173,7 @@ describe("graph updates", function() -- E -- / \ -- F G - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() @@ -223,7 +222,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - a("b") + setA("b") assert(e() == "b b", "e should be 'b b'") assert(eSpyCalls == 1, "e should run once after update") @@ -238,7 +237,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - a("c") + setA("c") assert(e() == "c c", "e should be 'c c'") assert(eSpyCalls == 1, "e should run once after update") @@ -259,7 +258,7 @@ describe("graph updates", function() -- *A -- / \ -- *B C <- we don't listen to C - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() @@ -274,7 +273,7 @@ describe("graph updates", function() assert(b() == "a", "b should be 'a'") assert(spyCalls == 0, "compute should not run") - a("aa") + setA("aa") assert(b() == "aa", "b should be 'aa'") assert(spyCalls == 0, "compute should not run") end) @@ -288,7 +287,7 @@ describe("graph updates", function() -- *B D <- we don't listen to C -- | -- *C - local a = signal("a") + local a, setA = signal("a") local spyBCalls = 0 local spyB = function() spyBCalls += 1 @@ -319,7 +318,7 @@ describe("graph updates", function() spyCCalls = 0 unsub() - a("aa") + setA("aa") assert(spyBCalls == 0, "b should not run") assert(spyCCalls == 0, "c should not run") @@ -336,7 +335,7 @@ describe("graph updates", function() -- B *C <- returns same value every time -- \ / -- D - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -354,7 +353,7 @@ describe("graph updates", function() assert(d() == "a c", "d should be 'a c'") spyResult = nil - a("aa") + setA("aa") d() assert(spyResult == "aa c", "d should have returned 'aa c'") end) @@ -368,7 +367,7 @@ describe("graph updates", function() -- B *C *D -- \ | / -- E - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -390,13 +389,13 @@ describe("graph updates", function() assert(e() == "a c d", "e should be 'a c d'") spyResult = nil - a("aa") + setA("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 a, setA = signal(0) local b = computed(function() return a() end) @@ -405,10 +404,10 @@ describe("graph updates", function() end) assert(c() == 0, "c should be 0") - a(1) + setA(1) assert(c() == 1, "c should be 1") - a(0) + setA(0) assert(c() == 0, "c should be 0 again") end) @@ -420,7 +419,7 @@ describe("graph updates", function() -- *B *C -- \ / -- D - local a = signal("a") + local a, setA = signal("a") local b = computed(function() a() return "b" @@ -439,14 +438,14 @@ describe("graph updates", function() assert(d() == "b c", "d should be 'b c'") spyCalls = 0 - a("aa") + setA("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 a, setA = signal(0) local b = computed(function() error("fail") end) @@ -460,12 +459,12 @@ describe("error handling", function() assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - a(1) + setA(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 a, setA = signal(0) local b = computed(function() if a() == 1 then error("fail") @@ -478,14 +477,14 @@ describe("error handling", function() assert(c() == 0, "c should be 0") - a(1) + setA(1) local success, result = pcall(function() b() end) assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - a(2) + setA(2) assert(c() == 2, "c should recover and return 2") end) end) diff --git a/tests/signals/untrack.spec.luau b/pkgs/charm/__tests__/signals/untrack.spec.luau similarity index 77% rename from tests/signals/untrack.spec.luau rename to pkgs/charm/__tests__/signals/untrack.spec.luau index 860ea0b..c08c3e1 100644 --- a/tests/signals/untrack.spec.luau +++ b/pkgs/charm/__tests__/signals/untrack.spec.luau @@ -1,7 +1,6 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local computed = signals.computed local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking @@ -9,7 +8,7 @@ local signal = signals.signal local test = suite.test test("should pause tracking", function() - local src = signal(0) + local src, setSrc = signal(0) local c = computed(function() pauseTracking() local value = src() @@ -18,7 +17,7 @@ test("should pause tracking", function() end) assert(c() == 0, "should not track src") - src(1) + setSrc(1) assert(c() == 0, "should not track src") end) diff --git a/pkgs/charm/__tests__/spec.luau b/pkgs/charm/__tests__/spec.luau new file mode 100644 index 0000000..7ae1948 --- /dev/null +++ b/pkgs/charm/__tests__/spec.luau @@ -0,0 +1,20 @@ +local suite = require("@tests/suite") + +local describe = suite.describe + +local function collect(name: string) + describe(name, function() + return require(`./{name}/spec`) :: any + end) +end + +local function spec(name: string) + describe(name, function() + return require(`./{name}.spec`) :: any + end) +end + +collect("signals") +spec("subscribe") + +return {} diff --git a/pkgs/charm/__tests__/subscribe.spec.luau b/pkgs/charm/__tests__/subscribe.spec.luau new file mode 100644 index 0000000..1199594 --- /dev/null +++ b/pkgs/charm/__tests__/subscribe.spec.luau @@ -0,0 +1,28 @@ +local atom = require("../src/atom") +local subscribe = require("../src/subscribe") +local suite = require("@tests/suite") + +local test = suite.test + +test("should subscribe to atom", function() + local source = atom(0) + local current, previous + + local unsub = subscribe(source, function(a, b) + current = a + previous = b + end) + + assert(not current and not previous, "should not run initially") + source(1) + assert(current == 1, "should run with new value") + assert(previous == 0, "should run with old value") + source(2) + assert(current == 2, "should run with new value 2") + assert(previous == 1, "should run with old value 2") + unsub() + source(3) + assert(current == 2, "should not run after unsubscribing") +end) + +return {} diff --git a/packages/charm/default.project.json b/pkgs/charm/default.project.json similarity index 100% rename from packages/charm/default.project.json rename to pkgs/charm/default.project.json diff --git a/pkgs/charm/src/atom.luau b/pkgs/charm/src/atom.luau new file mode 100644 index 0000000..fc847e0 --- /dev/null +++ b/pkgs/charm/src/atom.luau @@ -0,0 +1,54 @@ +local signals = require("./signals") +local types = require("./types") + +type Atom = types.Atom +type AtomOptions = types.AtomOptions + +local signal = signals.signal + +local function atomGetterSetterEquals( + source: () -> T, + setSource: (((T) -> T) | T) -> T, + equals: (T, T) -> boolean +): Atom + return function(...: ((T) -> T) | T): T + if select("#", ...) == 0 then + return source() + end + + local nextState = (...) + + if type(nextState) == "function" then + return setSource(function(currentState): T + local nextState = nextState(currentState) + return if equals(currentState, nextState) then currentState else nextState + end) + else + return setSource(function(currentState): T + return if equals(currentState, nextState) then currentState else nextState + end) + end + end +end + +local function atomGetterSetter(source: () -> T, setSource: (((T) -> T) | T) -> T): Atom + return function(...: ((T) -> T) | T): T + if select("#", ...) == 0 then + return source() + else + return setSource((...)) + end + end +end + +local function atom(state: T, equals: ((previous: T, current: T) -> boolean)?): Atom + local source, setSource = signal(state) + + if equals then + return atomGetterSetterEquals(source, setSource, equals) + else + return atomGetterSetter(source, setSource) + end +end + +return atom diff --git a/packages/charm/src/batched.luau b/pkgs/charm/src/batched.luau similarity index 100% rename from packages/charm/src/batched.luau rename to pkgs/charm/src/batched.luau diff --git a/pkgs/charm/src/effect.luau b/pkgs/charm/src/effect.luau new file mode 100644 index 0000000..67551ec --- /dev/null +++ b/pkgs/charm/src/effect.luau @@ -0,0 +1,27 @@ +local signals = require("./signals") + +local _effect = signals.effect +local onEffectCleanup = signals.onEffectCleanup + +local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> () + local disposeImmediately = false + + local function dispose() + disposeImmediately = true + end + + dispose = _effect(function() + local cleanup = callback(dispose) + if cleanup then + onEffectCleanup(cleanup) + end + end) + + if disposeImmediately then + dispose() + end + + return dispose +end + +return effect diff --git a/pkgs/charm/src/effectScope.luau b/pkgs/charm/src/effectScope.luau new file mode 100644 index 0000000..a324bb9 --- /dev/null +++ b/pkgs/charm/src/effectScope.luau @@ -0,0 +1,15 @@ +local signals = require("./signals") + +local _effectScope = signals.effectScope +local onScopeDispose = signals.onScopeDispose + +local function effectScope(callback: () -> ...() -> ()?): () -> () + return _effectScope(function() + local cleanup = callback() + if cleanup then + onScopeDispose(cleanup) + end + end) +end + +return effectScope diff --git a/packages/charm/src/init.luau b/pkgs/charm/src/init.luau similarity index 100% rename from packages/charm/src/init.luau rename to pkgs/charm/src/init.luau diff --git a/packages/charm/src/lib.luau b/pkgs/charm/src/lib.luau similarity index 83% rename from packages/charm/src/lib.luau rename to pkgs/charm/src/lib.luau index 8741d13..adcfb67 100644 --- a/packages/charm/src/lib.luau +++ b/pkgs/charm/src/lib.luau @@ -15,6 +15,8 @@ export type Selector = types.Selector local computed = signals.computed local effectScope = signals.effectScope +local onEffectCleanup = signals.onEffectCleanup +local onScopeDispose = signals.onScopeDispose return { atom = atom, @@ -27,5 +29,7 @@ return { observe = observe, subscribe = subscribe, untracked = untracked, + onEffectCleanup = onEffectCleanup, + onScopeDispose = onScopeDispose, signals = signals, } diff --git a/packages/charm/src/listen.luau b/pkgs/charm/src/listen.luau similarity index 52% rename from packages/charm/src/listen.luau rename to pkgs/charm/src/listen.luau index e16e623..7a760e5 100644 --- a/packages/charm/src/listen.luau +++ b/pkgs/charm/src/listen.luau @@ -6,40 +6,31 @@ local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () - local disposed = false - local mounted = true + local disposeImmediately = false local current: T? - local dispose: () -> () - local function disposeSafe() - disposed = true - if dispose then - dispose() - end + local function dispose() + disposeImmediately = true end dispose = effect(function() local value = selector() - if mounted then - mounted = false - elseif value == current then - return + if value ~= current then + local previous = current + current = value + + pauseTracking() + spawn(listener, value, previous, dispose) + resumeTracking() end - - local previous = current - current = value - - pauseTracking() - spawn(listener, value, previous, disposeSafe) - resumeTracking() end) - if disposed then + if disposeImmediately then dispose() end - return disposeSafe + return dispose end return listen diff --git a/packages/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau similarity index 96% rename from packages/charm/src/mapped.luau rename to pkgs/charm/src/mapped.luau index a842b95..f0bfc05 100644 --- a/packages/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -19,7 +19,7 @@ local function mapped(selector: () -> { [KI]: VI }, transform: ( resumeTracking() if not success then - error(`observe() transform function failed: {valueOut}`) + error(`mapped() transform function failed: {valueOut}`) end return valueOut, keyOut @@ -47,6 +47,10 @@ local function mapped(selector: () -> { [KI]: VI }, transform: ( return mappedItems end + if previousItems == currentItems then + return previousMappedItems + end + local mappedItems = table.clone(previousMappedItems) -- Update and remove mapped keys if the source values changed diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau new file mode 100644 index 0000000..4f07020 --- /dev/null +++ b/pkgs/charm/src/observe.luau @@ -0,0 +1,60 @@ +local signals = require("./signals") + +local computed = signals.computed +local effect = signals.effect +local getCurrentSub = signals.getCurrentSub +local onEffectCleanup = signals.onEffectCleanup +local onScopeDispose = signals.onScopeDispose + +local function noop() end + +local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> () + local tracked = computed(function() + return selector() + end) + + local cleanups: { [K]: () -> () } = {} + local currentItems: { [K]: V } = {} + + local function update(currentItems: { [K]: V }, previousItems: { [K]: V }) + 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 currentItems do + if previousItems[key] == nil then + cleanups[key] = observer(value, key) or noop + end + end + end + + local function teardown() + for _, cleanup in cleanups do + cleanup() + end + table.clear(cleanups) + end + + local disposeEffect = effect(function() + local previousItems = currentItems + currentItems = tracked() + update(currentItems, previousItems) + end) + + if getCurrentSub() then + onEffectCleanup(teardown) + else + onScopeDispose(teardown) + end + + return function() + disposeEffect() + teardown() + end +end + +return observe diff --git a/packages/charm/src/signals.luau b/pkgs/charm/src/signals.luau similarity index 75% rename from packages/charm/src/signals.luau rename to pkgs/charm/src/signals.luau index 0fad3e2..a265fee 100644 --- a/packages/charm/src/signals.luau +++ b/pkgs/charm/src/signals.luau @@ -1,5 +1,5 @@ --[=[ - alien-signals 2.0.1 implementation with effect cleanups, adopted from + alien-signals 2.0.1 implementation adopted from https://github.com/Nicell/alien-signals-luau https://github.com/stackblitz/alien-signals @@ -47,7 +47,7 @@ type EffectScope = ReactiveNode & { } type Effect = ReactiveNode & { - fn: () -> (() -> ())?, + fn: () -> (), cleanup: (() -> ())?, } @@ -100,26 +100,36 @@ local function setCurrentScope(scope: EffectScope?) return prevScope end -local function pauseTracking() - pauseStackCount += 1 - pauseStack[pauseStackCount] = activeSub - activeSub = nil +local function addCleanup(e: EffectScope, cleanup: () -> ()) + local prevCleanup = e.cleanup + if prevCleanup then + e.cleanup = function() + prevCleanup() + cleanup() + end + else + e.cleanup = cleanup + end end -local function resumeTracking() - activeSub = pauseStack[pauseStackCount] - pauseStack[pauseStackCount] = nil - pauseStackCount -= 1 +local function onEffectCleanup(cleanup: () -> ()) + if activeSub then + addCleanup(activeSub :: Effect, cleanup) + end end -local function cleanup(e: ReactiveNode) - local e = e :: EffectScope - local cleanup = e.cleanup - if cleanup then - e.cleanup = nil +local function onScopeDispose(cleanup: () -> ()) + if activeScope then + addCleanup(activeScope, cleanup) + end +end + +local function cleanupEffect(e: EffectScope) + if e.cleanup then local prev = setCurrentSub(nil) - local success, result: unknown = pcall(cleanup) + local success, result: unknown = pcall(e.cleanup) setCurrentSub(prev) + e.cleanup = nil if not success then error(result) end @@ -128,16 +138,15 @@ 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) + cleanupEffect(e) local prev = setCurrentSub(e) startTracking(e) - local success, result = pcall((e :: Effect).fn) + local success, result: unknown = 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)) @@ -182,6 +191,18 @@ local function endBatch() end 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 updateComputed(c: Computed): boolean local prevSub = setCurrentSub(c) startTracking(c) @@ -210,22 +231,8 @@ local function updateSignal(s: Signal, value: any): boolean 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 function signalGetterSetter(this: Signal): (() -> T, (value: ((T) -> T) | T) -> T) + local function getter(): T local value = this.value if bit32.band(this.flags, DIRTY) ~= 0 then if updateSignal(this, value) then @@ -240,64 +247,93 @@ local function signalOper(this: Signal, ...: T): T? 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 function setter(newValue: ((T) -> T) | T): T + local value = this.value + local newValue = newValue :: T + if type(newValue) == "function" then + local prev = setCurrentSub(nil) + local success, result = pcall(newValue, value) + setCurrentSub(prev) + if not success then + error(result) + end + newValue = result + end + if value ~= newValue then + this.value = newValue + this.flags = bit32.bor(MUTABLE, DIRTY) local subs = this.subs if subs then - shallowPropagate(subs) + propagate(subs) + if batchDepth == 0 then + flush() + end end end - elseif bit32.band(flags, PENDING) ~= 0 then - this.flags = bit32.band(flags, bit32.bnot(PENDING)) + return newValue end - if activeSub then - link(this, activeSub) - elseif activeScope then - link(this, activeScope) - end - return this.value :: T + + return getter, setter end -local function effectOper(this: EffectScope): () - local dep = this.deps - while dep do - dep = unlink(dep, this) +local function computedGetter(this: Computed): () -> T + return function() + 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 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 effectStop(this: EffectScope): () -> () + return function() + 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 + cleanupEffect(this) + end +end -local function signal(initialValue: T): ((T) -> ()) & (() -> T) - local this: Signal = { +local function signal(initialValue: T): (() -> T, (value: ((T) -> T) | T) -> T) + return signalGetterSetter({ previousValue = initialValue, value = initialValue, flags = MUTABLE, - } - return setmetatable(this, signalMetatable) :: any + }) end local function computed(getter: (previousValue: T?) -> T): () -> T - local this: Computed = { + return computedGetter({ flags = bit32.bor(MUTABLE, DIRTY), getter = getter, - } - return setmetatable(this, computedMetatable) :: any + }) end -local function effect(fn: () -> ...() -> ()?): () -> () +local function effect(fn: () -> ()): () -> () local e: Effect = { fn = fn, flags = WATCHING, @@ -308,16 +344,15 @@ local function effect(fn: () -> ...() -> ()?): () -> () link(e, activeScope) end local prev = setCurrentSub(e) - local success, result = pcall(fn) + local success, result: unknown = pcall(fn) setCurrentSub(prev) if not success then error(result) end - e.cleanup = result - return setmetatable(e, effectMetatable) :: any + return effectStop(e) end -local function effectScope(fn: () -> ...() -> ()?): () -> () +local function effectScope(fn: () -> ()): () -> () local e: EffectScope = { flags = NONE, } @@ -325,13 +360,12 @@ local function effectScope(fn: () -> ...() -> ()?): () -> () link(e, activeScope) end local prev = setCurrentScope(e) - local success, result = pcall(fn) + local success, result: unknown = pcall(fn) setCurrentScope(prev) if not success then error(result) end - e.cleanup = result - return setmetatable(e, effectMetatable) :: any + return effectStop(e) end local function notify(e: ReactiveNode) @@ -343,7 +377,7 @@ local function notify(e: ReactiveNode) notify(subs.sub :: Effect | EffectScope) else queuedEffectsLength += 1 - queuedEffects[queuedEffectsLength] = e :: Effect | EffectScope + queuedEffects[queuedEffectsLength] = e :: Effect end end end @@ -364,7 +398,7 @@ local function unwatched(signal: ReactiveNode) until not toRemove signal.flags = bit32.bor(signal.flags, DIRTY) end - cleanup(signal) + cleanupEffect(signal :: EffectScope) end local reactiveSystem = createReactiveSystem({ @@ -393,6 +427,8 @@ return { endBatch = endBatch, pauseTracking = pauseTracking, resumeTracking = resumeTracking, + onEffectCleanup = onEffectCleanup, + onScopeDispose = onScopeDispose, signal = signal, computed = computed, effect = effect, diff --git a/packages/charm/src/spawn.luau b/pkgs/charm/src/spawn.luau similarity index 100% rename from packages/charm/src/spawn.luau rename to pkgs/charm/src/spawn.luau diff --git a/packages/charm/src/subscribe.luau b/pkgs/charm/src/subscribe.luau similarity index 86% rename from packages/charm/src/subscribe.luau rename to pkgs/charm/src/subscribe.luau index 745b03a..59ecfff 100644 --- a/packages/charm/src/subscribe.luau +++ b/pkgs/charm/src/subscribe.luau @@ -8,9 +8,8 @@ local resumeTracking = signals.resumeTracking local function subscribe(selector: () -> T, listener: (T, T) -> ()): () -> () local current: T local mounted = true - local dispose: () -> () - dispose = effect(function() + return effect(function() local value = selector() if mounted then @@ -25,10 +24,6 @@ local function subscribe(selector: () -> T, listener: (T, T) -> ()): () -> () resumeTracking() end end) - - return function() - dispose() - end end return subscribe diff --git a/packages/charm/src/system.luau b/pkgs/charm/src/system.luau similarity index 100% rename from packages/charm/src/system.luau rename to pkgs/charm/src/system.luau diff --git a/packages/charm/src/types.luau b/pkgs/charm/src/types.luau similarity index 100% rename from packages/charm/src/types.luau rename to pkgs/charm/src/types.luau diff --git a/packages/charm/src/untracked.luau b/pkgs/charm/src/untracked.luau similarity index 100% rename from packages/charm/src/untracked.luau rename to pkgs/charm/src/untracked.luau diff --git a/packages/charm/wally.toml b/pkgs/charm/wally.toml similarity index 100% rename from packages/charm/wally.toml rename to pkgs/charm/wally.toml diff --git a/tests/utils/renderHook.luau b/pkgs/react-charm/__tests__/helpers/renderHook.luau similarity index 100% rename from tests/utils/renderHook.luau rename to pkgs/react-charm/__tests__/helpers/renderHook.luau diff --git a/pkgs/react-charm/__tests__/spec.luau b/pkgs/react-charm/__tests__/spec.luau new file mode 100644 index 0000000..6cd8895 --- /dev/null +++ b/pkgs/react-charm/__tests__/spec.luau @@ -0,0 +1,15 @@ +local ServerScriptService = game:GetService("ServerScriptService") + +local suite = require(ServerScriptService.Tests.suite) + +local describe = suite.describe + +local function spec(name: string) + describe(name, function() + return require(`./{name}.spec`) :: any + end) +end + +spec("useAtom") + +return {} diff --git a/tests/react-charm/useAtom.spec.luau b/pkgs/react-charm/__tests__/useAtom.spec.luau similarity index 94% rename from tests/react-charm/useAtom.spec.luau rename to pkgs/react-charm/__tests__/useAtom.spec.luau index 4e01cd0..340177e 100644 --- a/tests/react-charm/useAtom.spec.luau +++ b/pkgs/react-charm/__tests__/useAtom.spec.luau @@ -3,7 +3,7 @@ local ServerScriptService = game:GetService("ServerScriptService") local Charm = require(ReplicatedStorage.Packages.Charm) local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm) -local renderHook = require(ServerScriptService.Tests.utils.renderHook) +local renderHook = require(script.Parent.helpers.renderHook) local suite = require(ServerScriptService.Tests.suite) local atom = Charm.atom diff --git a/packages/react-charm/default.project.json b/pkgs/react-charm/default.project.json similarity index 100% rename from packages/react-charm/default.project.json rename to pkgs/react-charm/default.project.json diff --git a/packages/react-charm/src/init.luau b/pkgs/react-charm/src/init.luau similarity index 100% rename from packages/react-charm/src/init.luau rename to pkgs/react-charm/src/init.luau diff --git a/packages/react-charm/wally.toml b/pkgs/react-charm/wally.toml similarity index 100% rename from packages/react-charm/wally.toml rename to pkgs/react-charm/wally.toml diff --git a/pkgs/vide-charm/__tests__/spec.luau b/pkgs/vide-charm/__tests__/spec.luau new file mode 100644 index 0000000..6cd8895 --- /dev/null +++ b/pkgs/vide-charm/__tests__/spec.luau @@ -0,0 +1,15 @@ +local ServerScriptService = game:GetService("ServerScriptService") + +local suite = require(ServerScriptService.Tests.suite) + +local describe = suite.describe + +local function spec(name: string) + describe(name, function() + return require(`./{name}.spec`) :: any + end) +end + +spec("useAtom") + +return {} diff --git a/tests/vide-charm/useAtom.spec.luau b/pkgs/vide-charm/__tests__/useAtom.spec.luau similarity index 100% rename from tests/vide-charm/useAtom.spec.luau rename to pkgs/vide-charm/__tests__/useAtom.spec.luau diff --git a/packages/vide-charm/default.project.json b/pkgs/vide-charm/default.project.json similarity index 100% rename from packages/vide-charm/default.project.json rename to pkgs/vide-charm/default.project.json diff --git a/packages/vide-charm/src/init.luau b/pkgs/vide-charm/src/init.luau similarity index 100% rename from packages/vide-charm/src/init.luau rename to pkgs/vide-charm/src/init.luau diff --git a/packages/vide-charm/wally.toml b/pkgs/vide-charm/wally.toml similarity index 100% rename from packages/vide-charm/wally.toml rename to pkgs/vide-charm/wally.toml diff --git a/tests/benchmarks/propagate.bench.luau b/tests/benchmarks/propagate.bench.luau deleted file mode 100644 index b81dec7..0000000 --- a/tests/benchmarks/propagate.bench.luau +++ /dev/null @@ -1,39 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.Packages.Charm) - -local signals = 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 deleted file mode 100644 index ee56eeb..0000000 --- a/tests/benchmarks/propagate.luau +++ /dev/null @@ -1,59 +0,0 @@ -local Charm = require("../charm") - -local atom = Charm.atom -local computed = Charm.computed -local effect = Charm.effect - -local signal = Charm.signals.signal -local computedSignal = Charm.signals.computed -local effectSignal = Charm.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/spec.luau b/tests/charm-sync/spec.luau deleted file mode 100644 index b53aefc..0000000 --- a/tests/charm-sync/spec.luau +++ /dev/null @@ -1,17 +0,0 @@ -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/charm.luau b/tests/charm.luau deleted file mode 100644 index aedbca8..0000000 --- a/tests/charm.luau +++ /dev/null @@ -1,3 +0,0 @@ -return if game - then require(game:GetService("ReplicatedStorage").Packages.Charm) - else require("../packages/charm/src/lib") diff --git a/tests/init.luau b/tests/init.luau new file mode 100644 index 0000000..c9f0300 --- /dev/null +++ b/tests/init.luau @@ -0,0 +1,14 @@ +local suite = require("@tests/suite") + +local describe = suite.describe +local finish = suite.finish + +if not table.isfrozen(_G) then + _G.__DEV__ = true +end + +describe("charm", function() + require("@pkgs/charm/__tests__/spec") +end) + +finish() diff --git a/tests/react-charm/spec.luau b/tests/react-charm/spec.luau deleted file mode 100644 index 7fcdaf0..0000000 --- a/tests/react-charm/spec.luau +++ /dev/null @@ -1,13 +0,0 @@ -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/signals/spec.luau b/tests/signals/spec.luau deleted file mode 100644 index 4853275..0000000 --- a/tests/signals/spec.luau +++ /dev/null @@ -1,18 +0,0 @@ -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/spec.server.luau b/tests/spec.server.luau index efb9162..61b038c 100644 --- a/tests/spec.server.luau +++ b/tests/spec.server.luau @@ -3,19 +3,18 @@ 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 +_G.__DEV__ = true -test("signals") +describe("charm-sync", function() + require(script.Parent.CharmSync.spec) +end) -if game then - _G.__DEV__ = true - test("charm-sync") - test("react-charm") - test("vide-charm") -end +describe("react-charm", function() + require(script.Parent.ReactCharm.spec) +end) + +describe("vide-charm", function() + require(script.Parent.VideCharm.spec) +end) finish() diff --git a/tests/utils/collect.luau b/tests/utils/collect.luau deleted file mode 100644 index 0f498aa..0000000 --- a/tests/utils/collect.luau +++ /dev/null @@ -1,12 +0,0 @@ ---[=[ - Trigger a garbage collection cycle by allocating a large amount of junk memory - and then waiting for the next frame. -]=] -local function collect() - for _ = 1, 1e4 do - local _ = table.create(1e3) - end - task.wait() -end - -return collect diff --git a/tests/utils/count.luau b/tests/utils/count.luau deleted file mode 100644 index 251776a..0000000 --- a/tests/utils/count.luau +++ /dev/null @@ -1,15 +0,0 @@ ---[=[ - Counts the number of elements in a table. - - @param object The table to count. - @return The number of elements in the table. -]=] -local function count(object: any): number - local count = 0 - for _ in next, object do - count += 1 - end - return count -end - -return count diff --git a/tests/vide-charm/spec.luau b/tests/vide-charm/spec.luau deleted file mode 100644 index 7fcdaf0..0000000 --- a/tests/vide-charm/spec.luau +++ /dev/null @@ -1,13 +0,0 @@ -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 {} From d33c4ce8912d223b36749169f6974dd5d0833179 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 5 May 2025 22:18:12 -0700 Subject: [PATCH 008/160] fix: update pkgs dir references --- .luaurc | 2 +- bin/check.sh | 6 +++--- pkgs/charm-sync/__tests__/patch.spec.luau | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.luaurc b/.luaurc index e9cc27f..7199120 100644 --- a/.luaurc +++ b/.luaurc @@ -5,6 +5,6 @@ }, "aliases": { "tests": "tests", - "pkgs": "packages" + "pkgs": "pkgs" } } diff --git a/bin/check.sh b/bin/check.sh index 7aec81b..6da7706 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -7,10 +7,10 @@ luau-lsp analyze \ --flag:LuauFixIndexerSubtypingOrdering=true \ --sourcemap=sourcemap.json \ --ignore="roblox_packages/**" \ - packages tests + pkgs tests -selene packages tests +selene pkgs tests -stylua --check packages tests +stylua --check pkgs tests rm bin/roblox.d.luau diff --git a/pkgs/charm-sync/__tests__/patch.spec.luau b/pkgs/charm-sync/__tests__/patch.spec.luau index 9622b7b..c5a3f8a 100644 --- a/pkgs/charm-sync/__tests__/patch.spec.luau +++ b/pkgs/charm-sync/__tests__/patch.spec.luau @@ -1,8 +1,9 @@ local HttpService = game:GetService("HttpService") local ReplicatedStorage = game:GetService("ReplicatedStorage") +local ServerScriptService = game:GetService("ServerScriptService") local CharmSync = require(ReplicatedStorage.Packages.CharmSync) -local suite = require("../suite") +local suite = require(ServerScriptService.Tests.suite) local patch = CharmSync.patch local test = suite.test From 0b37f9cd54a777220eda73d40925f90f5ad1f54f Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 5 May 2025 22:38:59 -0700 Subject: [PATCH 009/160] ci: simplify pesde install --- .github/workflows/ci.yml | 2 +- .gitignore | 3 + bin/install.sh | 2 - pesde.lock | 131 ++++++++++++++++++++++++++++++++++++++- pesde.toml | 8 ++- rokit.toml | 2 +- 6 files changed, 140 insertions(+), 8 deletions(-) delete mode 100644 bin/install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de89233..a314d57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: - uses: CompeyDev/setup-rokit@v0.1.2 - name: Install dependencies - run: sh bin/install.sh + run: pesde install - name: Check code quality run: sh bin/check.sh diff --git a/.gitignore b/.gitignore index ac9c72d..cb77d9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ /.vscode +/.pesde /build /roblox_packages +/luau_packages +/lune_packages /pkgs/**/wally.lock /sourcemap.json *.tsbuildinfo diff --git a/bin/install.sh b/bin/install.sh deleted file mode 100644 index ba622eb..0000000 --- a/bin/install.sh +++ /dev/null @@ -1,2 +0,0 @@ -pesde install -rojo sourcemap --output sourcemap.json diff --git a/pesde.lock b/pesde.lock index e27fd50..e2444f3 100644 --- a/pesde.lock +++ b/pesde.lock @@ -5,8 +5,133 @@ name = "littensy/charm" version = "0.1.0" target = "roblox" +[graph."0x5eal/unzip@0.1.3 luau".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."0x5eal/unzip@0.1.3 luau".pkg_ref.dependencies] +asciitable = [{ name = "kimpure/asciitable", version = "^0.2.1", index = "https://github.com/pesde-pkg/index" }, "dev"] +frktest = [{ name = "itsfrank/frktest", version = "^0.0.2", index = "https://github.com/pesde-pkg/index", target = "lune" }, "dev"] +luau-lsp = [{ name = "pesde/luau_lsp", version = "=1.39.2", index = "https://github.com/pesde-pkg/index", target = "lune" }, "dev"] +stylua = [{ name = "pesde/stylua", version = "=2.0.2", index = "https://github.com/pesde-pkg/index", target = "lune" }, "dev"] + +[graph."corecii/greentea@0.4.11 lune".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/daimond113/pesde-index" + +[graph."jiwonz/dirs@0.3.0 lune".dependencies] +greentea = ["corecii/greentea@0.4.11 lune", "standard"] +pathfs = ["jiwonz/pathfs@0.3.2 lune", "standard"] + +[graph."jiwonz/dirs@0.3.0 lune".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."jiwonz/dirs@0.3.0 lune".pkg_ref.dependencies] +greentea = [{ name = "corecii/greentea", version = "^0.4.10", index = "https://github.com/daimond113/pesde-index" }, "standard"] +luau_lsp = [{ name = "pesde/luau_lsp", version = "^1.38.1", index = "https://github.com/daimond113/pesde-index" }, "dev"] +pathfs = [{ name = "jiwonz/pathfs", version = "^0.3.0", index = "https://github.com/daimond113/pesde-index" }, "standard"] +selene = [{ name = "pesde/selene", version = "^0.28.0", index = "https://github.com/daimond113/pesde-index" }, "dev"] +stylua = [{ name = "pesde/stylua", version = "^2.0.2", index = "https://github.com/daimond113/pesde-index" }, "dev"] + +[graph."jiwonz/luau_disk@0.1.4 luau".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."jiwonz/luau_path@0.1.4 luau".dependencies] +luau_disk = ["jiwonz/luau_disk@0.1.4 luau", "standard"] + +[graph."jiwonz/luau_path@0.1.4 luau".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/daimond113/pesde-index" + +[graph."jiwonz/luau_path@0.1.4 luau".pkg_ref.dependencies] +luau_disk = [{ name = "jiwonz/luau_disk", version = "^0.1.4", index = "https://github.com/pesde-pkg/index" }, "standard"] + +[graph."jiwonz/pathfs@0.3.2 lune".dependencies] +greentea = ["corecii/greentea@0.4.11 lune", "standard"] +luau_path = ["jiwonz/luau_path@0.1.4 luau", "standard"] + +[graph."jiwonz/pathfs@0.3.2 lune".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."jiwonz/pathfs@0.3.2 lune".pkg_ref.dependencies] +frktest = [{ name = "itsfrank/frktest", version = "^0.0.2", index = "https://github.com/daimond113/pesde-index" }, "dev"] +greentea = [{ name = "corecii/greentea", version = "^0.4.11", index = "https://github.com/daimond113/pesde-index" }, "standard"] +luau_check = [{ name = "jiwonz/luau_check", version = "^0.3.8", index = "https://github.com/daimond113/pesde-index" }, "dev"] +luau_path = [{ name = "jiwonz/luau_path", version = "^0.1.1", index = "https://github.com/daimond113/pesde-index", target = "luau" }, "standard"] + +[graph."lukadev_0/option@1.2.0 lune".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."lukadev_0/result@1.2.0 lune".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."pesde/rojo@7.5.1 lune"] +direct = ["rojo", { name = "pesde/rojo", version = "^7.4.4", index = "default", target = "lune" }, "dev"] + +[graph."pesde/rojo@7.5.1 lune".dependencies] +core = ["pesde/toolchainlib@0.1.14 lune", "standard"] +option = ["lukadev_0/option@1.2.0 lune", "standard"] +result = ["lukadev_0/result@1.2.0 lune", "standard"] + +[graph."pesde/rojo@7.5.1 lune".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."pesde/rojo@7.5.1 lune".pkg_ref.dependencies] +core = [{ name = "pesde/toolchainlib", version = "^0.1.13", index = "https://github.com/pesde-pkg/index", target = "lune" }, "standard"] +option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/pesde-pkg/index" }, "standard"] +result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/pesde-pkg/index" }, "standard"] + +[graph."pesde/scripts_core@0.0.1 lune".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."pesde/scripts_core@0.0.1 lune".pkg_ref.dependencies] +frktest = [{ name = "itsfrank/frktest", version = "^0.0.2", index = "https://github.com/pesde-pkg/index" }, "dev"] +luau-lsp = [{ name = "pesde/luau_lsp", version = "^1.36.0", index = "https://github.com/pesde-pkg/index" }, "dev"] +pathfs = [{ name = "jiwonz/pathfs", version = "^0.1.0", index = "https://github.com/pesde-pkg/index" }, "dev"] +stylua = [{ name = "pesde/stylua", version = "^2.0.1", index = "https://github.com/pesde-pkg/index" }, "dev"] + +[graph."pesde/scripts_rojo@0.1.0 lune"] +direct = ["scripts", { name = "pesde/scripts_rojo", version = "^0.1.0", index = "default", target = "lune" }, "dev"] + +[graph."pesde/scripts_rojo@0.1.0 lune".dependencies] +core = ["pesde/scripts_core@0.0.1 lune", "standard"] +rojo = ["pesde/rojo@7.5.1 lune", "peer"] + +[graph."pesde/scripts_rojo@0.1.0 lune".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."pesde/scripts_rojo@0.1.0 lune".pkg_ref.dependencies] +core = [{ name = "pesde/scripts_core", version = "^0.0.1", index = "https://github.com/pesde-pkg/index", target = "lune" }, "standard"] +rojo = [{ name = "pesde/rojo", version = "^7.4.4", index = "https://github.com/pesde-pkg/index" }, "peer"] + +[graph."pesde/toolchainlib@0.1.14 lune".dependencies] +dirs = ["jiwonz/dirs@0.3.0 lune", "standard"] +option = ["lukadev_0/option@1.2.0 lune", "peer"] +pathfs = ["jiwonz/pathfs@0.3.2 lune", "standard"] +result = ["lukadev_0/result@1.2.0 lune", "peer"] +unzip = ["0x5eal/unzip@0.1.3 luau", "standard"] + +[graph."pesde/toolchainlib@0.1.14 lune".pkg_ref] +ref_ty = "pesde" +index_url = "https://github.com/pesde-pkg/index" + +[graph."pesde/toolchainlib@0.1.14 lune".pkg_ref.dependencies] +dirs = [{ name = "jiwonz/dirs", version = "^0.3.0", index = "https://github.com/pesde-pkg/index" }, "standard"] +option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/pesde-pkg/index" }, "peer"] +pathfs = [{ name = "jiwonz/pathfs", version = "^0.3.0", index = "https://github.com/pesde-pkg/index" }, "standard"] +result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/pesde-pkg/index" }, "peer"] +unzip = [{ name = "0x5eal/unzip", version = "^0.1.0", index = "https://github.com/pesde-pkg/index", target = "luau" }, "standard"] + [graph."wally#centau/vide@0.3.1 roblox"] -direct = ["Vide", { wally = "wally#centau/vide", version = "^0.3.1", index = "default" }, "standard"] +direct = ["Vide", { wally = "wally#centau/vide", version = "^0.3.1", index = "default" }, "dev"] [graph."wally#centau/vide@0.3.1 roblox".pkg_ref] ref_ty = "wally" @@ -93,7 +218,7 @@ ref_ty = "wally" index_url = "https://github.com/UpliftGames/wally-index" [graph."wally#jsdotlua/react@17.2.1 roblox"] -direct = ["React", { wally = "wally#jsdotlua/react", version = "^17.2.1", index = "default" }, "standard"] +direct = ["React", { wally = "wally#jsdotlua/react", version = "^17.2.1", index = "default" }, "dev"] [graph."wally#jsdotlua/react@17.2.1 roblox".dependencies] luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] @@ -126,7 +251,7 @@ scheduler = [{ wally = "wally#jsdotlua/scheduler", version = ">=17.2.1, <18.0.0" shared = [{ wally = "wally#jsdotlua/shared", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] [graph."wally#jsdotlua/react-roblox@17.2.1 roblox"] -direct = ["ReactRoblox", { wally = "wally#jsdotlua/react-roblox", version = "^17.2.1", index = "default" }, "standard"] +direct = ["ReactRoblox", { wally = "wally#jsdotlua/react-roblox", version = "^17.2.1", index = "default" }, "dev"] [graph."wally#jsdotlua/react-roblox@17.2.1 roblox".dependencies] luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] diff --git a/pesde.toml b/pesde.toml index 22657f1..8e9bcb2 100644 --- a/pesde.toml +++ b/pesde.toml @@ -11,7 +11,13 @@ default = "https://github.com/pesde-pkg/index" [wally_indices] default = "https://github.com/UpliftGames/wally-index" -[dependencies] +[scripts] +roblox_sync_config_generator = ".pesde/scripts/roblox_sync_config_generator.luau" +sourcemap_generator = ".pesde/scripts/sourcemap_generator.luau" + +[dev_dependencies] +scripts = { name = "pesde/scripts_rojo", version = "^0.1.0", target = "lune" } +rojo = { name = "pesde/rojo", version = "^7.4.4", target = "lune" } React = { wally = "jsdotlua/react", version = "^17.2.1" } ReactRoblox = { wally = "jsdotlua/react-roblox", version = "^17.2.1" } Vide = { wally = "centau/vide", version = "^0.3.1" } diff --git a/rokit.toml b/rokit.toml index 4be8a9f..4c21356 100644 --- a/rokit.toml +++ b/rokit.toml @@ -10,4 +10,4 @@ stylua = "johnnymorganz/stylua@2.1.0" selene = "Kampfkarren/selene@0.28.0" luau-lsp = "johnnymorganz/luau-lsp@1.44.1" lute = "luau-lang/lute@0.1.0-nightly.20250503" -pesde = "daimond113/pesde@0.7.0-rc.1+registry.0.2.3-rc.1" +pesde = "daimond113/pesde@0.7.0-rc.2+registry.0.2.3-rc.1" From 7c48bd2fa4d0d6016286bdf43185f3fbc4aecf8b Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 00:33:56 -0700 Subject: [PATCH 010/160] build: install dev deps with wally --- .github/workflows/ci.yml | 2 +- .gitignore | 4 +- bin/check.sh | 2 +- default.project.json | 14 +- pesde.lock | 320 ------------------ pesde.toml | 23 -- .../patch.bench.luau | 0 .../{__tests__ => tests}/client.spec.luau | 0 .../{__tests__ => tests}/flatten.spec.luau | 0 .../{__tests__ => tests}/patch.spec.luau | 0 .../{__tests__ => tests}/server.spec.luau | 0 .../charm-sync/{__tests__ => tests}/spec.luau | 0 .../{__tests__ => tests}/validate.spec.luau | 0 .../effect.bench.luau | 0 .../propagate.bench.luau | 0 .../signals/computed.spec.luau | 0 .../signals/effect.spec.luau | 0 .../signals/effectScope.spec.luau | 0 .../signals/issue_48.spec.luau | 0 .../{__tests__ => tests}/signals/spec.luau | 0 .../signals/topology.spec.luau | 0 .../signals/untrack.spec.luau | 0 pkgs/charm/{__tests__ => tests}/spec.luau | 0 .../{__tests__ => tests}/subscribe.spec.luau | 0 .../helpers/renderHook.luau | 0 .../{__tests__ => tests}/spec.luau | 0 .../{__tests__ => tests}/useAtom.spec.luau | 0 .../vide-charm/{__tests__ => tests}/spec.luau | 0 .../{__tests__ => tests}/useAtom.spec.luau | 0 rokit.toml | 3 +- tests/init.luau | 2 +- wally.lock | 98 ++++++ wally.toml | 10 + 33 files changed, 120 insertions(+), 358 deletions(-) delete mode 100644 pesde.lock delete mode 100644 pesde.toml rename pkgs/charm-sync/{__benchmarks__ => benchmarks}/patch.bench.luau (100%) rename pkgs/charm-sync/{__tests__ => tests}/client.spec.luau (100%) rename pkgs/charm-sync/{__tests__ => tests}/flatten.spec.luau (100%) rename pkgs/charm-sync/{__tests__ => tests}/patch.spec.luau (100%) rename pkgs/charm-sync/{__tests__ => tests}/server.spec.luau (100%) rename pkgs/charm-sync/{__tests__ => tests}/spec.luau (100%) rename pkgs/charm-sync/{__tests__ => tests}/validate.spec.luau (100%) rename pkgs/charm/{__benchmarks__ => benchmarks}/effect.bench.luau (100%) rename pkgs/charm/{__benchmarks__ => benchmarks}/propagate.bench.luau (100%) rename pkgs/charm/{__tests__ => tests}/signals/computed.spec.luau (100%) rename pkgs/charm/{__tests__ => tests}/signals/effect.spec.luau (100%) rename pkgs/charm/{__tests__ => tests}/signals/effectScope.spec.luau (100%) rename pkgs/charm/{__tests__ => tests}/signals/issue_48.spec.luau (100%) rename pkgs/charm/{__tests__ => tests}/signals/spec.luau (100%) rename pkgs/charm/{__tests__ => tests}/signals/topology.spec.luau (100%) rename pkgs/charm/{__tests__ => tests}/signals/untrack.spec.luau (100%) rename pkgs/charm/{__tests__ => tests}/spec.luau (100%) rename pkgs/charm/{__tests__ => tests}/subscribe.spec.luau (100%) rename pkgs/react-charm/{__tests__ => tests}/helpers/renderHook.luau (100%) rename pkgs/react-charm/{__tests__ => tests}/spec.luau (100%) rename pkgs/react-charm/{__tests__ => tests}/useAtom.spec.luau (100%) rename pkgs/vide-charm/{__tests__ => tests}/spec.luau (100%) rename pkgs/vide-charm/{__tests__ => tests}/useAtom.spec.luau (100%) create mode 100644 wally.lock create mode 100644 wally.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a314d57..4b357e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: - uses: CompeyDev/setup-rokit@v0.1.2 - name: Install dependencies - run: pesde install + run: wally install - name: Check code quality run: sh bin/check.sh diff --git a/.gitignore b/.gitignore index cb77d9b..89b59ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,7 @@ /.vscode /.pesde /build -/roblox_packages -/luau_packages -/lune_packages +/DevPackages /pkgs/**/wally.lock /sourcemap.json *.tsbuildinfo diff --git a/bin/check.sh b/bin/check.sh index 6da7706..8cf8dba 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -6,7 +6,7 @@ luau-lsp analyze \ --defs=bin/roblox.d.luau \ --flag:LuauFixIndexerSubtypingOrdering=true \ --sourcemap=sourcemap.json \ - --ignore="roblox_packages/**" \ + --ignore="**/_Index/**" \ pkgs tests selene pkgs tests diff --git a/default.project.json b/default.project.json index 576c28c..08fc194 100644 --- a/default.project.json +++ b/default.project.json @@ -5,7 +5,7 @@ "ReplicatedStorage": { "$className": "ReplicatedStorage", "Packages": { - "$path": "roblox_packages", + "$path": "DevPackages", "Charm": { "$path": "pkgs/charm" }, "CharmSync": { "$path": "pkgs/charm-sync" }, "ReactCharm": { "$path": "pkgs/react-charm" }, @@ -16,15 +16,15 @@ "$className": "ServerScriptService", "Tests": { "$path": "tests", - "Charm": { "$path": "pkgs/charm/__tests__" }, - "CharmSync": { "$path": "pkgs/charm-sync/__tests__" }, - "ReactCharm": { "$path": "pkgs/react-charm/__tests__" }, - "VideCharm": { "$path": "pkgs/vide-charm/__tests__" } + "Charm": { "$path": "pkgs/charm/tests" }, + "CharmSync": { "$path": "pkgs/charm-sync/tests" }, + "ReactCharm": { "$path": "pkgs/react-charm/tests" }, + "VideCharm": { "$path": "pkgs/vide-charm/tests" } }, "Benchmarks": { "$className": "Folder", - "Charm": { "$path": "pkgs/charm/__benchmarks__" }, - "CharmSync": { "$path": "pkgs/charm-sync/__benchmarks__" } + "Charm": { "$path": "pkgs/charm/benchmarks" }, + "CharmSync": { "$path": "pkgs/charm-sync/benchmarks" } } } } diff --git a/pesde.lock b/pesde.lock deleted file mode 100644 index e2444f3..0000000 --- a/pesde.lock +++ /dev/null @@ -1,320 +0,0 @@ -# This file is automatically @generated by pesde. -# It is not intended for manual editing. -format = 2 -name = "littensy/charm" -version = "0.1.0" -target = "roblox" - -[graph."0x5eal/unzip@0.1.3 luau".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."0x5eal/unzip@0.1.3 luau".pkg_ref.dependencies] -asciitable = [{ name = "kimpure/asciitable", version = "^0.2.1", index = "https://github.com/pesde-pkg/index" }, "dev"] -frktest = [{ name = "itsfrank/frktest", version = "^0.0.2", index = "https://github.com/pesde-pkg/index", target = "lune" }, "dev"] -luau-lsp = [{ name = "pesde/luau_lsp", version = "=1.39.2", index = "https://github.com/pesde-pkg/index", target = "lune" }, "dev"] -stylua = [{ name = "pesde/stylua", version = "=2.0.2", index = "https://github.com/pesde-pkg/index", target = "lune" }, "dev"] - -[graph."corecii/greentea@0.4.11 lune".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/daimond113/pesde-index" - -[graph."jiwonz/dirs@0.3.0 lune".dependencies] -greentea = ["corecii/greentea@0.4.11 lune", "standard"] -pathfs = ["jiwonz/pathfs@0.3.2 lune", "standard"] - -[graph."jiwonz/dirs@0.3.0 lune".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."jiwonz/dirs@0.3.0 lune".pkg_ref.dependencies] -greentea = [{ name = "corecii/greentea", version = "^0.4.10", index = "https://github.com/daimond113/pesde-index" }, "standard"] -luau_lsp = [{ name = "pesde/luau_lsp", version = "^1.38.1", index = "https://github.com/daimond113/pesde-index" }, "dev"] -pathfs = [{ name = "jiwonz/pathfs", version = "^0.3.0", index = "https://github.com/daimond113/pesde-index" }, "standard"] -selene = [{ name = "pesde/selene", version = "^0.28.0", index = "https://github.com/daimond113/pesde-index" }, "dev"] -stylua = [{ name = "pesde/stylua", version = "^2.0.2", index = "https://github.com/daimond113/pesde-index" }, "dev"] - -[graph."jiwonz/luau_disk@0.1.4 luau".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."jiwonz/luau_path@0.1.4 luau".dependencies] -luau_disk = ["jiwonz/luau_disk@0.1.4 luau", "standard"] - -[graph."jiwonz/luau_path@0.1.4 luau".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/daimond113/pesde-index" - -[graph."jiwonz/luau_path@0.1.4 luau".pkg_ref.dependencies] -luau_disk = [{ name = "jiwonz/luau_disk", version = "^0.1.4", index = "https://github.com/pesde-pkg/index" }, "standard"] - -[graph."jiwonz/pathfs@0.3.2 lune".dependencies] -greentea = ["corecii/greentea@0.4.11 lune", "standard"] -luau_path = ["jiwonz/luau_path@0.1.4 luau", "standard"] - -[graph."jiwonz/pathfs@0.3.2 lune".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."jiwonz/pathfs@0.3.2 lune".pkg_ref.dependencies] -frktest = [{ name = "itsfrank/frktest", version = "^0.0.2", index = "https://github.com/daimond113/pesde-index" }, "dev"] -greentea = [{ name = "corecii/greentea", version = "^0.4.11", index = "https://github.com/daimond113/pesde-index" }, "standard"] -luau_check = [{ name = "jiwonz/luau_check", version = "^0.3.8", index = "https://github.com/daimond113/pesde-index" }, "dev"] -luau_path = [{ name = "jiwonz/luau_path", version = "^0.1.1", index = "https://github.com/daimond113/pesde-index", target = "luau" }, "standard"] - -[graph."lukadev_0/option@1.2.0 lune".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."lukadev_0/result@1.2.0 lune".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."pesde/rojo@7.5.1 lune"] -direct = ["rojo", { name = "pesde/rojo", version = "^7.4.4", index = "default", target = "lune" }, "dev"] - -[graph."pesde/rojo@7.5.1 lune".dependencies] -core = ["pesde/toolchainlib@0.1.14 lune", "standard"] -option = ["lukadev_0/option@1.2.0 lune", "standard"] -result = ["lukadev_0/result@1.2.0 lune", "standard"] - -[graph."pesde/rojo@7.5.1 lune".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."pesde/rojo@7.5.1 lune".pkg_ref.dependencies] -core = [{ name = "pesde/toolchainlib", version = "^0.1.13", index = "https://github.com/pesde-pkg/index", target = "lune" }, "standard"] -option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/pesde-pkg/index" }, "standard"] -result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/pesde-pkg/index" }, "standard"] - -[graph."pesde/scripts_core@0.0.1 lune".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."pesde/scripts_core@0.0.1 lune".pkg_ref.dependencies] -frktest = [{ name = "itsfrank/frktest", version = "^0.0.2", index = "https://github.com/pesde-pkg/index" }, "dev"] -luau-lsp = [{ name = "pesde/luau_lsp", version = "^1.36.0", index = "https://github.com/pesde-pkg/index" }, "dev"] -pathfs = [{ name = "jiwonz/pathfs", version = "^0.1.0", index = "https://github.com/pesde-pkg/index" }, "dev"] -stylua = [{ name = "pesde/stylua", version = "^2.0.1", index = "https://github.com/pesde-pkg/index" }, "dev"] - -[graph."pesde/scripts_rojo@0.1.0 lune"] -direct = ["scripts", { name = "pesde/scripts_rojo", version = "^0.1.0", index = "default", target = "lune" }, "dev"] - -[graph."pesde/scripts_rojo@0.1.0 lune".dependencies] -core = ["pesde/scripts_core@0.0.1 lune", "standard"] -rojo = ["pesde/rojo@7.5.1 lune", "peer"] - -[graph."pesde/scripts_rojo@0.1.0 lune".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."pesde/scripts_rojo@0.1.0 lune".pkg_ref.dependencies] -core = [{ name = "pesde/scripts_core", version = "^0.0.1", index = "https://github.com/pesde-pkg/index", target = "lune" }, "standard"] -rojo = [{ name = "pesde/rojo", version = "^7.4.4", index = "https://github.com/pesde-pkg/index" }, "peer"] - -[graph."pesde/toolchainlib@0.1.14 lune".dependencies] -dirs = ["jiwonz/dirs@0.3.0 lune", "standard"] -option = ["lukadev_0/option@1.2.0 lune", "peer"] -pathfs = ["jiwonz/pathfs@0.3.2 lune", "standard"] -result = ["lukadev_0/result@1.2.0 lune", "peer"] -unzip = ["0x5eal/unzip@0.1.3 luau", "standard"] - -[graph."pesde/toolchainlib@0.1.14 lune".pkg_ref] -ref_ty = "pesde" -index_url = "https://github.com/pesde-pkg/index" - -[graph."pesde/toolchainlib@0.1.14 lune".pkg_ref.dependencies] -dirs = [{ name = "jiwonz/dirs", version = "^0.3.0", index = "https://github.com/pesde-pkg/index" }, "standard"] -option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/pesde-pkg/index" }, "peer"] -pathfs = [{ name = "jiwonz/pathfs", version = "^0.3.0", index = "https://github.com/pesde-pkg/index" }, "standard"] -result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/pesde-pkg/index" }, "peer"] -unzip = [{ name = "0x5eal/unzip", version = "^0.1.0", index = "https://github.com/pesde-pkg/index", target = "luau" }, "standard"] - -[graph."wally#centau/vide@0.3.1 roblox"] -direct = ["Vide", { wally = "wally#centau/vide", version = "^0.3.1", index = "default" }, "dev"] - -[graph."wally#centau/vide@0.3.1 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/boolean@1.2.7 roblox".dependencies] -number = ["wally#jsdotlua/number@1.2.7 roblox", "standard"] - -[graph."wally#jsdotlua/boolean@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/boolean@1.2.7 roblox".pkg_ref.dependencies] -number = [{ wally = "wally#jsdotlua/number", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/collections@1.2.7 roblox".dependencies] -es7-types = ["wally#jsdotlua/es7-types@1.2.7 roblox", "standard"] -instance-of = ["wally#jsdotlua/instance-of@1.2.7 roblox", "standard"] - -[graph."wally#jsdotlua/collections@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/collections@1.2.7 roblox".pkg_ref.dependencies] -es7-types = [{ wally = "wally#jsdotlua/es7-types", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -instance-of = [{ wally = "wally#jsdotlua/instance-of", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/console@1.2.7 roblox".dependencies] -collections = ["wally#jsdotlua/collections@1.2.7 roblox", "standard"] - -[graph."wally#jsdotlua/console@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/console@1.2.7 roblox".pkg_ref.dependencies] -collections = [{ wally = "wally#jsdotlua/collections", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/es7-types@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/instance-of@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/luau-polyfill@1.2.7 roblox".dependencies] -boolean = ["wally#jsdotlua/boolean@1.2.7 roblox", "standard"] -collections = ["wally#jsdotlua/collections@1.2.7 roblox", "standard"] -console = ["wally#jsdotlua/console@1.2.7 roblox", "standard"] -es7-types = ["wally#jsdotlua/es7-types@1.2.7 roblox", "standard"] -instance-of = ["wally#jsdotlua/instance-of@1.2.7 roblox", "standard"] -math = ["wally#jsdotlua/math@1.2.7 roblox", "standard"] -number = ["wally#jsdotlua/number@1.2.7 roblox", "standard"] -string = ["wally#jsdotlua/string@1.2.7 roblox", "standard"] -symbol-luau = ["wally#jsdotlua/symbol-luau@1.0.1 roblox", "standard"] -timers = ["wally#jsdotlua/timers@1.2.7 roblox", "standard"] - -[graph."wally#jsdotlua/luau-polyfill@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/luau-polyfill@1.2.7 roblox".pkg_ref.dependencies] -boolean = [{ wally = "wally#jsdotlua/boolean", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -collections = [{ wally = "wally#jsdotlua/collections", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -console = [{ wally = "wally#jsdotlua/console", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -es7-types = [{ wally = "wally#jsdotlua/es7-types", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -instance-of = [{ wally = "wally#jsdotlua/instance-of", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -math = [{ wally = "wally#jsdotlua/math", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -number = [{ wally = "wally#jsdotlua/number", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -string = [{ wally = "wally#jsdotlua/string", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -symbol-luau = [{ wally = "wally#jsdotlua/symbol-luau", version = ">=1.0.0, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -timers = [{ wally = "wally#jsdotlua/timers", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/math@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/number@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/promise@3.5.2 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/react@17.2.1 roblox"] -direct = ["React", { wally = "wally#jsdotlua/react", version = "^17.2.1", index = "default" }, "dev"] - -[graph."wally#jsdotlua/react@17.2.1 roblox".dependencies] -luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] -shared = ["wally#jsdotlua/shared@17.2.1 roblox", "standard"] - -[graph."wally#jsdotlua/react@17.2.1 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/react@17.2.1 roblox".pkg_ref.dependencies] -luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -shared = [{ wally = "wally#jsdotlua/shared", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/react-reconciler@17.2.1 roblox".dependencies] -luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] -promise = ["wally#jsdotlua/promise@3.5.2 roblox", "standard"] -react = ["wally#jsdotlua/react@17.2.1 roblox", "standard"] -scheduler = ["wally#jsdotlua/scheduler@17.2.1 roblox", "standard"] -shared = ["wally#jsdotlua/shared@17.2.1 roblox", "standard"] - -[graph."wally#jsdotlua/react-reconciler@17.2.1 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/react-reconciler@17.2.1 roblox".pkg_ref.dependencies] -luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -promise = [{ wally = "wally#jsdotlua/promise", version = ">=3.5.0, <4.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -react = [{ wally = "wally#jsdotlua/react", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -scheduler = [{ wally = "wally#jsdotlua/scheduler", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -shared = [{ wally = "wally#jsdotlua/shared", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/react-roblox@17.2.1 roblox"] -direct = ["ReactRoblox", { wally = "wally#jsdotlua/react-roblox", version = "^17.2.1", index = "default" }, "dev"] - -[graph."wally#jsdotlua/react-roblox@17.2.1 roblox".dependencies] -luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] -react = ["wally#jsdotlua/react@17.2.1 roblox", "standard"] -react-reconciler = ["wally#jsdotlua/react-reconciler@17.2.1 roblox", "standard"] -scheduler = ["wally#jsdotlua/scheduler@17.2.1 roblox", "standard"] -shared = ["wally#jsdotlua/shared@17.2.1 roblox", "standard"] - -[graph."wally#jsdotlua/react-roblox@17.2.1 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/react-roblox@17.2.1 roblox".pkg_ref.dependencies] -luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -react = [{ wally = "wally#jsdotlua/react", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -react-reconciler = [{ wally = "wally#jsdotlua/react-reconciler", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -scheduler = [{ wally = "wally#jsdotlua/scheduler", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -shared = [{ wally = "wally#jsdotlua/shared", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/scheduler@17.2.1 roblox".dependencies] -luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] -shared = ["wally#jsdotlua/shared@17.2.1 roblox", "standard"] - -[graph."wally#jsdotlua/scheduler@17.2.1 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/scheduler@17.2.1 roblox".pkg_ref.dependencies] -luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -shared = [{ wally = "wally#jsdotlua/shared", version = ">=17.2.1, <18.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/shared@17.2.1 roblox".dependencies] -luau-polyfill = ["wally#jsdotlua/luau-polyfill@1.2.7 roblox", "standard"] - -[graph."wally#jsdotlua/shared@17.2.1 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/shared@17.2.1 roblox".pkg_ref.dependencies] -luau-polyfill = [{ wally = "wally#jsdotlua/luau-polyfill", version = ">=1.2.6, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/string@1.2.7 roblox".dependencies] -es7-types = ["wally#jsdotlua/es7-types@1.2.7 roblox", "standard"] -number = ["wally#jsdotlua/number@1.2.7 roblox", "standard"] - -[graph."wally#jsdotlua/string@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/string@1.2.7 roblox".pkg_ref.dependencies] -es7-types = [{ wally = "wally#jsdotlua/es7-types", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] -number = [{ wally = "wally#jsdotlua/number", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] - -[graph."wally#jsdotlua/symbol-luau@1.0.1 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/timers@1.2.7 roblox".dependencies] -collections = ["wally#jsdotlua/collections@1.2.7 roblox", "standard"] - -[graph."wally#jsdotlua/timers@1.2.7 roblox".pkg_ref] -ref_ty = "wally" -index_url = "https://github.com/UpliftGames/wally-index" - -[graph."wally#jsdotlua/timers@1.2.7 roblox".pkg_ref.dependencies] -collections = [{ wally = "wally#jsdotlua/collections", version = ">=1.2.7, <2.0.0", index = "https://github.com/UpliftGames/wally-index" }, "standard"] diff --git a/pesde.toml b/pesde.toml deleted file mode 100644 index 8e9bcb2..0000000 --- a/pesde.toml +++ /dev/null @@ -1,23 +0,0 @@ -name = "littensy/charm" -version = "0.1.0" -license = "MIT" - -[target] -environment = "roblox" - -[indices] -default = "https://github.com/pesde-pkg/index" - -[wally_indices] -default = "https://github.com/UpliftGames/wally-index" - -[scripts] -roblox_sync_config_generator = ".pesde/scripts/roblox_sync_config_generator.luau" -sourcemap_generator = ".pesde/scripts/sourcemap_generator.luau" - -[dev_dependencies] -scripts = { name = "pesde/scripts_rojo", version = "^0.1.0", target = "lune" } -rojo = { name = "pesde/rojo", version = "^7.4.4", target = "lune" } -React = { wally = "jsdotlua/react", version = "^17.2.1" } -ReactRoblox = { wally = "jsdotlua/react-roblox", version = "^17.2.1" } -Vide = { wally = "centau/vide", version = "^0.3.1" } diff --git a/pkgs/charm-sync/__benchmarks__/patch.bench.luau b/pkgs/charm-sync/benchmarks/patch.bench.luau similarity index 100% rename from pkgs/charm-sync/__benchmarks__/patch.bench.luau rename to pkgs/charm-sync/benchmarks/patch.bench.luau diff --git a/pkgs/charm-sync/__tests__/client.spec.luau b/pkgs/charm-sync/tests/client.spec.luau similarity index 100% rename from pkgs/charm-sync/__tests__/client.spec.luau rename to pkgs/charm-sync/tests/client.spec.luau diff --git a/pkgs/charm-sync/__tests__/flatten.spec.luau b/pkgs/charm-sync/tests/flatten.spec.luau similarity index 100% rename from pkgs/charm-sync/__tests__/flatten.spec.luau rename to pkgs/charm-sync/tests/flatten.spec.luau diff --git a/pkgs/charm-sync/__tests__/patch.spec.luau b/pkgs/charm-sync/tests/patch.spec.luau similarity index 100% rename from pkgs/charm-sync/__tests__/patch.spec.luau rename to pkgs/charm-sync/tests/patch.spec.luau diff --git a/pkgs/charm-sync/__tests__/server.spec.luau b/pkgs/charm-sync/tests/server.spec.luau similarity index 100% rename from pkgs/charm-sync/__tests__/server.spec.luau rename to pkgs/charm-sync/tests/server.spec.luau diff --git a/pkgs/charm-sync/__tests__/spec.luau b/pkgs/charm-sync/tests/spec.luau similarity index 100% rename from pkgs/charm-sync/__tests__/spec.luau rename to pkgs/charm-sync/tests/spec.luau diff --git a/pkgs/charm-sync/__tests__/validate.spec.luau b/pkgs/charm-sync/tests/validate.spec.luau similarity index 100% rename from pkgs/charm-sync/__tests__/validate.spec.luau rename to pkgs/charm-sync/tests/validate.spec.luau diff --git a/pkgs/charm/__benchmarks__/effect.bench.luau b/pkgs/charm/benchmarks/effect.bench.luau similarity index 100% rename from pkgs/charm/__benchmarks__/effect.bench.luau rename to pkgs/charm/benchmarks/effect.bench.luau diff --git a/pkgs/charm/__benchmarks__/propagate.bench.luau b/pkgs/charm/benchmarks/propagate.bench.luau similarity index 100% rename from pkgs/charm/__benchmarks__/propagate.bench.luau rename to pkgs/charm/benchmarks/propagate.bench.luau diff --git a/pkgs/charm/__tests__/signals/computed.spec.luau b/pkgs/charm/tests/signals/computed.spec.luau similarity index 100% rename from pkgs/charm/__tests__/signals/computed.spec.luau rename to pkgs/charm/tests/signals/computed.spec.luau diff --git a/pkgs/charm/__tests__/signals/effect.spec.luau b/pkgs/charm/tests/signals/effect.spec.luau similarity index 100% rename from pkgs/charm/__tests__/signals/effect.spec.luau rename to pkgs/charm/tests/signals/effect.spec.luau diff --git a/pkgs/charm/__tests__/signals/effectScope.spec.luau b/pkgs/charm/tests/signals/effectScope.spec.luau similarity index 100% rename from pkgs/charm/__tests__/signals/effectScope.spec.luau rename to pkgs/charm/tests/signals/effectScope.spec.luau diff --git a/pkgs/charm/__tests__/signals/issue_48.spec.luau b/pkgs/charm/tests/signals/issue_48.spec.luau similarity index 100% rename from pkgs/charm/__tests__/signals/issue_48.spec.luau rename to pkgs/charm/tests/signals/issue_48.spec.luau diff --git a/pkgs/charm/__tests__/signals/spec.luau b/pkgs/charm/tests/signals/spec.luau similarity index 100% rename from pkgs/charm/__tests__/signals/spec.luau rename to pkgs/charm/tests/signals/spec.luau diff --git a/pkgs/charm/__tests__/signals/topology.spec.luau b/pkgs/charm/tests/signals/topology.spec.luau similarity index 100% rename from pkgs/charm/__tests__/signals/topology.spec.luau rename to pkgs/charm/tests/signals/topology.spec.luau diff --git a/pkgs/charm/__tests__/signals/untrack.spec.luau b/pkgs/charm/tests/signals/untrack.spec.luau similarity index 100% rename from pkgs/charm/__tests__/signals/untrack.spec.luau rename to pkgs/charm/tests/signals/untrack.spec.luau diff --git a/pkgs/charm/__tests__/spec.luau b/pkgs/charm/tests/spec.luau similarity index 100% rename from pkgs/charm/__tests__/spec.luau rename to pkgs/charm/tests/spec.luau diff --git a/pkgs/charm/__tests__/subscribe.spec.luau b/pkgs/charm/tests/subscribe.spec.luau similarity index 100% rename from pkgs/charm/__tests__/subscribe.spec.luau rename to pkgs/charm/tests/subscribe.spec.luau diff --git a/pkgs/react-charm/__tests__/helpers/renderHook.luau b/pkgs/react-charm/tests/helpers/renderHook.luau similarity index 100% rename from pkgs/react-charm/__tests__/helpers/renderHook.luau rename to pkgs/react-charm/tests/helpers/renderHook.luau diff --git a/pkgs/react-charm/__tests__/spec.luau b/pkgs/react-charm/tests/spec.luau similarity index 100% rename from pkgs/react-charm/__tests__/spec.luau rename to pkgs/react-charm/tests/spec.luau diff --git a/pkgs/react-charm/__tests__/useAtom.spec.luau b/pkgs/react-charm/tests/useAtom.spec.luau similarity index 100% rename from pkgs/react-charm/__tests__/useAtom.spec.luau rename to pkgs/react-charm/tests/useAtom.spec.luau diff --git a/pkgs/vide-charm/__tests__/spec.luau b/pkgs/vide-charm/tests/spec.luau similarity index 100% rename from pkgs/vide-charm/__tests__/spec.luau rename to pkgs/vide-charm/tests/spec.luau diff --git a/pkgs/vide-charm/__tests__/useAtom.spec.luau b/pkgs/vide-charm/tests/useAtom.spec.luau similarity index 100% rename from pkgs/vide-charm/__tests__/useAtom.spec.luau rename to pkgs/vide-charm/tests/useAtom.spec.luau diff --git a/rokit.toml b/rokit.toml index 4c21356..d7c0aef 100644 --- a/rokit.toml +++ b/rokit.toml @@ -9,5 +9,4 @@ rojo = "rojo-rbx/rojo@7.5.1" stylua = "johnnymorganz/stylua@2.1.0" selene = "Kampfkarren/selene@0.28.0" luau-lsp = "johnnymorganz/luau-lsp@1.44.1" -lute = "luau-lang/lute@0.1.0-nightly.20250503" -pesde = "daimond113/pesde@0.7.0-rc.2+registry.0.2.3-rc.1" +lute = "luau-lang/lute@0.1.0-nightly.20250506" diff --git a/tests/init.luau b/tests/init.luau index c9f0300..7682db0 100644 --- a/tests/init.luau +++ b/tests/init.luau @@ -8,7 +8,7 @@ if not table.isfrozen(_G) then end describe("charm", function() - require("@pkgs/charm/__tests__/spec") + require("@pkgs/charm/tests/spec") end) finish() diff --git a/wally.lock b/wally.lock new file mode 100644 index 0000000..f568be9 --- /dev/null +++ b/wally.lock @@ -0,0 +1,98 @@ +# This file is automatically @generated by Wally. +# It is not intended for manual editing. +registry = "test" + +[[package]] +name = "centau/vide" +version = "0.3.1" +dependencies = [] + +[[package]] +name = "jsdotlua/boolean" +version = "1.2.7" +dependencies = [["number", "jsdotlua/number@1.2.7"]] + +[[package]] +name = "jsdotlua/collections" +version = "1.2.7" +dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"]] + +[[package]] +name = "jsdotlua/console" +version = "1.2.7" +dependencies = [["collections", "jsdotlua/collections@1.2.7"]] + +[[package]] +name = "jsdotlua/es7-types" +version = "1.2.7" +dependencies = [] + +[[package]] +name = "jsdotlua/instance-of" +version = "1.2.7" +dependencies = [] + +[[package]] +name = "jsdotlua/luau-polyfill" +version = "1.2.7" +dependencies = [["boolean", "jsdotlua/boolean@1.2.7"], ["collections", "jsdotlua/collections@1.2.7"], ["console", "jsdotlua/console@1.2.7"], ["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"], ["math", "jsdotlua/math@1.2.7"], ["number", "jsdotlua/number@1.2.7"], ["string", "jsdotlua/string@1.2.7"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.7"]] + +[[package]] +name = "jsdotlua/math" +version = "1.2.7" +dependencies = [] + +[[package]] +name = "jsdotlua/number" +version = "1.2.7" +dependencies = [] + +[[package]] +name = "jsdotlua/promise" +version = "3.5.2" +dependencies = [] + +[[package]] +name = "jsdotlua/react" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] + +[[package]] +name = "jsdotlua/react-reconciler" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] + +[[package]] +name = "jsdotlua/react-roblox" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["react", "jsdotlua/react@17.2.1"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] + +[[package]] +name = "jsdotlua/scheduler" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] + +[[package]] +name = "jsdotlua/shared" +version = "17.2.1" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"]] + +[[package]] +name = "jsdotlua/string" +version = "1.2.7" +dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["number", "jsdotlua/number@1.2.7"]] + +[[package]] +name = "jsdotlua/symbol-luau" +version = "1.0.1" +dependencies = [] + +[[package]] +name = "jsdotlua/timers" +version = "1.2.7" +dependencies = [["collections", "jsdotlua/collections@1.2.7"]] + +[[package]] +name = "richerd/charm" +version = "0.1.0" +dependencies = [["React", "jsdotlua/react@17.2.1"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.1"], ["Vide", "centau/vide@0.3.1"]] diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..2c4ec2b --- /dev/null +++ b/wally.toml @@ -0,0 +1,10 @@ +[package] +name = "richerd/charm" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dev-dependencies] +React = "jsdotlua/react@17" +ReactRoblox = "jsdotlua/react-roblox@17" +Vide = "centau/vide@0.3" From e276863b2d217acfb889530867d8c0970d6334a6 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 02:26:36 -0700 Subject: [PATCH 011/160] feat: add detached, add more tests --- pkgs/charm/benchmarks/effect.bench.luau | 39 ------------ pkgs/charm/benchmarks/mapped.bench.luau | 79 +++++++++++++++++++++++++ pkgs/charm/src/effectScope.luau | 4 +- pkgs/charm/src/observe.luau | 58 +++++++++++------- pkgs/charm/src/signals.luau | 4 +- pkgs/charm/tests/gc.spec.luau | 72 ++++++++++++++++++++++ pkgs/charm/tests/listen.spec.luau | 29 +++++++++ pkgs/charm/tests/observe.spec.luau | 64 ++++++++++++++++++++ pkgs/charm/tests/spec.luau | 3 + pkgs/charm/tests/subscribe.spec.luau | 52 ++++++++++++---- 10 files changed, 327 insertions(+), 77 deletions(-) delete mode 100644 pkgs/charm/benchmarks/effect.bench.luau create mode 100644 pkgs/charm/benchmarks/mapped.bench.luau create mode 100644 pkgs/charm/tests/gc.spec.luau create mode 100644 pkgs/charm/tests/listen.spec.luau create mode 100644 pkgs/charm/tests/observe.spec.luau diff --git a/pkgs/charm/benchmarks/effect.bench.luau b/pkgs/charm/benchmarks/effect.bench.luau deleted file mode 100644 index 45ab9c7..0000000 --- a/pkgs/charm/benchmarks/effect.bench.luau +++ /dev/null @@ -1,39 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.Packages.Charm) - -local computed = Charm.computed -local effect = Charm.effect -local atom = Charm.atom - -return { - Functions = { - atom = function() - local state = atom(0) - - effect(function() - state() - end) - - for _ = 1, 500 do - state(math.random(1, 2)) - end - end, - - computed = function() - local state = atom(0) - - local double = computed(function() - return 2 * state() - end) - - effect(function() - double() - end) - - for _ = 1, 500 do - state(math.random(1, 2)) - end - end, - }, -} diff --git a/pkgs/charm/benchmarks/mapped.bench.luau b/pkgs/charm/benchmarks/mapped.bench.luau new file mode 100644 index 0000000..327136c --- /dev/null +++ b/pkgs/charm/benchmarks/mapped.bench.luau @@ -0,0 +1,79 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.Packages.Charm) + +local atom = Charm.atom +local computed = Charm.computed +local mapped = Charm.mapped + +type Person = { + name: string, + age: number, +} + +local personCount = 1000 +local initialState: { Person } = table.create(personCount) + +for i = 1, personCount do + initialState[i] = { + name = "Person " .. i, + age = math.random(1, 100), + } +end + +local function mapper(person: Person): Person + return { + name = person.name .. "!", + age = person.age + 1, + } +end + +local function simpleMapped(source: () -> { Person }): () -> { Person } + return computed(function() + local state = table.clone(source()) + for index, person in state do + state[index] = mapper(person) + end + return state + end) +end + +local function updatePercentageOfPeople(percent: number): ({ Person }) -> { Person } + return function(state) + local nextState = table.clone(state) + for index, person in state do + if math.random() < percent then + nextState[index] = mapper(person) + end + end + return nextState + end +end + +return { + Functions = { + ["mapped 50%"] = function(profiler) + profiler.Begin("init") + local src = atom(initialState) + local out = mapped(src, mapper) + out() + profiler.End() + + profiler.Begin("update") + src(updatePercentageOfPeople(0.5)) + profiler.End() + end, + + ["simple 50%"] = function(profiler) + profiler.Begin("init") + local src = atom(initialState) + local out = simpleMapped(src) + out() + profiler.End() + + profiler.Begin("update") + src(updatePercentageOfPeople(0.5)) + profiler.End() + end, + }, +} diff --git a/pkgs/charm/src/effectScope.luau b/pkgs/charm/src/effectScope.luau index a324bb9..c23ed4f 100644 --- a/pkgs/charm/src/effectScope.luau +++ b/pkgs/charm/src/effectScope.luau @@ -3,13 +3,13 @@ local signals = require("./signals") local _effectScope = signals.effectScope local onScopeDispose = signals.onScopeDispose -local function effectScope(callback: () -> ...() -> ()?): () -> () +local function effectScope(callback: () -> ...() -> ()?, detached: boolean?): () -> () return _effectScope(function() local cleanup = callback() if cleanup then onScopeDispose(cleanup) end - end) + end, detached) end return effectScope diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau index 4f07020..6d467d4 100644 --- a/pkgs/charm/src/observe.luau +++ b/pkgs/charm/src/observe.luau @@ -1,59 +1,71 @@ local signals = require("./signals") -local computed = signals.computed local effect = signals.effect +local effectScope = signals.effectScope local getCurrentSub = signals.getCurrentSub local onEffectCleanup = signals.onEffectCleanup local onScopeDispose = signals.onScopeDispose - -local function noop() end +local pauseTracking = signals.pauseTracking +local resumeTracking = signals.resumeTracking local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> () - local tracked = computed(function() - return selector() - end) - - local cleanups: { [K]: () -> () } = {} + local scopes: { [K]: () -> () } = {} local currentItems: { [K]: V } = {} local function update(currentItems: { [K]: V }, previousItems: { [K]: V }) for key in previousItems do - local cleanup = cleanups[key] - if currentItems[key] == nil and cleanup then - cleanups[key] = nil - cleanup() + if currentItems[key] == nil then + local dispose = scopes[key] + scopes[key] = nil + dispose() end end for key, value in currentItems do if previousItems[key] == nil then - cleanups[key] = observer(value, key) or noop + scopes[key] = effectScope(function() + local cleanup = observer(value, key) + if cleanup then + onScopeDispose(cleanup) + end + end, true) end end end - local function teardown() - for _, cleanup in cleanups do - cleanup() + local function disposeScopes() + for _, dispose in scopes do + dispose() end - table.clear(cleanups) + table.clear(scopes) end local disposeEffect = effect(function() - local previousItems = currentItems - currentItems = tracked() - update(currentItems, previousItems) + local nextItems = selector() + + if nextItems ~= currentItems then + local previousItems = currentItems + currentItems = nextItems + + pauseTracking() + local success, result: unknown = pcall(update, currentItems, previousItems) + resumeTracking() + + if not success then + error(`observe() encountered an error: {result}`, 2) + end + end end) if getCurrentSub() then - onEffectCleanup(teardown) + onEffectCleanup(disposeScopes) else - onScopeDispose(teardown) + onScopeDispose(disposeScopes) end return function() disposeEffect() - teardown() + disposeScopes() end end diff --git a/pkgs/charm/src/signals.luau b/pkgs/charm/src/signals.luau index a265fee..f5222d5 100644 --- a/pkgs/charm/src/signals.luau +++ b/pkgs/charm/src/signals.luau @@ -352,11 +352,11 @@ local function effect(fn: () -> ()): () -> () return effectStop(e) end -local function effectScope(fn: () -> ()): () -> () +local function effectScope(fn: () -> (), detached: boolean?): () -> () local e: EffectScope = { flags = NONE, } - if activeScope then + if activeScope and not detached then link(e, activeScope) end local prev = setCurrentScope(e) diff --git a/pkgs/charm/tests/gc.spec.luau b/pkgs/charm/tests/gc.spec.luau new file mode 100644 index 0000000..6173496 --- /dev/null +++ b/pkgs/charm/tests/gc.spec.luau @@ -0,0 +1,72 @@ +local Charm = require("../src/lib") +local suite = require("@tests/suite") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local test = suite.test + +--- Creates a weak reference to a value. +--- @return Function to dereference the value. +local function weakRef(value: T): () -> T? + local ref = setmetatable({ value = value }, { __mode = "v" }) + return function() + return ref.value + end +end + +--- Triggers garbage collection by allocating excessive memory. +local function gc() + for _ = 1, 1000 do + local _ = table.create(100, 0) + end +end + +--- In alien-signals, a computed value unlinks from dependencies when its last +--- parent scope is disposed. Calling a computed value outside of a scope links +--- it to dependencies, but the two-way link holds a strong reference to the +--- computed value, so it won't GC until called in a scope and disposed. +--- +--- This function releases a computed value by linking it to a scope and then +--- immediately closing it, causing alien-signals to unlink the computed value +--- and allow it to be garbage collected. +local function release(getter: () -> ()) + effectScope(function() + getter() + end)() +end + +test("should release computed cache", function() + local srcRef = weakRef({}) + local src = atom(srcRef()) + + local c = computed(function() + return src() + end) + + c() -- cache src value + src(nil) -- release value + c = release(c) -- release computed + + gc() + assert(srcRef() == nil, "should release computed cache") +end) + +test("should not release effect for atom", function() + local effectCalls = 0 + local src = atom(0) + + effect(function() + effectCalls += 1 + src() + end) + + assert(effectCalls == 1, "should call effect once") + + gc() + src(src() + 1) + assert(effectCalls == 2, "should call effect again") +end) + +return {} diff --git a/pkgs/charm/tests/listen.spec.luau b/pkgs/charm/tests/listen.spec.luau new file mode 100644 index 0000000..15d7457 --- /dev/null +++ b/pkgs/charm/tests/listen.spec.luau @@ -0,0 +1,29 @@ +local Charm = require("../src/lib") +local suite = require("@tests/suite") + +local atom = Charm.atom +local listen = Charm.listen +local test = suite.test + +test("should listen to selector", function() + local source = atom(0) + local current, previous + + local unsub = listen(function() + return source() + 1 + end, function(a, b) + current = a + previous = b + end) + + assert(current == 1 and previous == nil, "should run initially") + source(0) + assert(current == 1 and previous == nil, "should skip redundant update") + source(1) + assert(current == 2 and previous == 1, "should run with new value") + unsub() + source(2) + assert(current == 2 and previous == 1, "should not run after unsub") +end) + +return {} diff --git a/pkgs/charm/tests/observe.spec.luau b/pkgs/charm/tests/observe.spec.luau new file mode 100644 index 0000000..57da889 --- /dev/null +++ b/pkgs/charm/tests/observe.spec.luau @@ -0,0 +1,64 @@ +local Charm = require("../src/lib") +local suite = require("@tests/suite") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local observe = Charm.observe +local test = suite.test + +test("should observe items", function() + local items = atom({ 0, 0, 0 }) + local updates = {} + local cleanups = {} + + observe(items, function(_, key) + local get = computed(function() + return items()[key] + end) + + effect(function() + table.insert(updates, get()) + end) + + return function() + table.insert(cleanups, key) + end + end) + + assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`) + + table.clear(updates) + items({ 1, 2, 3 }) + assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`) + + table.clear(updates) + items({ 1, 2, nil :: any, 4 }) + assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`) + assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`) +end) + +test("should dispose scopes", function() + local items = atom({ 1, 2, 3 }) + local a = atom(0) + local runs = {} + + local dispose = observe(items, function(item) + effect(function() + table.insert(runs, item) + a() + end) + end) + + table.clear(runs) + items({ 1, 2 }) + a(1) + assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`) + + table.clear(runs) + dispose() + a(2) + assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`) +end) + +return {} diff --git a/pkgs/charm/tests/spec.luau b/pkgs/charm/tests/spec.luau index 7ae1948..7a60b4d 100644 --- a/pkgs/charm/tests/spec.luau +++ b/pkgs/charm/tests/spec.luau @@ -15,6 +15,9 @@ local function spec(name: string) end collect("signals") +spec("gc") spec("subscribe") +spec("listen") +spec("observe") return {} diff --git a/pkgs/charm/tests/subscribe.spec.luau b/pkgs/charm/tests/subscribe.spec.luau index 1199594..c2d5bd5 100644 --- a/pkgs/charm/tests/subscribe.spec.luau +++ b/pkgs/charm/tests/subscribe.spec.luau @@ -1,28 +1,58 @@ -local atom = require("../src/atom") -local subscribe = require("../src/subscribe") +local Charm = require("../src/lib") local suite = require("@tests/suite") +type Atom = Charm.Atom + +local atom = Charm.atom +local computed = Charm.computed +local subscribe = Charm.subscribe local test = suite.test -test("should subscribe to atom", function() +test("should subscribe to selector", function() local source = atom(0) local current, previous - local unsub = subscribe(source, function(a, b) + local unsub = subscribe(function() + return source() + 1 + end, function(a, b) current = a previous = b end) assert(not current and not previous, "should not run initially") + source(0) + assert(not current and not previous, "should skip redundant update") source(1) - assert(current == 1, "should run with new value") - assert(previous == 0, "should run with old value") - source(2) - assert(current == 2, "should run with new value 2") - assert(previous == 1, "should run with old value 2") + assert(current == 2 and previous == 1, "should run with new value") unsub() - source(3) - assert(current == 2, "should not run after unsubscribing") + source(2) + assert(current == 2 and previous == 1, "should not run after unsub") +end) + +test("should ensure correct execution order", function() + local order = {} + local n1 = atom(0) + local n2 = atom(0) + local sum = computed(function() + return n1() + n2() + end) + + subscribe(n1, function() + table.insert(order, 1) + n2(n2() + 1) + end) + + subscribe(sum, function() + table.insert(order, 2) + end) + + subscribe(n1, function() + table.insert(order, 3) + end) + + n1(n1() + 1) + + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) end) return {} From 1d5d3aed8c3fbf7f6f8357428d21c41b5c760c5f Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 02:28:05 -0700 Subject: [PATCH 012/160] test: simplify mapped test update --- pkgs/charm/benchmarks/mapped.bench.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/charm/benchmarks/mapped.bench.luau b/pkgs/charm/benchmarks/mapped.bench.luau index 327136c..cf1a454 100644 --- a/pkgs/charm/benchmarks/mapped.bench.luau +++ b/pkgs/charm/benchmarks/mapped.bench.luau @@ -43,7 +43,7 @@ local function updatePercentageOfPeople(percent: number): ({ Person }) -> { Pers local nextState = table.clone(state) for index, person in state do if math.random() < percent then - nextState[index] = mapper(person) + nextState[index] = table.clone(person) end end return nextState From 939a812b43aac5cf7dc815c5c027fc32ce081b60 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 02:30:33 -0700 Subject: [PATCH 013/160] chore: rephrase doc comment --- pkgs/charm/tests/gc.spec.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/charm/tests/gc.spec.luau b/pkgs/charm/tests/gc.spec.luau index 6173496..033a66f 100644 --- a/pkgs/charm/tests/gc.spec.luau +++ b/pkgs/charm/tests/gc.spec.luau @@ -25,7 +25,7 @@ end --- In alien-signals, a computed value unlinks from dependencies when its last --- parent scope is disposed. Calling a computed value outside of a scope links ---- it to dependencies, but the two-way link holds a strong reference to the +--- it to dependencies, but the dependencies hold strong references to the --- computed value, so it won't GC until called in a scope and disposed. --- --- This function releases a computed value by linking it to a scope and then From 6bfd2f3d21c9933d6da2e4e4bdb15a8155aac365 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 11:04:39 -0700 Subject: [PATCH 014/160] perf: optimize subscribe and mapped --- pkgs/charm/benchmarks/propagate.luau | 74 +++++++++++++ pkgs/charm/src/atom.luau | 1 - pkgs/charm/src/effect.luau | 7 +- pkgs/charm/src/effectScope.luau | 7 +- pkgs/charm/src/init.luau | 1 - pkgs/charm/src/lib.luau | 1 - pkgs/charm/src/listen.luau | 17 +-- pkgs/charm/src/mapped.luau | 154 ++++++++++----------------- pkgs/charm/src/observe.luau | 16 +-- pkgs/charm/src/signals.luau | 76 ++++++------- pkgs/charm/src/spawn.luau | 36 ------- pkgs/charm/src/subscribe.luau | 18 ++-- pkgs/charm/src/types.luau | 4 - tests/init.luau | 5 +- 14 files changed, 204 insertions(+), 213 deletions(-) create mode 100644 pkgs/charm/benchmarks/propagate.luau delete mode 100644 pkgs/charm/src/spawn.luau diff --git a/pkgs/charm/benchmarks/propagate.luau b/pkgs/charm/benchmarks/propagate.luau new file mode 100644 index 0000000..b6780f5 --- /dev/null +++ b/pkgs/charm/benchmarks/propagate.luau @@ -0,0 +1,74 @@ +local Charm = require("@pkgs/charm/src/lib") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local listen = Charm.listen + +local signal = Charm.signals.signal +local computedSignal = Charm.signals.computed +local effectSignal = Charm.signals.effect + +local w = 10 +local h = 10 + +local function bench(name: string, test: () -> ()) + local start = os.clock() + local iter = 0 + while os.clock() - start < 2 do + iter += 1 + test() + end + local endTime = os.clock() + local elapsed = endTime - start + print(`{name}: {elapsed / iter * 1000 * 1000} us/iter`) +end + +bench(`listen {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 + listen(last, function() end) + end + src(src() + 1) +end) + +bench(`effect {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(`signals {w}*{h}`, function() + local src, setSrc = 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() + end) + end + setSrc(src() + 1) +end) diff --git a/pkgs/charm/src/atom.luau b/pkgs/charm/src/atom.luau index fc847e0..7e7eccb 100644 --- a/pkgs/charm/src/atom.luau +++ b/pkgs/charm/src/atom.luau @@ -2,7 +2,6 @@ local signals = require("./signals") local types = require("./types") type Atom = types.Atom -type AtomOptions = types.AtomOptions local signal = signals.signal diff --git a/pkgs/charm/src/effect.luau b/pkgs/charm/src/effect.luau index 67551ec..572891e 100644 --- a/pkgs/charm/src/effect.luau +++ b/pkgs/charm/src/effect.luau @@ -1,16 +1,17 @@ local signals = require("./signals") -local _effect = signals.effect local onEffectCleanup = signals.onEffectCleanup -local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> () +type Cleanup = () -> () + +local function effect(callback: (dispose: () -> ()) -> ...Cleanup?): () -> () local disposeImmediately = false local function dispose() disposeImmediately = true end - dispose = _effect(function() + dispose = signals.effect(function() local cleanup = callback(dispose) if cleanup then onEffectCleanup(cleanup) diff --git a/pkgs/charm/src/effectScope.luau b/pkgs/charm/src/effectScope.luau index c23ed4f..7327d9a 100644 --- a/pkgs/charm/src/effectScope.luau +++ b/pkgs/charm/src/effectScope.luau @@ -1,10 +1,11 @@ local signals = require("./signals") -local _effectScope = signals.effectScope local onScopeDispose = signals.onScopeDispose -local function effectScope(callback: () -> ...() -> ()?, detached: boolean?): () -> () - return _effectScope(function() +type Cleanup = () -> () + +local function effectScope(callback: () -> ...Cleanup?, detached: boolean?): () -> () + return signals.effectScope(function() local cleanup = callback() if cleanup then onScopeDispose(cleanup) diff --git a/pkgs/charm/src/init.luau b/pkgs/charm/src/init.luau index ef19757..c3dec01 100644 --- a/pkgs/charm/src/init.luau +++ b/pkgs/charm/src/init.luau @@ -1,7 +1,6 @@ local lib = require(script.lib) export type Atom = lib.Atom -export type AtomOptions = lib.AtomOptions export type Selector = lib.Selector return lib diff --git a/pkgs/charm/src/lib.luau b/pkgs/charm/src/lib.luau index adcfb67..479af84 100644 --- a/pkgs/charm/src/lib.luau +++ b/pkgs/charm/src/lib.luau @@ -10,7 +10,6 @@ local types = require("./types") local untracked = require("./untracked") export type Atom = types.Atom -export type AtomOptions = types.AtomOptions export type Selector = types.Selector local computed = signals.computed diff --git a/pkgs/charm/src/listen.luau b/pkgs/charm/src/listen.luau index 7a760e5..d6c9a31 100644 --- a/pkgs/charm/src/listen.luau +++ b/pkgs/charm/src/listen.luau @@ -1,5 +1,4 @@ local signals = require("./signals") -local spawn = require("./spawn") local effect = signals.effect local pauseTracking = signals.pauseTracking @@ -7,22 +6,24 @@ local resumeTracking = signals.resumeTracking local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () local disposeImmediately = false - local current: T? + local current: T local function dispose() disposeImmediately = true end dispose = effect(function() - local value = selector() - - if value ~= current then - local previous = current - current = value + local previous: T? = current + current = selector() + if current ~= previous then pauseTracking() - spawn(listener, value, previous, dispose) + local success, result: unknown = pcall(listener, current, previous, dispose) resumeTracking() + + if not success then + error(`listen() encountered an error: {result}`) + end end end) diff --git a/pkgs/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau index f0bfc05..b871aa5 100644 --- a/pkgs/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -4,128 +4,86 @@ local computed = signals.computed local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking -type MappedKey = { - key: KO, - value: VO, -} +type Mapped = + & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) + & ((selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) -local function mapped(selector: () -> { [KI]: VI }, transform: (item: VI, key: KI) -> (VO, KO?)): () -> { [KO]: VO } - local mappedKeys: { [KI]: MappedKey } = {} - local previousItems: { [KI]: VI }? +local function ensureCanMutateOutput(output: { [KO]: VO }, previous: { [KO]: VO }?): { [KO]: VO } + return if output == previous then table.clone(output) else output +end - local function getTransform(value: VI, key: KI): (VO, KO?) +local function mapped(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } + local currentData: { [KI]: VI } = {} + local keyCache: { [KI]: KO } = {} + + local function map(value: VI, key: KI): (VO, KO) pauseTracking() - local success, valueOut, keyOut = pcall(transform, value, key) + local success, valueOut, keyOut = pcall(mapper, value, key) resumeTracking() if not success then error(`mapped() transform function failed: {valueOut}`) end - return valueOut, keyOut + return valueOut, if keyOut ~= nil then keyOut else key :: any end - return computed(function(previousMappedItems) - local currentItems = selector() + return computed(function(previousOutput) + local output: { [KO]: VO } = previousOutput or {} + local previousData = currentData + currentData = selector() - -- If this is the initial run, create a map of the items and store the - -- updated keys and values. - if not previousItems or not previousMappedItems then - local mappedItems: { [KO]: VO } = {} - previousItems = currentItems - - for key, value in currentItems do - local valueOut, keyOut = getTransform(value, key) - local keyOut = if keyOut ~= nil then keyOut else key :: never - - if valueOut ~= nil then - mappedKeys[key] = { key = keyOut, value = valueOut } - mappedItems[keyOut] = valueOut - end - end - - return mappedItems + -- Check equality since the selector might not be a computed value + if currentData == previousData then + return output end - if previousItems == currentItems then - return previousMappedItems + -- Clear removed values from the output and key cache + for key in previousData do + if currentData[key] == nil then + local keyOut = keyCache[key] + + if keyOut then + output = ensureCanMutateOutput(output, previousOutput) + output[keyOut] = nil + keyCache[key] = nil + end + end 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 + -- Process new or changed values + for key, value in currentData do + if value == previousData[key] then 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 + local valueOut, keyOut = map(value, key) + local cachedKeyOut = keyCache[key] if valueOut ~= nil then - mappedKeys[key] = { key = keyOut, value = valueOut } - mappedItems[keyOut] = valueOut + -- Skip update if the key and value are the same + if keyOut == cachedKeyOut and valueOut == output[cachedKeyOut] then + continue + end + + output = ensureCanMutateOutput(output, previousOutput) + output[keyOut] = valueOut + keyCache[key] = keyOut + + -- Remove the old key if a new key is assigned + if cachedKeyOut ~= nil and keyOut ~= cachedKeyOut then + output[cachedKeyOut] = nil + end + elseif cachedKeyOut ~= nil then + -- Remove the cached key if the new value is nil + output = ensureCanMutateOutput(output, previousOutput) + output[cachedKeyOut] = nil + keyCache[key] = nil end end - previousItems = currentItems - - return mappedItems + return output end) end -return mapped +return mapped :: Mapped diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau index 6d467d4..8cce9cb 100644 --- a/pkgs/charm/src/observe.luau +++ b/pkgs/charm/src/observe.luau @@ -8,11 +8,13 @@ local onScopeDispose = signals.onScopeDispose local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking -local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> () +type Cleanup = () -> () + +local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...Cleanup?): () -> () local scopes: { [K]: () -> () } = {} local currentItems: { [K]: V } = {} - local function update(currentItems: { [K]: V }, previousItems: { [K]: V }) + local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V }) for key in previousItems do if currentItems[key] == nil then local dispose = scopes[key] @@ -41,14 +43,12 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key end local disposeEffect = effect(function() - local nextItems = selector() - - if nextItems ~= currentItems then - local previousItems = currentItems - currentItems = nextItems + local previousItems = currentItems + currentItems = selector() + if currentItems ~= previousItems then pauseTracking() - local success, result: unknown = pcall(update, currentItems, previousItems) + local success, result: unknown = pcall(updateScopes, currentItems, previousItems) resumeTracking() if not success then diff --git a/pkgs/charm/src/signals.luau b/pkgs/charm/src/signals.luau index f5222d5..2cb796b 100644 --- a/pkgs/charm/src/signals.luau +++ b/pkgs/charm/src/signals.luau @@ -78,7 +78,7 @@ local batchDepth = 0 local notifyIndex = 0 local queuedEffectsLength = 0 local activeSub: ReactiveNode? -local activeScope: EffectScope? +local activeScope: Effect | EffectScope? local function getCurrentSub(): ReactiveNode? return activeSub @@ -90,26 +90,28 @@ local function setCurrentSub(sub: ReactiveNode?) return prevSub end -local function getCurrentScope(): EffectScope? +local function getCurrentScope(): Effect | EffectScope? return activeScope end -local function setCurrentScope(scope: EffectScope?) +local function setCurrentScope(scope: Effect | EffectScope?) local prevScope = activeScope activeScope = scope return prevScope end -local function addCleanup(e: EffectScope, cleanup: () -> ()) - local prevCleanup = e.cleanup +local function addCleanup(e: Effect | EffectScope, cleanup: () -> ()) + local prevCleanup: (() -> ())? = e.cleanup + local nextCleanup = cleanup + if prevCleanup then - e.cleanup = function() + function cleanup() prevCleanup() - cleanup() + nextCleanup() end - else - e.cleanup = cleanup end + + (e :: Effect).cleanup = cleanup end local function onEffectCleanup(cleanup: () -> ()) @@ -124,19 +126,19 @@ local function onScopeDispose(cleanup: () -> ()) end end -local function cleanupEffect(e: EffectScope) +local function cleanupEffect(e: Effect | EffectScope) if e.cleanup then local prev = setCurrentSub(nil) local success, result: unknown = pcall(e.cleanup) - setCurrentSub(prev) - e.cleanup = nil + setCurrentSub(prev); + (e :: Effect).cleanup = nil if not success then error(result) end end end -local function run(e: EffectScope, flags: ReactiveFlags): () +local function run(e: Effect | EffectScope, flags: ReactiveFlags): () if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(e.deps :: Link, e)) then cleanupEffect(e) local prev = setCurrentSub(e) @@ -149,7 +151,7 @@ local function run(e: EffectScope, flags: ReactiveFlags): () end return elseif bit32.band(flags, PENDING) ~= 0 then - e.flags = bit32.band(flags, bit32.bnot(PENDING)) + (e :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING)) end local link = e.deps while link do @@ -158,7 +160,7 @@ local function run(e: EffectScope, flags: ReactiveFlags): () if bit32.band(depFlags, QUEUED) ~= 0 then depFlags = bit32.band(depFlags, bit32.bnot(QUEUED)) dep.flags = depFlags - run(dep :: EffectScope, depFlags) + run(dep :: Effect, depFlags) end link = link.nextDep end @@ -167,7 +169,7 @@ end local function flush(): () while notifyIndex < queuedEffectsLength do notifyIndex += 1 - local effect = queuedEffects[notifyIndex] :: EffectScope + local effect = queuedEffects[notifyIndex] :: Effect queuedEffects[notifyIndex] = nil effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED)) run(effect, effect.flags) @@ -302,7 +304,7 @@ local function computedGetter(this: Computed): () -> T end end -local function effectStop(this: EffectScope): () -> () +local function effectStop(this: Effect | EffectScope): () -> () return function() local dep = this.deps while dep do @@ -313,7 +315,7 @@ local function effectStop(this: EffectScope): () -> () unlink(sub) sub = this.subs end - this.flags = NONE + (this :: Effect).flags = NONE cleanupEffect(this) end end @@ -368,43 +370,43 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> () return effectStop(e) end -local function notify(e: ReactiveNode) +local function update(signal: Signal | Computed): boolean + if (signal :: Computed).getter then + return updateComputed(signal :: Computed) + else + return updateSignal(signal :: Signal, signal.value) + end +end + +local function notify(e: Effect | EffectScope) local flags = e.flags if bit32.band(flags, QUEUED) == 0 then - e.flags = bit32.bor(flags, QUEUED) + (e :: Effect).flags = bit32.bor(flags, QUEUED) local subs = e.subs if subs then - notify(subs.sub :: Effect | EffectScope) + notify(subs.sub :: Effect) else queuedEffectsLength += 1 - queuedEffects[queuedEffectsLength] = e :: Effect + queuedEffects[queuedEffectsLength] = e 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 function unwatched(signal: Signal | Effect | Computed) local toRemove = signal.deps if toRemove then repeat toRemove = unlink(toRemove, signal) - until not toRemove - signal.flags = bit32.bor(signal.flags, DIRTY) + until toRemove == nil + (signal :: Signal).flags = bit32.bor(signal.flags, DIRTY) end - cleanupEffect(signal :: EffectScope) + cleanupEffect(signal :: Effect) end local reactiveSystem = createReactiveSystem({ - update = update, - notify = notify, - unwatched = unwatched, + update = update :: (ReactiveNode) -> boolean, + notify = notify :: (ReactiveNode) -> (), + unwatched = unwatched :: (ReactiveNode) -> (), }) link = reactiveSystem.link diff --git a/pkgs/charm/src/spawn.luau b/pkgs/charm/src/spawn.luau deleted file mode 100644 index 985dc07..0000000 --- a/pkgs/charm/src/spawn.luau +++ /dev/null @@ -1,36 +0,0 @@ -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/pkgs/charm/src/subscribe.luau b/pkgs/charm/src/subscribe.luau index 59ecfff..b038a4a 100644 --- a/pkgs/charm/src/subscribe.luau +++ b/pkgs/charm/src/subscribe.luau @@ -1,27 +1,27 @@ local signals = require("./signals") -local spawn = require("./spawn") local effect = signals.effect local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking -local function subscribe(selector: () -> T, listener: (T, T) -> ()): () -> () +local function subscribe(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () local current: T local mounted = true return effect(function() - local value = selector() + local previous = current + current = selector() if mounted then - current = value mounted = false - elseif value ~= current then - local previous = current - current = value - + elseif current ~= previous then pauseTracking() - spawn(listener, value, previous) + local success, result: unknown = pcall(listener, current, previous) resumeTracking() + + if not success then + error(`subscribe() encountered an error: {result}`) + end end end) end diff --git a/pkgs/charm/src/types.luau b/pkgs/charm/src/types.luau index 68edaea..20aa1f5 100644 --- a/pkgs/charm/src/types.luau +++ b/pkgs/charm/src/types.luau @@ -2,8 +2,4 @@ export type Selector = () -> T export type Atom = ((((T) -> T) | T) -> T) & (() -> T) -export type AtomOptions = { - equals: ((prev: T, next: T) -> boolean)?, -} - return {} diff --git a/tests/init.luau b/tests/init.luau index 7682db0..8431991 100644 --- a/tests/init.luau +++ b/tests/init.luau @@ -1,14 +1,11 @@ local suite = require("@tests/suite") -local describe = suite.describe local finish = suite.finish if not table.isfrozen(_G) then _G.__DEV__ = true end -describe("charm", function() - require("@pkgs/charm/tests/spec") -end) +require("@pkgs/charm/tests/spec") finish() From bcf02a615f83e999493579f19d9952af36579a23 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 11:06:27 -0700 Subject: [PATCH 015/160] refactor: avoid ambiguous optional function type --- pkgs/charm/tests/signals/issue_48.spec.luau | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/charm/tests/signals/issue_48.spec.luau b/pkgs/charm/tests/signals/issue_48.spec.luau index 519c0aa..122a44c 100644 --- a/pkgs/charm/tests/signals/issue_48.spec.luau +++ b/pkgs/charm/tests/signals/issue_48.spec.luau @@ -11,8 +11,8 @@ local test = suite.test type ReactionOptions = { fireImmediately: boolean?, equals: ((a: T, b: T?) -> boolean)?, - onError: (error: unknown) -> ()?, - scheduler: (fn: () -> ()) -> ()?, + onError: ((error: unknown) -> ())?, + scheduler: ((fn: () -> ()) -> ())?, once: boolean?, } From d56672384a0c5f752d3511f312f6c3424920dfb1 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 11:18:19 -0700 Subject: [PATCH 016/160] refactor: add assertion error messages --- pkgs/charm-sync/tests/client.spec.luau | 27 +++++++------- pkgs/charm-sync/tests/flatten.spec.luau | 10 +++--- pkgs/charm-sync/tests/patch.spec.luau | 46 ++++++++++++------------ pkgs/charm-sync/tests/server.spec.luau | 42 +++++++++++----------- pkgs/react-charm/tests/useAtom.spec.luau | 16 ++++----- pkgs/vide-charm/tests/useAtom.spec.luau | 8 ++--- selene_defs.yml | 5 --- 7 files changed, 74 insertions(+), 80 deletions(-) diff --git a/pkgs/charm-sync/tests/client.spec.luau b/pkgs/charm-sync/tests/client.spec.luau index 62dd4f6..d335660 100644 --- a/pkgs/charm-sync/tests/client.spec.luau +++ b/pkgs/charm-sync/tests/client.spec.luau @@ -19,7 +19,7 @@ test("should sync state payload", function() data = { a = 2 }, }) - assert(a() == 2) + assert(a() == 2, "should set a to the initial value") end) test("should sync state patch", function() @@ -32,16 +32,16 @@ test("should sync state patch", function() data = { a = a() }, }) - assert(a() == previous) + assert(a() == previous, "should set a to the initial value") client:sync({ type = "patch", data = { a = { b = 3 } }, }) - assert(a() ~= previous) - assert(a().b == 3) - assert(a().c == 2) + assert(a() ~= previous, "should apply patch") + assert(a().b == 3, "should set b to the new value") + assert(a().c == 2, "should not change c") end) test("should fix sparse array patches", function() @@ -61,8 +61,8 @@ test("should fix sparse array patches", function() data = { a = { ["2"] = 2 } }, }) - assert(a()["2" :: any] == nil) - assert(a()[2] == 2) + assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers") + assert(a()[2] == 2, "should convert serialized arrays to use numbers") end) test("should apply multiple patches", function() @@ -87,9 +87,8 @@ test("should apply multiple patches", function() data = { a = 3 }, }) - assert(a() == 3) - assert(calls[1] == 2) - assert(calls[2] == 3) + assert(a() == 3, "should apply the last patch") + assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`) end) test("should batch patches", function() @@ -116,10 +115,10 @@ test("should batch patches", function() data = { a = 1, b = 2 }, }) - assert(a() == 1) - assert(b() == 2) - assert(states.a == 1) - assert(states.b == 2) + assert(a() == 1, "should set a to the new value") + assert(b() == 2, "should set b to the new value") + assert(states.a == 1, "sub to a should run after b updates") + assert(states.b == 2, "sub to b should run after a updates") end) return {} diff --git a/pkgs/charm-sync/tests/flatten.spec.luau b/pkgs/charm-sync/tests/flatten.spec.luau index 727e035..1b1963f 100644 --- a/pkgs/charm-sync/tests/flatten.spec.luau +++ b/pkgs/charm-sync/tests/flatten.spec.luau @@ -17,12 +17,12 @@ test("should flatten atoms", function() } 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.a == input.a, "should flatten the first level") + assert(result["b/c"] == input.b.c, "should flatten the second level") + assert(result["d/e/f"] == input.d.e.f, "should flatten the third level") - assert(result.b == nil) - assert(result.d == nil) + assert(result.b == nil, "should remove the original nested tables") + assert(result.d == nil, "should remove the original nested tables") end) return {} diff --git a/pkgs/charm-sync/tests/patch.spec.luau b/pkgs/charm-sync/tests/patch.spec.luau index c5a3f8a..a38cc4a 100644 --- a/pkgs/charm-sync/tests/patch.spec.luau +++ b/pkgs/charm-sync/tests/patch.spec.luau @@ -17,9 +17,9 @@ test("returns a patch", function() local nextState = { foo = "bar", baz = "quux" } local patches = patch.diff(prevState, nextState) - assert(patches ~= nil) - assert(patches.baz == "quux") - assert(patches.foo == nil) + assert(patches ~= nil, "should return a patch") + assert(patches.baz == "quux", "should return the changed value") + assert(patches.foo == nil, "should omit unchanged values") end) test("applies a patch", function() @@ -27,10 +27,10 @@ test("applies a patch", function() 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") + assert(applied ~= nil, "should return a new state") + assert(applied.baz == "quux", "should apply the changed value") + assert(applied.foo == "bar", "should keep the unchanged value") + assert(applied.qaz == "qux", "should add the new value") end) test("allows nested patches", function() @@ -39,7 +39,7 @@ test("allows nested patches", function() local patches = patch.diff(prevState, nextState) local applied = patch.apply(prevState, patches) - assert(applied.foo.bar == "qux") + assert(applied.foo.bar == "qux", "should apply the nested patch") end) test("preserves array density when applying", function() @@ -47,13 +47,13 @@ test("preserves array density when applying", function() 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[1] == "a", "should keep a") + assert(applied[2] == "B", "should apply B") + assert(applied[3] == "c", "should keep c") + assert(applied[4] == "D", "should apply D") - assert(applied["2"] == nil) - assert(applied["4"] == nil) + assert(applied["2"] == nil, "should convert serialized arrays to use numbers") + assert(applied["4"] == nil, "should convert serialized arrays to use numbers") end) test("preserves array density after serdes", function() @@ -66,11 +66,11 @@ test("preserves array density after serdes", function() local applied = patch.apply(prevState, patches) for index in next, prevState do - assert(applied[index] == nextState[index]) + assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) end for index in next, applied do - assert(applied[index] == nextState[index]) + assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) end end) @@ -86,7 +86,7 @@ test("preserves large gaps between keys", function() 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]) + assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) end end) @@ -99,17 +99,17 @@ test("cannot preserve sparse arrays", function() -- 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) + assert(applied["2"] ~= nil, "will fail to convert string keys to numbers") + assert(applied[2] == nil, "will fail to convert string keys to numbers") end) test("optionally fixes sparse arrays", function() for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do - assert(type(index) == "string") + assert(type(index) == "string", `expected {type(index)} {index} to be string`) end for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do - assert(type(index) == "number") + assert(type(index) == "number", `expected {type(index)} {index} to be number`) end end) @@ -118,13 +118,13 @@ test("optionally validates state", function() patch.diff({}, { [0.1] = true }, true) end) - assert(not success) + assert(not success, "should not allow non-integer keys") success = pcall(function() patch.diff({}, { [0.1] = true }, false) end) - assert(success) + assert(success, "should skip validation if not requested") end) return {} diff --git a/pkgs/charm-sync/tests/server.spec.luau b/pkgs/charm-sync/tests/server.spec.luau index a6457c9..4a51fb0 100644 --- a/pkgs/charm-sync/tests/server.spec.luau +++ b/pkgs/charm-sync/tests/server.spec.luau @@ -25,9 +25,9 @@ test("sends state to new players", function() server:hydrate(target) - assert(player == target) - assert(payload.type == "init") - assert(payload.data.a == 1) + assert(player == target, "should send to the target player") + assert(payload.type == "init", "should be an init payload") + assert(payload.data.a == 1, "should send the initial state") disconnect() end) @@ -44,17 +44,17 @@ test("sends state patches", function() 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) + assert(player == target, "should send to the target player") + assert(payload.type == "patch", "should be a patch payload") + assert(payload.data.a.b == nil, "should not send unchanged values") + assert(payload.data.a.c == 5, "should send changed values") 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) + assert(payload.data.a.b == nil, "should not send unchanged values") + assert(patch.isNone(payload.data.a.c) == true, "should send None for removed values") + assert(payload.data.a.d == 1, "should send new values") disconnect() end) @@ -78,23 +78,23 @@ test("preserves history", function() 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) + assert(#payloads == 1, "should only have one payload") + assert(payloads[1].type == "patch", "should be a patch payload") + assert(payloads[1].data.a == 2, "should send the new value of a") + assert(payloads[1].data.b == 2, "should send the new value of b") 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) + assert(#payloads == 2, "should have two payloads") + assert(payloads[1].type == "patch", "1 should be a patch payload") + assert(payloads[1].data.a == 3, "1 should send the new value of a") + assert(payloads[1].data.b == nil, "1 should not send unchanged values") + assert(payloads[2].type == "patch", "2 should be a patch payload") + assert(payloads[2].data.a == 4, "2 should send the new value of a") + assert(payloads[2].data.b == 3, "2 should send the new value of b") disconnect() end) diff --git a/pkgs/react-charm/tests/useAtom.spec.luau b/pkgs/react-charm/tests/useAtom.spec.luau index 340177e..3db371e 100644 --- a/pkgs/react-charm/tests/useAtom.spec.luau +++ b/pkgs/react-charm/tests/useAtom.spec.luau @@ -15,9 +15,9 @@ test("tracks atom state", function() local hook = renderHook(function() return useAtom(state) end) - assert(hook.result == 0) + assert(hook.result == 0, "should return 0") state(1) - assert(hook.result == 1) + assert(hook.result == 1, "should return 1") end) test("accepts a selector", function() @@ -27,9 +27,9 @@ test("accepts a selector", function() return 2 * state() end) end) - assert(hook.result == 0) + assert(hook.result == 0, "should return 0") state(1) - assert(hook.result == 2) + assert(hook.result == 2, "should return 2") end) test("tracks dependencies", function() @@ -37,14 +37,14 @@ test("tracks dependencies", function() local hook = renderHook(function() return useAtom(state, { state }) end) - assert(hook.result == 0) + assert(hook.result == 0, "should return 0") state(1) - assert(hook.result == 1) + assert(hook.result == 1, "should return 1") state = atom(0) hook.rerender() - assert(hook.result == 0) + assert(hook.result == 0, "should return 0") state(1) - assert(hook.result == 1) + assert(hook.result == 1, "should return 1") end) return {} diff --git a/pkgs/vide-charm/tests/useAtom.spec.luau b/pkgs/vide-charm/tests/useAtom.spec.luau index 7231b85..84b202d 100644 --- a/pkgs/vide-charm/tests/useAtom.spec.luau +++ b/pkgs/vide-charm/tests/useAtom.spec.luau @@ -17,9 +17,9 @@ test("tracks atom state", function() local cleanup = root(function() source = useAtom(state) end) - assert(source() == 0) + assert(source() == 0, "should return 0") state(1) - assert(source() == 1) + assert(source() == 1, "should return 1") cleanup() end) @@ -31,9 +31,9 @@ test("accepts a selector", function() return 2 * state() end) end) - assert(source() == 0) + assert(source() == 0, "should return 0") state(1) - assert(source() == 2) + assert(source() == 2, "should return 2") cleanup() end) diff --git a/selene_defs.yml b/selene_defs.yml index 0b25bb6..89d1377 100644 --- a/selene_defs.yml +++ b/selene_defs.yml @@ -4,8 +4,3 @@ globals: require: args: - type: string - assert: - args: - - type: any - - type: any - required: false From ada1b80b87d2c631407ff63d931eb33a86742c6d Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 16:27:06 -0700 Subject: [PATCH 017/160] perf: optimize mapped, add benches --- benchmarks/bench.luau | 48 ++++++++ benchmarks/mapped.bench.luau | 99 ++++++++++++++++ benchmarks/mapped.luau | 80 +++++++++++++ .../patch.bench.luau | 0 .../propagate.bench.luau | 0 benchmarks/propagate.luau | 27 +++++ default.project.json | 4 +- pkgs/charm/benchmarks/mapped.bench.luau | 79 ------------- pkgs/charm/benchmarks/propagate.luau | 74 ------------ pkgs/charm/src/mapped.luau | 25 +++- pkgs/charm/tests/mapped.spec.luau | 107 ++++++++++++++++++ pkgs/charm/tests/spec.luau | 3 +- 12 files changed, 385 insertions(+), 161 deletions(-) create mode 100644 benchmarks/bench.luau create mode 100644 benchmarks/mapped.bench.luau create mode 100644 benchmarks/mapped.luau rename {pkgs/charm-sync/benchmarks => benchmarks}/patch.bench.luau (100%) rename {pkgs/charm/benchmarks => benchmarks}/propagate.bench.luau (100%) create mode 100644 benchmarks/propagate.luau delete mode 100644 pkgs/charm/benchmarks/mapped.bench.luau delete mode 100644 pkgs/charm/benchmarks/propagate.luau create mode 100644 pkgs/charm/tests/mapped.spec.luau diff --git a/benchmarks/bench.luau b/benchmarks/bench.luau new file mode 100644 index 0000000..8c14b30 --- /dev/null +++ b/benchmarks/bench.luau @@ -0,0 +1,48 @@ +local profiles: { [string]: number } = {} +local times: { [string]: number } = {} +local stack: { string } = {} + +local function run(name: string, test: () -> ()) + local start = os.clock() + local iter = 0 + + table.clear(profiles) + table.clear(times) + table.clear(stack) + + while os.clock() - start < 1 do + iter += 1 + test() + end + + local elapsed = os.clock() - start + local results = {} + + table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) + + for name, elapsed in times do + table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) + end + + print(table.concat(results, " | ")) +end + +local function profilebegin(name: string) + profiles[name] = os.clock() + table.insert(stack, name) +end + +local function profileend() + local name = table.remove(stack) + + if name then + times[name] = (times[name] or 0) + os.clock() - profiles[name] + profiles[name] = nil + end +end + +return { + run = run, + profilebegin = profilebegin, + profileend = profileend, +} diff --git a/benchmarks/mapped.bench.luau b/benchmarks/mapped.bench.luau new file mode 100644 index 0000000..89fdfad --- /dev/null +++ b/benchmarks/mapped.bench.luau @@ -0,0 +1,99 @@ +--!optimize 2 + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.Packages.Charm) + +local atom = Charm.atom +local computed = Charm.computed +local mapped = Charm.mapped +local pauseTracking = Charm.signals.pauseTracking +local resumeTracking = Charm.signals.resumeTracking + +type Entry = { number } + +local count = 512 +local initialState: { Entry } = table.create(count) + +for i = 1, count do + initialState[i] = table.create(32, i) +end + +local function mapper(value: Entry): Entry + return table.clone(value) +end + +local function simpleMapped(source: () -> { Entry }): () -> { Entry } + return computed(function() + local state = source() + local nextState = {} + + for index, value in state do + pauseTracking() + local success, valueOut = pcall(mapper, value) + resumeTracking() + + if success then + nextState[index] = valueOut + end + end + + return state + end) +end + +local function updatePercentage(percent: number): ({ Entry }) -> { Entry } + return function(state) + local nextState = table.clone(state) + for index, value in state do + if math.random() < percent then + nextState[index] = table.clone(value) + end + end + return nextState + end +end + +local function test( + percent: number, + profiler: any, + mapped: (src: () -> { Entry }, ...(Entry) -> Entry) -> () -> { Entry } +) + local src = atom(initialState) + local out = mapped(src, mapper) + + profiler.Begin("init") + out() + profiler.End() + + profiler.Begin("setup") + src(updatePercentage(percent)) + profiler.End() + + profiler.Begin("update") + out() + profiler.End() +end + +return { + Functions = { + ["mapped 10%"] = function(profiler) + test(0.1, profiler, mapped) + end, + ["mapped 50%"] = function(profiler) + test(0.5, profiler, mapped) + end, + ["mapped 100%"] = function(profiler) + test(1, profiler, mapped) + end, + ["control 10%"] = function(profiler) + test(0.1, profiler, simpleMapped) + end, + ["control 50%"] = function(profiler) + test(0.5, profiler, simpleMapped) + end, + ["control 100%"] = function(profiler) + test(1, profiler, simpleMapped) + end, + }, +} diff --git a/benchmarks/mapped.luau b/benchmarks/mapped.luau new file mode 100644 index 0000000..327197d --- /dev/null +++ b/benchmarks/mapped.luau @@ -0,0 +1,80 @@ +local Charm = require("@pkgs/charm/src/lib") +local bench = require("./bench") + +local atom = Charm.atom +local computed = Charm.computed +local mapped = Charm.mapped +local pauseTracking = Charm.signals.pauseTracking +local resumeTracking = Charm.signals.resumeTracking + +type Entry = { number } + +local count = 512 +local initialState: { Entry } = table.create(count) + +for i = 1, count do + initialState[i] = table.create(64, i) +end + +local function mapper(value: Entry, key: number): (Entry, number) + return table.clone(value), key + 1 +end + +local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry } + return computed(function() + local state = source() + local nextState = {} + + for index, value in state do + pauseTracking() + local success, valueOut, keyOut = pcall(mapper, value, index) + resumeTracking() + + if success then + nextState[keyOut] = valueOut + end + end + + return state + end) +end + +local function updatePercentage(percent: number): ({ Entry }) -> { Entry } + return function(state) + local nextState = table.clone(state) + for index, value in state do + if math.random() < percent then + nextState[index] = table.clone(value) + end + end + return nextState + end +end + +for _, percent in { 0.1, 0.5, 1 } do + bench.run(`mapped {percent}`, function() + local src = atom(initialState) + local out = mapped(src, mapper) + + bench.profilebegin("init") + out() + bench.profileend() + src(updatePercentage(percent)) + bench.profilebegin("update") + out() + bench.profileend() + end) + + bench.run(`control {percent}`, function() + local src = atom(initialState) + local out = simpleMapped(src, mapper) + + bench.profilebegin("init") + out() + bench.profileend() + src(updatePercentage(percent)) + bench.profilebegin("update") + out() + bench.profileend() + end) +end diff --git a/pkgs/charm-sync/benchmarks/patch.bench.luau b/benchmarks/patch.bench.luau similarity index 100% rename from pkgs/charm-sync/benchmarks/patch.bench.luau rename to benchmarks/patch.bench.luau diff --git a/pkgs/charm/benchmarks/propagate.bench.luau b/benchmarks/propagate.bench.luau similarity index 100% rename from pkgs/charm/benchmarks/propagate.bench.luau rename to benchmarks/propagate.bench.luau diff --git a/benchmarks/propagate.luau b/benchmarks/propagate.luau new file mode 100644 index 0000000..a08e8b1 --- /dev/null +++ b/benchmarks/propagate.luau @@ -0,0 +1,27 @@ +local Charm = require("@pkgs/charm/src/lib") +local bench = require("./bench") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect + +for _, w in { 1, 10, 100 } do + for _, h in { 1, 10, 100 } do + bench.run(`propagate {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) + end +end diff --git a/default.project.json b/default.project.json index 08fc194..904e9cd 100644 --- a/default.project.json +++ b/default.project.json @@ -22,9 +22,7 @@ "VideCharm": { "$path": "pkgs/vide-charm/tests" } }, "Benchmarks": { - "$className": "Folder", - "Charm": { "$path": "pkgs/charm/benchmarks" }, - "CharmSync": { "$path": "pkgs/charm-sync/benchmarks" } + "$path": "benchmarks" } } } diff --git a/pkgs/charm/benchmarks/mapped.bench.luau b/pkgs/charm/benchmarks/mapped.bench.luau deleted file mode 100644 index cf1a454..0000000 --- a/pkgs/charm/benchmarks/mapped.bench.luau +++ /dev/null @@ -1,79 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.Packages.Charm) - -local atom = Charm.atom -local computed = Charm.computed -local mapped = Charm.mapped - -type Person = { - name: string, - age: number, -} - -local personCount = 1000 -local initialState: { Person } = table.create(personCount) - -for i = 1, personCount do - initialState[i] = { - name = "Person " .. i, - age = math.random(1, 100), - } -end - -local function mapper(person: Person): Person - return { - name = person.name .. "!", - age = person.age + 1, - } -end - -local function simpleMapped(source: () -> { Person }): () -> { Person } - return computed(function() - local state = table.clone(source()) - for index, person in state do - state[index] = mapper(person) - end - return state - end) -end - -local function updatePercentageOfPeople(percent: number): ({ Person }) -> { Person } - return function(state) - local nextState = table.clone(state) - for index, person in state do - if math.random() < percent then - nextState[index] = table.clone(person) - end - end - return nextState - end -end - -return { - Functions = { - ["mapped 50%"] = function(profiler) - profiler.Begin("init") - local src = atom(initialState) - local out = mapped(src, mapper) - out() - profiler.End() - - profiler.Begin("update") - src(updatePercentageOfPeople(0.5)) - profiler.End() - end, - - ["simple 50%"] = function(profiler) - profiler.Begin("init") - local src = atom(initialState) - local out = simpleMapped(src) - out() - profiler.End() - - profiler.Begin("update") - src(updatePercentageOfPeople(0.5)) - profiler.End() - end, - }, -} diff --git a/pkgs/charm/benchmarks/propagate.luau b/pkgs/charm/benchmarks/propagate.luau deleted file mode 100644 index b6780f5..0000000 --- a/pkgs/charm/benchmarks/propagate.luau +++ /dev/null @@ -1,74 +0,0 @@ -local Charm = require("@pkgs/charm/src/lib") - -local atom = Charm.atom -local computed = Charm.computed -local effect = Charm.effect -local listen = Charm.listen - -local signal = Charm.signals.signal -local computedSignal = Charm.signals.computed -local effectSignal = Charm.signals.effect - -local w = 10 -local h = 10 - -local function bench(name: string, test: () -> ()) - local start = os.clock() - local iter = 0 - while os.clock() - start < 2 do - iter += 1 - test() - end - local endTime = os.clock() - local elapsed = endTime - start - print(`{name}: {elapsed / iter * 1000 * 1000} us/iter`) -end - -bench(`listen {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 - listen(last, function() end) - end - src(src() + 1) -end) - -bench(`effect {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(`signals {w}*{h}`, function() - local src, setSrc = 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() - end) - end - setSrc(src() + 1) -end) diff --git a/pkgs/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau index b871aa5..6869f5e 100644 --- a/pkgs/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -1,3 +1,5 @@ +--!optimize 2 + local signals = require("./signals") local computed = signals.computed @@ -8,12 +10,12 @@ type Mapped = & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) & ((selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) -local function ensureCanMutateOutput(output: { [KO]: VO }, previous: { [KO]: VO }?): { [KO]: VO } +local function ensureCanMutateOutput(output: { [KO]: VO }, previous: { [KO]: VO }): { [KO]: VO } return if output == previous then table.clone(output) else output end local function mapped(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } - local currentData: { [KI]: VI } = {} + local currentData: { [KI]: VI } local keyCache: { [KI]: KO } = {} local function map(value: VI, key: KI): (VO, KO) @@ -29,10 +31,26 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite end return computed(function(previousOutput) - local output: { [KO]: VO } = previousOutput or {} local previousData = currentData currentData = selector() + -- Only perform additions if this is the first run + if not previousOutput then + local output: { [KO]: VO } = {} + + for key, value in currentData do + local valueOut, keyOut = map(value, key) + if valueOut ~= nil then + output[keyOut] = valueOut + keyCache[key] = keyOut + end + end + + return output + end + + local output = previousOutput + -- Check equality since the selector might not be a computed value if currentData == previousData then return output @@ -42,7 +60,6 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite for key in previousData do if currentData[key] == nil then local keyOut = keyCache[key] - if keyOut then output = ensureCanMutateOutput(output, previousOutput) output[keyOut] = nil diff --git a/pkgs/charm/tests/mapped.spec.luau b/pkgs/charm/tests/mapped.spec.luau new file mode 100644 index 0000000..be2821c --- /dev/null +++ b/pkgs/charm/tests/mapped.spec.luau @@ -0,0 +1,107 @@ +local Charm = require("../src/lib") +local suite = require("@tests/suite") + +local atom = Charm.atom +local mapped = Charm.mapped +local test = suite.test + +test("should map atom", function() + local src = atom({ 1, 2, 3 }) + local map = mapped(src, function(value: number) + return value + 1 + end) + + assert(table.concat(map()) == "234", table.concat(map())) + src({ 4, 5, 6 }) + assert(table.concat(map()) == "567", table.concat(map())) +end) + +test("should not map unchanged values", function() + local calls = 0 + + local src = atom({ 1, 2, 3 }) + local map = mapped(src, function(value: number) + calls += 1 + return value + 1 + end) + + assert(table.concat(map()) == "234", table.concat(map())) + assert(calls == 3, "should map 3 values") + + src({ 1, 3, 3 }) + assert(table.concat(map()) == "244", table.concat(map())) + assert(calls == 4, "should map 1 change") + + src({}) + assert(#map() == 0, "should be empty") + assert(calls == 4, "should not map for removed values") +end) + +test("should not update if no values change", function() + local calls = 0 + + local src = atom({ 1, 2, 3 }) + local map = mapped(src, function(value: number) + calls += 1 + return value + 1 + end) + + local prev = map() + assert(calls == 3, "should map 3 values") + assert(table.concat(map()) == "234", table.concat(map())) + + src({ 4, 5, 6, 7 }) + src({ 1, 2, 3 }) + assert(calls == 3, "should not map unchanged values") + assert(table.concat(map()) == "234", table.concat(map())) + assert(prev == map(), "should not create new table") +end) + +test("should filter values", function() + local calls = 0 + + local src = atom({ a = 1, b = 2, c = 3 }) + local map = mapped(src, function(value: number) + calls += 1 + return if value < 0 then nil else value + end) + + local state = map() + assert(calls == 3, "should map 3 values") + assert(state.a and state.b and state.c, "should map all values") + + calls = 0 + src({ a = -1, b = 2, c = 3, d = 4 }) + state = map() + assert(calls == 2, "should map 2 values") + assert(state.a == nil, "should remove a") + assert(state.b and state.c, "should keep b and c") + assert(state.d == 4, "should add d") +end) + +test("should map keys", function() + local calls = 0 + + local src = atom({ a = 1, b = 2, c = 3 }) + local map = mapped(src, function(value: number, key: string) + calls += 1 + return if value < 0 then nil else value, string.upper(key) + end) + + local state = map() + assert(calls == 3, "should map 3 values") + assert(not (state.a and state.b and state.c), "should discard old keys") + assert(state.A == 1, "should map a") + assert(state.B == 2, "should map b") + assert(state.C == 3, "should map c") + + calls = 0 + src({ a = -1, b = 2, c = 3, d = 4 }) + state = map() + assert(calls == 2, "should map 2 values") + assert(state.A == nil and state.a == nil, "should remove a") + assert(state.B and state.C, "should keep b and c") + assert(state.D == 4, "should add d") +end) + +return {} diff --git a/pkgs/charm/tests/spec.luau b/pkgs/charm/tests/spec.luau index 7a60b4d..fc8f473 100644 --- a/pkgs/charm/tests/spec.luau +++ b/pkgs/charm/tests/spec.luau @@ -16,8 +16,9 @@ end collect("signals") spec("gc") -spec("subscribe") spec("listen") +spec("mapped") spec("observe") +spec("subscribe") return {} From f704acce71534901bf909f02172c5998cdf73ea1 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 16:27:50 -0700 Subject: [PATCH 018/160] chore: delete redundant benchmarks --- benchmarks/mapped.bench.luau | 99 --------------------------------- benchmarks/propagate.bench.luau | 63 --------------------- 2 files changed, 162 deletions(-) delete mode 100644 benchmarks/mapped.bench.luau delete mode 100644 benchmarks/propagate.bench.luau diff --git a/benchmarks/mapped.bench.luau b/benchmarks/mapped.bench.luau deleted file mode 100644 index 89fdfad..0000000 --- a/benchmarks/mapped.bench.luau +++ /dev/null @@ -1,99 +0,0 @@ ---!optimize 2 - -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.Packages.Charm) - -local atom = Charm.atom -local computed = Charm.computed -local mapped = Charm.mapped -local pauseTracking = Charm.signals.pauseTracking -local resumeTracking = Charm.signals.resumeTracking - -type Entry = { number } - -local count = 512 -local initialState: { Entry } = table.create(count) - -for i = 1, count do - initialState[i] = table.create(32, i) -end - -local function mapper(value: Entry): Entry - return table.clone(value) -end - -local function simpleMapped(source: () -> { Entry }): () -> { Entry } - return computed(function() - local state = source() - local nextState = {} - - for index, value in state do - pauseTracking() - local success, valueOut = pcall(mapper, value) - resumeTracking() - - if success then - nextState[index] = valueOut - end - end - - return state - end) -end - -local function updatePercentage(percent: number): ({ Entry }) -> { Entry } - return function(state) - local nextState = table.clone(state) - for index, value in state do - if math.random() < percent then - nextState[index] = table.clone(value) - end - end - return nextState - end -end - -local function test( - percent: number, - profiler: any, - mapped: (src: () -> { Entry }, ...(Entry) -> Entry) -> () -> { Entry } -) - local src = atom(initialState) - local out = mapped(src, mapper) - - profiler.Begin("init") - out() - profiler.End() - - profiler.Begin("setup") - src(updatePercentage(percent)) - profiler.End() - - profiler.Begin("update") - out() - profiler.End() -end - -return { - Functions = { - ["mapped 10%"] = function(profiler) - test(0.1, profiler, mapped) - end, - ["mapped 50%"] = function(profiler) - test(0.5, profiler, mapped) - end, - ["mapped 100%"] = function(profiler) - test(1, profiler, mapped) - end, - ["control 10%"] = function(profiler) - test(0.1, profiler, simpleMapped) - end, - ["control 50%"] = function(profiler) - test(0.5, profiler, simpleMapped) - end, - ["control 100%"] = function(profiler) - test(1, profiler, simpleMapped) - end, - }, -} diff --git a/benchmarks/propagate.bench.luau b/benchmarks/propagate.bench.luau deleted file mode 100644 index e2b0260..0000000 --- a/benchmarks/propagate.bench.luau +++ /dev/null @@ -1,63 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.Packages.Charm) - -local atom = Charm.atom -local computed = Charm.computed -local effect = Charm.effect -local listen = Charm.listen - -local function propagate(w: number, h: number) - 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 - -local function propagateListen(w: number, h: number) - 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 - listen(last, function() end) - end - src(src() + 1) -end - -return { - Functions = { - ["effect 1*100"] = function() - propagate(1, 100) - end, - ["effect 100*1"] = function() - propagate(100, 1) - end, - ["effect 10*10"] = function() - propagate(10, 10) - end, - ["listen 1*100"] = function() - propagateListen(1, 100) - end, - ["listen 100*1"] = function() - propagateListen(100, 1) - end, - ["listen 10*10"] = function() - propagateListen(10, 10) - end, - }, -} From b61d0b3b86ac71466ce04e4043c4ac6c54b515db Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 17:07:30 -0700 Subject: [PATCH 019/160] perf: simplify mapped logic --- benchmarks/bench.luau | 2 +- pkgs/charm/src/mapped.luau | 68 ++++++++------------------------------ 2 files changed, 14 insertions(+), 56 deletions(-) diff --git a/benchmarks/bench.luau b/benchmarks/bench.luau index 8c14b30..9c24c6e 100644 --- a/benchmarks/bench.luau +++ b/benchmarks/bench.luau @@ -10,7 +10,7 @@ local function run(name: string, test: () -> ()) table.clear(times) table.clear(stack) - while os.clock() - start < 1 do + while os.clock() - start < 2 do iter += 1 test() end diff --git a/pkgs/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau index 6869f5e..2c20797 100644 --- a/pkgs/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -10,12 +10,12 @@ type Mapped = & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) & ((selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) -local function ensureCanMutateOutput(output: { [KO]: VO }, previous: { [KO]: VO }): { [KO]: VO } +local function ensureCanMutateOutput(output: { [KO]: VO }, previous: { [KO]: VO }?): { [KO]: VO } return if output == previous then table.clone(output) else output end local function mapped(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } - local currentData: { [KI]: VI } + local currentData: { [KI]: VI } = {} local keyCache: { [KI]: KO } = {} local function map(value: VI, key: KI): (VO, KO) @@ -31,75 +31,33 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite end return computed(function(previousOutput) + local currentOutput: { [KO]: VO } = previousOutput or {} local previousData = currentData currentData = selector() - -- Only perform additions if this is the first run - if not previousOutput then - local output: { [KO]: VO } = {} - - for key, value in currentData do - local valueOut, keyOut = map(value, key) - if valueOut ~= nil then - output[keyOut] = valueOut - keyCache[key] = keyOut - end + for key, cachedKey in keyCache do + if currentData[key] ~= nil then + continue end - return output + currentOutput = ensureCanMutateOutput(currentOutput, previousOutput) + currentOutput[cachedKey] = nil + keyCache[key] = nil end - local output = previousOutput - - -- Check equality since the selector might not be a computed value - if currentData == previousData then - return output - end - - -- Clear removed values from the output and key cache - for key in previousData do - if currentData[key] == nil then - local keyOut = keyCache[key] - if keyOut then - output = ensureCanMutateOutput(output, previousOutput) - output[keyOut] = nil - keyCache[key] = nil - end - end - end - - -- Process new or changed values for key, value in currentData do if value == previousData[key] then continue end local valueOut, keyOut = map(value, key) - local cachedKeyOut = keyCache[key] - if valueOut ~= nil then - -- Skip update if the key and value are the same - if keyOut == cachedKeyOut and valueOut == output[cachedKeyOut] then - continue - end - - output = ensureCanMutateOutput(output, previousOutput) - output[keyOut] = valueOut - keyCache[key] = keyOut - - -- Remove the old key if a new key is assigned - if cachedKeyOut ~= nil and keyOut ~= cachedKeyOut then - output[cachedKeyOut] = nil - end - elseif cachedKeyOut ~= nil then - -- Remove the cached key if the new value is nil - output = ensureCanMutateOutput(output, previousOutput) - output[cachedKeyOut] = nil - keyCache[key] = nil - end + currentOutput = ensureCanMutateOutput(currentOutput, previousOutput) + currentOutput[keyOut] = valueOut + keyCache[key] = keyOut end - return output + return currentOutput end) end From 554010f1bdf3c59014384b66f0a04e9c6f028323 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 19:32:19 -0700 Subject: [PATCH 020/160] test: prettify test library --- pkgs/charm-sync/wally.toml | 2 +- pkgs/charm/wally.toml | 1 + pkgs/react-charm/wally.toml | 2 +- pkgs/vide-charm/wally.toml | 2 +- tests/spec.server.luau | 4 + tests/suite.luau | 145 ++++++++++++++++++++++++++++++------ 6 files changed, 129 insertions(+), 27 deletions(-) diff --git a/pkgs/charm-sync/wally.toml b/pkgs/charm-sync/wally.toml index 5832ab5..41d31ee 100644 --- a/pkgs/charm-sync/wally.toml +++ b/pkgs/charm-sync/wally.toml @@ -3,7 +3,7 @@ name = "littensy/charm-sync" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["Charm.luau"] +exclude = ["tests"] [dependencies] Charm = "littensy/charm@^0.10" diff --git a/pkgs/charm/wally.toml b/pkgs/charm/wally.toml index 4497d3a..1e798ac 100644 --- a/pkgs/charm/wally.toml +++ b/pkgs/charm/wally.toml @@ -3,5 +3,6 @@ name = "littensy/charm" version = "0.10.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["tests"] [dependencies] diff --git a/pkgs/react-charm/wally.toml b/pkgs/react-charm/wally.toml index 513bbd2..d5dd56e 100644 --- a/pkgs/react-charm/wally.toml +++ b/pkgs/react-charm/wally.toml @@ -3,7 +3,7 @@ name = "littensy/react-charm" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["Charm.luau", "React.luau"] +exclude = ["tests"] [dependencies] Charm = "littensy/charm@^0.10" diff --git a/pkgs/vide-charm/wally.toml b/pkgs/vide-charm/wally.toml index d344bdc..dc9d909 100644 --- a/pkgs/vide-charm/wally.toml +++ b/pkgs/vide-charm/wally.toml @@ -3,7 +3,7 @@ name = "littensy/vide-charm" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["Charm.luau", "Vide.luau"] +exclude = ["tests"] [dependencies] Charm = "littensy/charm@^0.10" diff --git a/tests/spec.server.luau b/tests/spec.server.luau index 61b038c..da42dc8 100644 --- a/tests/spec.server.luau +++ b/tests/spec.server.luau @@ -3,6 +3,10 @@ local suite = require("./suite") local describe = suite.describe local finish = suite.finish +if not script then + return +end + _G.__DEV__ = true describe("charm-sync", function() diff --git a/tests/suite.luau b/tests/suite.luau index 9ac76f3..4addce0 100644 --- a/tests/suite.luau +++ b/tests/suite.luau @@ -1,64 +1,161 @@ +local color = { + white = function(text: string | number) + return `\27[37;1m{text}\27[0m` + end, + gray = function(text: string | number) + return `\27[30;1m{text}\27[0m` + end, + green = function(text: string | number) + return `\27[32;1m{text}\27[0m` + end, + red = function(text: string | number) + return `\27[31;1m{text}\27[0m` + end, + blue = function(text: string | number) + return `\27[34;1m{text}\27[0m` + end, +} + +if script then + local function prepend(prefix: string?): (string | number) -> string + return function(text: string | number): string + return if prefix then `{prefix} {text}` else `{text}` + end + end + + color.white = prepend() + color.gray = prepend() + color.green = prepend(utf8.char(0x1F7E2)) -- green circle + color.red = prepend(utf8.char(0x1F534)) -- red circle + color.blue = prepend(utf8.char(0x1F535)) -- blue circle +end + +local WALL = color.gray("│") +local PASS = color.green("PASS") .. WALL +local FAIL = color.red("FAIL") .. WALL +local SKIP = color.blue("SKIP") .. WALL +local ERROR = color.red("ERROR") .. WALL + local results: { string } = {} local stack: { string } = {} local errors: { string } = {} -local tests = 0 + +local skipDepth = 0 +local skipCount = 0 + +local testsOk = 0 +local testsFailed = 0 +local testsSkipped = 0 +local filesOk = 0 +local filesFailed = 0 + +local start = os.clock() local function log(result: string, index: number?) - local index = index or #results + 1 - local indent = string.rep(" ", #stack - 1) + if index then + table.insert(results, index, result) + else + table.insert(results, result) + end +end - results[index] = indent .. result +local function skip() + skipDepth += 1 end local function test(name: string, callback: () -> ()) - tests += 1 + local path = color.gray(table.concat(stack, " > ")) + local line = color.red("-1:") + + if skipDepth > 0 then + testsSkipped += 1 + skipCount += 1 + return + end + table.insert(stack, name) local success = xpcall(callback, function(err) - local path = table.concat(stack, " > ") - table.insert(errors, `\n🔴 {path}:\n{err}`) + line = color.red(`{debug.info(3, "l")}:`) + table.insert(errors, `\n{ERROR} {path} > {line}{color.white(name)}\n{err}`) end) - if not success then - log(`🔴 {name}`) + if success then + testsOk += 1 + else + testsFailed += 1 + log(`{FAIL} {path} {color.gray(">")} {line}{color.white(name)}`) end table.remove(stack) end local function describe(name: string, callback: () -> ()) + local path = if #stack > 0 then color.gray(table.concat(stack, " > ")) .. color.gray(" > ") else "" + local index = #results + 1 + local start = os.clock() + + local initialTestsOk = testsOk + local initialTestsFailed = testsFailed + local initialSkipDepth = skipDepth + + skipCount = 0 + 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) + + skipDepth = initialSkipDepth + + local skippedEveryTest = initialTestsOk == testsOk and initialTestsFailed == testsFailed and skipCount > 0 + local passedEveryTest = testsFailed == initialTestsFailed + + local millis = string.format("%.2fms", (os.clock() - start) * 1000) + local skips = if skipCount > 0 then color.blue(` ({skipCount} skipped) `) else " " + + if skippedEveryTest then + filesOk += 1 + log(`{SKIP} {path .. color.white(name)}{skips}`, index) + elseif passedEveryTest then + filesOk += 1 + log(`{PASS} {path .. color.white(name)}{skips}{color.gray(millis)}`, index) + else + filesFailed += 1 + log(`{FAIL} {path .. color.white(name)}{skips}{color.gray(millis)}`, index) + end end local function finish() - local output = { `\n🔵 {tests - #errors} / {tests} tests passed\n` } + local tests = testsOk + testsFailed + testsSkipped + local files = filesOk + filesFailed + local failed = testsFailed + filesFailed > 0 + local millis = math.round((os.clock() - start) * 1000) + local output = {} + + table.insert(output, color.blue("Test Results\n")) for _, result in results do table.insert(output, result) end - print(table.concat(output, "\n") .. "\n") + local redOrGreen: (string | number) -> string = if failed then color.red else color.green - if #errors > 0 then - error(`{#errors} test(s) failed:\n{table.concat(errors, "\n")}\n`) + table.insert(output, "") + table.insert(output, `Test Files {redOrGreen(`{filesOk} passed`)} ({color.gray(`{files}`)})`) + table.insert(output, ` Tests {redOrGreen(`{testsOk} passed`)} ({color.gray(`{tests}`)})`) + table.insert(output, ` Duration {color.blue(`{millis}ms`)}`) + + print("\n" .. table.concat(output, "\n") .. "\n") + + if failed then + print(`{color.red(`{testsFailed} errors:`)}\n{table.concat(errors, "\n")}\n`) + error("Tests failed") end end return { test = test, describe = describe, + skip = skip, finish = finish, } From 3f1556a6e28adc58338726d35ca099e8ea7ae865 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 19:37:54 -0700 Subject: [PATCH 021/160] test: try different gray --- tests/suite.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/suite.luau b/tests/suite.luau index 4addce0..e677102 100644 --- a/tests/suite.luau +++ b/tests/suite.luau @@ -3,7 +3,7 @@ local color = { return `\27[37;1m{text}\27[0m` end, gray = function(text: string | number) - return `\27[30;1m{text}\27[0m` + return `\27[90;1m{text}\27[0m` end, green = function(text: string | number) return `\27[32;1m{text}\27[0m` From dcb1de2844b48a002fb0a9a640e796337b12da9b Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 19:40:24 -0700 Subject: [PATCH 022/160] test: color brackets --- tests/suite.luau | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/suite.luau b/tests/suite.luau index e677102..78c26dc 100644 --- a/tests/suite.luau +++ b/tests/suite.luau @@ -141,8 +141,8 @@ local function finish() local redOrGreen: (string | number) -> string = if failed then color.red else color.green table.insert(output, "") - table.insert(output, `Test Files {redOrGreen(`{filesOk} passed`)} ({color.gray(`{files}`)})`) - table.insert(output, ` Tests {redOrGreen(`{testsOk} passed`)} ({color.gray(`{tests}`)})`) + table.insert(output, `Test Files {redOrGreen(`{filesOk} passed`)} {color.gray(`({files})`)}`) + table.insert(output, ` Tests {redOrGreen(`{testsOk} passed`)} {color.gray(`({tests})`)}`) table.insert(output, ` Duration {color.blue(`{millis}ms`)}`) print("\n" .. table.concat(output, "\n") .. "\n") From 571d2c0d916da43bf507a9a0bc336021bd63df44 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 8 May 2025 00:04:00 -0700 Subject: [PATCH 023/160] refactor: sync 2.0.4 --- benchmarks/mapped.luau | 7 +-- pkgs/charm-sync/tests/spec.luau | 12 ++-- pkgs/charm/src/listen.luau | 7 +-- pkgs/charm/src/mapped.luau | 9 +-- pkgs/charm/src/observe.luau | 7 +-- pkgs/charm/src/signals.luau | 43 +++++++------- pkgs/charm/src/subscribe.luau | 7 +-- pkgs/charm/src/untracked.luau | 7 +-- pkgs/charm/tests/signals/effect.spec.luau | 19 +++--- pkgs/charm/tests/signals/gc.spec.luau | 72 +++++++++++++++++++++++ pkgs/charm/tests/signals/spec.luau | 15 +++-- pkgs/charm/tests/spec.luau | 9 +-- pkgs/react-charm/tests/spec.luau | 4 +- pkgs/vide-charm/tests/spec.luau | 4 +- tests/spec.server.luau | 15 +---- 15 files changed, 152 insertions(+), 85 deletions(-) create mode 100644 pkgs/charm/tests/signals/gc.spec.luau diff --git a/benchmarks/mapped.luau b/benchmarks/mapped.luau index 327197d..05358c1 100644 --- a/benchmarks/mapped.luau +++ b/benchmarks/mapped.luau @@ -4,8 +4,7 @@ local bench = require("./bench") local atom = Charm.atom local computed = Charm.computed local mapped = Charm.mapped -local pauseTracking = Charm.signals.pauseTracking -local resumeTracking = Charm.signals.resumeTracking +local setCurrentSub = Charm.signals.setCurrentSub type Entry = { number } @@ -26,9 +25,9 @@ local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> local nextState = {} for index, value in state do - pauseTracking() + local prevSub = setCurrentSub(nil) local success, valueOut, keyOut = pcall(mapper, value, index) - resumeTracking() + setCurrentSub(prevSub) if success then nextState[keyOut] = valueOut diff --git a/pkgs/charm-sync/tests/spec.luau b/pkgs/charm-sync/tests/spec.luau index b386f15..39ee48c 100644 --- a/pkgs/charm-sync/tests/spec.luau +++ b/pkgs/charm-sync/tests/spec.luau @@ -10,10 +10,12 @@ local function spec(name: string) end) end -spec("client") -spec("server") -spec("flatten") -spec("patch") -spec("validate") +describe("charm-sync", function() + spec("client") + spec("server") + spec("flatten") + spec("patch") + spec("validate") +end) return {} diff --git a/pkgs/charm/src/listen.luau b/pkgs/charm/src/listen.luau index d6c9a31..00c2141 100644 --- a/pkgs/charm/src/listen.luau +++ b/pkgs/charm/src/listen.luau @@ -1,8 +1,7 @@ local signals = require("./signals") local effect = signals.effect -local pauseTracking = signals.pauseTracking -local resumeTracking = signals.resumeTracking +local setCurrentSub = signals.setCurrentSub local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () local disposeImmediately = false @@ -17,9 +16,9 @@ local function listen(selector: () -> T, listener: (state: T, previous: T?, d current = selector() if current ~= previous then - pauseTracking() + local prevSub = setCurrentSub(nil) local success, result: unknown = pcall(listener, current, previous, dispose) - resumeTracking() + setCurrentSub(prevSub) if not success then error(`listen() encountered an error: {result}`) diff --git a/pkgs/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau index 2c20797..ae4dcff 100644 --- a/pkgs/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -1,10 +1,7 @@ ---!optimize 2 - local signals = require("./signals") local computed = signals.computed -local pauseTracking = signals.pauseTracking -local resumeTracking = signals.resumeTracking +local setCurrentSub = signals.setCurrentSub type Mapped = & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) @@ -19,9 +16,9 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite local keyCache: { [KI]: KO } = {} local function map(value: VI, key: KI): (VO, KO) - pauseTracking() + local prevSub = setCurrentSub(nil) local success, valueOut, keyOut = pcall(mapper, value, key) - resumeTracking() + setCurrentSub(prevSub) if not success then error(`mapped() transform function failed: {valueOut}`) diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau index 8cce9cb..a64c9b3 100644 --- a/pkgs/charm/src/observe.luau +++ b/pkgs/charm/src/observe.luau @@ -3,10 +3,9 @@ local signals = require("./signals") local effect = signals.effect local effectScope = signals.effectScope local getCurrentSub = signals.getCurrentSub +local setCurrentSub = signals.setCurrentSub local onEffectCleanup = signals.onEffectCleanup local onScopeDispose = signals.onScopeDispose -local pauseTracking = signals.pauseTracking -local resumeTracking = signals.resumeTracking type Cleanup = () -> () @@ -47,9 +46,9 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key currentItems = selector() if currentItems ~= previousItems then - pauseTracking() + local prevSub = setCurrentSub(nil) local success, result: unknown = pcall(updateScopes, currentItems, previousItems) - resumeTracking() + setCurrentSub(prevSub) if not success then error(`observe() encountered an error: {result}`, 2) diff --git a/pkgs/charm/src/signals.luau b/pkgs/charm/src/signals.luau index 2cb796b..bb27e45 100644 --- a/pkgs/charm/src/signals.luau +++ b/pkgs/charm/src/signals.luau @@ -62,8 +62,7 @@ type Signal = ReactiveNode & { } local pauseStack: { ReactiveNode? } = {} -local pauseStackCount = 0 -local queuedEffects: { Effect | EffectScope } = {} +local queuedEffects: { Effect | EffectScope? } = {} local link: (dep: ReactiveNode, sub: ReactiveNode) -> () local unlink: (link: Link, sub: ReactiveNode?) -> Link? @@ -193,16 +192,16 @@ local function endBatch() end end +--- @deprecated Will be removed in the next major version. Use +--- `const pausedSub = setCurrentSub(undefined)` instead for better performance. local function pauseTracking() - pauseStackCount += 1 - pauseStack[pauseStackCount] = activeSub - activeSub = nil + table.insert(pauseStack, setCurrentSub(nil)) end +--- @deprecated Will be removed in the next major version. Use +--- `setCurrentSub(pausedSub)` instead for better performance. local function resumeTracking() - activeSub = pauseStack[pauseStackCount] - pauseStack[pauseStackCount] = nil - pauseStackCount -= 1 + setCurrentSub(table.remove(pauseStack)) end local function updateComputed(c: Computed): boolean @@ -311,9 +310,8 @@ local function effectStop(this: Effect | EffectScope): () -> () dep = unlink(dep, this) end local sub = this.subs - while sub do + if sub then unlink(sub) - sub = this.subs end (this :: Effect).flags = NONE cleanupEffect(this) @@ -361,9 +359,11 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> () if activeScope and not detached then link(e, activeScope) end - local prev = setCurrentScope(e) + local prevSub = setCurrentSub(nil) + local prevScope = setCurrentScope(e) local success, result: unknown = pcall(fn) - setCurrentScope(prev) + setCurrentScope(prevScope) + setCurrentSub(prevSub) if not success then error(result) end @@ -392,15 +392,18 @@ local function notify(e: Effect | EffectScope) end end -local function unwatched(signal: Signal | Effect | Computed) - local toRemove = signal.deps - if toRemove then - repeat - toRemove = unlink(toRemove, signal) - until toRemove == nil - (signal :: Signal).flags = bit32.bor(signal.flags, DIRTY) +local function unwatched(node: Signal | Computed | Effect | EffectScope) + if (node :: Computed).getter then + local toRemove = node.deps + if toRemove then + (node :: Computed).flags = bit32.bor(MUTABLE, DIRTY) + repeat + toRemove = unlink(toRemove, node) + until not toRemove + end + elseif (node :: Signal).previousValue == nil then + effectStop(node :: Effect)() end - cleanupEffect(signal :: Effect) end local reactiveSystem = createReactiveSystem({ diff --git a/pkgs/charm/src/subscribe.luau b/pkgs/charm/src/subscribe.luau index b038a4a..48a2300 100644 --- a/pkgs/charm/src/subscribe.luau +++ b/pkgs/charm/src/subscribe.luau @@ -1,8 +1,7 @@ local signals = require("./signals") local effect = signals.effect -local pauseTracking = signals.pauseTracking -local resumeTracking = signals.resumeTracking +local setCurrentSub = signals.setCurrentSub local function subscribe(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () local current: T @@ -15,9 +14,9 @@ local function subscribe(selector: () -> T, listener: (state: T, lastState: T if mounted then mounted = false elseif current ~= previous then - pauseTracking() + local prevSub = setCurrentSub(nil) local success, result: unknown = pcall(listener, current, previous) - resumeTracking() + setCurrentSub(prevSub) if not success then error(`subscribe() encountered an error: {result}`) diff --git a/pkgs/charm/src/untracked.luau b/pkgs/charm/src/untracked.luau index d494345..d897e6b 100644 --- a/pkgs/charm/src/untracked.luau +++ b/pkgs/charm/src/untracked.luau @@ -1,12 +1,11 @@ local signals = require("./signals") -local pauseTracking = signals.pauseTracking -local resumeTracking = signals.resumeTracking +local setCurrentSub = signals.setCurrentSub local function untracked(callback: ((Args...) -> T) | (Args...) -> (), ...: Args...): T - pauseTracking() + local prevSub = setCurrentSub(nil) local success, result = pcall(callback, ...) - resumeTracking() + setCurrentSub(prevSub) if not success then error(result) diff --git a/pkgs/charm/tests/signals/effect.spec.luau b/pkgs/charm/tests/signals/effect.spec.luau index 7fb2546..76e2fda 100644 --- a/pkgs/charm/tests/signals/effect.spec.luau +++ b/pkgs/charm/tests/signals/effect.spec.luau @@ -303,9 +303,14 @@ end) test("should run inner effect cleanup", function() local cleanups = 0 - local a, setA = signal(false) + local src, setSrc = signal(false) local stopEffect = effect(function() - a() + src() + effect(function() + onEffectCleanup(function() + cleanups += 1 + end) + end) effect(function() onEffectCleanup(function() cleanups += 1 @@ -314,12 +319,12 @@ test("should run inner effect cleanup", function() end) assert(cleanups == 0, "should not cleanup immediately") - setA(true) - assert(cleanups == 1, "should cleanup after outer update") + setSrc(true) + assert(cleanups == 2, "should cleanup after outer update") stopEffect() - assert(cleanups == 2, "should cleanup after outer dispose") - setA(false) - assert(cleanups == 2, "should not cleanup after update") + assert(cleanups == 4, "should cleanup after outer dispose") + setSrc(false) + assert(cleanups == 4, "should not cleanup after update") end) return {} diff --git a/pkgs/charm/tests/signals/gc.spec.luau b/pkgs/charm/tests/signals/gc.spec.luau new file mode 100644 index 0000000..e08d265 --- /dev/null +++ b/pkgs/charm/tests/signals/gc.spec.luau @@ -0,0 +1,72 @@ +local Charm = require("@pkgs/charm/src/lib") +local suite = require("@tests/suite") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local test = suite.test + +--- Creates a weak reference to a value. +--- @return Function to dereference the value. +local function weakRef(value: T): () -> T? + local ref = setmetatable({ value = value }, { __mode = "v" }) + return function() + return ref.value + end +end + +--- Triggers garbage collection by allocating excessive memory. +local function gc() + for _ = 1, 1000 do + local _ = table.create(100, 0) + end +end + +--- In alien-signals, a computed value unlinks from dependencies when its last +--- parent scope is disposed. Calling a computed value outside of a scope links +--- it to dependencies, but the dependencies hold strong references to the +--- computed value, so it won't GC until called in a scope and disposed. +--- +--- This function releases a computed value by linking it to a scope and then +--- immediately closing it, causing alien-signals to unlink the computed value +--- and allow it to be garbage collected. +local function release(getter: () -> ()) + effectScope(function() + getter() + end)() +end + +test("should release computed cache", function() + local srcRef = weakRef({}) + local src = atom(srcRef()) + + local c = computed(function() + return src() + end) + + c() -- cache src value + src(nil) -- release value + c = release(c) -- release computed + + gc() + assert(srcRef() == nil, "should release computed cache") +end) + +test("should not release effect for atom", function() + local effectCalls = 0 + local src = atom(0) + + effect(function() + effectCalls += 1 + src() + end) + + assert(effectCalls == 1, "should call effect once") + + gc() + src(src() + 1) + assert(effectCalls == 2, "should call effect again") +end) + +return {} diff --git a/pkgs/charm/tests/signals/spec.luau b/pkgs/charm/tests/signals/spec.luau index fcc4a57..15c69f5 100644 --- a/pkgs/charm/tests/signals/spec.luau +++ b/pkgs/charm/tests/signals/spec.luau @@ -8,11 +8,14 @@ local function spec(name: string) end) end -spec("computed") -spec("effect") -spec("effectScope") -spec("issue_48") -spec("topology") -spec("untrack") +describe("signals", function() + spec("computed") + spec("effect") + spec("effectScope") + spec("issue_48") + spec("gc") + spec("topology") + spec("untrack") +end) return {} diff --git a/pkgs/charm/tests/spec.luau b/pkgs/charm/tests/spec.luau index fc8f473..f86a562 100644 --- a/pkgs/charm/tests/spec.luau +++ b/pkgs/charm/tests/spec.luau @@ -2,19 +2,14 @@ local suite = require("@tests/suite") local describe = suite.describe -local function collect(name: string) - describe(name, function() - return require(`./{name}/spec`) :: any - end) -end - local function spec(name: string) describe(name, function() return require(`./{name}.spec`) :: any end) end -collect("signals") +require("./signals/spec") + spec("gc") spec("listen") spec("mapped") diff --git a/pkgs/react-charm/tests/spec.luau b/pkgs/react-charm/tests/spec.luau index 6cd8895..2ce9682 100644 --- a/pkgs/react-charm/tests/spec.luau +++ b/pkgs/react-charm/tests/spec.luau @@ -10,6 +10,8 @@ local function spec(name: string) end) end -spec("useAtom") +describe("react-charm", function() + spec("useAtom") +end) return {} diff --git a/pkgs/vide-charm/tests/spec.luau b/pkgs/vide-charm/tests/spec.luau index 6cd8895..2bde270 100644 --- a/pkgs/vide-charm/tests/spec.luau +++ b/pkgs/vide-charm/tests/spec.luau @@ -10,6 +10,8 @@ local function spec(name: string) end) end -spec("useAtom") +describe("vide-charm", function() + spec("useAtom") +end) return {} diff --git a/tests/spec.server.luau b/tests/spec.server.luau index da42dc8..a3d8b9b 100644 --- a/tests/spec.server.luau +++ b/tests/spec.server.luau @@ -1,6 +1,5 @@ local suite = require("./suite") -local describe = suite.describe local finish = suite.finish if not script then @@ -9,16 +8,8 @@ end _G.__DEV__ = true -describe("charm-sync", function() - require(script.Parent.CharmSync.spec) -end) - -describe("react-charm", function() - require(script.Parent.ReactCharm.spec) -end) - -describe("vide-charm", function() - require(script.Parent.VideCharm.spec) -end) +require(script.Parent.CharmSync.spec) +require(script.Parent.ReactCharm.spec) +require(script.Parent.VideCharm.spec) finish() From a53683d59e29eea1acf5dad71c61869bfe10c15a Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 8 May 2025 00:12:51 -0700 Subject: [PATCH 024/160] refactor: use setCurrentSub instead of pauseTracking --- benchmarks/mapped.luau | 4 ++-- pkgs/charm/src/listen.luau | 4 ++-- pkgs/charm/src/mapped.luau | 4 ++-- pkgs/charm/src/observe.luau | 4 ++-- pkgs/charm/src/subscribe.luau | 4 ++-- pkgs/charm/src/untracked.luau | 4 ++-- pkgs/charm/tests/signals/effect.spec.luau | 7 +++---- pkgs/charm/tests/signals/issue_48.spec.luau | 7 +++---- pkgs/charm/tests/signals/untrack.spec.luau | 7 +++---- 9 files changed, 21 insertions(+), 24 deletions(-) diff --git a/benchmarks/mapped.luau b/benchmarks/mapped.luau index 05358c1..8e72c8f 100644 --- a/benchmarks/mapped.luau +++ b/benchmarks/mapped.luau @@ -25,9 +25,9 @@ local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> local nextState = {} for index, value in state do - local prevSub = setCurrentSub(nil) + local currentSub = setCurrentSub(nil) local success, valueOut, keyOut = pcall(mapper, value, index) - setCurrentSub(prevSub) + setCurrentSub(currentSub) if success then nextState[keyOut] = valueOut diff --git a/pkgs/charm/src/listen.luau b/pkgs/charm/src/listen.luau index 00c2141..c72b061 100644 --- a/pkgs/charm/src/listen.luau +++ b/pkgs/charm/src/listen.luau @@ -16,9 +16,9 @@ local function listen(selector: () -> T, listener: (state: T, previous: T?, d current = selector() if current ~= previous then - local prevSub = setCurrentSub(nil) + local currentSub = setCurrentSub(nil) local success, result: unknown = pcall(listener, current, previous, dispose) - setCurrentSub(prevSub) + setCurrentSub(currentSub) if not success then error(`listen() encountered an error: {result}`) diff --git a/pkgs/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau index ae4dcff..247ae31 100644 --- a/pkgs/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -16,9 +16,9 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite local keyCache: { [KI]: KO } = {} local function map(value: VI, key: KI): (VO, KO) - local prevSub = setCurrentSub(nil) + local currentSub = setCurrentSub(nil) local success, valueOut, keyOut = pcall(mapper, value, key) - setCurrentSub(prevSub) + setCurrentSub(currentSub) if not success then error(`mapped() transform function failed: {valueOut}`) diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau index a64c9b3..828ea0f 100644 --- a/pkgs/charm/src/observe.luau +++ b/pkgs/charm/src/observe.luau @@ -46,9 +46,9 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key currentItems = selector() if currentItems ~= previousItems then - local prevSub = setCurrentSub(nil) + local currentSub = setCurrentSub(nil) local success, result: unknown = pcall(updateScopes, currentItems, previousItems) - setCurrentSub(prevSub) + setCurrentSub(currentSub) if not success then error(`observe() encountered an error: {result}`, 2) diff --git a/pkgs/charm/src/subscribe.luau b/pkgs/charm/src/subscribe.luau index 48a2300..01c4a98 100644 --- a/pkgs/charm/src/subscribe.luau +++ b/pkgs/charm/src/subscribe.luau @@ -14,9 +14,9 @@ local function subscribe(selector: () -> T, listener: (state: T, lastState: T if mounted then mounted = false elseif current ~= previous then - local prevSub = setCurrentSub(nil) + local currentSub = setCurrentSub(nil) local success, result: unknown = pcall(listener, current, previous) - setCurrentSub(prevSub) + setCurrentSub(currentSub) if not success then error(`subscribe() encountered an error: {result}`) diff --git a/pkgs/charm/src/untracked.luau b/pkgs/charm/src/untracked.luau index d897e6b..ac77e85 100644 --- a/pkgs/charm/src/untracked.luau +++ b/pkgs/charm/src/untracked.luau @@ -3,9 +3,9 @@ local signals = require("./signals") local setCurrentSub = signals.setCurrentSub local function untracked(callback: ((Args...) -> T) | (Args...) -> (), ...: Args...): T - local prevSub = setCurrentSub(nil) + local currentSub = setCurrentSub(nil) local success, result = pcall(callback, ...) - setCurrentSub(prevSub) + setCurrentSub(currentSub) if not success then error(result) diff --git a/pkgs/charm/tests/signals/effect.spec.luau b/pkgs/charm/tests/signals/effect.spec.luau index 76e2fda..a0d70a2 100644 --- a/pkgs/charm/tests/signals/effect.spec.luau +++ b/pkgs/charm/tests/signals/effect.spec.luau @@ -5,8 +5,7 @@ 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 setCurrentSub = signals.setCurrentSub local onEffectCleanup = signals.onEffectCleanup local signal = signals.signal local startBatch = signals.startBatch @@ -203,9 +202,9 @@ test("should duplicate subscribers do not affect the notify order", function() effect(function() table.insert(order, "a") - pauseTracking() + local currentSub = setCurrentSub(nil) local isOne = src2() == 1 - resumeTracking() + setCurrentSub(currentSub) if isOne then src1() end diff --git a/pkgs/charm/tests/signals/issue_48.spec.luau b/pkgs/charm/tests/signals/issue_48.spec.luau index 122a44c..5089a84 100644 --- a/pkgs/charm/tests/signals/issue_48.spec.luau +++ b/pkgs/charm/tests/signals/issue_48.spec.luau @@ -3,8 +3,7 @@ local suite = require("@tests/suite") local computed = signals.computed local effect = signals.effect -local pauseTracking = signals.pauseTracking -local resumeTracking = signals.resumeTracking +local setCurrentSub = signals.setCurrentSub local signal = signals.signal local test = suite.test @@ -17,9 +16,9 @@ type ReactionOptions = { } local function untracked(callback: () -> T): T - pauseTracking() + local currentSub = setCurrentSub(nil) local success, result = pcall(callback) - resumeTracking() + setCurrentSub(currentSub) if not success then error(result) end diff --git a/pkgs/charm/tests/signals/untrack.spec.luau b/pkgs/charm/tests/signals/untrack.spec.luau index c08c3e1..56a28ae 100644 --- a/pkgs/charm/tests/signals/untrack.spec.luau +++ b/pkgs/charm/tests/signals/untrack.spec.luau @@ -2,17 +2,16 @@ local signals = require("../../src/signals") local suite = require("@tests/suite") local computed = signals.computed -local pauseTracking = signals.pauseTracking -local resumeTracking = signals.resumeTracking +local setCurrentSub = signals.setCurrentSub local signal = signals.signal local test = suite.test test("should pause tracking", function() local src, setSrc = signal(0) local c = computed(function() - pauseTracking() + local currentSub = setCurrentSub(nil) local value = src() - resumeTracking() + setCurrentSub(currentSub) return value end) assert(c() == 0, "should not track src") From 4e5f6561580e9638882f4683599249c236740563 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 11 May 2025 04:56:46 -0700 Subject: [PATCH 025/160] test: improve test suite --- rokit.toml | 2 +- tests/init.luau | 7 +- tests/spec.server.luau | 7 +- tests/suite.luau | 502 +++++++++++++++++++++++++++++++---------- 4 files changed, 395 insertions(+), 123 deletions(-) diff --git a/rokit.toml b/rokit.toml index d7c0aef..7394a8b 100644 --- a/rokit.toml +++ b/rokit.toml @@ -9,4 +9,4 @@ rojo = "rojo-rbx/rojo@7.5.1" stylua = "johnnymorganz/stylua@2.1.0" selene = "Kampfkarren/selene@0.28.0" luau-lsp = "johnnymorganz/luau-lsp@1.44.1" -lute = "luau-lang/lute@0.1.0-nightly.20250506" +lute = "luau-lang/lute@0.1.0-nightly.20250511" diff --git a/tests/init.luau b/tests/init.luau index 8431991..0b17cbc 100644 --- a/tests/init.luau +++ b/tests/init.luau @@ -1,11 +1,12 @@ local suite = require("@tests/suite") -local finish = suite.finish - if not table.isfrozen(_G) then _G.__DEV__ = true end require("@pkgs/charm/tests/spec") -finish() +suite.finish({ + colors = true, + verbose = false, +}) diff --git a/tests/spec.server.luau b/tests/spec.server.luau index a3d8b9b..6b6956b 100644 --- a/tests/spec.server.luau +++ b/tests/spec.server.luau @@ -1,7 +1,5 @@ local suite = require("./suite") -local finish = suite.finish - if not script then return end @@ -12,4 +10,7 @@ require(script.Parent.CharmSync.spec) require(script.Parent.ReactCharm.spec) require(script.Parent.VideCharm.spec) -finish() +suite.finish({ + colors = false, + verbose = true, +}) diff --git a/tests/suite.luau b/tests/suite.luau index 78c26dc..fea23be 100644 --- a/tests/suite.luau +++ b/tests/suite.luau @@ -1,161 +1,431 @@ +type Config = { + colors: boolean?, + verbose: boolean?, +} + +type Description = { + status: number, + name: string, + only: boolean, + skip: boolean, + parent: Description?, + children: { Description | Test }, + passes: number, + fails: number, + skips: number, + runtime: number, +} + +type Test = { + status: number, + name: string, + only: boolean, + skip: boolean, + parent: Description?, + children: nil, + run: () -> (), + result: unknown?, + line: number?, + runtime: number, +} + +local options = { + colors = true, + verbose = false, +} + local color = { - white = function(text: string | number) - return `\27[37;1m{text}\27[0m` + white = function(text: string) + return if options.colors then `\27[37;1m{text}\27[0m` else text end, - gray = function(text: string | number) - return `\27[90;1m{text}\27[0m` + gray = function(text: string) + return if options.colors then `\27[90;1m{text}\27[0m` else text end, - green = function(text: string | number) - return `\27[32;1m{text}\27[0m` + green = function(text: string) + return if options.colors then `\27[32;1m{text}\27[0m` else text end, - red = function(text: string | number) - return `\27[31;1m{text}\27[0m` + red = function(text: string) + return if options.colors then `\27[31;1m{text}\27[0m` else text end, - blue = function(text: string | number) - return `\27[34;1m{text}\27[0m` + blue = function(text: string) + return if options.colors then `\27[34;1m{text}\27[0m` else text end, } -if script then - local function prepend(prefix: string?): (string | number) -> string - return function(text: string | number): string - return if prefix then `{prefix} {text}` else `{text}` - end - end +local PENDING = 0 +local PASS = 1 +local FAIL = 2 +local SKIP = 3 +local ERROR = 4 - color.white = prepend() - color.gray = prepend() - color.green = prepend(utf8.char(0x1F7E2)) -- green circle - color.red = prepend(utf8.char(0x1F534)) -- red circle - color.blue = prepend(utf8.char(0x1F535)) -- blue circle +local tests: { Test } = {} +local descriptions: { Description } = {} +local parent: Description = { + status = PENDING, + name = "root", + only = false, + skip = false, + children = {}, + passes = 0, + fails = 0, + skips = 0, + runtime = 0, +} + +local function describe(name: string, run: () -> ()): Description + local this: Description = { + status = PENDING, + name = name, + only = false, + skip = false, + parent = parent, + children = {}, + passes = 0, + fails = 0, + skips = 0, + runtime = 0, + } + + local prevDescription = parent + parent = this + run() + parent = prevDescription + + table.insert(parent.children, this) + table.insert(descriptions, this) + + return this end -local WALL = color.gray("│") -local PASS = color.green("PASS") .. WALL -local FAIL = color.red("FAIL") .. WALL -local SKIP = color.blue("SKIP") .. WALL -local ERROR = color.red("ERROR") .. WALL +local function test(name: string, run: () -> ()): Test + local this: Test = { + status = PENDING, + name = name, + only = false, + skip = false, + parent = parent, + run = run, + runtime = 0, + } -local results: { string } = {} -local stack: { string } = {} -local errors: { string } = {} + table.insert(parent.children, this) + table.insert(tests, this) -local skipDepth = 0 -local skipCount = 0 - -local testsOk = 0 -local testsFailed = 0 -local testsSkipped = 0 -local filesOk = 0 -local filesFailed = 0 - -local start = os.clock() - -local function log(result: string, index: number?) - if index then - table.insert(results, index, result) - else - table.insert(results, result) - end + return this end local function skip() - skipDepth += 1 + parent.skip = true end -local function test(name: string, callback: () -> ()) - local path = color.gray(table.concat(stack, " > ")) - local line = color.red("-1:") +local function only() + parent.only = true + parent.only = true +end - if skipDepth > 0 then - testsSkipped += 1 - skipCount += 1 +local function testSkip(name: string, run: () -> ()): Test + local this = test(name, run) + this.skip = true + return this +end + +local function testOnly(name: string, run: () -> ()): Test + local this = test(name, run) + this.only = true + parent.only = true + return this +end + +local function describeSkip(name: string, run: () -> ()): Description + local this = describe(name, run) + this.skip = true + return this +end + +local function describeOnly(name: string, run: () -> ()): Description + local this = describe(name, run) + this.only = true + parent.only = true + return this +end + +local function isOnly(test: Test): boolean + if not parent.only or test.only then + return true + end + + local description = test.parent + while description and description ~= parent do + if description.only then + return true + elseif description.skip then + return false + end + description = description.parent + end + + return false +end + +local function isSkip(test: Test): boolean + if test.only then + return false + elseif test.skip then + return true + end + + local description = test.parent + while description do + if description.only and description ~= parent then + return false + elseif description.skip then + return true + end + description = description.parent + end + + return false +end + +local function runTests() + for _, test in tests do + if isSkip(test) or not isOnly(test) then + test.status = SKIP + continue + end + + local start = os.clock() + local success = xpcall(test.run, function(result) + test.status = FAIL + test.result = result + test.line = debug.info(3, "l") + end) + + test.runtime = os.clock() - start + + if success then + test.status = PASS + end + end +end + +local function updateDescription(description: Description) + if not description.children[1] then + description.status = PASS return end - table.insert(stack, name) + description.status = SKIP - local success = xpcall(callback, function(err) - line = color.red(`{debug.info(3, "l")}:`) - table.insert(errors, `\n{ERROR} {path} > {line}{color.white(name)}\n{err}`) - end) + for _, child in description.children do + if child.children then + updateDescription(child) + description.passes += child.passes + description.fails += child.fails + elseif child.status == PASS then + description.passes += 1 + elseif child.status == FAIL then + description.fails += 1 + end - if success then - testsOk += 1 - else - testsFailed += 1 - log(`{FAIL} {path} {color.gray(">")} {line}{color.white(name)}`) - end + -- Only count skips in direct children + if child.status == SKIP then + description.skips += 1 + end - table.remove(stack) -end + description.runtime += child.runtime -local function describe(name: string, callback: () -> ()) - local path = if #stack > 0 then color.gray(table.concat(stack, " > ")) .. color.gray(" > ") else "" - local index = #results + 1 - local start = os.clock() - - local initialTestsOk = testsOk - local initialTestsFailed = testsFailed - local initialSkipDepth = skipDepth - - skipCount = 0 - - table.insert(stack, name) - callback() - table.remove(stack) - - skipDepth = initialSkipDepth - - local skippedEveryTest = initialTestsOk == testsOk and initialTestsFailed == testsFailed and skipCount > 0 - local passedEveryTest = testsFailed == initialTestsFailed - - local millis = string.format("%.2fms", (os.clock() - start) * 1000) - local skips = if skipCount > 0 then color.blue(` ({skipCount} skipped) `) else " " - - if skippedEveryTest then - filesOk += 1 - log(`{SKIP} {path .. color.white(name)}{skips}`, index) - elseif passedEveryTest then - filesOk += 1 - log(`{PASS} {path .. color.white(name)}{skips}{color.gray(millis)}`, index) - else - filesFailed += 1 - log(`{FAIL} {path .. color.white(name)}{skips}{color.gray(millis)}`, index) + if child.status == FAIL then + description.status = FAIL + elseif description.status == SKIP and child.status == PASS then + description.status = PASS + end end end -local function finish() - local tests = testsOk + testsFailed + testsSkipped - local files = filesOk + filesFailed - local failed = testsFailed + filesFailed > 0 - local millis = math.round((os.clock() - start) * 1000) +local function getIndent(node: Description | Test): string + local depth = 0 + local description = node.parent + while description and description ~= parent do + depth += 1 + description = description.parent + end + return if depth > 0 then string.rep(" ", depth - 1) .. color.gray("· ") else "" +end + +local function getPath(node: Description | Test): string + local path = { "" } + local description = node.parent + while description and description ~= parent do + table.insert(path, 1, description.name) + description = description.parent + end + return table.concat(path, " > ") +end + +local function greenOrRed(text: string, good: boolean): string + return if good then color.green(text) else color.red(text) +end + +local function colorStatus(status: number): string + local wall = color.gray("│") + + if status == PASS then + return color.green("PASS") .. wall + elseif status == FAIL then + return color.red("FAIL") .. wall + elseif status == SKIP then + return color.blue("SKIP") .. wall + else + return color.red("ERROR") .. wall + end +end + +local function report() local output = {} - table.insert(output, color.blue("Test Results\n")) + local function reportDescription(description: Description) + local name = color.white(description.name) + local text = `{colorStatus(description.status)} {getIndent(description)}{name}` - for _, result in results do - table.insert(output, result) + if description.fails > 0 then + text ..= " " .. color.red(`({description.fails})`) + end + + if description.skips > 0 then + text ..= " " .. color.blue(`({description.skips} skipped)`) + elseif description.fails == 0 then + text ..= " " .. color.gray(`({string.format("%.2fms", description.runtime * 1000)})`) + end + + table.insert(output, text) end - local redOrGreen: (string | number) -> string = if failed then color.red else color.green + local function reportTest(test: Test, reportError: boolean?) + if test.status ~= FAIL and not options.verbose then + return + end + + local line = color.red(`{test.line}:`) + + if reportError then + local name = color.white(test.name) + local path = color.gray(getPath(test)) + + table.insert(output, `{colorStatus(ERROR)} {path}{line}{name}`) + table.insert(output, `{test.result}`) + else + local name = color.gray(test.name) + local indent = getIndent(test) + + if test.status == FAIL then + table.insert(output, `{colorStatus(FAIL)} {indent}{line}{name}`) + else + table.insert(output, `{colorStatus(test.status)} {indent}{name}`) + end + end + end + + local function reportNode(node: Description | Test) + if not node.children then + return reportTest(node :: Test) + end + + reportDescription(node) + + for _, child in node.children do + reportNode(child) + end + end + + local function reportSummary() + local describePasses = 0 + local describeFails = 0 + + for _, description in descriptions do + if description.status == PASS then + describePasses += 1 + elseif description.status == FAIL then + describeFails += 1 + end + end + + local describes = + `{greenOrRed(`{describePasses} passed`, describeFails == 0)} {color.gray(`({#descriptions})`)}` + local tests = `{greenOrRed(`{parent.passes} passed`, parent.fails == 0)} {color.gray(`({#tests})`)}` + local duration = `{color.blue(`{string.format("%.2fms", parent.runtime * 1000)}`)}` + + table.insert(output, "") + table.insert(output, `Describe {describes}`) + table.insert(output, ` Tests {tests}`) + table.insert(output, `Duration {duration}`) + table.insert(output, "") + end + + local function reportErrors() + if parent.fails == 0 then + return + end + + for _, test in tests do + if test.status == FAIL then + reportTest(test, true) + table.insert(output, "") + end + end + end table.insert(output, "") - table.insert(output, `Test Files {redOrGreen(`{filesOk} passed`)} {color.gray(`({files})`)}`) - table.insert(output, ` Tests {redOrGreen(`{testsOk} passed`)} {color.gray(`({tests})`)}`) - table.insert(output, ` Duration {color.blue(`{millis}ms`)}`) + table.insert(output, color.blue("Test Results")) + table.insert(output, "") - print("\n" .. table.concat(output, "\n") .. "\n") + for _, child in parent.children do + reportNode(child) + end - if failed then - print(`{color.red(`{testsFailed} errors:`)}\n{table.concat(errors, "\n")}\n`) - error("Tests failed") + reportSummary() + reportErrors() + + print(table.concat(output, "\n")) + + if parent.fails > 0 then + error(color.red(`{parent.fails} test(s) failed`)) end end +local function finish(config: Config?) + if config then + options.colors = if config.colors ~= nil then config.colors else options.colors + options.verbose = if config.verbose ~= nil then config.verbose else options.verbose + end + + runTests() + updateDescription(parent) + report() +end + +local testImpl = setmetatable({ + skip = testSkip, + only = testOnly, +}, { + __call = function(_, name: string, run: () -> ()) + return test(name, run) + end, +}) + +local describeImpl = setmetatable({ + skip = describeSkip, + only = describeOnly, +}, { + __call = function(_, name: string, run: () -> ()) + return describe(name, run) + end, +}) + return { - test = test, - describe = describe, + test = testImpl, + describe = describeImpl, skip = skip, + only = only, finish = finish, } From 546df8cabed2dcea9139778590bd9442379ee75a Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 11 May 2025 14:45:24 -0700 Subject: [PATCH 026/160] test: separate parent from root --- tests/suite.luau | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/tests/suite.luau b/tests/suite.luau index fea23be..e23b9d4 100644 --- a/tests/suite.luau +++ b/tests/suite.luau @@ -60,7 +60,7 @@ local ERROR = 4 local tests: { Test } = {} local descriptions: { Description } = {} -local parent: Description = { +local root: Description = { status = PENDING, name = "root", only = false, @@ -71,6 +71,7 @@ local parent: Description = { skips = 0, runtime = 0, } +local parent: Description = root local function describe(name: string, run: () -> ()): Description local this: Description = { @@ -120,7 +121,7 @@ end local function only() parent.only = true - parent.only = true + root.only = true end local function testSkip(name: string, run: () -> ()): Test @@ -132,7 +133,7 @@ end local function testOnly(name: string, run: () -> ()): Test local this = test(name, run) this.only = true - parent.only = true + root.only = true return this end @@ -145,17 +146,17 @@ end local function describeOnly(name: string, run: () -> ()): Description local this = describe(name, run) this.only = true - parent.only = true + root.only = true return this end local function isOnly(test: Test): boolean - if not parent.only or test.only then + if not root.only or test.only then return true end local description = test.parent - while description and description ~= parent do + while description and description ~= root do if description.only then return true elseif description.skip then @@ -176,7 +177,7 @@ local function isSkip(test: Test): boolean local description = test.parent while description do - if description.only and description ~= parent then + if description.only and description ~= root then return false elseif description.skip then return true @@ -246,7 +247,7 @@ end local function getIndent(node: Description | Test): string local depth = 0 local description = node.parent - while description and description ~= parent do + while description and description ~= root do depth += 1 description = description.parent end @@ -256,7 +257,7 @@ end local function getPath(node: Description | Test): string local path = { "" } local description = node.parent - while description and description ~= parent do + while description and description ~= root do table.insert(path, 1, description.name) description = description.parent end @@ -295,7 +296,7 @@ local function report() if description.skips > 0 then text ..= " " .. color.blue(`({description.skips} skipped)`) elseif description.fails == 0 then - text ..= " " .. color.gray(`({string.format("%.2fms", description.runtime * 1000)})`) + text ..= " " .. color.gray(string.format("%.2fms", description.runtime * 1000)) end table.insert(output, text) @@ -352,8 +353,8 @@ local function report() local describes = `{greenOrRed(`{describePasses} passed`, describeFails == 0)} {color.gray(`({#descriptions})`)}` - local tests = `{greenOrRed(`{parent.passes} passed`, parent.fails == 0)} {color.gray(`({#tests})`)}` - local duration = `{color.blue(`{string.format("%.2fms", parent.runtime * 1000)}`)}` + local tests = `{greenOrRed(`{root.passes} passed`, root.fails == 0)} {color.gray(`({#tests})`)}` + local duration = `{color.blue(`{string.format("%.2fms", root.runtime * 1000)}`)}` table.insert(output, "") table.insert(output, `Describe {describes}`) @@ -363,7 +364,7 @@ local function report() end local function reportErrors() - if parent.fails == 0 then + if root.fails == 0 then return end @@ -379,7 +380,7 @@ local function report() table.insert(output, color.blue("Test Results")) table.insert(output, "") - for _, child in parent.children do + for _, child in root.children do reportNode(child) end @@ -388,8 +389,8 @@ local function report() print(table.concat(output, "\n")) - if parent.fails > 0 then - error(color.red(`{parent.fails} test(s) failed`)) + if root.fails > 0 then + error(color.red(`{root.fails} test(s) failed`)) end end @@ -400,7 +401,7 @@ local function finish(config: Config?) end runTests() - updateDescription(parent) + updateDescription(root) report() end From 210e9482255257ca90db1dc69d72b743aa54db65 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 11 May 2025 17:49:01 -0700 Subject: [PATCH 027/160] refactor: iterate over previous data in mapped --- pkgs/charm/src/mapped.luau | 12 ++++++++---- pkgs/charm/tests/mapped.spec.luau | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/pkgs/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau index 247ae31..fa4f7f7 100644 --- a/pkgs/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -32,14 +32,18 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite local previousData = currentData currentData = selector() - for key, cachedKey in keyCache do + for key in previousData do if currentData[key] ~= nil then continue end - currentOutput = ensureCanMutateOutput(currentOutput, previousOutput) - currentOutput[cachedKey] = nil - keyCache[key] = nil + local cachedKey = keyCache[key] + + if cachedKey then + currentOutput = ensureCanMutateOutput(currentOutput, previousOutput) + currentOutput[cachedKey] = nil + keyCache[key] = nil + end end for key, value in currentData do diff --git a/pkgs/charm/tests/mapped.spec.luau b/pkgs/charm/tests/mapped.spec.luau index be2821c..7bc4a0c 100644 --- a/pkgs/charm/tests/mapped.spec.luau +++ b/pkgs/charm/tests/mapped.spec.luau @@ -52,6 +52,7 @@ test("should not update if no values change", function() src({ 4, 5, 6, 7 }) src({ 1, 2, 3 }) + map() assert(calls == 3, "should not map unchanged values") assert(table.concat(map()) == "234", table.concat(map())) assert(prev == map(), "should not create new table") From 72f936591ee10dab332c3d054ddd66020e0d42c6 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 12 May 2025 02:15:49 -0700 Subject: [PATCH 028/160] refactor: improve logic --- .github/workflows/ci.yml | 2 +- .luaurc | 2 +- README.md | 2 +- {images => assets}/logo.png | Bin {benchmarks => benches}/bench.luau | 0 {benchmarks => benches}/mapped.luau | 0 {benchmarks => benches}/patch.bench.luau | 0 benches/propagate.luau | 42 ++++++++++ benchmarks/propagate.luau | 27 ------- bin/check.sh | 6 +- default.project.json | 4 +- pkgs/charm-sync/tests/spec.luau | 28 +++---- pkgs/charm/src/atom.luau | 53 ++++++------- pkgs/charm/src/effect.luau | 10 +-- pkgs/charm/src/effectScope.luau | 4 +- pkgs/charm/src/init.luau | 3 + pkgs/charm/src/lib.luau | 3 + pkgs/charm/src/listen.luau | 27 +++---- pkgs/charm/src/mapped.luau | 17 +++-- pkgs/charm/src/noYield.luau | 25 ++++++ pkgs/charm/src/observe.luau | 20 ++--- pkgs/charm/src/signals.luau | 51 +++++-------- pkgs/charm/src/subscribe.luau | 23 +++--- pkgs/charm/src/system.luau | 4 +- pkgs/charm/src/types.luau | 6 +- pkgs/charm/src/untracked.luau | 3 +- pkgs/charm/tests/gc.spec.luau | 72 ------------------ pkgs/charm/tests/listen.spec.luau | 32 +++++++- pkgs/charm/tests/mapped.spec.luau | 2 +- pkgs/charm/tests/observe.spec.luau | 2 +- pkgs/charm/tests/signals/computed.spec.luau | 2 +- pkgs/charm/tests/signals/effect.spec.luau | 2 +- .../charm/tests/signals/effectScope.spec.luau | 2 +- pkgs/charm/tests/signals/gc.spec.luau | 22 +++--- pkgs/charm/tests/signals/issue_48.spec.luau | 2 +- pkgs/charm/tests/signals/spec.luau | 38 +++++---- pkgs/charm/tests/signals/topology.spec.luau | 2 +- pkgs/charm/tests/signals/untrack.spec.luau | 2 +- pkgs/charm/tests/spec.luau | 29 +++---- pkgs/charm/tests/subscribe.spec.luau | 32 +++++++- pkgs/react-charm/src/init.luau | 56 ++++++++++---- pkgs/react-charm/tests/spec.luau | 15 ++-- .../tests/useAtomBinding.spec.luau | 50 ++++++++++++ pkgs/vide-charm/src/init.luau | 17 ++--- pkgs/vide-charm/tests/spec.luau | 12 +-- {tests => test}/init.luau | 2 +- {tests => test}/spec.server.luau | 0 {tests => test}/suite.luau | 28 ++++--- 48 files changed, 434 insertions(+), 349 deletions(-) rename {images => assets}/logo.png (100%) rename {benchmarks => benches}/bench.luau (100%) rename {benchmarks => benches}/mapped.luau (100%) rename {benchmarks => benches}/patch.bench.luau (100%) create mode 100644 benches/propagate.luau delete mode 100644 benchmarks/propagate.luau create mode 100644 pkgs/charm/src/noYield.luau delete mode 100644 pkgs/charm/tests/gc.spec.luau create mode 100644 pkgs/react-charm/tests/useAtomBinding.spec.luau rename {tests => test}/init.luau (79%) rename {tests => test}/spec.server.luau (100%) rename {tests => test}/suite.luau (92%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b357e8..3e92647 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: run: sh bin/check.sh - name: Run tests - run: lute tests/init.luau + run: lute test/init.luau - name: Build models run: sh bin/build.sh diff --git a/.luaurc b/.luaurc index 7199120..a3678db 100644 --- a/.luaurc +++ b/.luaurc @@ -4,7 +4,7 @@ "LocalShadow": false }, "aliases": { - "tests": "tests", + "test": "test", "pkgs": "pkgs" } } diff --git a/README.md b/README.md index f832d4d..e3a421f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- Logo + Logo

Charm

diff --git a/images/logo.png b/assets/logo.png similarity index 100% rename from images/logo.png rename to assets/logo.png diff --git a/benchmarks/bench.luau b/benches/bench.luau similarity index 100% rename from benchmarks/bench.luau rename to benches/bench.luau diff --git a/benchmarks/mapped.luau b/benches/mapped.luau similarity index 100% rename from benchmarks/mapped.luau rename to benches/mapped.luau diff --git a/benchmarks/patch.bench.luau b/benches/patch.bench.luau similarity index 100% rename from benchmarks/patch.bench.luau rename to benches/patch.bench.luau diff --git a/benches/propagate.luau b/benches/propagate.luau new file mode 100644 index 0000000..27b1aed --- /dev/null +++ b/benches/propagate.luau @@ -0,0 +1,42 @@ +local Charm = require("@pkgs/charm/src/lib") +local bench = require("./bench") + +local signals = Charm.signals + +for _, w in { 10, 100 } do + for _, h in { 10, 100 } do + bench.run(`charm {w}*{h}`, function() + local src = Charm.atom(1) + for _ = 1, w do + local last = src + for _ = 1, h do + local prev = last + last = Charm.computed(function() + return prev() + 1 + end) + end + Charm.effect(function() + last() + end) + end + src(src() + 1) + end) + + bench.run(`alien-signals {w}*{h}`, function() + local src, setSrc = signals.signal(1) + for _ = 1, w do + local last = src + for _ = 1, h do + local prev = last + last = signals.computed(function() + return prev() + 1 + end) + end + signals.effect(function() + last() + end) + end + setSrc(src() + 1) + end) + end +end diff --git a/benchmarks/propagate.luau b/benchmarks/propagate.luau deleted file mode 100644 index a08e8b1..0000000 --- a/benchmarks/propagate.luau +++ /dev/null @@ -1,27 +0,0 @@ -local Charm = require("@pkgs/charm/src/lib") -local bench = require("./bench") - -local atom = Charm.atom -local computed = Charm.computed -local effect = Charm.effect - -for _, w in { 1, 10, 100 } do - for _, h in { 1, 10, 100 } do - bench.run(`propagate {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) - end -end diff --git a/bin/check.sh b/bin/check.sh index 8cf8dba..f3cccd4 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -7,10 +7,10 @@ luau-lsp analyze \ --flag:LuauFixIndexerSubtypingOrdering=true \ --sourcemap=sourcemap.json \ --ignore="**/_Index/**" \ - pkgs tests + pkgs test -selene pkgs tests +selene pkgs test -stylua --check pkgs tests +stylua --check pkgs test rm bin/roblox.d.luau diff --git a/default.project.json b/default.project.json index 904e9cd..1348524 100644 --- a/default.project.json +++ b/default.project.json @@ -15,14 +15,14 @@ "ServerScriptService": { "$className": "ServerScriptService", "Tests": { - "$path": "tests", + "$path": "test", "Charm": { "$path": "pkgs/charm/tests" }, "CharmSync": { "$path": "pkgs/charm-sync/tests" }, "ReactCharm": { "$path": "pkgs/react-charm/tests" }, "VideCharm": { "$path": "pkgs/vide-charm/tests" } }, "Benchmarks": { - "$path": "benchmarks" + "$path": "benches" } } } diff --git a/pkgs/charm-sync/tests/spec.luau b/pkgs/charm-sync/tests/spec.luau index 39ee48c..725714b 100644 --- a/pkgs/charm-sync/tests/spec.luau +++ b/pkgs/charm-sync/tests/spec.luau @@ -2,20 +2,22 @@ local ServerScriptService = game:GetService("ServerScriptService") local suite = require(ServerScriptService.Tests.suite) -local describe = suite.describe - -local function spec(name: string) - describe(name, function() - return require(`./{name}.spec`) :: any +suite.describe("charm-sync", function() + suite.describe("client", function() + require("./client.spec") + end) + suite.describe("server", function() + require("./server.spec") + end) + suite.describe("flatten", function() + require("./flatten.spec") + end) + suite.describe("patch", function() + require("./patch.spec") + end) + suite.describe("validate", function() + require("./validate.spec") end) -end - -describe("charm-sync", function() - spec("client") - spec("server") - spec("flatten") - spec("patch") - spec("validate") end) return {} diff --git a/pkgs/charm/src/atom.luau b/pkgs/charm/src/atom.luau index 7e7eccb..ec70d79 100644 --- a/pkgs/charm/src/atom.luau +++ b/pkgs/charm/src/atom.luau @@ -2,51 +2,46 @@ local signals = require("./signals") local types = require("./types") type Atom = types.Atom +type Getter = types.Getter +type Setter = types.Setter +type Update = types.Update local signal = signals.signal -local function atomGetterSetterEquals( - source: () -> T, - setSource: (((T) -> T) | T) -> T, - equals: (T, T) -> boolean -): Atom - return function(...: ((T) -> T) | T): T +type Equals = (current: T, incoming: T) -> boolean + +local function createAtomWithEqualityCheck(getter: Getter, setter: Setter, equals: Equals): Atom + local function atom(...: Update): T if select("#", ...) == 0 then - return source() + return getter() end local nextState = (...) - if type(nextState) == "function" then - return setSource(function(currentState): T - local nextState = nextState(currentState) - return if equals(currentState, nextState) then currentState else nextState - end) - else - return setSource(function(currentState): T - return if equals(currentState, nextState) then currentState else nextState - end) - end + return setter(function(currentState): T + local nextState = if type(nextState) == "function" then nextState(currentState) else nextState + return if equals(currentState, nextState) then currentState else nextState + end) end + + return atom end -local function atomGetterSetter(source: () -> T, setSource: (((T) -> T) | T) -> T): Atom - return function(...: ((T) -> T) | T): T - if select("#", ...) == 0 then - return source() - else - return setSource((...)) - end +local function createAtom(getter: Getter, setter: Setter): Atom + local function atom(...: Update): T + return if select("#", ...) == 0 then getter() else setter((...)) end + + return atom end -local function atom(state: T, equals: ((previous: T, current: T) -> boolean)?): Atom - local source, setSource = signal(state) +local function atom(state: T, equals: Equals?): Atom + local getter, setter = signal(state) - if equals then - return atomGetterSetterEquals(source, setSource, equals) + if equals == nil then + return createAtom(getter, setter) else - return atomGetterSetter(source, setSource) + return createAtomWithEqualityCheck(getter, setter, equals) end end diff --git a/pkgs/charm/src/effect.luau b/pkgs/charm/src/effect.luau index 572891e..2eb0802 100644 --- a/pkgs/charm/src/effect.luau +++ b/pkgs/charm/src/effect.luau @@ -2,13 +2,11 @@ local signals = require("./signals") local onEffectCleanup = signals.onEffectCleanup -type Cleanup = () -> () - -local function effect(callback: (dispose: () -> ()) -> ...Cleanup?): () -> () - local disposeImmediately = false +local function effect(callback: (dispose: () -> ()) -> ...(() -> ())?): () -> () + local disposed = false local function dispose() - disposeImmediately = true + disposed = true end dispose = signals.effect(function() @@ -18,7 +16,7 @@ local function effect(callback: (dispose: () -> ()) -> ...Cleanup?): () -> () end end) - if disposeImmediately then + if disposed then dispose() end diff --git a/pkgs/charm/src/effectScope.luau b/pkgs/charm/src/effectScope.luau index 7327d9a..7a40ee2 100644 --- a/pkgs/charm/src/effectScope.luau +++ b/pkgs/charm/src/effectScope.luau @@ -2,9 +2,7 @@ local signals = require("./signals") local onScopeDispose = signals.onScopeDispose -type Cleanup = () -> () - -local function effectScope(callback: () -> ...Cleanup?, detached: boolean?): () -> () +local function effectScope(callback: () -> ...(() -> ())?, detached: boolean?): () -> () return signals.effectScope(function() local cleanup = callback() if cleanup then diff --git a/pkgs/charm/src/init.luau b/pkgs/charm/src/init.luau index c3dec01..bc510ce 100644 --- a/pkgs/charm/src/init.luau +++ b/pkgs/charm/src/init.luau @@ -1,6 +1,9 @@ local lib = require(script.lib) export type Atom = lib.Atom +export type Getter = lib.Getter export type Selector = lib.Selector +export type Setter = lib.Setter +export type Update = lib.Update return lib diff --git a/pkgs/charm/src/lib.luau b/pkgs/charm/src/lib.luau index 479af84..bae8330 100644 --- a/pkgs/charm/src/lib.luau +++ b/pkgs/charm/src/lib.luau @@ -10,7 +10,10 @@ local types = require("./types") local untracked = require("./untracked") export type Atom = types.Atom +export type Getter = types.Getter export type Selector = types.Selector +export type Setter = types.Setter +export type Update = types.Update local computed = signals.computed local effectScope = signals.effectScope diff --git a/pkgs/charm/src/listen.luau b/pkgs/charm/src/listen.luau index c72b061..816c165 100644 --- a/pkgs/charm/src/listen.luau +++ b/pkgs/charm/src/listen.luau @@ -1,32 +1,27 @@ local signals = require("./signals") +local computed = signals.computed local effect = signals.effect -local setCurrentSub = signals.setCurrentSub local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () - local disposeImmediately = false + local tracked = computed(function() + return selector() + end) + local current: T + local disposed = false local function dispose() - disposeImmediately = true + disposed = true end dispose = effect(function() - local previous: T? = current - current = selector() - - if current ~= previous then - local currentSub = setCurrentSub(nil) - local success, result: unknown = pcall(listener, current, previous, dispose) - setCurrentSub(currentSub) - - if not success then - error(`listen() encountered an error: {result}`) - end - end + local previous = current + current = tracked() + listener(current, previous, dispose) end) - if disposeImmediately then + if disposed then dispose() end diff --git a/pkgs/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau index fa4f7f7..847968c 100644 --- a/pkgs/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -1,3 +1,4 @@ +local noYield = require("./noYield") local signals = require("./signals") local computed = signals.computed @@ -17,14 +18,18 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite local function map(value: VI, key: KI): (VO, KO) local currentSub = setCurrentSub(nil) - local success, valueOut, keyOut = pcall(mapper, value, key) + local success, valueOut, keyOut = noYield(mapper, value, key) setCurrentSub(currentSub) if not success then error(`mapped() transform function failed: {valueOut}`) end - return valueOut, if keyOut ~= nil then keyOut else key :: any + if keyOut == nil then + keyOut = key :: any + end + + return valueOut, keyOut end return computed(function(previousOutput) @@ -53,9 +58,11 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite local valueOut, keyOut = map(value, key) - currentOutput = ensureCanMutateOutput(currentOutput, previousOutput) - currentOutput[keyOut] = valueOut - keyCache[key] = keyOut + if currentOutput[keyOut] ~= valueOut then + currentOutput = ensureCanMutateOutput(currentOutput, previousOutput) + currentOutput[keyOut] = valueOut + keyCache[key] = keyOut + end end return currentOutput diff --git a/pkgs/charm/src/noYield.luau b/pkgs/charm/src/noYield.luau new file mode 100644 index 0000000..2d381b1 --- /dev/null +++ b/pkgs/charm/src/noYield.luau @@ -0,0 +1,25 @@ +local function handleNoYield(thread: thread, ok: boolean, ...) + if not ok then + local err = (...) + if typeof(err) == "string" then + return false, debug.traceback(thread, err) + else + return false, tostring(err) + end + end + + if coroutine.status(thread) ~= "dead" then + return false, debug.traceback(thread, "Attempted to yield!") + end + + return true, ... +end + +local function noYield(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...) + local thread = coroutine.create(callback) + return handleNoYield(thread, coroutine.resume(thread, ...)) +end + +-- To eliminate the overhead of noYield returning callback(...) in debug mode, +-- replace it with pcall to avoid wrapping the callback in another function. +return if _G.__DEV__ then noYield else pcall :: never diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau index 828ea0f..108b969 100644 --- a/pkgs/charm/src/observe.luau +++ b/pkgs/charm/src/observe.luau @@ -2,23 +2,19 @@ local signals = require("./signals") local effect = signals.effect local effectScope = signals.effectScope -local getCurrentSub = signals.getCurrentSub -local setCurrentSub = signals.setCurrentSub local onEffectCleanup = signals.onEffectCleanup local onScopeDispose = signals.onScopeDispose +local getCurrentSub = signals.getCurrentSub -type Cleanup = () -> () - -local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...Cleanup?): () -> () +local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () local scopes: { [K]: () -> () } = {} local currentItems: { [K]: V } = {} local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V }) for key in previousItems do - if currentItems[key] == nil then - local dispose = scopes[key] + if currentItems[key] == nil and scopes[key] then + scopes[key]() scopes[key] = nil - dispose() end end @@ -46,13 +42,7 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key currentItems = selector() if currentItems ~= previousItems then - local currentSub = setCurrentSub(nil) - local success, result: unknown = pcall(updateScopes, currentItems, previousItems) - setCurrentSub(currentSub) - - if not success then - error(`observe() encountered an error: {result}`, 2) - end + updateScopes(currentItems, previousItems) end end) diff --git a/pkgs/charm/src/signals.luau b/pkgs/charm/src/signals.luau index bb27e45..a1f4ae0 100644 --- a/pkgs/charm/src/signals.luau +++ b/pkgs/charm/src/signals.luau @@ -1,4 +1,4 @@ ---[=[ +--[[ alien-signals 2.0.1 implementation adopted from https://github.com/Nicell/alien-signals-luau https://github.com/stackblitz/alien-signals @@ -24,8 +24,9 @@ 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 noYield = require("./noYield") local system = require("./system") export type ReactiveFlags = system.ReactiveFlags @@ -43,12 +44,12 @@ local PENDING = 0b0100000 local QUEUED = 0b1000000 type EffectScope = ReactiveNode & { - cleanup: (() -> ())?, + cleanups: { () -> () }, } type Effect = ReactiveNode & { fn: () -> (), - cleanup: (() -> ())?, + cleanups: { () -> () }, } type Computed = ReactiveNode & { @@ -99,42 +100,28 @@ local function setCurrentScope(scope: Effect | EffectScope?) return prevScope end -local function addCleanup(e: Effect | EffectScope, cleanup: () -> ()) - local prevCleanup: (() -> ())? = e.cleanup - local nextCleanup = cleanup - - if prevCleanup then - function cleanup() - prevCleanup() - nextCleanup() - end - end - - (e :: Effect).cleanup = cleanup -end - local function onEffectCleanup(cleanup: () -> ()) if activeSub then - addCleanup(activeSub :: Effect, cleanup) + table.insert((activeSub :: Effect).cleanups, cleanup) end end local function onScopeDispose(cleanup: () -> ()) if activeScope then - addCleanup(activeScope, cleanup) + table.insert(activeScope.cleanups, cleanup) end end local function cleanupEffect(e: Effect | EffectScope) - if e.cleanup then - local prev = setCurrentSub(nil) - local success, result: unknown = pcall(e.cleanup) - setCurrentSub(prev); - (e :: Effect).cleanup = nil + for _, cleanup in e.cleanups :: { () -> () } do + local prevSub = setCurrentSub(nil) + local success, result: unknown = noYield(cleanup) + setCurrentSub(prevSub) if not success then error(result) end end + table.clear(e.cleanups) end local function run(e: Effect | EffectScope, flags: ReactiveFlags): () @@ -142,7 +129,7 @@ local function run(e: Effect | EffectScope, flags: ReactiveFlags): () cleanupEffect(e) local prev = setCurrentSub(e) startTracking(e) - local success, result: unknown = pcall((e :: Effect).fn) + local success, result: unknown = noYield((e :: Effect).fn) setCurrentSub(prev) endTracking(e) if not success then @@ -208,7 +195,7 @@ local function updateComputed(c: Computed): boolean local prevSub = setCurrentSub(c) startTracking(c) local oldValue = c.value - local success, result = pcall(c.getter, oldValue) + local success, result = noYield(c.getter, oldValue) setCurrentSub(prevSub) endTracking(c) if not success then @@ -254,7 +241,7 @@ local function signalGetterSetter(this: Signal): (() -> T, (value: ((T) -> local newValue = newValue :: T if type(newValue) == "function" then local prev = setCurrentSub(nil) - local success, result = pcall(newValue, value) + local success, result = noYield(newValue, value) setCurrentSub(prev) if not success then error(result) @@ -336,6 +323,7 @@ end local function effect(fn: () -> ()): () -> () local e: Effect = { fn = fn, + cleanups = {}, flags = WATCHING, } if activeSub then @@ -344,7 +332,7 @@ local function effect(fn: () -> ()): () -> () link(e, activeScope) end local prev = setCurrentSub(e) - local success, result: unknown = pcall(fn) + local success, result: unknown = noYield(fn) setCurrentSub(prev) if not success then error(result) @@ -354,6 +342,7 @@ end local function effectScope(fn: () -> (), detached: boolean?): () -> () local e: EffectScope = { + cleanups = {}, flags = NONE, } if activeScope and not detached then @@ -361,7 +350,7 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> () end local prevSub = setCurrentSub(nil) local prevScope = setCurrentScope(e) - local success, result: unknown = pcall(fn) + local success, result: unknown = noYield(fn) setCurrentScope(prevScope) setCurrentSub(prevSub) if not success then @@ -401,7 +390,7 @@ local function unwatched(node: Signal | Computed | Effect | EffectScope) toRemove = unlink(toRemove, node) until not toRemove end - elseif (node :: Signal).previousValue == nil then + elseif (node :: Effect).cleanups ~= nil then effectStop(node :: Effect)() end end diff --git a/pkgs/charm/src/subscribe.luau b/pkgs/charm/src/subscribe.luau index 01c4a98..6f5b84d 100644 --- a/pkgs/charm/src/subscribe.luau +++ b/pkgs/charm/src/subscribe.luau @@ -1,26 +1,21 @@ local signals = require("./signals") +local computed = signals.computed local effect = signals.effect -local setCurrentSub = signals.setCurrentSub local function subscribe(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () - local current: T - local mounted = true + local tracked = computed(function() + return selector() + end) + + local current = tracked() return effect(function() local previous = current - current = selector() + current = tracked() - if mounted then - mounted = false - elseif current ~= previous then - local currentSub = setCurrentSub(nil) - local success, result: unknown = pcall(listener, current, previous) - setCurrentSub(currentSub) - - if not success then - error(`subscribe() encountered an error: {result}`) - end + if current ~= previous then + listener(current, previous) end end) end diff --git a/pkgs/charm/src/system.luau b/pkgs/charm/src/system.luau index 79d3a48..0c6334b 100644 --- a/pkgs/charm/src/system.luau +++ b/pkgs/charm/src/system.luau @@ -1,4 +1,4 @@ ---[=[ +--[[ alien-signals 2.0.1 implementation adopted from https://github.com/Nicell/alien-signals-luau https://github.com/stackblitz/alien-signals @@ -24,7 +24,7 @@ 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?, diff --git a/pkgs/charm/src/types.luau b/pkgs/charm/src/types.luau index 20aa1f5..b237054 100644 --- a/pkgs/charm/src/types.luau +++ b/pkgs/charm/src/types.luau @@ -1,5 +1,9 @@ +export type Getter = () -> T +export type Setter = (update: Update) -> T +export type Update = T | (previous: T) -> T + export type Selector = () -> T -export type Atom = ((((T) -> T) | T) -> T) & (() -> T) +export type Atom = Setter & Getter return {} diff --git a/pkgs/charm/src/untracked.luau b/pkgs/charm/src/untracked.luau index ac77e85..4330920 100644 --- a/pkgs/charm/src/untracked.luau +++ b/pkgs/charm/src/untracked.luau @@ -1,10 +1,11 @@ +local noYield = require("./noYield") local signals = require("./signals") local setCurrentSub = signals.setCurrentSub local function untracked(callback: ((Args...) -> T) | (Args...) -> (), ...: Args...): T local currentSub = setCurrentSub(nil) - local success, result = pcall(callback, ...) + local success, result = noYield(callback, ...) setCurrentSub(currentSub) if not success then diff --git a/pkgs/charm/tests/gc.spec.luau b/pkgs/charm/tests/gc.spec.luau deleted file mode 100644 index 033a66f..0000000 --- a/pkgs/charm/tests/gc.spec.luau +++ /dev/null @@ -1,72 +0,0 @@ -local Charm = require("../src/lib") -local suite = require("@tests/suite") - -local atom = Charm.atom -local computed = Charm.computed -local effect = Charm.effect -local effectScope = Charm.effectScope -local test = suite.test - ---- Creates a weak reference to a value. ---- @return Function to dereference the value. -local function weakRef(value: T): () -> T? - local ref = setmetatable({ value = value }, { __mode = "v" }) - return function() - return ref.value - end -end - ---- Triggers garbage collection by allocating excessive memory. -local function gc() - for _ = 1, 1000 do - local _ = table.create(100, 0) - end -end - ---- In alien-signals, a computed value unlinks from dependencies when its last ---- parent scope is disposed. Calling a computed value outside of a scope links ---- it to dependencies, but the dependencies hold strong references to the ---- computed value, so it won't GC until called in a scope and disposed. ---- ---- This function releases a computed value by linking it to a scope and then ---- immediately closing it, causing alien-signals to unlink the computed value ---- and allow it to be garbage collected. -local function release(getter: () -> ()) - effectScope(function() - getter() - end)() -end - -test("should release computed cache", function() - local srcRef = weakRef({}) - local src = atom(srcRef()) - - local c = computed(function() - return src() - end) - - c() -- cache src value - src(nil) -- release value - c = release(c) -- release computed - - gc() - assert(srcRef() == nil, "should release computed cache") -end) - -test("should not release effect for atom", function() - local effectCalls = 0 - local src = atom(0) - - effect(function() - effectCalls += 1 - src() - end) - - assert(effectCalls == 1, "should call effect once") - - gc() - src(src() + 1) - assert(effectCalls == 2, "should call effect again") -end) - -return {} diff --git a/pkgs/charm/tests/listen.spec.luau b/pkgs/charm/tests/listen.spec.luau index 15d7457..a244a45 100644 --- a/pkgs/charm/tests/listen.spec.luau +++ b/pkgs/charm/tests/listen.spec.luau @@ -1,5 +1,5 @@ local Charm = require("../src/lib") -local suite = require("@tests/suite") +local suite = require("@test/suite") local atom = Charm.atom local listen = Charm.listen @@ -26,4 +26,34 @@ test("should listen to selector", function() assert(current == 2 and previous == 1, "should not run after unsub") end) +test("should track inner subs", function() + local order = {} + local a = atom(1) + + local unsub = listen(a, function() + table.insert(order, 1) + listen(a, function() + table.insert(order, 2) + end) + listen(a, function() + table.insert(order, 3) + end) + end) + + assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`) + + table.clear(order) + a(a() + 1) + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) + + table.clear(order) + a(a() + 1) + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) + + table.clear(order) + unsub() + a(a() + 1) + assert(table.concat(order) == "", `bad order: {table.concat(order)}`) +end) + return {} diff --git a/pkgs/charm/tests/mapped.spec.luau b/pkgs/charm/tests/mapped.spec.luau index 7bc4a0c..6d623bc 100644 --- a/pkgs/charm/tests/mapped.spec.luau +++ b/pkgs/charm/tests/mapped.spec.luau @@ -1,5 +1,5 @@ local Charm = require("../src/lib") -local suite = require("@tests/suite") +local suite = require("@test/suite") local atom = Charm.atom local mapped = Charm.mapped diff --git a/pkgs/charm/tests/observe.spec.luau b/pkgs/charm/tests/observe.spec.luau index 57da889..ddceca9 100644 --- a/pkgs/charm/tests/observe.spec.luau +++ b/pkgs/charm/tests/observe.spec.luau @@ -1,5 +1,5 @@ local Charm = require("../src/lib") -local suite = require("@tests/suite") +local suite = require("@test/suite") local atom = Charm.atom local computed = Charm.computed diff --git a/pkgs/charm/tests/signals/computed.spec.luau b/pkgs/charm/tests/signals/computed.spec.luau index 3a4721a..5c74738 100644 --- a/pkgs/charm/tests/signals/computed.spec.luau +++ b/pkgs/charm/tests/signals/computed.spec.luau @@ -1,5 +1,5 @@ local signals = require("../../src/signals") -local suite = require("@tests/suite") +local suite = require("@test/suite") local computed = signals.computed local signal = signals.signal diff --git a/pkgs/charm/tests/signals/effect.spec.luau b/pkgs/charm/tests/signals/effect.spec.luau index a0d70a2..9fad3ce 100644 --- a/pkgs/charm/tests/signals/effect.spec.luau +++ b/pkgs/charm/tests/signals/effect.spec.luau @@ -1,5 +1,5 @@ local signals = require("../../src/signals") -local suite = require("@tests/suite") +local suite = require("@test/suite") local computed = signals.computed local effect = signals.effect diff --git a/pkgs/charm/tests/signals/effectScope.spec.luau b/pkgs/charm/tests/signals/effectScope.spec.luau index 711f785..e48dcbe 100644 --- a/pkgs/charm/tests/signals/effectScope.spec.luau +++ b/pkgs/charm/tests/signals/effectScope.spec.luau @@ -1,5 +1,5 @@ local signals = require("../../src/signals") -local suite = require("@tests/suite") +local suite = require("@test/suite") local effect = signals.effect local effectScope = signals.effectScope diff --git a/pkgs/charm/tests/signals/gc.spec.luau b/pkgs/charm/tests/signals/gc.spec.luau index e08d265..2179894 100644 --- a/pkgs/charm/tests/signals/gc.spec.luau +++ b/pkgs/charm/tests/signals/gc.spec.luau @@ -1,10 +1,10 @@ -local Charm = require("@pkgs/charm/src/lib") -local suite = require("@tests/suite") +local signals = require("../../src/signals") +local suite = require("@test/suite") -local atom = Charm.atom -local computed = Charm.computed -local effect = Charm.effect -local effectScope = Charm.effectScope +local signal = signals.signal +local computed = signals.computed +local effect = signals.effect +local effectScope = signals.effectScope local test = suite.test --- Creates a weak reference to a value. @@ -39,23 +39,23 @@ end test("should release computed cache", function() local srcRef = weakRef({}) - local src = atom(srcRef()) + local src, setSrc = signal(srcRef()) local c = computed(function() return src() end) c() -- cache src value - src(nil) -- release value + setSrc(nil) -- release value c = release(c) -- release computed gc() assert(srcRef() == nil, "should release computed cache") end) -test("should not release effect for atom", function() +test("should not release effect for signal", function() local effectCalls = 0 - local src = atom(0) + local src, setSrc = signal(0) effect(function() effectCalls += 1 @@ -65,7 +65,7 @@ test("should not release effect for atom", function() assert(effectCalls == 1, "should call effect once") gc() - src(src() + 1) + setSrc(src() + 1) assert(effectCalls == 2, "should call effect again") end) diff --git a/pkgs/charm/tests/signals/issue_48.spec.luau b/pkgs/charm/tests/signals/issue_48.spec.luau index 5089a84..a60b79d 100644 --- a/pkgs/charm/tests/signals/issue_48.spec.luau +++ b/pkgs/charm/tests/signals/issue_48.spec.luau @@ -1,5 +1,5 @@ local signals = require("../../src/signals") -local suite = require("@tests/suite") +local suite = require("@test/suite") local computed = signals.computed local effect = signals.effect diff --git a/pkgs/charm/tests/signals/spec.luau b/pkgs/charm/tests/signals/spec.luau index 15c69f5..7d5bdd7 100644 --- a/pkgs/charm/tests/signals/spec.luau +++ b/pkgs/charm/tests/signals/spec.luau @@ -1,21 +1,27 @@ -local suite = require("@tests/suite") +local suite = require("@test/suite") -local describe = suite.describe - -local function spec(name: string) - describe(name, function() - return require(`./{name}.spec`) :: any +suite.describe("signals", function() + suite.describe("computed", function() + require("./computed.spec") + end) + suite.describe("effect", function() + require("./effect.spec") + end) + suite.describe("effectScope", function() + require("./effectScope.spec") + end) + suite.describe("issue_48", function() + require("./issue_48.spec") + end) + suite.describe("gc", function() + require("./gc.spec") + end) + suite.describe("topology", function() + require("./topology.spec") + end) + suite.describe("untrack", function() + require("./untrack.spec") end) -end - -describe("signals", function() - spec("computed") - spec("effect") - spec("effectScope") - spec("issue_48") - spec("gc") - spec("topology") - spec("untrack") end) return {} diff --git a/pkgs/charm/tests/signals/topology.spec.luau b/pkgs/charm/tests/signals/topology.spec.luau index 070b30c..7fa961d 100644 --- a/pkgs/charm/tests/signals/topology.spec.luau +++ b/pkgs/charm/tests/signals/topology.spec.luau @@ -1,5 +1,5 @@ local signals = require("../../src/signals") -local suite = require("@tests/suite") +local suite = require("@test/suite") local computed = signals.computed local effect = signals.effect diff --git a/pkgs/charm/tests/signals/untrack.spec.luau b/pkgs/charm/tests/signals/untrack.spec.luau index 56a28ae..f8f738f 100644 --- a/pkgs/charm/tests/signals/untrack.spec.luau +++ b/pkgs/charm/tests/signals/untrack.spec.luau @@ -1,5 +1,5 @@ local signals = require("../../src/signals") -local suite = require("@tests/suite") +local suite = require("@test/suite") local computed = signals.computed local setCurrentSub = signals.setCurrentSub diff --git a/pkgs/charm/tests/spec.luau b/pkgs/charm/tests/spec.luau index f86a562..40c00f1 100644 --- a/pkgs/charm/tests/spec.luau +++ b/pkgs/charm/tests/spec.luau @@ -1,19 +1,20 @@ -local suite = require("@tests/suite") - -local describe = suite.describe - -local function spec(name: string) - describe(name, function() - return require(`./{name}.spec`) :: any - end) -end +local suite = require("@test/suite") require("./signals/spec") -spec("gc") -spec("listen") -spec("mapped") -spec("observe") -spec("subscribe") +suite.describe("charm", function() + suite.describe("listen", function() + require("./listen.spec") + end) + suite.describe("mapped", function() + require("./mapped.spec") + end) + suite.describe("observe", function() + require("./observe.spec") + end) + suite.describe("subscribe", function() + require("./subscribe.spec") + end) +end) return {} diff --git a/pkgs/charm/tests/subscribe.spec.luau b/pkgs/charm/tests/subscribe.spec.luau index c2d5bd5..c5a3907 100644 --- a/pkgs/charm/tests/subscribe.spec.luau +++ b/pkgs/charm/tests/subscribe.spec.luau @@ -1,11 +1,12 @@ local Charm = require("../src/lib") -local suite = require("@tests/suite") +local suite = require("@test/suite") type Atom = Charm.Atom local atom = Charm.atom local computed = Charm.computed local subscribe = Charm.subscribe +local listen = Charm.listen local test = suite.test test("should subscribe to selector", function() @@ -55,4 +56,33 @@ test("should ensure correct execution order", function() assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) end) +test("should track inner subs", function() + local order = {} + local a = atom(1) + + local unsub = subscribe(a, function() + table.insert(order, 1) + listen(a, function() + table.insert(order, 2) + end) + listen(a, function() + table.insert(order, 3) + end) + end) + + assert(table.concat(order) == "", "should not run immediately") + + a(a() + 1) + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) + + table.clear(order) + a(a() + 1) + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) + + table.clear(order) + unsub() + a(a() + 1) + assert(table.concat(order) == "", `bad order: {table.concat(order)}`) +end) + return {} diff --git a/pkgs/react-charm/src/init.luau b/pkgs/react-charm/src/init.luau index 9d13132..9293a5f 100644 --- a/pkgs/react-charm/src/init.luau +++ b/pkgs/react-charm/src/init.luau @@ -1,32 +1,56 @@ local Charm = require(script.Parent.Charm) local React = require(script.Parent.React) -local listen = Charm.listen +local effect = Charm.effect +local useBinding = React.useBinding 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. - - If the `dependencies` array is provided, the subscription to the atom or - selector is re-created whenever the dependencies change. Otherwise, the - subscription is created once when the component is mounted. - - @param callback The atom or selector to subscribe to. - @param dependencies An array of values that the subscription depends on. - @return The current state. -]=] -local function useAtom(callback: () -> State, dependencies: { any }?): State - local state, setState = useState(callback) +--- A hook that subscribes to changes in the given atom or selector. The +--- component is re-rendered whenever the state changes. +--- +--- If the `dependencies` array is provided, the effect is re-created whenever +--- the dependencies change. Otherwise, the subscription is created once when +--- the component is mounted. +--- +--- @param selector The atom or selector to subscribe to. +--- @param dependencies An array of values that the subscription depends on. +--- @return The current state. +local function useAtom(selector: () -> State, dependencies: { any }?): State + local state, setState = useState(selector) useEffect(function() - return listen(callback, setState) + return effect(function() + setState(selector()) + end) end, dependencies or {}) return state end +--- A hook that subscribes to changes in the given atom or selector. Returns +--- a binding that updates when the state changes. +--- +--- If the `dependencies` array is provided, the effect is re-created whenever +--- the dependencies change. Otherwise, the subscription is created once when +--- the component is mounted. +--- +--- @param selector The atom or selector to subscribe to. +--- @param dependencies An array of values that the subscription depends on. +--- @return A binding that stores the current state. +local function useAtomBinding(selector: () -> State, dependencies: { any }?) + local binding, setBinding = useBinding(selector) + + useEffect(function() + return effect(function() + setBinding(selector()) + end) + end, dependencies or {}) + + return binding +end + return { useAtom = useAtom, + useAtomBinding = useAtomBinding, } diff --git a/pkgs/react-charm/tests/spec.luau b/pkgs/react-charm/tests/spec.luau index 2ce9682..dabaab7 100644 --- a/pkgs/react-charm/tests/spec.luau +++ b/pkgs/react-charm/tests/spec.luau @@ -2,16 +2,13 @@ local ServerScriptService = game:GetService("ServerScriptService") local suite = require(ServerScriptService.Tests.suite) -local describe = suite.describe - -local function spec(name: string) - describe(name, function() - return require(`./{name}.spec`) :: any +suite.describe("react-charm", function() + suite.describe("useAtom", function() + require("./useAtom.spec") + end) + suite.describe("useAtomBinding", function() + require("./useAtomBinding.spec") end) -end - -describe("react-charm", function() - spec("useAtom") end) return {} diff --git a/pkgs/react-charm/tests/useAtomBinding.spec.luau b/pkgs/react-charm/tests/useAtomBinding.spec.luau new file mode 100644 index 0000000..28dcad2 --- /dev/null +++ b/pkgs/react-charm/tests/useAtomBinding.spec.luau @@ -0,0 +1,50 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local ServerScriptService = game:GetService("ServerScriptService") + +local Charm = require(ReplicatedStorage.Packages.Charm) +local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm) +local renderHook = require(script.Parent.helpers.renderHook) +local suite = require(ServerScriptService.Tests.suite) + +local atom = Charm.atom +local useAtomBinding = ReactCharm.useAtomBinding +local test = suite.test + +test("tracks atom state", function() + local state = atom(0) + local hook = renderHook(function() + return useAtomBinding(state) + end) + assert(hook.result:getValue() == 0, "should return 0") + state(1) + assert(hook.result:getValue() == 1, "should return 1") +end) + +test("accepts a selector", function() + local state = atom(0) + local hook = renderHook(function() + return useAtomBinding(function() + return 2 * state() + end) + end) + assert(hook.result:getValue() == 0, "should return 0") + state(1) + assert(hook.result:getValue() == 2, "should return 2") +end) + +test("tracks dependencies", function() + local state = atom(0) + local hook = renderHook(function() + return useAtomBinding(state, { state }) + end) + assert(hook.result:getValue() == 0, "should return 0") + state(1) + assert(hook.result:getValue() == 1, "should return 1") + state = atom(0) + hook.rerender() + assert(hook.result:getValue() == 0, "should return 0") + state(1) + assert(hook.result:getValue() == 1, "should return 1") +end) + +return {} diff --git a/pkgs/vide-charm/src/init.luau b/pkgs/vide-charm/src/init.luau index ef7bb25..805a634 100644 --- a/pkgs/vide-charm/src/init.luau +++ b/pkgs/vide-charm/src/init.luau @@ -5,15 +5,14 @@ local subscribe = Charm.subscribe local source = Vide.source local cleanup = Vide.cleanup ---[=[ - Subscribes to the state of an atom and returns a Vide source. - - @param callback The atom or selector to subscribe to. - @return The reactive source. -]=] -local function useAtom(callback: () -> State): () -> State - local state = source(callback()) - cleanup(subscribe(callback, state)) +--- Subscribes to the state of an atom or selector. Returns a source that +--- updates when the state changes. +--- +--- @param selector The atom or selector to subscribe to. +--- @return The reactive source. +local function useAtom(selector: () -> State): () -> State + local state = source(selector()) + cleanup(subscribe(selector, state)) return state end diff --git a/pkgs/vide-charm/tests/spec.luau b/pkgs/vide-charm/tests/spec.luau index 2bde270..4583308 100644 --- a/pkgs/vide-charm/tests/spec.luau +++ b/pkgs/vide-charm/tests/spec.luau @@ -2,16 +2,10 @@ local ServerScriptService = game:GetService("ServerScriptService") local suite = require(ServerScriptService.Tests.suite) -local describe = suite.describe - -local function spec(name: string) - describe(name, function() - return require(`./{name}.spec`) :: any +suite.describe("vide-charm", function() + suite.describe("useAtom", function() + require("./useAtom.spec") end) -end - -describe("vide-charm", function() - spec("useAtom") end) return {} diff --git a/tests/init.luau b/test/init.luau similarity index 79% rename from tests/init.luau rename to test/init.luau index 0b17cbc..467958e 100644 --- a/tests/init.luau +++ b/test/init.luau @@ -1,4 +1,4 @@ -local suite = require("@tests/suite") +local suite = require("@test/suite") if not table.isfrozen(_G) then _G.__DEV__ = true diff --git a/tests/spec.server.luau b/test/spec.server.luau similarity index 100% rename from tests/spec.server.luau rename to test/spec.server.luau diff --git a/tests/suite.luau b/test/suite.luau similarity index 92% rename from tests/suite.luau rename to test/suite.luau index e23b9d4..516db20 100644 --- a/tests/suite.luau +++ b/test/suite.luau @@ -42,13 +42,13 @@ local color = { return if options.colors then `\27[90;1m{text}\27[0m` else text end, green = function(text: string) - return if options.colors then `\27[32;1m{text}\27[0m` else text + return if options.colors then `\27[32;1m{text}\27[0m` else `\u{1F7E2} {text}` end, red = function(text: string) - return if options.colors then `\27[31;1m{text}\27[0m` else text + return if options.colors then `\27[31;1m{text}\27[0m` else `\u{1F534} {text}` end, blue = function(text: string) - return if options.colors then `\27[34;1m{text}\27[0m` else text + return if options.colors then `\27[34;1m{text}\27[0m` else `\u{1F535} {text}` end, } @@ -405,27 +405,33 @@ local function finish(config: Config?) report() end -local testImpl = setmetatable({ +local testCtor = setmetatable({ skip = testSkip, only = testOnly, }, { - __call = function(_, name: string, run: () -> ()) - return test(name, run) + __call = function(_, ...) + return test(...) end, }) -local describeImpl = setmetatable({ +local describeCtor = setmetatable({ skip = describeSkip, only = describeOnly, }, { - __call = function(_, name: string, run: () -> ()) - return describe(name, run) + __call = function(_, ...) + return describe(...) end, }) return { - test = testImpl, - describe = describeImpl, + test = testCtor :: ((name: string, callback: () -> ()) -> ()) & { + skip: (name: string, callback: () -> ()) -> (), + only: (name: string, callback: () -> ()) -> (), + }, + describe = describeCtor :: ((name: string, callback: () -> ()) -> ()) & { + skip: (name: string, callback: () -> ()) -> (), + only: (name: string, callback: () -> ()) -> (), + }, skip = skip, only = only, finish = finish, From 25728e589fc020ac1f0afc35f2dc1ca015813b2c Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 12 May 2025 02:20:41 -0700 Subject: [PATCH 029/160] style: use `---` doc comments --- pkgs/charm-sync/src/client.luau | 32 +++++-------- pkgs/charm-sync/src/init.luau | 4 -- pkgs/charm-sync/src/interval.luau | 12 ++--- pkgs/charm-sync/src/patch.luau | 52 +++++++++------------ pkgs/charm-sync/src/server.luau | 78 ++++++++++++------------------- pkgs/charm-sync/src/types.luau | 6 +-- pkgs/charm-sync/src/validate.luau | 12 ++--- 7 files changed, 77 insertions(+), 119 deletions(-) diff --git a/pkgs/charm-sync/src/client.luau b/pkgs/charm-sync/src/client.luau index 8dec492..a36c55b 100644 --- a/pkgs/charm-sync/src/client.luau +++ b/pkgs/charm-sync/src/client.luau @@ -6,34 +6,26 @@ type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload type ClientOptions = { - --[=[ - The atoms to synchronize with the server. - ]=] + --- The atoms to synchronize with the server. atoms: AtomMap, - --[=[ - Whether to ignore patches sent before the client has been hydrated. - Default is `true`. - ]=] + --- Whether to ignore patches sent before the client has been hydrated. + --- Default is `true`. ignoreUnhydrated: boolean?, } type ClientSyncer = { - --[=[ - Applies a patch or initializes the state of the atoms with the given - payload from the server. - - @param ...payloads The patches or hydration payloads to apply. - ]=] + --- Applies a patch or initializes the state of the atoms with the given + --- payload from the server. + --- + --- @param ...payloads The patches or hydration payloads to apply. sync: (self: ClientSyncer, ...SyncPayload) -> (), } ---[=[ - Creates a `ClientSyncer` object that receives patches from the server and - applies them to the local state. - - @param options The atoms to synchronize with the server. - @return A `ClientSyncer` object. -]=] +--- Creates a `ClientSyncer` object that receives patches from the server and +--- applies them to the local state. +--- +--- @param options The atoms to synchronize with the server. +--- @return A `ClientSyncer` object. local function client(options: ClientOptions): ClientSyncer local atoms = options.atoms local ignoreUnhydrated = options.ignoreUnhydrated ~= false diff --git a/pkgs/charm-sync/src/init.luau b/pkgs/charm-sync/src/init.luau index a3e8fb2..0978041 100644 --- a/pkgs/charm-sync/src/init.luau +++ b/pkgs/charm-sync/src/init.luau @@ -7,10 +7,6 @@ local validate = require(script.validate) export type SyncPayload = types.SyncPayload ---[=[ - Synchronizes state between the client and server. The server sends patches - to the client, which applies them to its local state. -]=] return { client = client, server = server, diff --git a/pkgs/charm-sync/src/interval.luau b/pkgs/charm-sync/src/interval.luau index 31df067..7788d41 100644 --- a/pkgs/charm-sync/src/interval.luau +++ b/pkgs/charm-sync/src/interval.luau @@ -1,12 +1,10 @@ local RunService = game:GetService("RunService") ---[=[ - Schedules a function to be called at a fixed interval of `seconds`. - - @param callback The function to call. - @param seconds The interval in seconds. - @return A function that cancels the interval. -]=] +--- Schedules a function to be called at a fixed interval of `seconds`. +--- +--- @param callback The function to call. +--- @param seconds The interval in seconds. +--- @return A function that cancels the interval. local function interval(callback: () -> (), seconds: number): () -> () if seconds < 0 then return function() end diff --git a/pkgs/charm-sync/src/patch.luau b/pkgs/charm-sync/src/patch.luau index 3d021b5..4d87738 100644 --- a/pkgs/charm-sync/src/patch.luau +++ b/pkgs/charm-sync/src/patch.luau @@ -2,26 +2,22 @@ local validate = require(script.Parent.validate) local __DEV__ = _G.__DEV__ ---[=[ - A special symbol that denotes the absence of a value. Used to represent - deleted values in patches. -]=] +--- A special symbol that denotes the absence of a value. Used to represent +--- deleted values in patches. local None = { __none = "__none" } local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" end ---[=[ - JSON serialization can drop the last values of a sparse array. For example, - `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the - serialization of sparse arrays can be resolved by converting them to - dictionaries with string keys. - - The `apply` function will convert these dictionaries back into arrays as - long as the target array is not sparse. This function ensures that the diff - function produces a patch that can be correctly applied. -]=] +--- JSON serialization can drop the last values of a sparse array. For example, +--- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the +--- serialization of sparse arrays can be resolved by converting them to +--- dictionaries with string keys. +--- +--- The `apply` function will convert these dictionaries back into arrays as +--- long as the target array is not sparse. This function ensures that the diff +--- function produces a patch that can be correctly applied. local function stringifySparseArray(object: { any }): { [string | number]: any } local maxn = table.maxn(object) @@ -39,15 +35,13 @@ local function stringifySparseArray(object: { any }): { [string | number]: any } return dictionary end ---[=[ - Compares two states and returns a patch that can be applied to the previous - state to produce the next state. - - @param prevState The previous state. - @param nextState The next state. - @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`. - @returns A patch that can be applied to the previous state. -]=] +--- Compares two states and returns a patch that can be applied to the previous +--- state to produce the next state. +--- +--- @param prevState The previous state. +--- @param nextState The next state. +--- @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`. +--- @returns A patch that can be applied to the previous state. local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?) serialize = serialize ~= false @@ -79,13 +73,11 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial return patches end ---[=[ - Applies a patch to a state and returns the next state. - - @param state The current state. - @param patches The patches to apply. - @returns The next state. -]=] +--- Applies a patch to a state and returns the next state. +--- +--- @param state The current state. +--- @param patches The patches to apply. +--- @returns The next state. local function apply(state: any, patches: any): any if type(patches) == "table" and patches.__none == "__none" then return nil diff --git a/pkgs/charm-sync/src/server.luau b/pkgs/charm-sync/src/server.luau index afb646e..80fac19 100644 --- a/pkgs/charm-sync/src/server.luau +++ b/pkgs/charm-sync/src/server.luau @@ -9,65 +9,49 @@ type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload type ServerOptions = { - --[=[ - The atoms to synchronize with the client. - ]=] + --- The atoms to synchronize with the client. atoms: AtomMap, - --[=[ - The interval at which to send patches to the client, in seconds. - Defaults to `0` (patches are sent up to once per frame). Set to a - negative value to disable automatic syncing. - ]=] + --- The interval at which to send patches to the client, in seconds. + --- Defaults to `0` (patches are sent up to once per frame). Set to a + --- negative value to disable automatic syncing. interval: number?, - --[=[ - Whether the history of state changes since the client's last update - should be preserved. This is useful for values that change multiple times - per frame, where each individual change is important. Defaults to `false`. - ]=] + --- Whether the history of state changes since the client's last update + --- should be preserved. This is useful for values that change multiple times + --- per frame, where each individual change is important. Defaults to `false`. preserveHistory: boolean?, - --[=[ - When `true`, Charm will apply validation and serialize unsafe arrays - to address remote event argument limitations. Defaults to `true`. - - This option should be disabled if your network library uses a custom - serialization method (i.e. Zap, ByteNet) to prevent interference. - ]=] + --- When `true`, Charm will apply validation and serialize unsafe arrays + --- to address remote event argument limitations. Defaults to `true`. + --- + --- This option should be disabled if your network library uses a custom + --- serialization method (i.e. Zap, ByteNet) to prevent interference. autoSerialize: boolean?, } type ServerSyncer = { - --[=[ - Sets up a subscription to each atom that schedules a patch to be sent to - the client whenever the state changes. When a change occurs, the `callback` - is called with the player and the payload to send. - - Note that the `payload` object should not be mutated. If you need to - modify the payload, apply the changes to a copy of the object. - - @param callback The function to call when the state changes. - @return A cleanup function that unsubscribes all listeners. - ]=] + --- Sets up a subscription to each atom that schedules a patch to be sent to + --- the client whenever the state changes. When a change occurs, the `callback` + --- is called with the player and the payload to send. + --- + --- Note that the `payload` object should not be mutated. If you need to + --- modify the payload, apply the changes to a copy of the object. + --- + --- @param callback The function to call when the state changes. + --- @return A cleanup function that unsubscribes all listeners. connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (), - --[=[ - Hydrates the client's state with the server's state. This should be - called when a player joins the game and requires the server's state. - - @param player The player to hydrate. - ]=] + --- Hydrates the client's state with the server's state. This should be + --- called when a player joins the game and requires the server's state. + --- + --- @param player The player to hydrate. hydrate: (self: ServerSyncer, player: Player) -> (), - --[=[ - @deprecated For internal use only. - ]=] + --- @deprecated For internal use only. _sendPatch: (self: ServerSyncer, player: Player) -> (), } ---[=[ - Creates a `ServerSyncer` object that sends patches to the client and - hydrates the client's state. - - @param options The atoms to synchronize with the client. - @return A `ServerSyncer` object. -]=] +--- Creates a `ServerSyncer` object that sends patches to the client and +--- hydrates the client's state. +--- +--- @param options The atoms to synchronize with the client. +--- @return A `ServerSyncer` object. local function server(options: ServerOptions): ServerSyncer local atoms = options.atoms local autoSerialize = options.autoSerialize ~= false diff --git a/pkgs/charm-sync/src/types.luau b/pkgs/charm-sync/src/types.luau index 7942c80..e29bd62 100644 --- a/pkgs/charm-sync/src/types.luau +++ b/pkgs/charm-sync/src/types.luau @@ -2,10 +2,8 @@ local Charm = require(script.Parent.Parent.Charm) type Atom = Charm.Atom ---[=[ - A payload that can be sent from the server to the client to synchronize - state between the two. -]=] +--- A payload that can be sent from the server to the client to synchronize +--- state between the two. export type SyncPayload = { type: "init", data: { [string | number]: any }, diff --git a/pkgs/charm-sync/src/validate.luau b/pkgs/charm-sync/src/validate.luau index c832542..769f410 100644 --- a/pkgs/charm-sync/src/validate.luau +++ b/pkgs/charm-sync/src/validate.luau @@ -27,13 +27,11 @@ local function isTableUnsafe(object: { [any]: any }) return false end ---[=[ - Validates a value to ensure it can be synced over a remote event. - - @param value The value to validate. - @param key The key of the value in the table. - @error Throws an error if the value cannot be synced. -]=] +--- Validates a value to ensure it can be synced over a remote event. +--- +--- @param value The value to validate. +--- @param key The key of the value in the table. +--- @error Throws an error if the value cannot be synced. local function validate(value: any, key: any) local typeOfKey = type(key) local typeOfValue = type(value) From 4931515a951512a320530863da88185203be889d Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 12 May 2025 19:26:15 -0700 Subject: [PATCH 030/160] refactor: improve logic --- pkgs/charm/src/atom.luau | 18 ++--- pkgs/charm/src/batched.luau | 18 +++-- pkgs/charm/src/lib.luau | 6 +- pkgs/charm/src/listen.luau | 9 ++- pkgs/charm/src/noYield.luau | 11 +-- pkgs/charm/src/observe.luau | 9 +-- pkgs/charm/src/onCleanup.luau | 20 ++++++ pkgs/charm/src/subscribe.luau | 16 ++++- pkgs/charm/src/types.luau | 4 +- pkgs/charm/src/untracked.luau | 17 +++-- pkgs/charm/tests/listen.spec.luau | 42 +++++++++++ pkgs/charm/tests/signals/effect.spec.luau | 30 ++++++++ pkgs/charm/tests/spec.luau | 3 + pkgs/charm/tests/subscribe.spec.luau | 33 +++++++++ pkgs/charm/tests/untracked.spec.luau | 86 +++++++++++++++++++++++ 15 files changed, 276 insertions(+), 46 deletions(-) create mode 100644 pkgs/charm/src/onCleanup.luau create mode 100644 pkgs/charm/tests/untracked.spec.luau diff --git a/pkgs/charm/src/atom.luau b/pkgs/charm/src/atom.luau index ec70d79..e4651ac 100644 --- a/pkgs/charm/src/atom.luau +++ b/pkgs/charm/src/atom.luau @@ -10,6 +10,14 @@ local signal = signals.signal type Equals = (current: T, incoming: T) -> boolean +local function createAtom(getter: Getter, setter: Setter): Atom + local function atom(...: Update): T + return if select("#", ...) == 0 then getter() else setter((...)) + end + + return atom +end + local function createAtomWithEqualityCheck(getter: Getter, setter: Setter, equals: Equals): Atom local function atom(...: Update): T if select("#", ...) == 0 then @@ -27,18 +35,10 @@ local function createAtomWithEqualityCheck(getter: Getter, setter: Setter< return atom end -local function createAtom(getter: Getter, setter: Setter): Atom - local function atom(...: Update): T - return if select("#", ...) == 0 then getter() else setter((...)) - end - - return atom -end - local function atom(state: T, equals: Equals?): Atom local getter, setter = signal(state) - if equals == nil then + if not equals then return createAtom(getter, setter) else return createAtomWithEqualityCheck(getter, setter, equals) diff --git a/pkgs/charm/src/batched.luau b/pkgs/charm/src/batched.luau index 4eaa86b..4c23803 100644 --- a/pkgs/charm/src/batched.luau +++ b/pkgs/charm/src/batched.luau @@ -1,16 +1,22 @@ local signals = require("./signals") +type ReactiveNode = signals.ReactiveNode + local startBatch = signals.startBatch local endBatch = signals.endBatch -local function batched(callback: () -> ()) - startBatch() - local success, result: any = pcall(callback) +local function handleBatched(ok: boolean, ...) endBatch() - - if not success then - error(result) + if not ok then + return ... + else + error(...) end end +local function batched(callback: (Args...) -> Results..., ...: Args...): Results... + startBatch() + return handleBatched(pcall(callback, ...)) +end + return batched diff --git a/pkgs/charm/src/lib.luau b/pkgs/charm/src/lib.luau index bae8330..89bb935 100644 --- a/pkgs/charm/src/lib.luau +++ b/pkgs/charm/src/lib.luau @@ -4,6 +4,7 @@ local effect = require("./effect") local listen = require("./listen") local mapped = require("./mapped") local observe = require("./observe") +local onCleanup = require("./onCleanup") local signals = require("./signals") local subscribe = require("./subscribe") local types = require("./types") @@ -17,8 +18,6 @@ export type Update = types.Update local computed = signals.computed local effectScope = signals.effectScope -local onEffectCleanup = signals.onEffectCleanup -local onScopeDispose = signals.onScopeDispose return { atom = atom, @@ -31,7 +30,6 @@ return { observe = observe, subscribe = subscribe, untracked = untracked, - onEffectCleanup = onEffectCleanup, - onScopeDispose = onScopeDispose, + onCleanup = onCleanup, signals = signals, } diff --git a/pkgs/charm/src/listen.luau b/pkgs/charm/src/listen.luau index 816c165..89688cf 100644 --- a/pkgs/charm/src/listen.luau +++ b/pkgs/charm/src/listen.luau @@ -2,6 +2,8 @@ local signals = require("./signals") local computed = signals.computed local effect = signals.effect +local effectScope = signals.effectScope +local onEffectCleanup = signals.onEffectCleanup local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () local tracked = computed(function() @@ -18,7 +20,12 @@ local function listen(selector: () -> T, listener: (state: T, previous: T?, d dispose = effect(function() local previous = current current = tracked() - listener(current, previous, dispose) + + local stopScope = effectScope(function() + listener(current, previous, dispose) + end) + + onEffectCleanup(stopScope) end) if disposed then diff --git a/pkgs/charm/src/noYield.luau b/pkgs/charm/src/noYield.luau index 2d381b1..d132fe6 100644 --- a/pkgs/charm/src/noYield.luau +++ b/pkgs/charm/src/noYield.luau @@ -1,11 +1,6 @@ local function handleNoYield(thread: thread, ok: boolean, ...) if not ok then - local err = (...) - if typeof(err) == "string" then - return false, debug.traceback(thread, err) - else - return false, tostring(err) - end + return false, debug.traceback(thread, tostring((...))) end if coroutine.status(thread) ~= "dead" then @@ -20,6 +15,6 @@ local function noYield(callback: (Args...) -> Results..., . return handleNoYield(thread, coroutine.resume(thread, ...)) end --- To eliminate the overhead of noYield returning callback(...) in debug mode, --- replace it with pcall to avoid wrapping the callback in another function. +-- To eliminate the overhead of noYield returning callback(...) in production, +-- replace it with pcall to simulate the same behavior. return if _G.__DEV__ then noYield else pcall :: never diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau index 108b969..9a0d1a9 100644 --- a/pkgs/charm/src/observe.luau +++ b/pkgs/charm/src/observe.luau @@ -1,10 +1,9 @@ +local onCleanup = require("./onCleanup") local signals = require("./signals") local effect = signals.effect local effectScope = signals.effectScope -local onEffectCleanup = signals.onEffectCleanup local onScopeDispose = signals.onScopeDispose -local getCurrentSub = signals.getCurrentSub local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () local scopes: { [K]: () -> () } = {} @@ -46,11 +45,7 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key end end) - if getCurrentSub() then - onEffectCleanup(disposeScopes) - else - onScopeDispose(disposeScopes) - end + onCleanup(disposeScopes, true) return function() disposeEffect() diff --git a/pkgs/charm/src/onCleanup.luau b/pkgs/charm/src/onCleanup.luau new file mode 100644 index 0000000..f96266d --- /dev/null +++ b/pkgs/charm/src/onCleanup.luau @@ -0,0 +1,20 @@ +local signals = require("./signals") + +local onEffectCleanup = signals.onEffectCleanup +local onScopeDispose = signals.onScopeDispose +local getCurrentScope = signals.getCurrentScope +local getCurrentSub = signals.getCurrentSub + +local warn = warn or error :: never + +local function onCleanup(cleanup: () -> (), failSilently: boolean?) + if getCurrentSub() then + onEffectCleanup(cleanup) + elseif getCurrentScope() then + onScopeDispose(cleanup) + elseif _G.__DEV__ and not failSilently then + warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) + end +end + +return onCleanup diff --git a/pkgs/charm/src/subscribe.luau b/pkgs/charm/src/subscribe.luau index 6f5b84d..73c3782 100644 --- a/pkgs/charm/src/subscribe.luau +++ b/pkgs/charm/src/subscribe.luau @@ -2,21 +2,31 @@ local signals = require("./signals") local computed = signals.computed local effect = signals.effect +local effectScope = signals.effectScope +local onEffectCleanup = signals.onEffectCleanup local function subscribe(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () local tracked = computed(function() return selector() end) - local current = tracked() + local current: T + local mounted = true return effect(function() local previous = current current = tracked() - if current ~= previous then - listener(current, previous) + if mounted then + mounted = false + return end + + local stopScope = effectScope(function() + listener(current, previous) + end) + + onEffectCleanup(stopScope) end) end diff --git a/pkgs/charm/src/types.luau b/pkgs/charm/src/types.luau index b237054..7775ece 100644 --- a/pkgs/charm/src/types.luau +++ b/pkgs/charm/src/types.luau @@ -1,9 +1,11 @@ export type Getter = () -> T + export type Setter = (update: Update) -> T + export type Update = T | (previous: T) -> T export type Selector = () -> T -export type Atom = Setter & Getter +export type Atom = (...Update) -> T return {} diff --git a/pkgs/charm/src/untracked.luau b/pkgs/charm/src/untracked.luau index 4330920..503b1bd 100644 --- a/pkgs/charm/src/untracked.luau +++ b/pkgs/charm/src/untracked.luau @@ -1,18 +1,21 @@ local noYield = require("./noYield") local signals = require("./signals") +type ReactiveNode = signals.ReactiveNode + local setCurrentSub = signals.setCurrentSub -local function untracked(callback: ((Args...) -> T) | (Args...) -> (), ...: Args...): T - local currentSub = setCurrentSub(nil) - local success, result = noYield(callback, ...) +local function handleUntracked(currentSub: ReactiveNode?, ok: boolean, ...) setCurrentSub(currentSub) - - if not success then - error(result) + if ok then + return ... + else + error(...) end +end - return result +local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... + return handleUntracked(setCurrentSub(nil), noYield(callback, ...)) end return untracked diff --git a/pkgs/charm/tests/listen.spec.luau b/pkgs/charm/tests/listen.spec.luau index a244a45..ad3fbec 100644 --- a/pkgs/charm/tests/listen.spec.luau +++ b/pkgs/charm/tests/listen.spec.luau @@ -3,6 +3,7 @@ local suite = require("@test/suite") local atom = Charm.atom local listen = Charm.listen +local effect = Charm.effect local test = suite.test test("should listen to selector", function() @@ -26,6 +27,47 @@ test("should listen to selector", function() assert(current == 2 and previous == 1, "should not run after unsub") end) +test("should run in untracked", function() + local runs = 0 + local cleanups = 0 + local a = atom(0) + local b = atom(0) + + listen(a, function() + runs += 1 + b() + effect(function() + return function() + cleanups += 1 + end + end) + end) + + assert(runs == 1, "should run once on init") + assert(cleanups == 0, "should not cleanup yet") + + a(1) + assert(runs == 2, "should run on a change") + assert(cleanups == 1, "should cleanup first call") + + b(1) + assert(runs == 2, "should not run on b change") + assert(cleanups == 1, "should not cleanup second call") +end) + +test("should run if first value is nil", function() + local runs = 0 + local source = atom(nil :: number?) + + listen(source, function() + runs += 1 + end) + + assert(runs == 1, "should run once on init") + source(1) + assert(runs == 2, "should run once on change") +end) + test("should track inner subs", function() local order = {} local a = atom(1) diff --git a/pkgs/charm/tests/signals/effect.spec.luau b/pkgs/charm/tests/signals/effect.spec.luau index 9fad3ce..58ad958 100644 --- a/pkgs/charm/tests/signals/effect.spec.luau +++ b/pkgs/charm/tests/signals/effect.spec.luau @@ -326,4 +326,34 @@ test("should run inner effect cleanup", function() assert(cleanups == 4, "should not cleanup after update") end) +test("should run cleanups outside any evaluation context", function() + local calls = 0 + local a, setA = signal(0) + local b, setB = signal(0) + local c = computed(function() + if a() == 0 then + effect(function() + onEffectCleanup(function() + b() + end) + end) + end + return a() + end) + + effect(function() + calls += 1 + c() + end) + assert(calls == 1, "should run once") + + calls = 0 + setA(1) + assert(calls == 1, "should run once after a update") + + calls = 0 + setB(1) + assert(calls == 0, "should not run after b update") +end) + return {} diff --git a/pkgs/charm/tests/spec.luau b/pkgs/charm/tests/spec.luau index 40c00f1..a2b8653 100644 --- a/pkgs/charm/tests/spec.luau +++ b/pkgs/charm/tests/spec.luau @@ -15,6 +15,9 @@ suite.describe("charm", function() suite.describe("subscribe", function() require("./subscribe.spec") end) + suite.describe("untracked", function() + require("./untracked.spec") + end) end) return {} diff --git a/pkgs/charm/tests/subscribe.spec.luau b/pkgs/charm/tests/subscribe.spec.luau index c5a3907..7e7ae87 100644 --- a/pkgs/charm/tests/subscribe.spec.luau +++ b/pkgs/charm/tests/subscribe.spec.luau @@ -7,6 +7,7 @@ local atom = Charm.atom local computed = Charm.computed local subscribe = Charm.subscribe local listen = Charm.listen +local effect = Charm.effect local test = suite.test test("should subscribe to selector", function() @@ -30,6 +31,38 @@ test("should subscribe to selector", function() assert(current == 2 and previous == 1, "should not run after unsub") end) +test("should run in untracked", function() + local runs = 0 + local cleanups = 0 + local a = atom(0) + local b = atom(0) + + subscribe(a, function() + runs += 1 + b() + effect(function() + return function() + cleanups += 1 + end + end) + end) + + assert(runs == 0, "should not run once on init") + assert(cleanups == 0, "should not cleanup yet") + + a(1) + assert(runs == 1, "should run once on change") + assert(cleanups == 0, "should not cleanup yet") + + b(1) + assert(runs == 1, "should not run on b change") + assert(cleanups == 0, "should not cleanup first call") + + a(2) + assert(runs == 2, "should run on a change") + assert(cleanups == 1, "should cleanup first call") +end) + test("should ensure correct execution order", function() local order = {} local n1 = atom(0) diff --git a/pkgs/charm/tests/untracked.spec.luau b/pkgs/charm/tests/untracked.spec.luau new file mode 100644 index 0000000..a4dd1c7 --- /dev/null +++ b/pkgs/charm/tests/untracked.spec.luau @@ -0,0 +1,86 @@ +local Charm = require("../src/lib") +local suite = require("@test/suite") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local untracked = Charm.untracked +local test = suite.test + +test("should block tracking inside effects", function() + local a = atom(1) + local b = atom(2) + local spyCalls = 0 + + effect(function() + untracked(function() + spyCalls += 1 + return a() + b() + end) + end) + assert(spyCalls == 1, "should be called once") + + a(10) + b(20) + assert(spyCalls == 1, "should be called once") +end) + +test("should block tracking even when run inside effect run inside untracked", function() + local s = atom(1) + local spyCalls = 0 + + untracked(function() + effect(function() + untracked(function() + spyCalls += 1 + return s() + end) + end) + end) + assert(spyCalls == 1, "should be called once") + + s(2) + assert(spyCalls == 1, "should be called once") +end) + +test("should not cause atom assignments throw", function() + local a = atom(1) + local aChangedTime = atom(0) + + local dispose = effect(function() + a() + untracked(function() + aChangedTime(aChangedTime() + 1) + end) + end) + + a(2) + assert(aChangedTime() == 2, "aChangedTime should be 2") + a(3) + assert(aChangedTime() == 3, "aChangedTime should be 3") + + dispose() +end) + +test("should block tracking inside computed atoms", function() + local a = atom(1) + local b = atom(2) + local spyCalls = 0 + local c = computed(function() + return untracked(function() + spyCalls += 1 + return a() + b() + end) + end) + + assert(spyCalls == 0, "should not be called yet") + assert(c() == 3, "c should be 3") + a(10) + c() + b(20) + c() + assert(spyCalls == 1, "should be called once") + assert(c() == 3, "c should be 3") +end) + +return {} From 4edc952c05f07535072799ddec09f7b9bd633f89 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 21 May 2025 12:16:29 -0700 Subject: [PATCH 031/160] chore: housekeeping --- .gitignore | 4 +- .gitmodules | 3 + .luaurc | 2 +- .npmrc | 1 + benches/mapped.luau | 2 +- benches/propagate.luau | 2 +- bin/build.sh | 8 +- bin/check.sh | 8 +- default.project.json | 19 +- package.json | 14 + .../charm-sync/default.project.json | 0 {pkgs => packages}/charm-sync/src/client.luau | 0 .../charm-sync/src/flatten.luau | 0 {pkgs => packages}/charm-sync/src/init.luau | 0 .../charm-sync/src/interval.luau | 0 {pkgs => packages}/charm-sync/src/patch.luau | 0 {pkgs => packages}/charm-sync/src/server.luau | 0 {pkgs => packages}/charm-sync/src/types.luau | 0 .../charm-sync/src/validate.luau | 0 .../charm-sync/tests/client.spec.luau | 0 .../charm-sync/tests/flatten.spec.luau | 0 .../charm-sync/tests/patch.spec.luau | 0 .../charm-sync/tests/server.spec.luau | 0 {pkgs => packages}/charm-sync/tests/spec.luau | 0 .../charm-sync/tests/validate.spec.luau | 0 {pkgs => packages}/charm-sync/wally.toml | 0 {pkgs => packages}/charm/default.project.json | 0 {pkgs => packages}/charm/src/atom.luau | 0 {pkgs => packages}/charm/src/batched.luau | 2 +- {pkgs => packages}/charm/src/effect.luau | 0 {pkgs => packages}/charm/src/effectScope.luau | 0 .../lib.luau => packages/charm/src/init.luau | 22 +- {pkgs => packages}/charm/src/listen.luau | 0 {pkgs => packages}/charm/src/mapped.luau | 0 {pkgs => packages}/charm/src/noYield.luau | 0 {pkgs => packages}/charm/src/observe.luau | 0 {pkgs => packages}/charm/src/onCleanup.luau | 0 {pkgs => packages}/charm/src/signals.luau | 0 {pkgs => packages}/charm/src/subscribe.luau | 0 {pkgs => packages}/charm/src/system.luau | 0 {pkgs => packages}/charm/src/types.luau | 0 {pkgs => packages}/charm/src/untracked.luau | 0 .../charm/tests/listen.spec.luau | 2 +- .../charm/tests/mapped.spec.luau | 2 +- .../charm/tests/observe.spec.luau | 2 +- .../charm/tests/signals/computed.spec.luau | 0 .../charm/tests/signals/effect.spec.luau | 0 .../charm/tests/signals/effectScope.spec.luau | 0 .../charm/tests/signals/gc.spec.luau | 0 .../charm/tests/signals/issue_48.spec.luau | 0 .../charm/tests/signals/spec.luau | 0 .../charm/tests/signals/topology.spec.luau | 0 .../charm/tests/signals/untrack.spec.luau | 0 {pkgs => packages}/charm/tests/spec.luau | 0 .../charm/tests/subscribe.spec.luau | 2 +- .../charm/tests/untracked.spec.luau | 2 +- {pkgs => packages}/charm/wally.toml | 0 .../react-charm/default.project.json | 0 {pkgs => packages}/react-charm/src/init.luau | 4 +- .../react-charm/tests/helpers/renderHook.luau | 0 .../react-charm/tests/spec.luau | 0 .../react-charm/tests/useAtom.spec.luau | 0 .../tests/useAtomBinding.spec.luau | 0 {pkgs => packages}/react-charm/wally.toml | 0 .../vide-charm/default.project.json | 0 {pkgs => packages}/vide-charm/src/init.luau | 0 {pkgs => packages}/vide-charm/tests/spec.luau | 0 .../vide-charm/tests/useAtom.spec.luau | 0 {pkgs => packages}/vide-charm/wally.toml | 0 pkgs/charm/src/init.luau | 9 - pnpm-lock.yaml | 164 +++++++ rokit.toml | 4 +- test/init.luau | 7 +- test/suite | 1 + test/suite.luau | 438 ------------------ wally.lock | 98 ---- wally.toml | 10 - 77 files changed, 230 insertions(+), 602 deletions(-) create mode 100644 .gitmodules create mode 100644 .npmrc create mode 100644 package.json rename {pkgs => packages}/charm-sync/default.project.json (100%) rename {pkgs => packages}/charm-sync/src/client.luau (100%) rename {pkgs => packages}/charm-sync/src/flatten.luau (100%) rename {pkgs => packages}/charm-sync/src/init.luau (100%) rename {pkgs => packages}/charm-sync/src/interval.luau (100%) rename {pkgs => packages}/charm-sync/src/patch.luau (100%) rename {pkgs => packages}/charm-sync/src/server.luau (100%) rename {pkgs => packages}/charm-sync/src/types.luau (100%) rename {pkgs => packages}/charm-sync/src/validate.luau (100%) rename {pkgs => packages}/charm-sync/tests/client.spec.luau (100%) rename {pkgs => packages}/charm-sync/tests/flatten.spec.luau (100%) rename {pkgs => packages}/charm-sync/tests/patch.spec.luau (100%) rename {pkgs => packages}/charm-sync/tests/server.spec.luau (100%) rename {pkgs => packages}/charm-sync/tests/spec.luau (100%) rename {pkgs => packages}/charm-sync/tests/validate.spec.luau (100%) rename {pkgs => packages}/charm-sync/wally.toml (100%) rename {pkgs => packages}/charm/default.project.json (100%) rename {pkgs => packages}/charm/src/atom.luau (100%) rename {pkgs => packages}/charm/src/batched.luau (96%) rename {pkgs => packages}/charm/src/effect.luau (100%) rename {pkgs => packages}/charm/src/effectScope.luau (100%) rename pkgs/charm/src/lib.luau => packages/charm/src/init.luau (55%) rename {pkgs => packages}/charm/src/listen.luau (100%) rename {pkgs => packages}/charm/src/mapped.luau (100%) rename {pkgs => packages}/charm/src/noYield.luau (100%) rename {pkgs => packages}/charm/src/observe.luau (100%) rename {pkgs => packages}/charm/src/onCleanup.luau (100%) rename {pkgs => packages}/charm/src/signals.luau (100%) rename {pkgs => packages}/charm/src/subscribe.luau (100%) rename {pkgs => packages}/charm/src/system.luau (100%) rename {pkgs => packages}/charm/src/types.luau (100%) rename {pkgs => packages}/charm/src/untracked.luau (100%) rename {pkgs => packages}/charm/tests/listen.spec.luau (98%) rename {pkgs => packages}/charm/tests/mapped.spec.luau (98%) rename {pkgs => packages}/charm/tests/observe.spec.luau (97%) rename {pkgs => packages}/charm/tests/signals/computed.spec.luau (100%) rename {pkgs => packages}/charm/tests/signals/effect.spec.luau (100%) rename {pkgs => packages}/charm/tests/signals/effectScope.spec.luau (100%) rename {pkgs => packages}/charm/tests/signals/gc.spec.luau (100%) rename {pkgs => packages}/charm/tests/signals/issue_48.spec.luau (100%) rename {pkgs => packages}/charm/tests/signals/spec.luau (100%) rename {pkgs => packages}/charm/tests/signals/topology.spec.luau (100%) rename {pkgs => packages}/charm/tests/signals/untrack.spec.luau (100%) rename {pkgs => packages}/charm/tests/spec.luau (100%) rename {pkgs => packages}/charm/tests/subscribe.spec.luau (98%) rename {pkgs => packages}/charm/tests/untracked.spec.luau (97%) rename {pkgs => packages}/charm/wally.toml (100%) rename {pkgs => packages}/react-charm/default.project.json (100%) rename {pkgs => packages}/react-charm/src/init.luau (93%) rename {pkgs => packages}/react-charm/tests/helpers/renderHook.luau (100%) rename {pkgs => packages}/react-charm/tests/spec.luau (100%) rename {pkgs => packages}/react-charm/tests/useAtom.spec.luau (100%) rename {pkgs => packages}/react-charm/tests/useAtomBinding.spec.luau (100%) rename {pkgs => packages}/react-charm/wally.toml (100%) rename {pkgs => packages}/vide-charm/default.project.json (100%) rename {pkgs => packages}/vide-charm/src/init.luau (100%) rename {pkgs => packages}/vide-charm/tests/spec.luau (100%) rename {pkgs => packages}/vide-charm/tests/useAtom.spec.luau (100%) rename {pkgs => packages}/vide-charm/wally.toml (100%) delete mode 100644 pkgs/charm/src/init.luau create mode 100644 pnpm-lock.yaml create mode 160000 test/suite delete mode 100644 test/suite.luau delete mode 100644 wally.lock delete mode 100644 wally.toml diff --git a/.gitignore b/.gitignore index 89b59ab..b3bbbd9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ /.vscode /.pesde /build -/DevPackages -/pkgs/**/wally.lock +/packages/**/wally.lock /sourcemap.json +node_modules *.tsbuildinfo *.rbxm .DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e1cba9d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "test/suite"] + path = test/suite + url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4 diff --git a/.luaurc b/.luaurc index a3678db..5930d72 100644 --- a/.luaurc +++ b/.luaurc @@ -5,6 +5,6 @@ }, "aliases": { "test": "test", - "pkgs": "pkgs" + "packages": "packages" } } diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b644580 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=*@rbxts* diff --git a/benches/mapped.luau b/benches/mapped.luau index 8e72c8f..9fe3ae1 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -1,4 +1,4 @@ -local Charm = require("@pkgs/charm/src/lib") +local Charm = require("@packages/charm/src") local bench = require("./bench") local atom = Charm.atom diff --git a/benches/propagate.luau b/benches/propagate.luau index 27b1aed..c31cf52 100644 --- a/benches/propagate.luau +++ b/benches/propagate.luau @@ -1,4 +1,4 @@ -local Charm = require("@pkgs/charm/src/lib") +local Charm = require("@packages/charm/src") local bench = require("./bench") local signals = Charm.signals diff --git a/bin/build.sh b/bin/build.sh index c228f96..be6fdf7 100644 --- a/bin/build.sh +++ b/bin/build.sh @@ -1,5 +1,5 @@ mkdir build -rojo build pkgs/charm -o build/charm.rbxm -rojo build pkgs/charm-sync -o build/charm-sync.rbxm -rojo build pkgs/react-charm -o build/react-charm.rbxm -rojo build pkgs/vide-charm -o build/vide-charm.rbxm +rojo build packages/charm -o build/charm.rbxm +rojo build packages/charm-sync -o build/charm-sync.rbxm +rojo build packages/react-charm -o build/react-charm.rbxm +rojo build packages/vide-charm -o build/vide-charm.rbxm diff --git a/bin/check.sh b/bin/check.sh index f3cccd4..25cd88d 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -6,11 +6,11 @@ luau-lsp analyze \ --defs=bin/roblox.d.luau \ --flag:LuauFixIndexerSubtypingOrdering=true \ --sourcemap=sourcemap.json \ - --ignore="**/_Index/**" \ - pkgs test + --ignore="**/node_modules/**" \ + packages test -selene pkgs test +selene packages test -stylua --check pkgs test +stylua --check packages test rm bin/roblox.d.luau diff --git a/default.project.json b/default.project.json index 1348524..f40f123 100644 --- a/default.project.json +++ b/default.project.json @@ -5,21 +5,22 @@ "ReplicatedStorage": { "$className": "ReplicatedStorage", "Packages": { - "$path": "DevPackages", - "Charm": { "$path": "pkgs/charm" }, - "CharmSync": { "$path": "pkgs/charm-sync" }, - "ReactCharm": { "$path": "pkgs/react-charm" }, - "VideCharm": { "$path": "pkgs/vide-charm" } + "$path": "node_modules/@rbxts-js", + "Vide": { "$path": "node_modules/@rbxts/vide/src" }, + "Charm": { "$path": "packages/charm" }, + "CharmSync": { "$path": "packages/charm-sync" }, + "ReactCharm": { "$path": "packages/react-charm" }, + "VideCharm": { "$path": "packages/vide-charm" } } }, "ServerScriptService": { "$className": "ServerScriptService", "Tests": { "$path": "test", - "Charm": { "$path": "pkgs/charm/tests" }, - "CharmSync": { "$path": "pkgs/charm-sync/tests" }, - "ReactCharm": { "$path": "pkgs/react-charm/tests" }, - "VideCharm": { "$path": "pkgs/vide-charm/tests" } + "Charm": { "$path": "packages/charm/tests" }, + "CharmSync": { "$path": "packages/charm-sync/tests" }, + "ReactCharm": { "$path": "packages/react-charm/tests" }, + "VideCharm": { "$path": "packages/vide-charm/tests" } }, "Benchmarks": { "$path": "benches" diff --git a/package.json b/package.json new file mode 100644 index 0000000..4d14e9f --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "scripts": { + "test": "lute test/init.luau", + "build": "sh bin/build.sh", + "typecheck": "sh bin/check.sh" + }, + "packageManager": "pnpm@10.11.0", + "devDependencies": { + "@rbxts-js/react": "17.1.20-ts.1", + "@rbxts-js/react-roblox": "17.1.20-ts.1", + "@rbxts/vide": "^0.5.7" + } +} diff --git a/pkgs/charm-sync/default.project.json b/packages/charm-sync/default.project.json similarity index 100% rename from pkgs/charm-sync/default.project.json rename to packages/charm-sync/default.project.json diff --git a/pkgs/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau similarity index 100% rename from pkgs/charm-sync/src/client.luau rename to packages/charm-sync/src/client.luau diff --git a/pkgs/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau similarity index 100% rename from pkgs/charm-sync/src/flatten.luau rename to packages/charm-sync/src/flatten.luau diff --git a/pkgs/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau similarity index 100% rename from pkgs/charm-sync/src/init.luau rename to packages/charm-sync/src/init.luau diff --git a/pkgs/charm-sync/src/interval.luau b/packages/charm-sync/src/interval.luau similarity index 100% rename from pkgs/charm-sync/src/interval.luau rename to packages/charm-sync/src/interval.luau diff --git a/pkgs/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau similarity index 100% rename from pkgs/charm-sync/src/patch.luau rename to packages/charm-sync/src/patch.luau diff --git a/pkgs/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau similarity index 100% rename from pkgs/charm-sync/src/server.luau rename to packages/charm-sync/src/server.luau diff --git a/pkgs/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau similarity index 100% rename from pkgs/charm-sync/src/types.luau rename to packages/charm-sync/src/types.luau diff --git a/pkgs/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau similarity index 100% rename from pkgs/charm-sync/src/validate.luau rename to packages/charm-sync/src/validate.luau diff --git a/pkgs/charm-sync/tests/client.spec.luau b/packages/charm-sync/tests/client.spec.luau similarity index 100% rename from pkgs/charm-sync/tests/client.spec.luau rename to packages/charm-sync/tests/client.spec.luau diff --git a/pkgs/charm-sync/tests/flatten.spec.luau b/packages/charm-sync/tests/flatten.spec.luau similarity index 100% rename from pkgs/charm-sync/tests/flatten.spec.luau rename to packages/charm-sync/tests/flatten.spec.luau diff --git a/pkgs/charm-sync/tests/patch.spec.luau b/packages/charm-sync/tests/patch.spec.luau similarity index 100% rename from pkgs/charm-sync/tests/patch.spec.luau rename to packages/charm-sync/tests/patch.spec.luau diff --git a/pkgs/charm-sync/tests/server.spec.luau b/packages/charm-sync/tests/server.spec.luau similarity index 100% rename from pkgs/charm-sync/tests/server.spec.luau rename to packages/charm-sync/tests/server.spec.luau diff --git a/pkgs/charm-sync/tests/spec.luau b/packages/charm-sync/tests/spec.luau similarity index 100% rename from pkgs/charm-sync/tests/spec.luau rename to packages/charm-sync/tests/spec.luau diff --git a/pkgs/charm-sync/tests/validate.spec.luau b/packages/charm-sync/tests/validate.spec.luau similarity index 100% rename from pkgs/charm-sync/tests/validate.spec.luau rename to packages/charm-sync/tests/validate.spec.luau diff --git a/pkgs/charm-sync/wally.toml b/packages/charm-sync/wally.toml similarity index 100% rename from pkgs/charm-sync/wally.toml rename to packages/charm-sync/wally.toml diff --git a/pkgs/charm/default.project.json b/packages/charm/default.project.json similarity index 100% rename from pkgs/charm/default.project.json rename to packages/charm/default.project.json diff --git a/pkgs/charm/src/atom.luau b/packages/charm/src/atom.luau similarity index 100% rename from pkgs/charm/src/atom.luau rename to packages/charm/src/atom.luau diff --git a/pkgs/charm/src/batched.luau b/packages/charm/src/batched.luau similarity index 96% rename from pkgs/charm/src/batched.luau rename to packages/charm/src/batched.luau index 4c23803..31be1dc 100644 --- a/pkgs/charm/src/batched.luau +++ b/packages/charm/src/batched.luau @@ -7,7 +7,7 @@ local endBatch = signals.endBatch local function handleBatched(ok: boolean, ...) endBatch() - if not ok then + if ok then return ... else error(...) diff --git a/pkgs/charm/src/effect.luau b/packages/charm/src/effect.luau similarity index 100% rename from pkgs/charm/src/effect.luau rename to packages/charm/src/effect.luau diff --git a/pkgs/charm/src/effectScope.luau b/packages/charm/src/effectScope.luau similarity index 100% rename from pkgs/charm/src/effectScope.luau rename to packages/charm/src/effectScope.luau diff --git a/pkgs/charm/src/lib.luau b/packages/charm/src/init.luau similarity index 55% rename from pkgs/charm/src/lib.luau rename to packages/charm/src/init.luau index 89bb935..0f049ab 100644 --- a/pkgs/charm/src/lib.luau +++ b/packages/charm/src/init.luau @@ -1,14 +1,14 @@ -local atom = require("./atom") -local batched = require("./batched") -local effect = require("./effect") -local listen = require("./listen") -local mapped = require("./mapped") -local observe = require("./observe") -local onCleanup = require("./onCleanup") -local signals = require("./signals") -local subscribe = require("./subscribe") -local types = require("./types") -local untracked = require("./untracked") +local atom = require("@self/atom") +local batched = require("@self/batched") +local effect = require("@self/effect") +local listen = require("@self/listen") +local mapped = require("@self/mapped") +local observe = require("@self/observe") +local onCleanup = require("@self/onCleanup") +local signals = require("@self/signals") +local subscribe = require("@self/subscribe") +local types = require("@self/types") +local untracked = require("@self/untracked") export type Atom = types.Atom export type Getter = types.Getter diff --git a/pkgs/charm/src/listen.luau b/packages/charm/src/listen.luau similarity index 100% rename from pkgs/charm/src/listen.luau rename to packages/charm/src/listen.luau diff --git a/pkgs/charm/src/mapped.luau b/packages/charm/src/mapped.luau similarity index 100% rename from pkgs/charm/src/mapped.luau rename to packages/charm/src/mapped.luau diff --git a/pkgs/charm/src/noYield.luau b/packages/charm/src/noYield.luau similarity index 100% rename from pkgs/charm/src/noYield.luau rename to packages/charm/src/noYield.luau diff --git a/pkgs/charm/src/observe.luau b/packages/charm/src/observe.luau similarity index 100% rename from pkgs/charm/src/observe.luau rename to packages/charm/src/observe.luau diff --git a/pkgs/charm/src/onCleanup.luau b/packages/charm/src/onCleanup.luau similarity index 100% rename from pkgs/charm/src/onCleanup.luau rename to packages/charm/src/onCleanup.luau diff --git a/pkgs/charm/src/signals.luau b/packages/charm/src/signals.luau similarity index 100% rename from pkgs/charm/src/signals.luau rename to packages/charm/src/signals.luau diff --git a/pkgs/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau similarity index 100% rename from pkgs/charm/src/subscribe.luau rename to packages/charm/src/subscribe.luau diff --git a/pkgs/charm/src/system.luau b/packages/charm/src/system.luau similarity index 100% rename from pkgs/charm/src/system.luau rename to packages/charm/src/system.luau diff --git a/pkgs/charm/src/types.luau b/packages/charm/src/types.luau similarity index 100% rename from pkgs/charm/src/types.luau rename to packages/charm/src/types.luau diff --git a/pkgs/charm/src/untracked.luau b/packages/charm/src/untracked.luau similarity index 100% rename from pkgs/charm/src/untracked.luau rename to packages/charm/src/untracked.luau diff --git a/pkgs/charm/tests/listen.spec.luau b/packages/charm/tests/listen.spec.luau similarity index 98% rename from pkgs/charm/tests/listen.spec.luau rename to packages/charm/tests/listen.spec.luau index ad3fbec..1142347 100644 --- a/pkgs/charm/tests/listen.spec.luau +++ b/packages/charm/tests/listen.spec.luau @@ -1,4 +1,4 @@ -local Charm = require("../src/lib") +local Charm = require("../src") local suite = require("@test/suite") local atom = Charm.atom diff --git a/pkgs/charm/tests/mapped.spec.luau b/packages/charm/tests/mapped.spec.luau similarity index 98% rename from pkgs/charm/tests/mapped.spec.luau rename to packages/charm/tests/mapped.spec.luau index 6d623bc..3d0fcf5 100644 --- a/pkgs/charm/tests/mapped.spec.luau +++ b/packages/charm/tests/mapped.spec.luau @@ -1,4 +1,4 @@ -local Charm = require("../src/lib") +local Charm = require("../src") local suite = require("@test/suite") local atom = Charm.atom diff --git a/pkgs/charm/tests/observe.spec.luau b/packages/charm/tests/observe.spec.luau similarity index 97% rename from pkgs/charm/tests/observe.spec.luau rename to packages/charm/tests/observe.spec.luau index ddceca9..84f5e77 100644 --- a/pkgs/charm/tests/observe.spec.luau +++ b/packages/charm/tests/observe.spec.luau @@ -1,4 +1,4 @@ -local Charm = require("../src/lib") +local Charm = require("../src") local suite = require("@test/suite") local atom = Charm.atom diff --git a/pkgs/charm/tests/signals/computed.spec.luau b/packages/charm/tests/signals/computed.spec.luau similarity index 100% rename from pkgs/charm/tests/signals/computed.spec.luau rename to packages/charm/tests/signals/computed.spec.luau diff --git a/pkgs/charm/tests/signals/effect.spec.luau b/packages/charm/tests/signals/effect.spec.luau similarity index 100% rename from pkgs/charm/tests/signals/effect.spec.luau rename to packages/charm/tests/signals/effect.spec.luau diff --git a/pkgs/charm/tests/signals/effectScope.spec.luau b/packages/charm/tests/signals/effectScope.spec.luau similarity index 100% rename from pkgs/charm/tests/signals/effectScope.spec.luau rename to packages/charm/tests/signals/effectScope.spec.luau diff --git a/pkgs/charm/tests/signals/gc.spec.luau b/packages/charm/tests/signals/gc.spec.luau similarity index 100% rename from pkgs/charm/tests/signals/gc.spec.luau rename to packages/charm/tests/signals/gc.spec.luau diff --git a/pkgs/charm/tests/signals/issue_48.spec.luau b/packages/charm/tests/signals/issue_48.spec.luau similarity index 100% rename from pkgs/charm/tests/signals/issue_48.spec.luau rename to packages/charm/tests/signals/issue_48.spec.luau diff --git a/pkgs/charm/tests/signals/spec.luau b/packages/charm/tests/signals/spec.luau similarity index 100% rename from pkgs/charm/tests/signals/spec.luau rename to packages/charm/tests/signals/spec.luau diff --git a/pkgs/charm/tests/signals/topology.spec.luau b/packages/charm/tests/signals/topology.spec.luau similarity index 100% rename from pkgs/charm/tests/signals/topology.spec.luau rename to packages/charm/tests/signals/topology.spec.luau diff --git a/pkgs/charm/tests/signals/untrack.spec.luau b/packages/charm/tests/signals/untrack.spec.luau similarity index 100% rename from pkgs/charm/tests/signals/untrack.spec.luau rename to packages/charm/tests/signals/untrack.spec.luau diff --git a/pkgs/charm/tests/spec.luau b/packages/charm/tests/spec.luau similarity index 100% rename from pkgs/charm/tests/spec.luau rename to packages/charm/tests/spec.luau diff --git a/pkgs/charm/tests/subscribe.spec.luau b/packages/charm/tests/subscribe.spec.luau similarity index 98% rename from pkgs/charm/tests/subscribe.spec.luau rename to packages/charm/tests/subscribe.spec.luau index 7e7ae87..67bebda 100644 --- a/pkgs/charm/tests/subscribe.spec.luau +++ b/packages/charm/tests/subscribe.spec.luau @@ -1,4 +1,4 @@ -local Charm = require("../src/lib") +local Charm = require("../src") local suite = require("@test/suite") type Atom = Charm.Atom diff --git a/pkgs/charm/tests/untracked.spec.luau b/packages/charm/tests/untracked.spec.luau similarity index 97% rename from pkgs/charm/tests/untracked.spec.luau rename to packages/charm/tests/untracked.spec.luau index a4dd1c7..53f7767 100644 --- a/pkgs/charm/tests/untracked.spec.luau +++ b/packages/charm/tests/untracked.spec.luau @@ -1,4 +1,4 @@ -local Charm = require("../src/lib") +local Charm = require("../src") local suite = require("@test/suite") local atom = Charm.atom diff --git a/pkgs/charm/wally.toml b/packages/charm/wally.toml similarity index 100% rename from pkgs/charm/wally.toml rename to packages/charm/wally.toml diff --git a/pkgs/react-charm/default.project.json b/packages/react-charm/default.project.json similarity index 100% rename from pkgs/react-charm/default.project.json rename to packages/react-charm/default.project.json diff --git a/pkgs/react-charm/src/init.luau b/packages/react-charm/src/init.luau similarity index 93% rename from pkgs/react-charm/src/init.luau rename to packages/react-charm/src/init.luau index 9293a5f..8fabbb0 100644 --- a/pkgs/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -5,6 +5,7 @@ local effect = Charm.effect local useBinding = React.useBinding local useEffect = React.useEffect local useState = React.useState +local useMemo = React.useMemo --- A hook that subscribes to changes in the given atom or selector. The --- component is re-rendered whenever the state changes. @@ -39,7 +40,8 @@ end --- @param dependencies An array of values that the subscription depends on. --- @return A binding that stores the current state. local function useAtomBinding(selector: () -> State, dependencies: { any }?) - local binding, setBinding = useBinding(selector) + local initialState = useMemo(selector, {}) + local binding, setBinding = useBinding(initialState) useEffect(function() return effect(function() diff --git a/pkgs/react-charm/tests/helpers/renderHook.luau b/packages/react-charm/tests/helpers/renderHook.luau similarity index 100% rename from pkgs/react-charm/tests/helpers/renderHook.luau rename to packages/react-charm/tests/helpers/renderHook.luau diff --git a/pkgs/react-charm/tests/spec.luau b/packages/react-charm/tests/spec.luau similarity index 100% rename from pkgs/react-charm/tests/spec.luau rename to packages/react-charm/tests/spec.luau diff --git a/pkgs/react-charm/tests/useAtom.spec.luau b/packages/react-charm/tests/useAtom.spec.luau similarity index 100% rename from pkgs/react-charm/tests/useAtom.spec.luau rename to packages/react-charm/tests/useAtom.spec.luau diff --git a/pkgs/react-charm/tests/useAtomBinding.spec.luau b/packages/react-charm/tests/useAtomBinding.spec.luau similarity index 100% rename from pkgs/react-charm/tests/useAtomBinding.spec.luau rename to packages/react-charm/tests/useAtomBinding.spec.luau diff --git a/pkgs/react-charm/wally.toml b/packages/react-charm/wally.toml similarity index 100% rename from pkgs/react-charm/wally.toml rename to packages/react-charm/wally.toml diff --git a/pkgs/vide-charm/default.project.json b/packages/vide-charm/default.project.json similarity index 100% rename from pkgs/vide-charm/default.project.json rename to packages/vide-charm/default.project.json diff --git a/pkgs/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau similarity index 100% rename from pkgs/vide-charm/src/init.luau rename to packages/vide-charm/src/init.luau diff --git a/pkgs/vide-charm/tests/spec.luau b/packages/vide-charm/tests/spec.luau similarity index 100% rename from pkgs/vide-charm/tests/spec.luau rename to packages/vide-charm/tests/spec.luau diff --git a/pkgs/vide-charm/tests/useAtom.spec.luau b/packages/vide-charm/tests/useAtom.spec.luau similarity index 100% rename from pkgs/vide-charm/tests/useAtom.spec.luau rename to packages/vide-charm/tests/useAtom.spec.luau diff --git a/pkgs/vide-charm/wally.toml b/packages/vide-charm/wally.toml similarity index 100% rename from pkgs/vide-charm/wally.toml rename to packages/vide-charm/wally.toml diff --git a/pkgs/charm/src/init.luau b/pkgs/charm/src/init.luau deleted file mode 100644 index bc510ce..0000000 --- a/pkgs/charm/src/init.luau +++ /dev/null @@ -1,9 +0,0 @@ -local lib = require(script.lib) - -export type Atom = lib.Atom -export type Getter = lib.Getter -export type Selector = lib.Selector -export type Setter = lib.Setter -export type Update = lib.Update - -return lib diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b51612c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,164 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@rbxts-js/react': + specifier: 17.1.20-ts.1 + version: 17.1.20-ts.1 + '@rbxts-js/react-roblox': + specifier: 17.1.20-ts.1 + version: 17.1.20-ts.1 + '@rbxts/vide': + specifier: ^0.5.7 + version: 0.5.7 + +packages: + + '@rbxts-js/boolean@1.2.3-ts.1': + resolution: {integrity: sha512-IlYFG9uatXQKXOLR1mdptVeEcxudwBCierO+Vu6VZRRaI+/+JDxUClm+QFZyce/kitwnDTmtNvhqVAUtFvCU0Q==} + + '@rbxts-js/collections@1.2.3-ts.1': + resolution: {integrity: sha512-RYT73QyEdsV+DCQMtyzT2bZ1P12j2b+JSHVdb1x6xrDq/KdVEkk/QAzW+o22vPrLXbHa6/jA5bmVlgVcOOrIjA==} + + '@rbxts-js/console@1.2.3-ts.1': + resolution: {integrity: sha512-qacyJE7bYf4n9448F7mn9e8Y5qljZb7QkSmsmnbQT2HA9Mecvg3gqc9RwCIdVkBV6o1AGVAHbatNQ3a39zPjaw==} + + '@rbxts-js/es7-types@1.2.3-ts.1': + resolution: {integrity: sha512-Z7NitBQp6tyzsr5eKlSX54rmopKXpzGu33lYikjF37llrnGFBpnRcZgCJwHKYciloRmjUtnR/UohUNwbWyG9Zg==} + + '@rbxts-js/instance-of@1.2.3-ts.1': + resolution: {integrity: sha512-3fKvDPvxHuEZALNBP2XdbXfzjEfbgsXAheCm1Jf5kC2r+HgszW+yqg4zY6TjW0pcsJM2JjH+fM52oomSNu3LLQ==} + + '@rbxts-js/luau-polyfill@1.2.3-ts.1': + resolution: {integrity: sha512-2asgvWrOhf311rNgEM/fxMO3+xOVI63Cz1K1PobyZVtDdd87ZJ6RGiZSFl1EAeU77SZc8280toJDVs9YSwxWnA==} + + '@rbxts-js/math@1.2.3-ts.1': + resolution: {integrity: sha512-mF7IMJJyxYE02cRNWJ1wsq4pPaAEXKX+CbGlmI0G3QBgWVHj+Oi02IR6pssyUyE6Y2BKvgvP3dcJuTO51OX25A==} + + '@rbxts-js/number@1.2.3-ts.1': + resolution: {integrity: sha512-tPdt2s7fEBSxImVFBUvuSMtBKIvaZUCtlOjBl58Y0+GKB15VPXFIRW/5biVruXPnv9d4sDE3mh986ZxxN4UQ5w==} + + '@rbxts-js/react-reconciler@17.1.20-ts.1': + resolution: {integrity: sha512-t4vY4nHeM+Scrs+9FbgNsuhK0QR6lg3SNWUhjbzvptNP+IA4+4F8TkHQghR2SSTxQDhegU02BTR7NBpk+AQcUw==} + + '@rbxts-js/react-roblox@17.1.20-ts.1': + resolution: {integrity: sha512-Jv0mq+9txXptgsJ6MDWHgCTwsohaon9HhmllkUe29zkaMwE+di4cHqzF1tgV8VHYGG/LQoitXDddK03CA3L+2g==} + + '@rbxts-js/react@17.1.20-ts.1': + resolution: {integrity: sha512-u/dNRTNj5AaVPuqJLza7FEJ24lyRfJ0CdjflbIS8JiUS1iXiO7yZeHQWwPk6V3oHuAOckv6tA4FN/Eju2rPMrQ==} + + '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': + resolution: {integrity: sha512-H8fxKVJkRnM60yTZ/riO4X43GZD/MViFm+ftCzzAALkAhE4vSaBDNv9E7IUjZZoWi21bG6vZ8mGIiYfavaljvw==} + + '@rbxts-js/safe-flags@17.1.20-ts.1': + resolution: {integrity: sha512-k+16jmCP63LT8sNfhLoytUrp3UB+CGHH7s54BFUAHte+zCANK3wfAKXbpT36NqnUfRqgKDM8grO4lWp6awuacQ==} + + '@rbxts-js/scheduler@17.1.20-ts.1': + resolution: {integrity: sha512-RoXs6bAW3LIB6HL/voMilR2hjcGAtutaVsr0yumu0hUYVFY2FwIkbOVepsdYWDrYhVqsk5zrj8CcVGgUxDldGg==} + + '@rbxts-js/shared@17.1.20-ts.1': + resolution: {integrity: sha512-/3K1BnWV519+any6JMWniIDx1rtq20H6RVgJStSCj4XofrM63kzju0EzRQg1af4lnXCfZNZBusRW8IGQ14xuZQ==} + + '@rbxts-js/string@1.2.3-ts.1': + resolution: {integrity: sha512-Je/WKgZbXF/sAHEsQPuns5n4rWzux327zjr0KRf9Gy62ieUzl2FRZZHjVYce3xdMtHif2JstIf/J3p513TUtRg==} + + '@rbxts-js/symbol-luau@1.0.0-ts.1': + resolution: {integrity: sha512-TQS/ImDCK5KPGvbmyScH5EZS0ELSCDvZEFR3W+ItPlsLl+bDRSI/0uaKtJ7rbwXgdGwBDKM1QXFPjqe+KOphIA==} + + '@rbxts-js/timers@1.2.3-ts.1': + resolution: {integrity: sha512-HKP4ymFX3QLWbR/2Gy4LXwvkhjC9OzbwZ5bNunxu+M8Ocbi1cjRduiZK/y5ogLr9cNIfSoxs6e1DQ3soc8/hwA==} + + '@rbxts/vide@0.5.7': + resolution: {integrity: sha512-Q9R7ybzuHK1phtLicBHgAjQnlJ/dMsXDs2k296PIMLgLqK9aX/f1skOP7F1K/vo7IrSE0jCG3B3XTOtQL1Sb0Q==} + +snapshots: + + '@rbxts-js/boolean@1.2.3-ts.1': + dependencies: + '@rbxts-js/collections': 1.2.3-ts.1 + + '@rbxts-js/collections@1.2.3-ts.1': + dependencies: + '@rbxts-js/es7-types': 1.2.3-ts.1 + '@rbxts-js/instance-of': 1.2.3-ts.1 + + '@rbxts-js/console@1.2.3-ts.1': + dependencies: + '@rbxts-js/collections': 1.2.3-ts.1 + + '@rbxts-js/es7-types@1.2.3-ts.1': {} + + '@rbxts-js/instance-of@1.2.3-ts.1': {} + + '@rbxts-js/luau-polyfill@1.2.3-ts.1': + dependencies: + '@rbxts-js/boolean': 1.2.3-ts.1 + '@rbxts-js/collections': 1.2.3-ts.1 + '@rbxts-js/console': 1.2.3-ts.1 + '@rbxts-js/es7-types': 1.2.3-ts.1 + '@rbxts-js/instance-of': 1.2.3-ts.1 + '@rbxts-js/math': 1.2.3-ts.1 + '@rbxts-js/number': 1.2.3-ts.1 + '@rbxts-js/string': 1.2.3-ts.1 + '@rbxts-js/symbol-luau': 1.0.0-ts.1 + '@rbxts-js/timers': 1.2.3-ts.1 + + '@rbxts-js/math@1.2.3-ts.1': {} + + '@rbxts-js/number@1.2.3-ts.1': {} + + '@rbxts-js/react-reconciler@17.1.20-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react': 17.1.20-ts.1 + '@rbxts-js/roblox-lua-promise': 3.5.0-ts.2 + '@rbxts-js/safe-flags': 17.1.20-ts.1 + '@rbxts-js/scheduler': 17.1.20-ts.1 + '@rbxts-js/shared': 17.1.20-ts.1 + + '@rbxts-js/react-roblox@17.1.20-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react': 17.1.20-ts.1 + '@rbxts-js/react-reconciler': 17.1.20-ts.1 + '@rbxts-js/scheduler': 17.1.20-ts.1 + '@rbxts-js/shared': 17.1.20-ts.1 + + '@rbxts-js/react@17.1.20-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/shared': 17.1.20-ts.1 + + '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': {} + + '@rbxts-js/safe-flags@17.1.20-ts.1': {} + + '@rbxts-js/scheduler@17.1.20-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/safe-flags': 17.1.20-ts.1 + '@rbxts-js/shared': 17.1.20-ts.1 + + '@rbxts-js/shared@17.1.20-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/safe-flags': 17.1.20-ts.1 + + '@rbxts-js/string@1.2.3-ts.1': + dependencies: + '@rbxts-js/es7-types': 1.2.3-ts.1 + '@rbxts-js/number': 1.2.3-ts.1 + + '@rbxts-js/symbol-luau@1.0.0-ts.1': {} + + '@rbxts-js/timers@1.2.3-ts.1': + dependencies: + '@rbxts-js/collections': 1.2.3-ts.1 + + '@rbxts/vide@0.5.7': {} diff --git a/rokit.toml b/rokit.toml index 7394a8b..93fcd9a 100644 --- a/rokit.toml +++ b/rokit.toml @@ -8,5 +8,5 @@ wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.5.1" stylua = "johnnymorganz/stylua@2.1.0" selene = "Kampfkarren/selene@0.28.0" -luau-lsp = "johnnymorganz/luau-lsp@1.44.1" -lute = "luau-lang/lute@0.1.0-nightly.20250511" +luau-lsp = "johnnymorganz/luau-lsp@1.47.0" +lute = "luau-lang/lute@0.1.0-nightly.20250518" diff --git a/test/init.luau b/test/init.luau index 467958e..07889bd 100644 --- a/test/init.luau +++ b/test/init.luau @@ -4,9 +4,6 @@ if not table.isfrozen(_G) then _G.__DEV__ = true end -require("@pkgs/charm/tests/spec") +require("@packages/charm/tests/spec") -suite.finish({ - colors = true, - verbose = false, -}) +suite.finish() diff --git a/test/suite b/test/suite new file mode 160000 index 0000000..2692eb0 --- /dev/null +++ b/test/suite @@ -0,0 +1 @@ +Subproject commit 2692eb055839cd56d0075ca72482658a5bdaddb9 diff --git a/test/suite.luau b/test/suite.luau deleted file mode 100644 index 516db20..0000000 --- a/test/suite.luau +++ /dev/null @@ -1,438 +0,0 @@ -type Config = { - colors: boolean?, - verbose: boolean?, -} - -type Description = { - status: number, - name: string, - only: boolean, - skip: boolean, - parent: Description?, - children: { Description | Test }, - passes: number, - fails: number, - skips: number, - runtime: number, -} - -type Test = { - status: number, - name: string, - only: boolean, - skip: boolean, - parent: Description?, - children: nil, - run: () -> (), - result: unknown?, - line: number?, - runtime: number, -} - -local options = { - colors = true, - verbose = false, -} - -local color = { - white = function(text: string) - return if options.colors then `\27[37;1m{text}\27[0m` else text - end, - gray = function(text: string) - return if options.colors then `\27[90;1m{text}\27[0m` else text - end, - green = function(text: string) - return if options.colors then `\27[32;1m{text}\27[0m` else `\u{1F7E2} {text}` - end, - red = function(text: string) - return if options.colors then `\27[31;1m{text}\27[0m` else `\u{1F534} {text}` - end, - blue = function(text: string) - return if options.colors then `\27[34;1m{text}\27[0m` else `\u{1F535} {text}` - end, -} - -local PENDING = 0 -local PASS = 1 -local FAIL = 2 -local SKIP = 3 -local ERROR = 4 - -local tests: { Test } = {} -local descriptions: { Description } = {} -local root: Description = { - status = PENDING, - name = "root", - only = false, - skip = false, - children = {}, - passes = 0, - fails = 0, - skips = 0, - runtime = 0, -} -local parent: Description = root - -local function describe(name: string, run: () -> ()): Description - local this: Description = { - status = PENDING, - name = name, - only = false, - skip = false, - parent = parent, - children = {}, - passes = 0, - fails = 0, - skips = 0, - runtime = 0, - } - - local prevDescription = parent - parent = this - run() - parent = prevDescription - - table.insert(parent.children, this) - table.insert(descriptions, this) - - return this -end - -local function test(name: string, run: () -> ()): Test - local this: Test = { - status = PENDING, - name = name, - only = false, - skip = false, - parent = parent, - run = run, - runtime = 0, - } - - table.insert(parent.children, this) - table.insert(tests, this) - - return this -end - -local function skip() - parent.skip = true -end - -local function only() - parent.only = true - root.only = true -end - -local function testSkip(name: string, run: () -> ()): Test - local this = test(name, run) - this.skip = true - return this -end - -local function testOnly(name: string, run: () -> ()): Test - local this = test(name, run) - this.only = true - root.only = true - return this -end - -local function describeSkip(name: string, run: () -> ()): Description - local this = describe(name, run) - this.skip = true - return this -end - -local function describeOnly(name: string, run: () -> ()): Description - local this = describe(name, run) - this.only = true - root.only = true - return this -end - -local function isOnly(test: Test): boolean - if not root.only or test.only then - return true - end - - local description = test.parent - while description and description ~= root do - if description.only then - return true - elseif description.skip then - return false - end - description = description.parent - end - - return false -end - -local function isSkip(test: Test): boolean - if test.only then - return false - elseif test.skip then - return true - end - - local description = test.parent - while description do - if description.only and description ~= root then - return false - elseif description.skip then - return true - end - description = description.parent - end - - return false -end - -local function runTests() - for _, test in tests do - if isSkip(test) or not isOnly(test) then - test.status = SKIP - continue - end - - local start = os.clock() - local success = xpcall(test.run, function(result) - test.status = FAIL - test.result = result - test.line = debug.info(3, "l") - end) - - test.runtime = os.clock() - start - - if success then - test.status = PASS - end - end -end - -local function updateDescription(description: Description) - if not description.children[1] then - description.status = PASS - return - end - - description.status = SKIP - - for _, child in description.children do - if child.children then - updateDescription(child) - description.passes += child.passes - description.fails += child.fails - elseif child.status == PASS then - description.passes += 1 - elseif child.status == FAIL then - description.fails += 1 - end - - -- Only count skips in direct children - if child.status == SKIP then - description.skips += 1 - end - - description.runtime += child.runtime - - if child.status == FAIL then - description.status = FAIL - elseif description.status == SKIP and child.status == PASS then - description.status = PASS - end - end -end - -local function getIndent(node: Description | Test): string - local depth = 0 - local description = node.parent - while description and description ~= root do - depth += 1 - description = description.parent - end - return if depth > 0 then string.rep(" ", depth - 1) .. color.gray("· ") else "" -end - -local function getPath(node: Description | Test): string - local path = { "" } - local description = node.parent - while description and description ~= root do - table.insert(path, 1, description.name) - description = description.parent - end - return table.concat(path, " > ") -end - -local function greenOrRed(text: string, good: boolean): string - return if good then color.green(text) else color.red(text) -end - -local function colorStatus(status: number): string - local wall = color.gray("│") - - if status == PASS then - return color.green("PASS") .. wall - elseif status == FAIL then - return color.red("FAIL") .. wall - elseif status == SKIP then - return color.blue("SKIP") .. wall - else - return color.red("ERROR") .. wall - end -end - -local function report() - local output = {} - - local function reportDescription(description: Description) - local name = color.white(description.name) - local text = `{colorStatus(description.status)} {getIndent(description)}{name}` - - if description.fails > 0 then - text ..= " " .. color.red(`({description.fails})`) - end - - if description.skips > 0 then - text ..= " " .. color.blue(`({description.skips} skipped)`) - elseif description.fails == 0 then - text ..= " " .. color.gray(string.format("%.2fms", description.runtime * 1000)) - end - - table.insert(output, text) - end - - local function reportTest(test: Test, reportError: boolean?) - if test.status ~= FAIL and not options.verbose then - return - end - - local line = color.red(`{test.line}:`) - - if reportError then - local name = color.white(test.name) - local path = color.gray(getPath(test)) - - table.insert(output, `{colorStatus(ERROR)} {path}{line}{name}`) - table.insert(output, `{test.result}`) - else - local name = color.gray(test.name) - local indent = getIndent(test) - - if test.status == FAIL then - table.insert(output, `{colorStatus(FAIL)} {indent}{line}{name}`) - else - table.insert(output, `{colorStatus(test.status)} {indent}{name}`) - end - end - end - - local function reportNode(node: Description | Test) - if not node.children then - return reportTest(node :: Test) - end - - reportDescription(node) - - for _, child in node.children do - reportNode(child) - end - end - - local function reportSummary() - local describePasses = 0 - local describeFails = 0 - - for _, description in descriptions do - if description.status == PASS then - describePasses += 1 - elseif description.status == FAIL then - describeFails += 1 - end - end - - local describes = - `{greenOrRed(`{describePasses} passed`, describeFails == 0)} {color.gray(`({#descriptions})`)}` - local tests = `{greenOrRed(`{root.passes} passed`, root.fails == 0)} {color.gray(`({#tests})`)}` - local duration = `{color.blue(`{string.format("%.2fms", root.runtime * 1000)}`)}` - - table.insert(output, "") - table.insert(output, `Describe {describes}`) - table.insert(output, ` Tests {tests}`) - table.insert(output, `Duration {duration}`) - table.insert(output, "") - end - - local function reportErrors() - if root.fails == 0 then - return - end - - for _, test in tests do - if test.status == FAIL then - reportTest(test, true) - table.insert(output, "") - end - end - end - - table.insert(output, "") - table.insert(output, color.blue("Test Results")) - table.insert(output, "") - - for _, child in root.children do - reportNode(child) - end - - reportSummary() - reportErrors() - - print(table.concat(output, "\n")) - - if root.fails > 0 then - error(color.red(`{root.fails} test(s) failed`)) - end -end - -local function finish(config: Config?) - if config then - options.colors = if config.colors ~= nil then config.colors else options.colors - options.verbose = if config.verbose ~= nil then config.verbose else options.verbose - end - - runTests() - updateDescription(root) - report() -end - -local testCtor = setmetatable({ - skip = testSkip, - only = testOnly, -}, { - __call = function(_, ...) - return test(...) - end, -}) - -local describeCtor = setmetatable({ - skip = describeSkip, - only = describeOnly, -}, { - __call = function(_, ...) - return describe(...) - end, -}) - -return { - test = testCtor :: ((name: string, callback: () -> ()) -> ()) & { - skip: (name: string, callback: () -> ()) -> (), - only: (name: string, callback: () -> ()) -> (), - }, - describe = describeCtor :: ((name: string, callback: () -> ()) -> ()) & { - skip: (name: string, callback: () -> ()) -> (), - only: (name: string, callback: () -> ()) -> (), - }, - skip = skip, - only = only, - finish = finish, -} diff --git a/wally.lock b/wally.lock deleted file mode 100644 index f568be9..0000000 --- a/wally.lock +++ /dev/null @@ -1,98 +0,0 @@ -# This file is automatically @generated by Wally. -# It is not intended for manual editing. -registry = "test" - -[[package]] -name = "centau/vide" -version = "0.3.1" -dependencies = [] - -[[package]] -name = "jsdotlua/boolean" -version = "1.2.7" -dependencies = [["number", "jsdotlua/number@1.2.7"]] - -[[package]] -name = "jsdotlua/collections" -version = "1.2.7" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"]] - -[[package]] -name = "jsdotlua/console" -version = "1.2.7" -dependencies = [["collections", "jsdotlua/collections@1.2.7"]] - -[[package]] -name = "jsdotlua/es7-types" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/instance-of" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/luau-polyfill" -version = "1.2.7" -dependencies = [["boolean", "jsdotlua/boolean@1.2.7"], ["collections", "jsdotlua/collections@1.2.7"], ["console", "jsdotlua/console@1.2.7"], ["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"], ["math", "jsdotlua/math@1.2.7"], ["number", "jsdotlua/number@1.2.7"], ["string", "jsdotlua/string@1.2.7"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.7"]] - -[[package]] -name = "jsdotlua/math" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/number" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/promise" -version = "3.5.2" -dependencies = [] - -[[package]] -name = "jsdotlua/react" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/react-reconciler" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/react-roblox" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["react", "jsdotlua/react@17.2.1"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/scheduler" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/shared" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"]] - -[[package]] -name = "jsdotlua/string" -version = "1.2.7" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["number", "jsdotlua/number@1.2.7"]] - -[[package]] -name = "jsdotlua/symbol-luau" -version = "1.0.1" -dependencies = [] - -[[package]] -name = "jsdotlua/timers" -version = "1.2.7" -dependencies = [["collections", "jsdotlua/collections@1.2.7"]] - -[[package]] -name = "richerd/charm" -version = "0.1.0" -dependencies = [["React", "jsdotlua/react@17.2.1"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.1"], ["Vide", "centau/vide@0.3.1"]] diff --git a/wally.toml b/wally.toml deleted file mode 100644 index 2c4ec2b..0000000 --- a/wally.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "richerd/charm" -version = "0.1.0" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" - -[dev-dependencies] -React = "jsdotlua/react@17" -ReactRoblox = "jsdotlua/react-roblox@17" -Vide = "centau/vide@0.3" From 61d9f424aa37764c8063fea6fb9b210c4dccfb97 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 21 May 2025 12:22:00 -0700 Subject: [PATCH 032/160] ci: add pnpm step --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e92647..cac34c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: true - uses: CompeyDev/setup-rokit@v0.1.2 - - name: Install dependencies - run: wally install - - name: Check code quality run: sh bin/check.sh From 581488cb4f0eff2ca5304dc22836e7b47624474d Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 21 May 2025 12:24:53 -0700 Subject: [PATCH 033/160] ci: init submodules --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cac34c6..0d47640 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: pnpm/action-setup@v4 with: run_install: true From a9c60c701f740437043ac5ba23d7a2980ba49a23 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 21 May 2025 15:42:48 -0700 Subject: [PATCH 034/160] test: use lune --- .github/workflows/ci.yml | 2 +- .luaurc | 4 ++-- package.json | 2 +- rokit.toml | 2 +- test/init.luau | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d47640..5d8c1ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: run: sh bin/check.sh - name: Run tests - run: lute test/init.luau + run: lune run test - name: Build models run: sh bin/build.sh diff --git a/.luaurc b/.luaurc index 5930d72..e03053a 100644 --- a/.luaurc +++ b/.luaurc @@ -4,7 +4,7 @@ "LocalShadow": false }, "aliases": { - "test": "test", - "packages": "packages" + "test": "./test", + "packages": "./packages" } } diff --git a/package.json b/package.json index 4d14e9f..8e29b88 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "scripts": { - "test": "lute test/init.luau", + "test": "lune run test", "build": "sh bin/build.sh", "typecheck": "sh bin/check.sh" }, diff --git a/rokit.toml b/rokit.toml index 93fcd9a..9412a69 100644 --- a/rokit.toml +++ b/rokit.toml @@ -9,4 +9,4 @@ rojo = "rojo-rbx/rojo@7.5.1" stylua = "johnnymorganz/stylua@2.1.0" selene = "Kampfkarren/selene@0.28.0" luau-lsp = "johnnymorganz/luau-lsp@1.47.0" -lute = "luau-lang/lute@0.1.0-nightly.20250518" +lune = "lune-org/lune@0.9.3" diff --git a/test/init.luau b/test/init.luau index 07889bd..8048eba 100644 --- a/test/init.luau +++ b/test/init.luau @@ -1,4 +1,4 @@ -local suite = require("@test/suite") +local suite = require("@self/suite") if not table.isfrozen(_G) then _G.__DEV__ = true From 95476e93ec6752ac4c6f0a09ce341cc07b35f6d7 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 2 Jul 2025 09:25:50 -0700 Subject: [PATCH 035/160] refactor: improve structure and alien-signals integration --- .eslintrc | 28 + .gitignore | 2 + .luaurc | 4 - .prettierrc | 6 + benches/mapped.luau | 4 +- benches/patch.bench.luau | 4 +- benches/propagate.luau | 31 +- bin/build.sh | 7 +- bin/check.sh | 9 +- build.project.json | 10 + default.project.json | 59 +- package.json | 23 +- packages/charm-sync/Charm.luau | 6 + packages/charm-sync/default.project.json | 2 +- packages/charm-sync/package.json | 27 + .../{tests => src/__tests__}/client.spec.luau | 9 +- .../__tests__}/flatten.spec.luau | 9 +- .../{tests => src/__tests__}/patch.spec.luau | 6 +- .../{tests => src/__tests__}/server.spec.luau | 9 +- .../{tests => src/__tests__}/spec.luau | 7 +- .../__tests__}/validate.spec.luau | 9 +- packages/charm-sync/src/client.luau | 12 +- packages/charm-sync/src/flatten.luau | 2 +- packages/charm-sync/src/index.d.ts | 1 + packages/charm-sync/src/init.luau | 12 +- packages/charm-sync/src/interval.luau | 4 +- packages/charm-sync/src/patch.luau | 7 +- packages/charm-sync/src/server.luau | 28 +- packages/charm-sync/src/types.luau | 2 +- packages/charm-sync/wally.toml | 8 +- packages/charm/default.project.json | 2 +- packages/charm/package.json | 24 + .../__tests__/core}/computed.spec.luau | 32 +- .../__tests__/core}/effect.spec.luau | 118 +- .../__tests__/core}/effectScope.spec.luau | 31 +- .../__tests__/core}/gc.spec.luau | 26 +- .../__tests__/core}/issue_48.spec.luau | 20 +- .../__tests__/core}/topology.spec.luau | 82 +- .../__tests__/core}/untrack.spec.luau | 14 +- .../{tests => src/__tests__}/listen.spec.luau | 4 +- .../{tests => src/__tests__}/mapped.spec.luau | 4 +- .../__tests__}/observe.spec.luau | 4 +- packages/charm/src/__tests__/spec.luau | 45 + .../__tests__}/subscribe.spec.luau | 4 +- .../__tests__}/untracked.spec.luau | 4 +- packages/charm/src/atom.luau | 48 - packages/charm/src/batched.luau | 10 +- .../charm/src/{signals.luau => core.luau} | 377 ++-- packages/charm/src/effect.luau | 26 - packages/charm/src/effectScope.luau | 14 - packages/charm/src/index.d.ts | 1 + packages/charm/src/init.luau | 21 +- packages/charm/src/listen.luau | 16 +- packages/charm/src/mapped.luau | 27 +- packages/charm/src/observe.luau | 20 +- packages/charm/src/onCleanup.luau | 20 - packages/charm/src/strict.luau | 8 + packages/charm/src/subscribe.luau | 19 +- packages/charm/src/system.luau | 20 +- packages/charm/src/types.luau | 12 +- packages/charm/src/untracked.luau | 15 +- .../charm/src/{noYield.luau => utils.luau} | 14 +- packages/charm/test/default.project.json | 6 + packages/charm/test/src/batched.luau | 20 + packages/charm/test/src/core.luau | 445 +++++ packages/charm/test/src/init.luau | 34 + packages/charm/test/src/listen.luau | 32 + packages/charm/test/src/mapped.luau | 59 + packages/charm/test/src/observe.luau | 52 + packages/charm/test/src/strict.luau | 8 + packages/charm/test/src/subscribe.luau | 26 + packages/charm/test/src/system.luau | 357 ++++ packages/charm/test/src/types.luau | 5 + packages/charm/test/src/untracked.luau | 18 + packages/charm/test/src/utils.luau | 18 + packages/charm/test/wally.toml | 8 + packages/charm/tests/signals/spec.luau | 27 - packages/charm/tests/spec.luau | 23 - packages/charm/wally.toml | 2 +- packages/react-charm/Charm.luau | 6 + packages/react-charm/React.luau | 12 + packages/react-charm/ReactRoblox.luau | 1 + packages/react-charm/default.project.json | 2 +- packages/react-charm/package.json | 30 + .../__tests__}/helpers/renderHook.luau | 6 +- .../{tests => src/__tests__}/spec.luau | 7 +- .../__tests__}/useAtom.spec.luau | 11 +- .../__tests__}/useAtomBinding.spec.luau | 11 +- packages/react-charm/src/index.d.ts | 1 + packages/react-charm/src/init.luau | 4 +- packages/react-charm/wally.toml | 9 +- packages/vide-charm/Charm.luau | 6 + packages/vide-charm/Vide.luau | 1 + packages/vide-charm/default.project.json | 2 +- packages/vide-charm/package.json | 30 + .../{tests => src/__tests__}/spec.luau | 7 +- .../__tests__}/useAtom.spec.luau | 11 +- packages/vide-charm/src/index.d.ts | 1 + packages/vide-charm/src/init.luau | 4 +- packages/vide-charm/wally.toml | 9 +- pnpm-lock.yaml | 1679 +++++++++++++++++ pnpm-workspace.yaml | 2 + rokit.toml | 5 +- selene.toml | 1 + test/init.luau | 9 - test/spec.server.luau | 19 +- tsconfig.json | 20 + 107 files changed, 3660 insertions(+), 815 deletions(-) create mode 100644 .eslintrc create mode 100644 .prettierrc create mode 100644 build.project.json create mode 100644 packages/charm-sync/Charm.luau create mode 100644 packages/charm-sync/package.json rename packages/charm-sync/{tests => src/__tests__}/client.spec.luau (89%) rename packages/charm-sync/{tests => src/__tests__}/flatten.spec.luau (66%) rename packages/charm-sync/{tests => src/__tests__}/patch.spec.luau (94%) rename packages/charm-sync/{tests => src/__tests__}/server.spec.luau (89%) rename packages/charm-sync/{tests => src/__tests__}/spec.luau (76%) rename packages/charm-sync/{tests => src/__tests__}/validate.spec.luau (76%) create mode 100644 packages/charm-sync/src/index.d.ts create mode 100644 packages/charm/package.json rename packages/charm/{tests/signals => src/__tests__/core}/computed.spec.luau (67%) rename packages/charm/{tests/signals => src/__tests__/core}/effect.spec.luau (80%) rename packages/charm/{tests/signals => src/__tests__/core}/effectScope.spec.luau (73%) rename packages/charm/{tests/signals => src/__tests__/core}/gc.spec.luau (70%) rename packages/charm/{tests/signals => src/__tests__/core}/issue_48.spec.luau (87%) rename packages/charm/{tests/signals => src/__tests__/core}/topology.spec.luau (89%) rename packages/charm/{tests/signals => src/__tests__/core}/untrack.spec.luau (57%) rename packages/charm/{tests => src/__tests__}/listen.spec.luau (96%) rename packages/charm/{tests => src/__tests__}/mapped.spec.luau (97%) rename packages/charm/{tests => src/__tests__}/observe.spec.luau (94%) create mode 100644 packages/charm/src/__tests__/spec.luau rename packages/charm/{tests => src/__tests__}/subscribe.spec.luau (97%) rename packages/charm/{tests => src/__tests__}/untracked.spec.luau (95%) delete mode 100644 packages/charm/src/atom.luau rename packages/charm/src/{signals.luau => core.luau} (52%) delete mode 100644 packages/charm/src/effect.luau delete mode 100644 packages/charm/src/effectScope.luau create mode 100644 packages/charm/src/index.d.ts delete mode 100644 packages/charm/src/onCleanup.luau create mode 100644 packages/charm/src/strict.luau rename packages/charm/src/{noYield.luau => utils.luau} (62%) create mode 100644 packages/charm/test/default.project.json create mode 100644 packages/charm/test/src/batched.luau create mode 100644 packages/charm/test/src/core.luau create mode 100644 packages/charm/test/src/init.luau create mode 100644 packages/charm/test/src/listen.luau create mode 100644 packages/charm/test/src/mapped.luau create mode 100644 packages/charm/test/src/observe.luau create mode 100644 packages/charm/test/src/strict.luau create mode 100644 packages/charm/test/src/subscribe.luau create mode 100644 packages/charm/test/src/system.luau create mode 100644 packages/charm/test/src/types.luau create mode 100644 packages/charm/test/src/untracked.luau create mode 100644 packages/charm/test/src/utils.luau create mode 100644 packages/charm/test/wally.toml delete mode 100644 packages/charm/tests/signals/spec.luau delete mode 100644 packages/charm/tests/spec.luau create mode 100644 packages/react-charm/Charm.luau create mode 100644 packages/react-charm/React.luau create mode 100644 packages/react-charm/ReactRoblox.luau create mode 100644 packages/react-charm/package.json rename packages/react-charm/{tests => src/__tests__}/helpers/renderHook.luau (79%) rename packages/react-charm/{tests => src/__tests__}/spec.luau (63%) rename packages/react-charm/{tests => src/__tests__}/useAtom.spec.luau (72%) rename packages/react-charm/{tests => src/__tests__}/useAtomBinding.spec.luau (74%) create mode 100644 packages/react-charm/src/index.d.ts create mode 100644 packages/vide-charm/Charm.luau create mode 100644 packages/vide-charm/Vide.luau create mode 100644 packages/vide-charm/package.json rename packages/vide-charm/{tests => src/__tests__}/spec.luau (51%) rename packages/vide-charm/{tests => src/__tests__}/useAtom.spec.luau (64%) create mode 100644 packages/vide-charm/src/index.d.ts create mode 100644 pnpm-workspace.yaml delete mode 100644 test/init.luau create mode 100644 tsconfig.json diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..5fe4250 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,28 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "jsx": true, + "useJSXTextNode": true, + "ecmaVersion": 2018, + "sourceType": "module", + "project": [ + "./tsconfig.json", + "./examples/ts/tsconfig.json" + ] + }, + "plugins": [ + "@typescript-eslint", + "roblox-ts", + "prettier" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:roblox-ts/recommended", + "plugin:prettier/recommended" + ], + "rules": { + "prettier/prettier": "warn", + "@typescript-eslint/no-explicit-any": "off" + } +} diff --git a/.gitignore b/.gitignore index b3bbbd9..815441d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /.pesde /build /packages/**/wally.lock +/packages/**/LICENSE.md +/packages/**/README.md /sourcemap.json node_modules *.tsbuildinfo diff --git a/.luaurc b/.luaurc index e03053a..4c8043d 100644 --- a/.luaurc +++ b/.luaurc @@ -2,9 +2,5 @@ "languageMode": "strict", "lint": { "LocalShadow": false - }, - "aliases": { - "test": "./test", - "packages": "./packages" } } diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..537ac33 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 120, + "tabWidth": 4, + "trailingComma": "all", + "useTabs": true +} diff --git a/benches/mapped.luau b/benches/mapped.luau index 9fe3ae1..539310c 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -1,10 +1,10 @@ -local Charm = require("@packages/charm/src") +local Charm = require("../packages/charm/src") local bench = require("./bench") local atom = Charm.atom local computed = Charm.computed local mapped = Charm.mapped -local setCurrentSub = Charm.signals.setCurrentSub +local setCurrentSub = Charm.core.setCurrentSub type Entry = { number } diff --git a/benches/patch.bench.luau b/benches/patch.bench.luau index 8d4eeea..a4c5829 100644 --- a/benches/patch.bench.luau +++ b/benches/patch.bench.luau @@ -1,6 +1,4 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local CharmSync = require(ReplicatedStorage.Packages.CharmSync) +local CharmSync = require("../packages/charm-sync/src") local patch = CharmSync.patch diff --git a/benches/propagate.luau b/benches/propagate.luau index c31cf52..9d40d9b 100644 --- a/benches/propagate.luau +++ b/benches/propagate.luau @@ -1,42 +1,27 @@ -local Charm = require("@packages/charm/src") +local Charm = require("../packages/charm/src") local bench = require("./bench") -local signals = Charm.signals +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect for _, w in { 10, 100 } do for _, h in { 10, 100 } do - bench.run(`charm {w}*{h}`, function() - local src = Charm.atom(1) + bench.run(`propagate {w}*{h}`, function() + local src = atom(1) for _ = 1, w do local last = src for _ = 1, h do local prev = last - last = Charm.computed(function() + last = computed(function() return prev() + 1 end) end - Charm.effect(function() + effect(function() last() end) end src(src() + 1) end) - - bench.run(`alien-signals {w}*{h}`, function() - local src, setSrc = signals.signal(1) - for _ = 1, w do - local last = src - for _ = 1, h do - local prev = last - last = signals.computed(function() - return prev() + 1 - end) - end - signals.effect(function() - last() - end) - end - setSrc(src() + 1) - end) end end diff --git a/bin/build.sh b/bin/build.sh index be6fdf7..f680537 100644 --- a/bin/build.sh +++ b/bin/build.sh @@ -1,5 +1,4 @@ +#!/usr/bin/env bash + mkdir build -rojo build packages/charm -o build/charm.rbxm -rojo build packages/charm-sync -o build/charm-sync.rbxm -rojo build packages/react-charm -o build/react-charm.rbxm -rojo build packages/vide-charm -o build/vide-charm.rbxm +rojo build build.project.json -o build/charm.rbxm diff --git a/bin/check.sh b/bin/check.sh index 25cd88d..091c4a4 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -5,12 +5,15 @@ rojo sourcemap -o sourcemap.json luau-lsp analyze \ --defs=bin/roblox.d.luau \ --flag:LuauFixIndexerSubtypingOrdering=true \ + --flag:LuauInstantiateInSubtyping=true \ --sourcemap=sourcemap.json \ --ignore="**/node_modules/**" \ - packages test + packages test benches -selene packages test +selene packages test benches -stylua --check packages test +stylua --check packages test benches + +pnpm eslint packages rm bin/roblox.d.luau diff --git a/build.project.json b/build.project.json new file mode 100644 index 0000000..8141b86 --- /dev/null +++ b/build.project.json @@ -0,0 +1,10 @@ +{ + "name": "Packages", + "tree": { + "$className": "Folder", + "Charm": { "$path": "packages/charm/src" }, + "CharmSync": { "$path": "packages/charm-sync/src" }, + "ReactCharm": { "$path": "packages/react-charm/src" }, + "VideCharm": { "$path": "packages/vide-charm/src" } + } +} diff --git a/default.project.json b/default.project.json index f40f123..43a7d1a 100644 --- a/default.project.json +++ b/default.project.json @@ -1,29 +1,50 @@ { "name": "charm-test", + "globIgnorePaths": [ + "**/default.project.json", + "node_modules/charm", + "node_modules/charm-sync", + "node_modules/react-charm", + "node_modules/vide-charm" + ], + "emitLegacyScripts": false, "tree": { "$className": "DataModel", "ReplicatedStorage": { "$className": "ReplicatedStorage", - "Packages": { - "$path": "node_modules/@rbxts-js", - "Vide": { "$path": "node_modules/@rbxts/vide/src" }, - "Charm": { "$path": "packages/charm" }, - "CharmSync": { "$path": "packages/charm-sync" }, - "ReactCharm": { "$path": "packages/react-charm" }, - "VideCharm": { "$path": "packages/vide-charm" } - } - }, - "ServerScriptService": { - "$className": "ServerScriptService", - "Tests": { - "$path": "test", - "Charm": { "$path": "packages/charm/tests" }, - "CharmSync": { "$path": "packages/charm-sync/tests" }, - "ReactCharm": { "$path": "packages/react-charm/tests" }, - "VideCharm": { "$path": "packages/vide-charm/tests" } + "packages": { + "$className": "Folder", + "vide": { + "$path": "node_modules/@rbxts/vide" + }, + "charm": { + "$className": "Folder", + "src": { "$path": "packages/charm/src" } + }, + "charm-sync": { + "$className": "Folder", + "src": { "$path": "packages/charm-sync/src" }, + "Charm": { "$path": "packages/charm-sync/Charm.luau" } + }, + "react-charm": { + "$className": "Folder", + "src": { "$path": "packages/react-charm/src" }, + "React": { "$path": "packages/react-charm/React.luau" }, + "ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" }, + "Charm": { "$path": "packages/react-charm/Charm.luau" } + }, + "vide-charm": { + "$className": "Folder", + "src": { "$path": "packages/vide-charm/src" }, + "Vide": { "$path": "packages/vide-charm/Vide.luau" }, + "Charm": { "$path": "packages/vide-charm/Charm.luau" } + } }, - "Benchmarks": { - "$path": "benches" + "@rbxts-js": { + "$path": "node_modules/@rbxts-js" + }, + "test": { + "$path": "test" } } } diff --git a/package.json b/package.json index 8e29b88..49c9f01 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,29 @@ { "private": true, "scripts": { - "test": "lune run test", + "test": "lune run test/spec.server.luau", "build": "sh bin/build.sh", - "typecheck": "sh bin/check.sh" + "check": "sh bin/check.sh", + "publish:npm": "pnpm publish -r --access public", + "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.11.0", + "packageManager": "pnpm@10.12.4", "devDependencies": { "@rbxts-js/react": "17.1.20-ts.1", "@rbxts-js/react-roblox": "17.1.20-ts.1", - "@rbxts/vide": "^0.5.7" + "@rbxts/compiler-types": "3.0.0-types.0", + "@rbxts/react": "17.3.0-alpha.1", + "@rbxts/react-roblox": "17.3.0-alpha.1", + "@rbxts/types": "^1.0.861", + "@rbxts/vide": "^0.5.7", + "@typescript-eslint/eslint-plugin": "^8.35.1", + "@typescript-eslint/parser": "^8.35.1", + "eslint": "^8.57.1", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-prettier": "^5.5.1", + "eslint-plugin-roblox-ts": "^0.0.36", + "prettier": "^3.6.2", + "roblox-ts": "^3.0.0", + "typescript": "^5.8.3" } } diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau new file mode 100644 index 0000000..6b6fb92 --- /dev/null +++ b/packages/charm-sync/Charm.luau @@ -0,0 +1,6 @@ +local Charm = require(script.Parent.Parent.charm.src) + +export type Atom = Charm.Atom +export type Selector = Charm.Selector + +return Charm diff --git a/packages/charm-sync/default.project.json b/packages/charm-sync/default.project.json index 5115ff0..9c44c7c 100644 --- a/packages/charm-sync/default.project.json +++ b/packages/charm-sync/default.project.json @@ -1,5 +1,5 @@ { - "name": "CharmSync", + "name": "charm-sync", "tree": { "$path": "src" } diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json new file mode 100644 index 0000000..129cf4a --- /dev/null +++ b/packages/charm-sync/package.json @@ -0,0 +1,27 @@ +{ + "name": "@rbxts/charm-sync", + "version": "0.3.0", + "description": "Simple server-to-client sync for Charm", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!**/__tests__" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:*" + } +} diff --git a/packages/charm-sync/tests/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau similarity index 89% rename from packages/charm-sync/tests/client.spec.luau rename to packages/charm-sync/src/__tests__/client.spec.luau index d335660..eaebab2 100644 --- a/packages/charm-sync/tests/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -1,9 +1,6 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local ServerScriptService = game:GetService("ServerScriptService") - -local Charm = require(ReplicatedStorage.Packages.Charm) -local CharmSync = require(ReplicatedStorage.Packages.CharmSync) -local suite = require(ServerScriptService.Tests.suite) +local Charm = require("../../Charm") +local CharmSync = require("../") +local suite = require("../../../../test/suite") local atom = Charm.atom local subscribe = Charm.subscribe diff --git a/packages/charm-sync/tests/flatten.spec.luau b/packages/charm-sync/src/__tests__/flatten.spec.luau similarity index 66% rename from packages/charm-sync/tests/flatten.spec.luau rename to packages/charm-sync/src/__tests__/flatten.spec.luau index 1b1963f..2507b17 100644 --- a/packages/charm-sync/tests/flatten.spec.luau +++ b/packages/charm-sync/src/__tests__/flatten.spec.luau @@ -1,9 +1,6 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local ServerScriptService = game:GetService("ServerScriptService") - -local Charm = require(ReplicatedStorage.Packages.Charm) -local CharmSync = require(ReplicatedStorage.Packages.CharmSync) -local suite = require(ServerScriptService.Tests.suite) +local Charm = require("../../Charm") +local CharmSync = require("../") +local suite = require("../../../../test/suite") local atom = Charm.atom local flatten = CharmSync.flatten diff --git a/packages/charm-sync/tests/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau similarity index 94% rename from packages/charm-sync/tests/patch.spec.luau rename to packages/charm-sync/src/__tests__/patch.spec.luau index a38cc4a..05bfef5 100644 --- a/packages/charm-sync/tests/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -1,9 +1,7 @@ local HttpService = game:GetService("HttpService") -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local ServerScriptService = game:GetService("ServerScriptService") -local CharmSync = require(ReplicatedStorage.Packages.CharmSync) -local suite = require(ServerScriptService.Tests.suite) +local CharmSync = require("../") +local suite = require("../../../../test/suite") local patch = CharmSync.patch local test = suite.test diff --git a/packages/charm-sync/tests/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau similarity index 89% rename from packages/charm-sync/tests/server.spec.luau rename to packages/charm-sync/src/__tests__/server.spec.luau index 4a51fb0..d88b36f 100644 --- a/packages/charm-sync/tests/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -1,9 +1,6 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local ServerScriptService = game:GetService("ServerScriptService") - -local Charm = require(ReplicatedStorage.Packages.Charm) -local CharmSync = require(ReplicatedStorage.Packages.CharmSync) -local suite = require(ServerScriptService.Tests.suite) +local Charm = require("../../Charm") +local CharmSync = require("../") +local suite = require("../../../../test/suite") type SyncPayload = CharmSync.SyncPayload diff --git a/packages/charm-sync/tests/spec.luau b/packages/charm-sync/src/__tests__/spec.luau similarity index 76% rename from packages/charm-sync/tests/spec.luau rename to packages/charm-sync/src/__tests__/spec.luau index 725714b..10f3bd9 100644 --- a/packages/charm-sync/tests/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -1,8 +1,9 @@ -local ServerScriptService = game:GetService("ServerScriptService") - -local suite = require(ServerScriptService.Tests.suite) +local suite = require("../../../../test/suite") suite.describe("charm-sync", function() + if not game then + return suite.skip() + end suite.describe("client", function() require("./client.spec") end) diff --git a/packages/charm-sync/tests/validate.spec.luau b/packages/charm-sync/src/__tests__/validate.spec.luau similarity index 76% rename from packages/charm-sync/tests/validate.spec.luau rename to packages/charm-sync/src/__tests__/validate.spec.luau index 87b048a..86d2edb 100644 --- a/packages/charm-sync/tests/validate.spec.luau +++ b/packages/charm-sync/src/__tests__/validate.spec.luau @@ -1,9 +1,6 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local ServerScriptService = game:GetService("ServerScriptService") - -local Charm = require(ReplicatedStorage.Packages.Charm) -local CharmSync = require(ReplicatedStorage.Packages.CharmSync) -local suite = require(ServerScriptService.Tests.suite) +local Charm = require("../../Charm") +local CharmSync = require("../") +local suite = require("../../../../test/suite") local atom = Charm.atom local validate = CharmSync.validate diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index a36c55b..3bb778d 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -1,6 +1,6 @@ -local Charm = require(script.Parent.Parent.Charm) -local patch = require(script.Parent.patch) -local types = require(script.Parent.types) +local Charm = require("../Charm") +local patch = require("./patch") +local types = require("./types") type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload @@ -36,7 +36,7 @@ local function client(options: ClientOptions): ClientSyncer local function hydrate(state: { [string | number]: any }) hydrated = true - for key, value in next, state do + for key, value in state do local atom = atoms[key] atom(value) end @@ -45,13 +45,13 @@ local function client(options: ClientOptions): ClientSyncer local function apply(data: { [string | number]: any }) local target = {} - for key, atom in next, atoms do + for key, atom in atoms do target[key] = atom() end target = patch.apply(target, data) - for key, atom in next, atoms do + for key, atom in atoms do atom(target[key]) end end diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau index 42a93eb..5d4a60d 100644 --- a/packages/charm-sync/src/flatten.luau +++ b/packages/charm-sync/src/flatten.luau @@ -1,4 +1,4 @@ -local types = require(script.Parent.types) +local types = require("./types") type AtomMap = types.AtomMap diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/charm-sync/src/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau index 0978041..249922f 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -1,9 +1,9 @@ -local client = require(script.client) -local flatten = require(script.flatten) -local patch = require(script.patch) -local server = require(script.server) -local types = require(script.types) -local validate = require(script.validate) +local client = require("@self/client") +local flatten = require("@self/flatten") +local patch = require("@self/patch") +local server = require("@self/server") +local types = require("@self/types") +local validate = require("@self/validate") export type SyncPayload = types.SyncPayload diff --git a/packages/charm-sync/src/interval.luau b/packages/charm-sync/src/interval.luau index 7788d41..1225ecb 100644 --- a/packages/charm-sync/src/interval.luau +++ b/packages/charm-sync/src/interval.luau @@ -1,5 +1,3 @@ -local RunService = game:GetService("RunService") - --- Schedules a function to be called at a fixed interval of `seconds`. --- --- @param callback The function to call. @@ -12,7 +10,7 @@ local function interval(callback: () -> (), seconds: number): () -> () local clock = 0 - local connection = RunService.Heartbeat:Connect(function(delta) + local connection = game:GetService("RunService").Heartbeat:Connect(function(delta) clock += delta if clock >= seconds then clock = 0 diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 4d87738..f96f6f6 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -1,6 +1,5 @@ -local validate = require(script.Parent.validate) - -local __DEV__ = _G.__DEV__ +local Charm = require("../Charm") +local validate = require("./validate") --- A special symbol that denotes the absence of a value. Used to represent --- deleted values in patches. @@ -64,7 +63,7 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial patches = stringifySparseArray(patches) end - if serialize and __DEV__ then + if serialize and Charm.strict.enabled then for key, value in next, patches do validate(value, key) end diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 80fac19..880944a 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -1,9 +1,7 @@ -local Players = game:GetService("Players") - -local Charm = require(script.Parent.Parent.Charm) -local interval = require(script.Parent.interval) -local patch = require(script.Parent.patch) -local types = require(script.Parent.types) +local Charm = require("../Charm") +local interval = require("./interval") +local patch = require("./patch") +local types = require("./types") type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload @@ -64,7 +62,7 @@ local function server(options: ServerOptions): ServerSyncer local function createSnapshot() local snapshot = {} - for key, atom in next, atoms do + for key, atom in atoms do snapshot[key] = atom() end @@ -110,7 +108,7 @@ local function server(options: ServerOptions): ServerSyncer -- Populate the snapshot with the initial state of each atom. -- Subscribe to each atom and update the state when it changes. - for key, atom in next, atoms do + for key, atom in atoms do subscriptions[key] = Charm.subscribe(atom, function(current, previous) pushSnapshot(key, current, previous) changed = true @@ -125,7 +123,7 @@ local function server(options: ServerOptions): ServerSyncer local payloads: { SyncPayload } = {} local lastSnapshot - for index, snapshot in next, snapshots do + for index, snapshot in snapshots do lastSnapshot = snapshot if index == 1 then @@ -141,7 +139,7 @@ local function server(options: ServerOptions): ServerSyncer snapshots = { lastSnapshot } changed = false - for _, player in next, Players:GetPlayers() do + for _, player in game:GetService("Players"):GetPlayers() do callback(player, unpack(payloads)) end end, syncRate) @@ -149,7 +147,7 @@ local function server(options: ServerOptions): ServerSyncer return function() disconnect() - for _, unsubscribe in next, subscriptions do + for _, unsubscribe in subscriptions do unsubscribe() end end @@ -164,7 +162,7 @@ local function server(options: ServerOptions): ServerSyncer local payloads: { SyncPayload } = {} - for index, snapshot in next, snapshots do + for index, snapshot in snapshots do if index == 1 then continue end @@ -194,7 +192,7 @@ local function server(options: ServerOptions): ServerSyncer sync = callback -- Subscribe to each atom and update the state when one changes. - for key, atom in next, atoms do + for key, atom in atoms do subscriptions[key] = Charm.subscribe(atom, function(state) currentSnapshot[key] = state changed = true @@ -214,7 +212,7 @@ local function server(options: ServerOptions): ServerSyncer previousSnapshot = table.clone(currentSnapshot) changed = false - for _, player in next, Players:GetPlayers() do + for _, player in game:GetService("Players"):GetPlayers() do callback(player, payload) end end, syncRate) @@ -222,7 +220,7 @@ local function server(options: ServerOptions): ServerSyncer return function() disconnect() - for _, unsubscribe in next, subscriptions do + for _, unsubscribe in subscriptions do unsubscribe() end end diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index e29bd62..791707f 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -1,4 +1,4 @@ -local Charm = require(script.Parent.Parent.Charm) +local Charm = require("../Charm") type Atom = Charm.Atom diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 41d31ee..c5fc5a0 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -3,7 +3,13 @@ name = "littensy/charm-sync" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["tests"] +exclude = [ + "node_modules", + "src/__tests__", + "src/index.d.ts", + "package.json", + "Charm.luau", +] [dependencies] Charm = "littensy/charm@^0.10" diff --git a/packages/charm/default.project.json b/packages/charm/default.project.json index 89d3ada..a485e4d 100644 --- a/packages/charm/default.project.json +++ b/packages/charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "Charm", + "name": "charm", "tree": { "$path": "src" } diff --git a/packages/charm/package.json b/packages/charm/package.json new file mode 100644 index 0000000..67c51c8 --- /dev/null +++ b/packages/charm/package.json @@ -0,0 +1,24 @@ +{ + "name": "@rbxts/charm", + "version": "0.10.0", + "description": "Atomic state management based on alien-signals", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!**/__tests__" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + } +} diff --git a/packages/charm/tests/signals/computed.spec.luau b/packages/charm/src/__tests__/core/computed.spec.luau similarity index 67% rename from packages/charm/tests/signals/computed.spec.luau rename to packages/charm/src/__tests__/core/computed.spec.luau index 5c74738..c5f37d3 100644 --- a/packages/charm/tests/signals/computed.spec.luau +++ b/packages/charm/src/__tests__/core/computed.spec.luau @@ -1,12 +1,12 @@ -local signals = require("../../src/signals") -local suite = require("@test/suite") +local core = require("../../core") +local suite = require("../../../../../test/suite") -local computed = signals.computed -local signal = signals.signal +local computed = core.computed +local atom = core.atom local test = suite.test -test("should correctly propagate changes through computed signals", function() - local src, setSrc = signal(0) +test("should correctly propagate changes through computed core", function() + local src = atom(0) local c1 = computed(function() return src() % 2 end) @@ -18,15 +18,15 @@ test("should correctly propagate changes through computed signals", function() end) c3() - setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty + src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty c2() -- c1 -> none, c2 -> none - setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty + 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, setSrc = signal(0) + local src = atom(0) local a = computed(function() return src() end) @@ -41,12 +41,12 @@ test("should propagate updated source value through chained computations", funct end) assert(d() == 0, "bad initial value") - setSrc(2) + src(2) assert(d() == 2, "bad updated value") end) test("should handle flags are indirectly updated during checkDirty", function() - local a, setA = signal(false) + local a = atom(false) local b = computed(function() return a() end) @@ -60,22 +60,22 @@ test("should handle flags are indirectly updated during checkDirty", function() end) assert(d() == false, "bad initial value") - setA(true) + a(true) assert(d() == true, "bad updated value") end) -test("should not update if the signal value is reverted", function() +test("should not update if the atom value is reverted", function() local times = 0 - local src, setSrc = signal(0) + local src = atom(0) local c1 = computed(function() times += 1 return src() end) c1() assert(times == 1, "computed did not run") - setSrc(1) - setSrc(0) + src(1) + src(0) c1() assert(times == 1, "computed ran when it should not") end) diff --git a/packages/charm/tests/signals/effect.spec.luau b/packages/charm/src/__tests__/core/effect.spec.luau similarity index 80% rename from packages/charm/tests/signals/effect.spec.luau rename to packages/charm/src/__tests__/core/effect.spec.luau index 58ad958..e02512a 100644 --- a/packages/charm/tests/signals/effect.spec.luau +++ b/packages/charm/src/__tests__/core/effect.spec.luau @@ -1,20 +1,20 @@ -local signals = require("../../src/signals") -local suite = require("@test/suite") +local core = require("../../core") +local suite = require("../../../../../test/suite") -local computed = signals.computed -local effect = signals.effect -local effectScope = signals.effectScope -local endBatch = signals.endBatch -local setCurrentSub = signals.setCurrentSub -local onEffectCleanup = signals.onEffectCleanup -local signal = signals.signal -local startBatch = signals.startBatch +local computed = core.computed +local effect = core.effect +local effectScope = core.effectScope +local endBatch = core.endBatch +local setCurrentSub = core.setCurrentSub +local onCleanup = core.onCleanup +local atom = core.atom +local startBatch = core.startBatch local test = suite.test test("should clear subscriptions when untracked by all subscribers", function() local bRunTimes = 0 - local a, setA = signal(1) + local a = atom(1) local b = computed(function() bRunTimes += 1 return a() * 2 @@ -24,15 +24,15 @@ test("should clear subscriptions when untracked by all subscribers", function() end) assert(bRunTimes == 1, "should run initially") - setA(2) + a(2) assert(bRunTimes == 2, "should run after change") stopEffect() - setA(3) + a(3) assert(bRunTimes == 2, "should not run after stop") end) test("should not run untracked inner effect", function() - local a, setA = signal(3) + local a = atom(3) local b = computed(function() return a() > 0 end) @@ -47,14 +47,14 @@ test("should not run untracked inner effect", function() end end) - setA(2) - setA(1) - setA(0) + a(2) + a(1) + a(0) end) test("should run outer effect first", function() - local a, setA = signal(1) - local b, setB = signal(1) + local a = atom(1) + local b = atom(1) effect(function() if a() > 0 then @@ -68,13 +68,13 @@ test("should run outer effect first", function() end) startBatch() - setB(0) - setA(0) + b(0) + a(0) endBatch() end) test("should not trigger inner effect when resolve maybe dirty", function() - local a, setA = signal(0) + local a = atom(0) local b = computed(function() return a() % 2 end) @@ -91,12 +91,12 @@ test("should not trigger inner effect when resolve maybe dirty", function() end) end) - setA(2) + a(2) end) test("should trigger inner effects in sequence", function() - local a, setA = signal(0) - local b, setB = signal(0) + local a = atom(0) + local b = atom(0) local c = computed(function() return a() - b() end) @@ -120,16 +120,16 @@ test("should trigger inner effects in sequence", function() table.clear(order) startBatch() - setB(1) - setA(1) + 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, setA = signal(0) - local b, setB = signal(0) + local a = atom(0) + local b = atom(0) local order: { string } = {} effectScope(function() @@ -148,8 +148,8 @@ test("should trigger inner effects in sequence in effect scope", function() table.clear(order) startBatch() - setB(1) - setA(1) + b(1) + a(1) endBatch() assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`) @@ -168,13 +168,13 @@ test("should custom effect support batch", function() end local logs: { string } = {} - local a = signal(0) - local b, setB = signal(0) + local a = atom(0) + local b = atom(0) local aa = computed(function() table.insert(logs, "aa-0") if a() == 0 then - setB(1) + b(1) end table.insert(logs, "aa-1") return nil @@ -196,8 +196,8 @@ test("should custom effect support batch", function() end) test("should duplicate subscribers do not affect the notify order", function() - local src1, setSrc1 = signal(0) - local src2, setSrc2 = signal(0) + local src1 = atom(0) + local src2 = atom(0) local order: { string } = {} effect(function() @@ -215,17 +215,17 @@ test("should duplicate subscribers do not affect the notify order", function() table.insert(order, "b") src1() end) - setSrc2(1) -- src1.subs: a -> b -> a + src2(1) -- src1.subs: a -> b -> a table.clear(order) - setSrc1(src1() + 1) + 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, setA = signal(0) - local b, setB = signal(0) + local a = atom(0) + local b = atom(0) local order: { string } = {} effect(function() @@ -240,14 +240,14 @@ test("should handle side effect with inner effects", function() assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) table.clear(order) - setB(1) - setA(1) + 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, setA = signal(false) + local a = atom(false) local b = computed(function() return a() end) @@ -267,27 +267,27 @@ test("should handle flags are indirectly updated during checkDirty", function() triggers += 1 end) assert(triggers == 1, "did not run initially") - setA(true) + a(true) assert(triggers == 2, "did not update indirectly") end) test("should run cleanups in sequence", function() local order = {} - local a, setA = signal(false) + local a = atom(false) local stopEffect = effect(function() a() - onEffectCleanup(function() + onCleanup(function() table.insert(order, "a") end) - onEffectCleanup(function() + onCleanup(function() table.insert(order, "b") end) end) assert(table.concat(order) == "", "should not cleanup immediately") - setA(true) + a(true) assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`) table.clear(order) @@ -295,45 +295,45 @@ test("should run cleanups in sequence", function() assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`) table.clear(order) - setA(false) + a(false) assert(table.concat(order) == "", "should not cleanup after update") end) test("should run inner effect cleanup", function() local cleanups = 0 - local src, setSrc = signal(false) + local src = atom(false) local stopEffect = effect(function() src() effect(function() - onEffectCleanup(function() + onCleanup(function() cleanups += 1 end) end) effect(function() - onEffectCleanup(function() + onCleanup(function() cleanups += 1 end) end) end) assert(cleanups == 0, "should not cleanup immediately") - setSrc(true) + src(true) assert(cleanups == 2, "should cleanup after outer update") stopEffect() assert(cleanups == 4, "should cleanup after outer dispose") - setSrc(false) + src(false) assert(cleanups == 4, "should not cleanup after update") end) test("should run cleanups outside any evaluation context", function() local calls = 0 - local a, setA = signal(0) - local b, setB = signal(0) + local a = atom(0) + local b = atom(0) local c = computed(function() if a() == 0 then effect(function() - onEffectCleanup(function() + onCleanup(function() b() end) end) @@ -348,11 +348,11 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 1, "should run once") calls = 0 - setA(1) + a(1) assert(calls == 1, "should run once after a update") calls = 0 - setB(1) + b(1) assert(calls == 0, "should not run after b update") end) diff --git a/packages/charm/tests/signals/effectScope.spec.luau b/packages/charm/src/__tests__/core/effectScope.spec.luau similarity index 73% rename from packages/charm/tests/signals/effectScope.spec.luau rename to packages/charm/src/__tests__/core/effectScope.spec.luau index e48dcbe..c5dcbe8 100644 --- a/packages/charm/tests/signals/effectScope.spec.luau +++ b/packages/charm/src/__tests__/core/effectScope.spec.luau @@ -1,15 +1,14 @@ -local signals = require("../../src/signals") -local suite = require("@test/suite") +local core = require("../../core") +local suite = require("../../../../../test/suite") -local effect = signals.effect -local effectScope = signals.effectScope -local signal = signals.signal -local onEffectCleanup = signals.onEffectCleanup -local onScopeDispose = signals.onScopeDispose +local effect = core.effect +local effectScope = core.effectScope +local atom = core.atom +local onCleanup = core.onCleanup local test = suite.test test("should not trigger after stop", function() - local count, setCount = signal(1) + local count = atom(1) local triggers = 0 @@ -20,14 +19,14 @@ test("should not trigger after stop", function() end) assert(triggers == 1, "did not run initially") - setCount(2) + count(2) assert(triggers == 2, "did not run after update") end) - setCount(3) + count(3) assert(triggers == 3, "did not run after update outside scope") stopScope() - setCount(4) + count(4) assert(triggers == 3, "ran after stop") end) @@ -37,7 +36,7 @@ test("should run indirect scope cleanup", function() local cleanup local stopScope = effectScope(function() cleanup = effectScope(function() - onScopeDispose(function() + onCleanup(function() cleanups += 1 end) end) @@ -53,22 +52,22 @@ end) test("should run indirect effect cleanup", function() local cleanups = 0 - local a, setA = signal(false) + local a = atom(false) local stopScope = effectScope(function() effect(function() a() - onEffectCleanup(function() + onCleanup(function() cleanups += 1 end) end) end) assert(cleanups == 0, "should not cleanup immediately") - setA(true) + a(true) assert(cleanups == 1, "should cleanup once") stopScope() assert(cleanups == 2, "should cleanup after outer dispose") - setA(false) + a(false) assert(cleanups == 2, "should not cleanup after update") end) diff --git a/packages/charm/tests/signals/gc.spec.luau b/packages/charm/src/__tests__/core/gc.spec.luau similarity index 70% rename from packages/charm/tests/signals/gc.spec.luau rename to packages/charm/src/__tests__/core/gc.spec.luau index 2179894..9914415 100644 --- a/packages/charm/tests/signals/gc.spec.luau +++ b/packages/charm/src/__tests__/core/gc.spec.luau @@ -1,10 +1,10 @@ -local signals = require("../../src/signals") -local suite = require("@test/suite") +local core = require("../../core") +local suite = require("../../../../../test/suite") -local signal = signals.signal -local computed = signals.computed -local effect = signals.effect -local effectScope = signals.effectScope +local atom = core.atom +local computed = core.computed +local effect = core.effect +local effectScope = core.effectScope local test = suite.test --- Creates a weak reference to a value. @@ -23,13 +23,13 @@ local function gc() end end ---- In alien-signals, a computed value unlinks from dependencies when its last +--- In alien-core, a computed value unlinks from dependencies when its last --- parent scope is disposed. Calling a computed value outside of a scope links --- it to dependencies, but the dependencies hold strong references to the --- computed value, so it won't GC until called in a scope and disposed. --- --- This function releases a computed value by linking it to a scope and then ---- immediately closing it, causing alien-signals to unlink the computed value +--- immediately closing it, causing alien-core to unlink the computed value --- and allow it to be garbage collected. local function release(getter: () -> ()) effectScope(function() @@ -39,23 +39,23 @@ end test("should release computed cache", function() local srcRef = weakRef({}) - local src, setSrc = signal(srcRef()) + local src = atom(srcRef()) local c = computed(function() return src() end) c() -- cache src value - setSrc(nil) -- release value + src(nil) -- release value c = release(c) -- release computed gc() assert(srcRef() == nil, "should release computed cache") end) -test("should not release effect for signal", function() +test("should not release effect for atom", function() local effectCalls = 0 - local src, setSrc = signal(0) + local src = atom(0) effect(function() effectCalls += 1 @@ -65,7 +65,7 @@ test("should not release effect for signal", function() assert(effectCalls == 1, "should call effect once") gc() - setSrc(src() + 1) + src(src() + 1) assert(effectCalls == 2, "should call effect again") end) diff --git a/packages/charm/tests/signals/issue_48.spec.luau b/packages/charm/src/__tests__/core/issue_48.spec.luau similarity index 87% rename from packages/charm/tests/signals/issue_48.spec.luau rename to packages/charm/src/__tests__/core/issue_48.spec.luau index a60b79d..ba67e4e 100644 --- a/packages/charm/tests/signals/issue_48.spec.luau +++ b/packages/charm/src/__tests__/core/issue_48.spec.luau @@ -1,10 +1,10 @@ -local signals = require("../../src/signals") -local suite = require("@test/suite") +local core = require("../../core") +local suite = require("../../../../../test/suite") -local computed = signals.computed -local effect = signals.effect -local setCurrentSub = signals.setCurrentSub -local signal = signals.signal +local computed = core.computed +local effect = core.effect +local setCurrentSub = core.setCurrentSub +local atom = core.atom local test = suite.test type ReactionOptions = { @@ -90,7 +90,7 @@ local function reaction( end test("#48", function() - local source, setSource = signal(0) + local source = atom(0) local disposeInner: () -> () reaction(function() @@ -105,9 +105,9 @@ test("#48", function() end end) - setSource(1) - setSource(2) - setSource(3) + source(1) + source(2) + source(3) end) return {} diff --git a/packages/charm/tests/signals/topology.spec.luau b/packages/charm/src/__tests__/core/topology.spec.luau similarity index 89% rename from packages/charm/tests/signals/topology.spec.luau rename to packages/charm/src/__tests__/core/topology.spec.luau index 7fa961d..d3cdbf2 100644 --- a/packages/charm/tests/signals/topology.spec.luau +++ b/packages/charm/src/__tests__/core/topology.spec.luau @@ -1,15 +1,15 @@ -local signals = require("../../src/signals") -local suite = require("@test/suite") +local core = require("../../core") +local suite = require("../../../../../test/suite") -local computed = signals.computed -local effect = signals.effect -local signal = signals.signal +local computed = core.computed +local effect = core.effect +local atom = core.atom local describe = suite.describe local test = suite.test --[[ - Tests adopted with thanks from preact-signals implementation at - https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx + Tests adopted with thanks from preact-core implementation at + https://github.com/preactjs/core/blob/main/packages/core/test/atom.test.tsx The MIT License (MIT) @@ -43,7 +43,7 @@ describe("graph updates", function() -- C -- | -- D - local a, setA = signal(2) + local a = atom(2) local b = computed(function() return a() - 1 @@ -64,12 +64,12 @@ describe("graph updates", function() assert(computeCalls == 1, "compute should be called once") computeCalls = 0 - setA(4) + a(4) d() assert(computeCalls == 1, "compute should drop update") end) - test("should only update every signal once (diamond graph)", function() + test("should only update every atom 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 @@ -78,7 +78,7 @@ describe("graph updates", function() -- \ / -- D - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() end) @@ -96,12 +96,12 @@ describe("graph updates", function() assert(d() == "a a", "d should be 'a a'") assert(spyCalls == 1, "compute should be called once") - setA("aa") + 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() + test("should only update every atom once (diamond graph + tail)", function() -- "E" will be likely updated twice if our mark+sweep logic is buggy. -- A -- / \ @@ -111,7 +111,7 @@ describe("graph updates", function() -- | -- E - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() end) @@ -133,7 +133,7 @@ describe("graph updates", function() assert(e() == "a a", "e should be 'a a'") assert(spyCalls == 1, "compute should be called once") - setA("aa") + a("aa") assert(e() == "aa aa", "e should be 'aa aa'") assert(spyCalls == 2, "compute should be called only twice") end) @@ -141,7 +141,7 @@ describe("graph updates", function() test("should bail out if result is the same", function() -- Bail out if value of "B" never changes -- A->B->C - local a, setA = signal("a") + local a = atom("a") local b = computed(function() a() return "foo" @@ -157,12 +157,12 @@ describe("graph updates", function() assert(c() == "foo", "c should be 'foo'") assert(spyCalls == 1, "compute should be called once") - setA("aa") + 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() + test("should only update every atom once (jagged diamond graph + tails)", function() -- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy. -- A -- / \ @@ -173,7 +173,7 @@ describe("graph updates", function() -- E -- / \ -- F G - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() @@ -222,7 +222,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - setA("b") + a("b") assert(e() == "b b", "e should be 'b b'") assert(eSpyCalls == 1, "e should run once after update") @@ -237,7 +237,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - setA("c") + a("c") assert(e() == "c c", "e should be 'c c'") assert(eSpyCalls == 1, "e should run once after update") @@ -254,11 +254,11 @@ describe("graph updates", function() assert(eSpyTime < gSpyTime, "e should be called before g") end) - test("should only subscribe to signals listened to", function() + test("should only subscribe to core listened to", function() -- *A -- / \ -- *B C <- we don't listen to C - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() @@ -273,12 +273,12 @@ describe("graph updates", function() assert(b() == "a", "b should be 'a'") assert(spyCalls == 0, "compute should not run") - setA("aa") + 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() + test("should only subscribe to core listened to II", function() -- Here both "B" and "C" are active in the beginning, but -- "B" becomes inactive later. At that point it should -- not receive any updates anymore. @@ -287,7 +287,7 @@ describe("graph updates", function() -- *B D <- we don't listen to C -- | -- *C - local a, setA = signal("a") + local a = atom("a") local spyBCalls = 0 local spyB = function() spyBCalls += 1 @@ -318,7 +318,7 @@ describe("graph updates", function() spyCCalls = 0 unsub() - setA("aa") + a("aa") assert(spyBCalls == 0, "b should not run") assert(spyCCalls == 0, "c should not run") @@ -335,7 +335,7 @@ describe("graph updates", function() -- B *C <- returns same value every time -- \ / -- D - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() end) @@ -353,7 +353,7 @@ describe("graph updates", function() assert(d() == "a c", "d should be 'a c'") spyResult = nil - setA("aa") + a("aa") d() assert(spyResult == "aa c", "d should have returned 'aa c'") end) @@ -367,7 +367,7 @@ describe("graph updates", function() -- B *C *D -- \ | / -- E - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() end) @@ -389,13 +389,13 @@ describe("graph updates", function() assert(e() == "a c d", "e should be 'a c d'") spyResult = nil - setA("aa") + a("aa") e() assert(spyResult == "aa c d", "e should have returned 'aa c d'") end) test("should support lazy branches", function() - local a, setA = signal(0) + local a = atom(0) local b = computed(function() return a() end) @@ -404,10 +404,10 @@ describe("graph updates", function() end) assert(c() == 0, "c should be 0") - setA(1) + a(1) assert(c() == 1, "c should be 1") - setA(0) + a(0) assert(c() == 0, "c should be 0 again") end) @@ -419,7 +419,7 @@ describe("graph updates", function() -- *B *C -- \ / -- D - local a, setA = signal("a") + local a = atom("a") local b = computed(function() a() return "b" @@ -438,14 +438,14 @@ describe("graph updates", function() assert(d() == "b c", "d should be 'b c'") spyCalls = 0 - setA("aa") + 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, setA = signal(0) + local a = atom(0) local b = computed(function() error("fail") end) @@ -459,12 +459,12 @@ describe("error handling", function() assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - setA(1) + a(1) assert(c() == 1, "c should recover and return 1") end) test("should keep graph consistent on errors in computeds", function() - local a, setA = signal(0) + local a = atom(0) local b = computed(function() if a() == 1 then error("fail") @@ -477,14 +477,14 @@ describe("error handling", function() assert(c() == 0, "c should be 0") - setA(1) + a(1) local success, result = pcall(function() b() end) assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - setA(2) + a(2) assert(c() == 2, "c should recover and return 2") end) end) diff --git a/packages/charm/tests/signals/untrack.spec.luau b/packages/charm/src/__tests__/core/untrack.spec.luau similarity index 57% rename from packages/charm/tests/signals/untrack.spec.luau rename to packages/charm/src/__tests__/core/untrack.spec.luau index f8f738f..177c3d7 100644 --- a/packages/charm/tests/signals/untrack.spec.luau +++ b/packages/charm/src/__tests__/core/untrack.spec.luau @@ -1,13 +1,13 @@ -local signals = require("../../src/signals") -local suite = require("@test/suite") +local core = require("../../core") +local suite = require("../../../../../test/suite") -local computed = signals.computed -local setCurrentSub = signals.setCurrentSub -local signal = signals.signal +local computed = core.computed +local setCurrentSub = core.setCurrentSub +local atom = core.atom local test = suite.test test("should pause tracking", function() - local src, setSrc = signal(0) + local src = atom(0) local c = computed(function() local currentSub = setCurrentSub(nil) local value = src() @@ -16,7 +16,7 @@ test("should pause tracking", function() end) assert(c() == 0, "should not track src") - setSrc(1) + src(1) assert(c() == 0, "should not track src") end) diff --git a/packages/charm/tests/listen.spec.luau b/packages/charm/src/__tests__/listen.spec.luau similarity index 96% rename from packages/charm/tests/listen.spec.luau rename to packages/charm/src/__tests__/listen.spec.luau index 1142347..097d2aa 100644 --- a/packages/charm/tests/listen.spec.luau +++ b/packages/charm/src/__tests__/listen.spec.luau @@ -1,5 +1,5 @@ -local Charm = require("../src") -local suite = require("@test/suite") +local Charm = require("../") +local suite = require("../../../../test/suite") local atom = Charm.atom local listen = Charm.listen diff --git a/packages/charm/tests/mapped.spec.luau b/packages/charm/src/__tests__/mapped.spec.luau similarity index 97% rename from packages/charm/tests/mapped.spec.luau rename to packages/charm/src/__tests__/mapped.spec.luau index 3d0fcf5..2d1e857 100644 --- a/packages/charm/tests/mapped.spec.luau +++ b/packages/charm/src/__tests__/mapped.spec.luau @@ -1,5 +1,5 @@ -local Charm = require("../src") -local suite = require("@test/suite") +local Charm = require("../") +local suite = require("../../../../test/suite") local atom = Charm.atom local mapped = Charm.mapped diff --git a/packages/charm/tests/observe.spec.luau b/packages/charm/src/__tests__/observe.spec.luau similarity index 94% rename from packages/charm/tests/observe.spec.luau rename to packages/charm/src/__tests__/observe.spec.luau index 84f5e77..ed392f4 100644 --- a/packages/charm/tests/observe.spec.luau +++ b/packages/charm/src/__tests__/observe.spec.luau @@ -1,5 +1,5 @@ -local Charm = require("../src") -local suite = require("@test/suite") +local Charm = require("../") +local suite = require("../../../../test/suite") local atom = Charm.atom local computed = Charm.computed diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau new file mode 100644 index 0000000..7d0d54a --- /dev/null +++ b/packages/charm/src/__tests__/spec.luau @@ -0,0 +1,45 @@ +local suite = require("../../../../test/suite") + +suite.describe("core", function() + suite.describe("computed", function() + require("./core/computed.spec") + end) + suite.describe("effect", function() + require("./core/effect.spec") + end) + suite.describe("effectScope", function() + require("./core/effectScope.spec") + end) + suite.describe("issue_48", function() + require("./core/issue_48.spec") + end) + suite.describe("gc", function() + require("./core/gc.spec") + end) + suite.describe("topology", function() + require("./core/topology.spec") + end) + suite.describe("untrack", function() + require("./core/untrack.spec") + end) +end) + +suite.describe("charm", function() + suite.describe("listen", function() + require("./listen.spec") + end) + suite.describe("mapped", function() + require("./mapped.spec") + end) + suite.describe("observe", function() + require("./observe.spec") + end) + suite.describe("subscribe", function() + require("./subscribe.spec") + end) + suite.describe("untracked", function() + require("./untracked.spec") + end) +end) + +return {} diff --git a/packages/charm/tests/subscribe.spec.luau b/packages/charm/src/__tests__/subscribe.spec.luau similarity index 97% rename from packages/charm/tests/subscribe.spec.luau rename to packages/charm/src/__tests__/subscribe.spec.luau index 67bebda..d91a952 100644 --- a/packages/charm/tests/subscribe.spec.luau +++ b/packages/charm/src/__tests__/subscribe.spec.luau @@ -1,5 +1,5 @@ -local Charm = require("../src") -local suite = require("@test/suite") +local Charm = require("../") +local suite = require("../../../../test/suite") type Atom = Charm.Atom diff --git a/packages/charm/tests/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau similarity index 95% rename from packages/charm/tests/untracked.spec.luau rename to packages/charm/src/__tests__/untracked.spec.luau index 53f7767..37a40aa 100644 --- a/packages/charm/tests/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -1,5 +1,5 @@ -local Charm = require("../src") -local suite = require("@test/suite") +local Charm = require("../") +local suite = require("../../../../test/suite") local atom = Charm.atom local computed = Charm.computed diff --git a/packages/charm/src/atom.luau b/packages/charm/src/atom.luau deleted file mode 100644 index e4651ac..0000000 --- a/packages/charm/src/atom.luau +++ /dev/null @@ -1,48 +0,0 @@ -local signals = require("./signals") -local types = require("./types") - -type Atom = types.Atom -type Getter = types.Getter -type Setter = types.Setter -type Update = types.Update - -local signal = signals.signal - -type Equals = (current: T, incoming: T) -> boolean - -local function createAtom(getter: Getter, setter: Setter): Atom - local function atom(...: Update): T - return if select("#", ...) == 0 then getter() else setter((...)) - end - - return atom -end - -local function createAtomWithEqualityCheck(getter: Getter, setter: Setter, equals: Equals): Atom - local function atom(...: Update): T - if select("#", ...) == 0 then - return getter() - end - - local nextState = (...) - - return setter(function(currentState): T - local nextState = if type(nextState) == "function" then nextState(currentState) else nextState - return if equals(currentState, nextState) then currentState else nextState - end) - end - - return atom -end - -local function atom(state: T, equals: Equals?): Atom - local getter, setter = signal(state) - - if not equals then - return createAtom(getter, setter) - else - return createAtomWithEqualityCheck(getter, setter, equals) - end -end - -return atom diff --git a/packages/charm/src/batched.luau b/packages/charm/src/batched.luau index 31be1dc..685365a 100644 --- a/packages/charm/src/batched.luau +++ b/packages/charm/src/batched.luau @@ -1,16 +1,14 @@ -local signals = require("./signals") +local core = require("./core") -type ReactiveNode = signals.ReactiveNode - -local startBatch = signals.startBatch -local endBatch = signals.endBatch +local startBatch = core.startBatch +local endBatch = core.endBatch local function handleBatched(ok: boolean, ...) endBatch() if ok then return ... else - error(...) + error((...)) end end diff --git a/packages/charm/src/signals.luau b/packages/charm/src/core.luau similarity index 52% rename from packages/charm/src/signals.luau rename to packages/charm/src/core.luau index a1f4ae0..75c8319 100644 --- a/packages/charm/src/signals.luau +++ b/packages/charm/src/core.luau @@ -1,5 +1,5 @@ --[[ - alien-signals 2.0.1 implementation adopted from + alien-signals 2.0.5 implementation adopted from https://github.com/Nicell/alien-signals-luau https://github.com/stackblitz/alien-signals @@ -26,8 +26,9 @@ SOFTWARE. ]] -local noYield = require("./noYield") +local strict = require("./strict") local system = require("./system") +local utils = require("./utils") export type ReactiveFlags = system.ReactiveFlags export type ReactiveNode = system.ReactiveNode @@ -35,6 +36,7 @@ export type Link = system.Link local ReactiveFlags = system.ReactiveFlags local createReactiveSystem = system.createReactiveSystem +local noYield = utils.noYield local NONE = 0b0000000 local MUTABLE = 0b0000001 @@ -48,23 +50,22 @@ type EffectScope = ReactiveNode & { } type Effect = ReactiveNode & { - fn: () -> (), + fn: () -> ...(() -> ())?, cleanups: { () -> () }, } type Computed = ReactiveNode & { value: T?, getter: (previousValue: T?) -> T, + equals: ((T, T) -> boolean)?, } -type Signal = ReactiveNode & { +type Atom = ReactiveNode & { previousValue: T, value: T, + equals: ((T, T) -> boolean)?, } -local pauseStack: { ReactiveNode? } = {} -local queuedEffects: { Effect | EffectScope? } = {} - local link: (dep: ReactiveNode, sub: ReactiveNode) -> () local unlink: (link: Link, sub: ReactiveNode?) -> Link? local propagate: (link: Link) -> () @@ -74,9 +75,10 @@ local startTracking: (sub: ReactiveNode) -> () local shallowPropagate: (link: Link) -> () local batchDepth = 0 - -local notifyIndex = 0 +local queuedEffects: { Effect | EffectScope? } = {} local queuedEffectsLength = 0 +local notifyIndex = 0 +local activeSubTracking = true local activeSub: ReactiveNode? local activeScope: Effect | EffectScope? @@ -100,50 +102,58 @@ local function setCurrentScope(scope: Effect | EffectScope?) return prevScope end -local function onEffectCleanup(cleanup: () -> ()) +local function setCurrentTracking(tracking: boolean): boolean + local prevTracking = activeSubTracking + activeSubTracking = tracking + return prevTracking +end + +local function runCleanups(effect: EffectScope) + for _, cleanup in effect.cleanups do + local prev = setCurrentSub(nil) + local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup) + setCurrentSub(prev) + if not success then + error(result) + end + end + table.clear(effect.cleanups) +end + +local function onCleanup(cleanup: () -> (), failSilently: boolean?) if activeSub then table.insert((activeSub :: Effect).cleanups, cleanup) - end -end - -local function onScopeDispose(cleanup: () -> ()) - if activeScope then + elseif activeScope then table.insert(activeScope.cleanups, cleanup) + elseif not failSilently then + warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) end end -local function cleanupEffect(e: Effect | EffectScope) - for _, cleanup in e.cleanups :: { () -> () } do - local prevSub = setCurrentSub(nil) - local success, result: unknown = noYield(cleanup) - setCurrentSub(prevSub) - if not success then - error(result) - end - end - table.clear(e.cleanups) -end - -local function run(e: Effect | EffectScope, flags: ReactiveFlags): () - if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(e.deps :: Link, e)) then - cleanupEffect(e) - local prev = setCurrentSub(e) - startTracking(e) - local success, result: unknown = noYield((e :: Effect).fn) +local function run(effect: Effect | EffectScope, flags: ReactiveFlags): () + if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then + runCleanups(effect) + local prev = setCurrentSub(effect) + local prevTracking = setCurrentTracking(true) + startTracking(effect) + local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn) setCurrentSub(prev) - endTracking(e) + endTracking(effect) + setCurrentTracking(prevTracking) if not success then error(result) + elseif result then + table.insert(effect.cleanups, result) end return - elseif bit32.band(flags, PENDING) ~= 0 then - (e :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING)) + elseif bit32.btest(flags, PENDING) then + (effect :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING)) end - local link = e.deps + local link = effect.deps while link do local dep = link.dep local depFlags = dep.flags - if bit32.band(depFlags, QUEUED) ~= 0 then + if bit32.btest(depFlags, QUEUED) then depFlags = bit32.band(depFlags, bit32.bnot(QUEUED)) dep.flags = depFlags run(dep :: Effect, depFlags) @@ -179,209 +189,154 @@ local function endBatch() end end ---- @deprecated Will be removed in the next major version. Use ---- `const pausedSub = setCurrentSub(undefined)` instead for better performance. -local function pauseTracking() - table.insert(pauseStack, setCurrentSub(nil)) -end - ---- @deprecated Will be removed in the next major version. Use ---- `setCurrentSub(pausedSub)` instead for better performance. -local function resumeTracking() - setCurrentSub(table.remove(pauseStack)) -end - -local function updateComputed(c: Computed): boolean - local prevSub = setCurrentSub(c) - startTracking(c) - local oldValue = c.value - local success, result = noYield(c.getter, oldValue) +local function updateComputed(computed: Computed): boolean + local prevSub = setCurrentSub(computed) + local prevTracking = setCurrentTracking(true) + startTracking(computed) + local oldValue = computed.value + local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue) setCurrentSub(prevSub) - endTracking(c) + endTracking(computed) + setCurrentTracking(prevTracking) if not success then error(result) end - if oldValue ~= result then - c.value = result + local equals = computed.equals + if if equals then not equals(oldValue, result) else oldValue ~= result then + computed.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 +local function updateAtom(atom: Atom, value: any): boolean + atom.flags = MUTABLE + if atom.previousValue ~= value then + atom.previousValue = value return true else return false end end -local function signalGetterSetter(this: Signal): (() -> T, (value: ((T) -> T) | T) -> T) - local function getter(): T - 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 +local function readAtom(atom: Atom): T + local value = atom.value + if bit32.btest(atom.flags, DIRTY) then + if updateAtom(atom, value) then + local subs = atom.subs + if subs then + shallowPropagate(subs) end end - if activeSub then - link(this, activeSub) - end - return value end + if activeSub and activeSubTracking then + link(atom, activeSub) + end + return value +end - local function setter(newValue: ((T) -> T) | T): T - local value = this.value - local newValue = newValue :: T +local function writeAtom(atom: Atom, oldValue: T, newValue: T): T + local equals = atom.equals + if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then + atom.value = newValue + atom.flags = bit32.bor(MUTABLE, DIRTY) + local subs = atom.subs + if subs then + propagate(subs) + if batchDepth == 0 then + flush() + end + end + return newValue + end + return oldValue +end + +local function atomGetterSetter(atom: Atom): (...((T) -> T) | T) -> T + return function(...) + if select("#", ...) == 0 then + return readAtom(atom) + end + local oldValue = atom.value + local newValue = ... if type(newValue) == "function" then local prev = setCurrentSub(nil) - local success, result = noYield(newValue, value) + local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue) setCurrentSub(prev) if not success then error(result) end newValue = result end - if 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 newValue + return writeAtom(atom, oldValue, newValue :: T) end - - return getter, setter end -local function computedGetter(this: Computed): () -> T +local function computedGetter(computed: Computed): () -> T return function() - local flags = this.flags + local flags = computed.flags if - bit32.band(flags, DIRTY) ~= 0 - or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this)) + bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(computed.deps :: Link, computed)) then - if updateComputed(this) then - local subs = this.subs + if updateComputed(computed) then + local subs = computed.subs if subs then shallowPropagate(subs) end end - elseif bit32.band(flags, PENDING) ~= 0 then - this.flags = bit32.band(flags, bit32.bnot(PENDING)) + elseif bit32.btest(flags, PENDING) then + computed.flags = bit32.band(flags, bit32.bnot(PENDING)) end if activeSub then - link(this, activeSub) + if activeSubTracking then + link(computed, activeSub) + end elseif activeScope then - link(this, activeScope) + link(computed, activeScope) end - return this.value :: T + return computed.value :: T end end -local function effectStop(this: Effect | EffectScope): () -> () +local function effectStop(self: Effect | EffectScope): () -> () return function() - local dep = this.deps + local dep = self.deps while dep do - dep = unlink(dep, this) + dep = unlink(dep, self) end - local sub = this.subs + local sub = self.subs if sub then unlink(sub) end - (this :: Effect).flags = NONE - cleanupEffect(this) + (self :: Effect).flags = NONE + runCleanups(self) end end -local function signal(initialValue: T): (() -> T, (value: ((T) -> T) | T) -> T) - return signalGetterSetter({ - previousValue = initialValue, - value = initialValue, - flags = MUTABLE, - }) -end - -local function computed(getter: (previousValue: T?) -> T): () -> T - return computedGetter({ - flags = bit32.bor(MUTABLE, DIRTY), - getter = getter, - }) -end - -local function effect(fn: () -> ()): () -> () - local e: Effect = { - fn = fn, - cleanups = {}, - flags = WATCHING, - } - if activeSub then - link(e, activeSub) - elseif activeScope then - link(e, activeScope) - end - local prev = setCurrentSub(e) - local success, result: unknown = noYield(fn) - setCurrentSub(prev) - if not success then - error(result) - end - return effectStop(e) -end - -local function effectScope(fn: () -> (), detached: boolean?): () -> () - local e: EffectScope = { - cleanups = {}, - flags = NONE, - } - if activeScope and not detached then - link(e, activeScope) - end - local prevSub = setCurrentSub(nil) - local prevScope = setCurrentScope(e) - local success, result: unknown = noYield(fn) - setCurrentScope(prevScope) - setCurrentSub(prevSub) - if not success then - error(result) - end - return effectStop(e) -end - -local function update(signal: Signal | Computed): boolean - if (signal :: Computed).getter then - return updateComputed(signal :: Computed) +local function update(atom: Atom | Computed): boolean + if (atom :: Computed).getter then + return updateComputed(atom :: Computed) else - return updateSignal(signal :: Signal, signal.value) + return updateAtom(atom :: Atom, atom.value) end end -local function notify(e: Effect | EffectScope) - local flags = e.flags - if bit32.band(flags, QUEUED) == 0 then - (e :: Effect).flags = bit32.bor(flags, QUEUED) - local subs = e.subs +local function notify(effect: Effect | EffectScope) + local flags = effect.flags + if not bit32.btest(flags, QUEUED) then + (effect :: Effect).flags = bit32.bor(flags, QUEUED) + local subs = effect.subs if subs then notify(subs.sub :: Effect) else queuedEffectsLength += 1 - queuedEffects[queuedEffectsLength] = e + queuedEffects[queuedEffectsLength] = effect end end end -local function unwatched(node: Signal | Computed | Effect | EffectScope) +local function unwatched(node: Atom | Computed | Effect | EffectScope) if (node :: Computed).getter then local toRemove = node.deps if toRemove then @@ -390,11 +345,73 @@ local function unwatched(node: Signal | Computed | Effect | EffectScope) toRemove = unlink(toRemove, node) until not toRemove end - elseif (node :: Effect).cleanups ~= nil then + elseif (node :: Effect).cleanups then effectStop(node :: Effect)() end end +local function atom(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T + return atomGetterSetter({ + previousValue = initialValue, + value = initialValue, + flags = MUTABLE, + equals = equals, + }) +end + +local function computed(getter: (previousValue: T?) -> T, equals: ((T, T) -> boolean)?): () -> T + return computedGetter({ + flags = bit32.bor(MUTABLE, DIRTY), + getter = getter, + equals = equals, + }) +end + +local function effect(fn: () -> ...(() -> ())?): () -> () + local self: Effect = { + fn = fn, + flags = WATCHING, + cleanups = {}, + } + if activeSub then + link(self, activeSub) + elseif activeScope then + link(self, activeScope) + end + local prev = setCurrentSub(self) + local prevTracking = setCurrentTracking(true) + local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) + setCurrentSub(prev) + setCurrentTracking(prevTracking) + if not success then + error(result) + elseif result then + table.insert(self.cleanups, result) + end + return effectStop(self) +end + +local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () + local self: EffectScope = { + flags = NONE, + cleanups = {}, + } + if activeScope and not detached then + link(self, activeScope) + end + local prevSub = setCurrentSub(nil) + local prevScope = setCurrentScope(self) + local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) + setCurrentScope(prevScope) + setCurrentSub(prevSub) + if not success then + error(result) + elseif result then + table.insert(self.cleanups, result) + end + return effectStop(self) +end + local reactiveSystem = createReactiveSystem({ update = update :: (ReactiveNode) -> boolean, notify = notify :: (ReactiveNode) -> (), @@ -416,14 +433,12 @@ return { setCurrentSub = setCurrentSub, getCurrentScope = getCurrentScope, setCurrentScope = setCurrentScope, + setCurrentTracking = setCurrentTracking, + onCleanup = onCleanup, getBatchDepth = getBatchDepth, startBatch = startBatch, endBatch = endBatch, - pauseTracking = pauseTracking, - resumeTracking = resumeTracking, - onEffectCleanup = onEffectCleanup, - onScopeDispose = onScopeDispose, - signal = signal, + atom = atom, computed = computed, effect = effect, effectScope = effectScope, diff --git a/packages/charm/src/effect.luau b/packages/charm/src/effect.luau deleted file mode 100644 index 2eb0802..0000000 --- a/packages/charm/src/effect.luau +++ /dev/null @@ -1,26 +0,0 @@ -local signals = require("./signals") - -local onEffectCleanup = signals.onEffectCleanup - -local function effect(callback: (dispose: () -> ()) -> ...(() -> ())?): () -> () - local disposed = false - - local function dispose() - disposed = true - end - - dispose = signals.effect(function() - local cleanup = callback(dispose) - if cleanup then - onEffectCleanup(cleanup) - end - end) - - if disposed then - dispose() - end - - return dispose -end - -return effect diff --git a/packages/charm/src/effectScope.luau b/packages/charm/src/effectScope.luau deleted file mode 100644 index 7a40ee2..0000000 --- a/packages/charm/src/effectScope.luau +++ /dev/null @@ -1,14 +0,0 @@ -local signals = require("./signals") - -local onScopeDispose = signals.onScopeDispose - -local function effectScope(callback: () -> ...(() -> ())?, detached: boolean?): () -> () - return signals.effectScope(function() - local cleanup = callback() - if cleanup then - onScopeDispose(cleanup) - end - end, detached) -end - -return effectScope diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/charm/src/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 0f049ab..e583ae6 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,25 +1,25 @@ -local atom = require("@self/atom") local batched = require("@self/batched") -local effect = require("@self/effect") +local core = require("@self/core") local listen = require("@self/listen") local mapped = require("@self/mapped") local observe = require("@self/observe") -local onCleanup = require("@self/onCleanup") -local signals = require("@self/signals") +local strict = require("@self/strict") local subscribe = require("@self/subscribe") local types = require("@self/types") local untracked = require("@self/untracked") export type Atom = types.Atom -export type Getter = types.Getter export type Selector = types.Selector -export type Setter = types.Setter -export type Update = types.Update -local computed = signals.computed -local effectScope = signals.effectScope +local atom = core.atom +local computed = core.computed +local effect = core.effect +local effectScope = core.effectScope +local onCleanup = core.onCleanup return { + core = core, + strict = strict, atom = atom, batched = batched, computed = computed, @@ -28,8 +28,7 @@ return { listen = listen, mapped = mapped, observe = observe, + onCleanup = onCleanup, subscribe = subscribe, untracked = untracked, - onCleanup = onCleanup, - signals = signals, } diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 89688cf..865e704 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -1,9 +1,8 @@ -local signals = require("./signals") +local core = require("./core") +local untracked = require("./untracked") -local computed = signals.computed -local effect = signals.effect -local effectScope = signals.effectScope -local onEffectCleanup = signals.onEffectCleanup +local computed = core.computed +local effect = core.effect local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () local tracked = computed(function() @@ -20,12 +19,7 @@ local function listen(selector: () -> T, listener: (state: T, previous: T?, d dispose = effect(function() local previous = current current = tracked() - - local stopScope = effectScope(function() - listener(current, previous, dispose) - end) - - onEffectCleanup(stopScope) + untracked(listener, current, previous, dispose) end) if disposed then diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index 847968c..f6289be 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -1,35 +1,26 @@ -local noYield = require("./noYield") -local signals = require("./signals") +local core = require("./core") -local computed = signals.computed -local setCurrentSub = signals.setCurrentSub +local computed = core.computed +local setCurrentSub = core.setCurrentSub type Mapped = & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) & ((selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) -local function ensureCanMutateOutput(output: { [KO]: VO }, previous: { [KO]: VO }?): { [KO]: VO } - return if output == previous then table.clone(output) else output -end - local function mapped(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } local currentData: { [KI]: VI } = {} local keyCache: { [KI]: KO } = {} local function map(value: VI, key: KI): (VO, KO) local currentSub = setCurrentSub(nil) - local success, valueOut, keyOut = noYield(mapper, value, key) + local success, valueOut, keyOut = pcall(mapper, value, key) setCurrentSub(currentSub) if not success then error(`mapped() transform function failed: {valueOut}`) end - if keyOut == nil then - keyOut = key :: any - end - - return valueOut, keyOut + return valueOut, if keyOut == nil then key else keyOut :: any end return computed(function(previousOutput) @@ -41,11 +32,9 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite if currentData[key] ~= nil then continue end - local cachedKey = keyCache[key] - if cachedKey then - currentOutput = ensureCanMutateOutput(currentOutput, previousOutput) + currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput currentOutput[cachedKey] = nil keyCache[key] = nil end @@ -55,11 +44,9 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite if value == previousData[key] then continue end - local valueOut, keyOut = map(value, key) - if currentOutput[keyOut] ~= valueOut then - currentOutput = ensureCanMutateOutput(currentOutput, previousOutput) + currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput currentOutput[keyOut] = valueOut keyCache[key] = keyOut end diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index 9a0d1a9..7272584 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -1,9 +1,8 @@ -local onCleanup = require("./onCleanup") -local signals = require("./signals") +local core = require("./core") -local effect = signals.effect -local effectScope = signals.effectScope -local onScopeDispose = signals.onScopeDispose +local effect = core.effect +local effectScope = core.effectScope +local onCleanup = core.onCleanup local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () local scopes: { [K]: () -> () } = {} @@ -11,8 +10,9 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V }) for key in previousItems do - if currentItems[key] == nil and scopes[key] then - scopes[key]() + local dispose = scopes[key] + if currentItems[key] == nil and dispose then + dispose() scopes[key] = nil end end @@ -20,10 +20,7 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key for key, value in currentItems do if previousItems[key] == nil then scopes[key] = effectScope(function() - local cleanup = observer(value, key) - if cleanup then - onScopeDispose(cleanup) - end + return observer(value, key) end, true) end end @@ -39,7 +36,6 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key local disposeEffect = effect(function() local previousItems = currentItems currentItems = selector() - if currentItems ~= previousItems then updateScopes(currentItems, previousItems) end diff --git a/packages/charm/src/onCleanup.luau b/packages/charm/src/onCleanup.luau deleted file mode 100644 index f96266d..0000000 --- a/packages/charm/src/onCleanup.luau +++ /dev/null @@ -1,20 +0,0 @@ -local signals = require("./signals") - -local onEffectCleanup = signals.onEffectCleanup -local onScopeDispose = signals.onScopeDispose -local getCurrentScope = signals.getCurrentScope -local getCurrentSub = signals.getCurrentSub - -local warn = warn or error :: never - -local function onCleanup(cleanup: () -> (), failSilently: boolean?) - if getCurrentSub() then - onEffectCleanup(cleanup) - elseif getCurrentScope() then - onScopeDispose(cleanup) - elseif _G.__DEV__ and not failSilently then - warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) - end -end - -return onCleanup diff --git a/packages/charm/src/strict.luau b/packages/charm/src/strict.luau new file mode 100644 index 0000000..1955aa1 --- /dev/null +++ b/packages/charm/src/strict.luau @@ -0,0 +1,8 @@ +local function inlineTest(): string + return debug.info(1, "n") +end + +return { + -- Enable strict mode in O2 + enabled = inlineTest() == "inlineTest", +} diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index 73c3782..1ceca66 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -1,9 +1,8 @@ -local signals = require("./signals") +local core = require("./core") +local untracked = require("./untracked") -local computed = signals.computed -local effect = signals.effect -local effectScope = signals.effectScope -local onEffectCleanup = signals.onEffectCleanup +local computed = core.computed +local effect = core.effect local function subscribe(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () local tracked = computed(function() @@ -16,17 +15,11 @@ local function subscribe(selector: () -> T, listener: (state: T, lastState: T return effect(function() local previous = current current = tracked() - if mounted then mounted = false - return + else + untracked(listener, current, previous) end - - local stopScope = effectScope(function() - listener(current, previous) - end) - - onEffectCleanup(stopScope) end) end diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 0c6334b..d3d5692 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -100,7 +100,7 @@ local function createReactiveSystem(props: { return end local nextDep: Link? - local recursedCheck = bit32.band(sub.flags, RECURSED_CHECK) ~= 0 + local recursedCheck = bit32.btest(sub.flags, RECURSED_CHECK) if recursedCheck then nextDep = if prevDep then prevDep.nextDep else sub.deps if nextDep and nextDep.dep == dep then @@ -178,14 +178,14 @@ local function createReactiveSystem(props: { 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 + if bit32.btest(flags, bit32.bor(MUTABLE, WATCHING)) then + if not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) then sub.flags = bit32.bor(flags, PENDING) - elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then + elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then flags = NONE - elseif bit32.band(flags, RECURSED_CHECK) == 0 then + elseif not bit32.btest(flags, RECURSED_CHECK) 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 + elseif not bit32.btest(flags, bit32.bor(DIRTY, PENDING)) and isValidLink(link, sub) then sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) flags = bit32.band(flags, MUTABLE) else @@ -193,11 +193,11 @@ local function createReactiveSystem(props: { flags = NONE end - if bit32.band(flags, WATCHING) ~= 0 then + if bit32.btest(flags, WATCHING) then notify(sub) end - if bit32.band(flags, MUTABLE) ~= 0 then + if bit32.btest(flags, MUTABLE) then local subSubs = sub.subs if subSubs then link = subSubs @@ -257,7 +257,7 @@ local function createReactiveSystem(props: { 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 + if bit32.btest(subFlags, WATCHING) then notify(sub) end end @@ -275,7 +275,7 @@ local function createReactiveSystem(props: { local dirty = false - if bit32.band(sub.flags, DIRTY) ~= 0 then + if bit32.btest(sub.flags, DIRTY) then dirty = true elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then if update(dep) then diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau index 7775ece..d5c0314 100644 --- a/packages/charm/src/types.luau +++ b/packages/charm/src/types.luau @@ -1,11 +1,5 @@ -export type Getter = () -> T - -export type Setter = (update: Update) -> T - -export type Update = T | (previous: T) -> T - -export type Selector = () -> T - -export type Atom = (...Update) -> T +type Update = ((previous: T) -> T) | T +export type Atom = (...Update) -> T +export type Selector = () -> T return {} diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau index 503b1bd..b766aae 100644 --- a/packages/charm/src/untracked.luau +++ b/packages/charm/src/untracked.luau @@ -1,21 +1,18 @@ -local noYield = require("./noYield") -local signals = require("./signals") +local core = require("./core") -type ReactiveNode = signals.ReactiveNode +local setCurrentTracking = core.setCurrentTracking -local setCurrentSub = signals.setCurrentSub - -local function handleUntracked(currentSub: ReactiveNode?, ok: boolean, ...) - setCurrentSub(currentSub) +local function handleUntracked(prevTracking: boolean, ok: boolean, ...) + setCurrentTracking(prevTracking) if ok then return ... else - error(...) + error((...)) end end local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - return handleUntracked(setCurrentSub(nil), noYield(callback, ...)) + return handleUntracked(setCurrentTracking(false), pcall(callback, ...)) end return untracked diff --git a/packages/charm/src/noYield.luau b/packages/charm/src/utils.luau similarity index 62% rename from packages/charm/src/noYield.luau rename to packages/charm/src/utils.luau index d132fe6..536e086 100644 --- a/packages/charm/src/noYield.luau +++ b/packages/charm/src/utils.luau @@ -1,13 +1,11 @@ local function handleNoYield(thread: thread, ok: boolean, ...) if not ok then return false, debug.traceback(thread, tostring((...))) - end - - if coroutine.status(thread) ~= "dead" then + elseif coroutine.status(thread) ~= "dead" then return false, debug.traceback(thread, "Attempted to yield!") + else + return true, ... end - - return true, ... end local function noYield(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...) @@ -15,6 +13,6 @@ local function noYield(callback: (Args...) -> Results..., . return handleNoYield(thread, coroutine.resume(thread, ...)) end --- To eliminate the overhead of noYield returning callback(...) in production, --- replace it with pcall to simulate the same behavior. -return if _G.__DEV__ then noYield else pcall :: never +return { + noYield = noYield :: typeof(pcall), +} diff --git a/packages/charm/test/default.project.json b/packages/charm/test/default.project.json new file mode 100644 index 0000000..4cd33dd --- /dev/null +++ b/packages/charm/test/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "charm", + "tree": { + "$path": "src" + } +} \ No newline at end of file diff --git a/packages/charm/test/src/batched.luau b/packages/charm/test/src/batched.luau new file mode 100644 index 0000000..685365a --- /dev/null +++ b/packages/charm/test/src/batched.luau @@ -0,0 +1,20 @@ +local core = require("./core") + +local startBatch = core.startBatch +local endBatch = core.endBatch + +local function handleBatched(ok: boolean, ...) + endBatch() + if ok then + return ... + else + error((...)) + end +end + +local function batched(callback: (Args...) -> Results..., ...: Args...): Results... + startBatch() + return handleBatched(pcall(callback, ...)) +end + +return batched diff --git a/packages/charm/test/src/core.luau b/packages/charm/test/src/core.luau new file mode 100644 index 0000000..75c8319 --- /dev/null +++ b/packages/charm/test/src/core.luau @@ -0,0 +1,445 @@ +--[[ + alien-signals 2.0.5 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. +]] + +local strict = require("./strict") +local system = require("./system") +local utils = require("./utils") + +export type ReactiveFlags = system.ReactiveFlags +export type ReactiveNode = system.ReactiveNode +export type Link = system.Link + +local ReactiveFlags = system.ReactiveFlags +local createReactiveSystem = system.createReactiveSystem +local noYield = utils.noYield + +local NONE = 0b0000000 +local MUTABLE = 0b0000001 +local WATCHING = 0b0000010 +local DIRTY = 0b0010000 +local PENDING = 0b0100000 +local QUEUED = 0b1000000 + +type EffectScope = ReactiveNode & { + cleanups: { () -> () }, +} + +type Effect = ReactiveNode & { + fn: () -> ...(() -> ())?, + cleanups: { () -> () }, +} + +type Computed = ReactiveNode & { + value: T?, + getter: (previousValue: T?) -> T, + equals: ((T, T) -> boolean)?, +} + +type Atom = ReactiveNode & { + previousValue: T, + value: T, + equals: ((T, T) -> boolean)?, +} + +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 queuedEffects: { Effect | EffectScope? } = {} +local queuedEffectsLength = 0 +local notifyIndex = 0 +local activeSubTracking = true +local activeSub: ReactiveNode? +local activeScope: Effect | EffectScope? + +local function getCurrentSub(): ReactiveNode? + return activeSub +end + +local function setCurrentSub(sub: ReactiveNode?) + local prevSub = activeSub + activeSub = sub + return prevSub +end + +local function getCurrentScope(): Effect | EffectScope? + return activeScope +end + +local function setCurrentScope(scope: Effect | EffectScope?) + local prevScope = activeScope + activeScope = scope + return prevScope +end + +local function setCurrentTracking(tracking: boolean): boolean + local prevTracking = activeSubTracking + activeSubTracking = tracking + return prevTracking +end + +local function runCleanups(effect: EffectScope) + for _, cleanup in effect.cleanups do + local prev = setCurrentSub(nil) + local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup) + setCurrentSub(prev) + if not success then + error(result) + end + end + table.clear(effect.cleanups) +end + +local function onCleanup(cleanup: () -> (), failSilently: boolean?) + if activeSub then + table.insert((activeSub :: Effect).cleanups, cleanup) + elseif activeScope then + table.insert(activeScope.cleanups, cleanup) + elseif not failSilently then + warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) + end +end + +local function run(effect: Effect | EffectScope, flags: ReactiveFlags): () + if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then + runCleanups(effect) + local prev = setCurrentSub(effect) + local prevTracking = setCurrentTracking(true) + startTracking(effect) + local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn) + setCurrentSub(prev) + endTracking(effect) + setCurrentTracking(prevTracking) + if not success then + error(result) + elseif result then + table.insert(effect.cleanups, result) + end + return + elseif bit32.btest(flags, PENDING) then + (effect :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING)) + end + local link = effect.deps + while link do + local dep = link.dep + local depFlags = dep.flags + if bit32.btest(depFlags, QUEUED) then + depFlags = bit32.band(depFlags, bit32.bnot(QUEUED)) + dep.flags = depFlags + run(dep :: Effect, depFlags) + end + link = link.nextDep + end +end + +local function flush(): () + while notifyIndex < queuedEffectsLength do + notifyIndex += 1 + local effect = queuedEffects[notifyIndex] :: Effect + 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(computed: Computed): boolean + local prevSub = setCurrentSub(computed) + local prevTracking = setCurrentTracking(true) + startTracking(computed) + local oldValue = computed.value + local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue) + setCurrentSub(prevSub) + endTracking(computed) + setCurrentTracking(prevTracking) + if not success then + error(result) + end + local equals = computed.equals + if if equals then not equals(oldValue, result) else oldValue ~= result then + computed.value = result + return true + else + return false + end +end + +local function updateAtom(atom: Atom, value: any): boolean + atom.flags = MUTABLE + if atom.previousValue ~= value then + atom.previousValue = value + return true + else + return false + end +end + +local function readAtom(atom: Atom): T + local value = atom.value + if bit32.btest(atom.flags, DIRTY) then + if updateAtom(atom, value) then + local subs = atom.subs + if subs then + shallowPropagate(subs) + end + end + end + if activeSub and activeSubTracking then + link(atom, activeSub) + end + return value +end + +local function writeAtom(atom: Atom, oldValue: T, newValue: T): T + local equals = atom.equals + if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then + atom.value = newValue + atom.flags = bit32.bor(MUTABLE, DIRTY) + local subs = atom.subs + if subs then + propagate(subs) + if batchDepth == 0 then + flush() + end + end + return newValue + end + return oldValue +end + +local function atomGetterSetter(atom: Atom): (...((T) -> T) | T) -> T + return function(...) + if select("#", ...) == 0 then + return readAtom(atom) + end + local oldValue = atom.value + local newValue = ... + if type(newValue) == "function" then + local prev = setCurrentSub(nil) + local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue) + setCurrentSub(prev) + if not success then + error(result) + end + newValue = result + end + return writeAtom(atom, oldValue, newValue :: T) + end +end + +local function computedGetter(computed: Computed): () -> T + return function() + local flags = computed.flags + if + bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(computed.deps :: Link, computed)) + then + if updateComputed(computed) then + local subs = computed.subs + if subs then + shallowPropagate(subs) + end + end + elseif bit32.btest(flags, PENDING) then + computed.flags = bit32.band(flags, bit32.bnot(PENDING)) + end + if activeSub then + if activeSubTracking then + link(computed, activeSub) + end + elseif activeScope then + link(computed, activeScope) + end + return computed.value :: T + end +end + +local function effectStop(self: Effect | EffectScope): () -> () + return function() + local dep = self.deps + while dep do + dep = unlink(dep, self) + end + local sub = self.subs + if sub then + unlink(sub) + end + (self :: Effect).flags = NONE + runCleanups(self) + end +end + +local function update(atom: Atom | Computed): boolean + if (atom :: Computed).getter then + return updateComputed(atom :: Computed) + else + return updateAtom(atom :: Atom, atom.value) + end +end + +local function notify(effect: Effect | EffectScope) + local flags = effect.flags + if not bit32.btest(flags, QUEUED) then + (effect :: Effect).flags = bit32.bor(flags, QUEUED) + local subs = effect.subs + if subs then + notify(subs.sub :: Effect) + else + queuedEffectsLength += 1 + queuedEffects[queuedEffectsLength] = effect + end + end +end + +local function unwatched(node: Atom | Computed | Effect | EffectScope) + if (node :: Computed).getter then + local toRemove = node.deps + if toRemove then + (node :: Computed).flags = bit32.bor(MUTABLE, DIRTY) + repeat + toRemove = unlink(toRemove, node) + until not toRemove + end + elseif (node :: Effect).cleanups then + effectStop(node :: Effect)() + end +end + +local function atom(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T + return atomGetterSetter({ + previousValue = initialValue, + value = initialValue, + flags = MUTABLE, + equals = equals, + }) +end + +local function computed(getter: (previousValue: T?) -> T, equals: ((T, T) -> boolean)?): () -> T + return computedGetter({ + flags = bit32.bor(MUTABLE, DIRTY), + getter = getter, + equals = equals, + }) +end + +local function effect(fn: () -> ...(() -> ())?): () -> () + local self: Effect = { + fn = fn, + flags = WATCHING, + cleanups = {}, + } + if activeSub then + link(self, activeSub) + elseif activeScope then + link(self, activeScope) + end + local prev = setCurrentSub(self) + local prevTracking = setCurrentTracking(true) + local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) + setCurrentSub(prev) + setCurrentTracking(prevTracking) + if not success then + error(result) + elseif result then + table.insert(self.cleanups, result) + end + return effectStop(self) +end + +local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () + local self: EffectScope = { + flags = NONE, + cleanups = {}, + } + if activeScope and not detached then + link(self, activeScope) + end + local prevSub = setCurrentSub(nil) + local prevScope = setCurrentScope(self) + local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) + setCurrentScope(prevScope) + setCurrentSub(prevSub) + if not success then + error(result) + elseif result then + table.insert(self.cleanups, result) + end + return effectStop(self) +end + +local reactiveSystem = createReactiveSystem({ + update = update :: (ReactiveNode) -> boolean, + notify = notify :: (ReactiveNode) -> (), + unwatched = unwatched :: (ReactiveNode) -> (), +}) + +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, + setCurrentTracking = setCurrentTracking, + onCleanup = onCleanup, + getBatchDepth = getBatchDepth, + startBatch = startBatch, + endBatch = endBatch, + atom = atom, + computed = computed, + effect = effect, + effectScope = effectScope, +} diff --git a/packages/charm/test/src/init.luau b/packages/charm/test/src/init.luau new file mode 100644 index 0000000..e583ae6 --- /dev/null +++ b/packages/charm/test/src/init.luau @@ -0,0 +1,34 @@ +local batched = require("@self/batched") +local core = require("@self/core") +local listen = require("@self/listen") +local mapped = require("@self/mapped") +local observe = require("@self/observe") +local strict = require("@self/strict") +local subscribe = require("@self/subscribe") +local types = require("@self/types") +local untracked = require("@self/untracked") + +export type Atom = types.Atom +export type Selector = types.Selector + +local atom = core.atom +local computed = core.computed +local effect = core.effect +local effectScope = core.effectScope +local onCleanup = core.onCleanup + +return { + core = core, + strict = strict, + atom = atom, + batched = batched, + computed = computed, + effect = effect, + effectScope = effectScope, + listen = listen, + mapped = mapped, + observe = observe, + onCleanup = onCleanup, + subscribe = subscribe, + untracked = untracked, +} diff --git a/packages/charm/test/src/listen.luau b/packages/charm/test/src/listen.luau new file mode 100644 index 0000000..865e704 --- /dev/null +++ b/packages/charm/test/src/listen.luau @@ -0,0 +1,32 @@ +local core = require("./core") +local untracked = require("./untracked") + +local computed = core.computed +local effect = core.effect + +local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () + local tracked = computed(function() + return selector() + end) + + local current: T + local disposed = false + + local function dispose() + disposed = true + end + + dispose = effect(function() + local previous = current + current = tracked() + untracked(listener, current, previous, dispose) + end) + + if disposed then + dispose() + end + + return dispose +end + +return listen diff --git a/packages/charm/test/src/mapped.luau b/packages/charm/test/src/mapped.luau new file mode 100644 index 0000000..f6289be --- /dev/null +++ b/packages/charm/test/src/mapped.luau @@ -0,0 +1,59 @@ +local core = require("./core") + +local computed = core.computed +local setCurrentSub = core.setCurrentSub + +type Mapped = + & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) + & ((selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) + +local function mapped(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } + local currentData: { [KI]: VI } = {} + local keyCache: { [KI]: KO } = {} + + local function map(value: VI, key: KI): (VO, KO) + local currentSub = setCurrentSub(nil) + local success, valueOut, keyOut = pcall(mapper, value, key) + setCurrentSub(currentSub) + + if not success then + error(`mapped() transform function failed: {valueOut}`) + end + + return valueOut, if keyOut == nil then key else keyOut :: any + end + + return computed(function(previousOutput) + local currentOutput: { [KO]: VO } = previousOutput or {} + local previousData = currentData + currentData = selector() + + for key in previousData do + if currentData[key] ~= nil then + continue + end + local cachedKey = keyCache[key] + if cachedKey then + currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput + currentOutput[cachedKey] = nil + keyCache[key] = nil + end + end + + for key, value in currentData do + if value == previousData[key] then + continue + end + local valueOut, keyOut = map(value, key) + if currentOutput[keyOut] ~= valueOut then + currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput + currentOutput[keyOut] = valueOut + keyCache[key] = keyOut + end + end + + return currentOutput + end) +end + +return mapped :: Mapped diff --git a/packages/charm/test/src/observe.luau b/packages/charm/test/src/observe.luau new file mode 100644 index 0000000..7272584 --- /dev/null +++ b/packages/charm/test/src/observe.luau @@ -0,0 +1,52 @@ +local core = require("./core") + +local effect = core.effect +local effectScope = core.effectScope +local onCleanup = core.onCleanup + +local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () + local scopes: { [K]: () -> () } = {} + local currentItems: { [K]: V } = {} + + local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V }) + for key in previousItems do + local dispose = scopes[key] + if currentItems[key] == nil and dispose then + dispose() + scopes[key] = nil + end + end + + for key, value in currentItems do + if previousItems[key] == nil then + scopes[key] = effectScope(function() + return observer(value, key) + end, true) + end + end + end + + local function disposeScopes() + for _, dispose in scopes do + dispose() + end + table.clear(scopes) + end + + local disposeEffect = effect(function() + local previousItems = currentItems + currentItems = selector() + if currentItems ~= previousItems then + updateScopes(currentItems, previousItems) + end + end) + + onCleanup(disposeScopes, true) + + return function() + disposeEffect() + disposeScopes() + end +end + +return observe diff --git a/packages/charm/test/src/strict.luau b/packages/charm/test/src/strict.luau new file mode 100644 index 0000000..1955aa1 --- /dev/null +++ b/packages/charm/test/src/strict.luau @@ -0,0 +1,8 @@ +local function inlineTest(): string + return debug.info(1, "n") +end + +return { + -- Enable strict mode in O2 + enabled = inlineTest() == "inlineTest", +} diff --git a/packages/charm/test/src/subscribe.luau b/packages/charm/test/src/subscribe.luau new file mode 100644 index 0000000..1ceca66 --- /dev/null +++ b/packages/charm/test/src/subscribe.luau @@ -0,0 +1,26 @@ +local core = require("./core") +local untracked = require("./untracked") + +local computed = core.computed +local effect = core.effect + +local function subscribe(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () + local tracked = computed(function() + return selector() + end) + + local current: T + local mounted = true + + return effect(function() + local previous = current + current = tracked() + if mounted then + mounted = false + else + untracked(listener, current, previous) + end + end) +end + +return subscribe diff --git a/packages/charm/test/src/system.luau b/packages/charm/test/src/system.luau new file mode 100644 index 0000000..d3d5692 --- /dev/null +++ b/packages/charm/test/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.btest(sub.flags, RECURSED_CHECK) + 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.btest(flags, bit32.bor(MUTABLE, WATCHING)) then + if not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) then + sub.flags = bit32.bor(flags, PENDING) + elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then + flags = NONE + elseif not bit32.btest(flags, RECURSED_CHECK) then + sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) + elseif not bit32.btest(flags, bit32.bor(DIRTY, PENDING)) 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.btest(flags, WATCHING) then + notify(sub) + end + + if bit32.btest(flags, MUTABLE) 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.btest(subFlags, WATCHING) 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.btest(sub.flags, DIRTY) 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/test/src/types.luau b/packages/charm/test/src/types.luau new file mode 100644 index 0000000..d5c0314 --- /dev/null +++ b/packages/charm/test/src/types.luau @@ -0,0 +1,5 @@ +type Update = ((previous: T) -> T) | T +export type Atom = (...Update) -> T +export type Selector = () -> T + +return {} diff --git a/packages/charm/test/src/untracked.luau b/packages/charm/test/src/untracked.luau new file mode 100644 index 0000000..b766aae --- /dev/null +++ b/packages/charm/test/src/untracked.luau @@ -0,0 +1,18 @@ +local core = require("./core") + +local setCurrentTracking = core.setCurrentTracking + +local function handleUntracked(prevTracking: boolean, ok: boolean, ...) + setCurrentTracking(prevTracking) + if ok then + return ... + else + error((...)) + end +end + +local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... + return handleUntracked(setCurrentTracking(false), pcall(callback, ...)) +end + +return untracked diff --git a/packages/charm/test/src/utils.luau b/packages/charm/test/src/utils.luau new file mode 100644 index 0000000..536e086 --- /dev/null +++ b/packages/charm/test/src/utils.luau @@ -0,0 +1,18 @@ +local function handleNoYield(thread: thread, ok: boolean, ...) + if not ok then + return false, debug.traceback(thread, tostring((...))) + elseif coroutine.status(thread) ~= "dead" then + return false, debug.traceback(thread, "Attempted to yield!") + else + return true, ... + end +end + +local function noYield(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...) + local thread = coroutine.create(callback) + return handleNoYield(thread, coroutine.resume(thread, ...)) +end + +return { + noYield = noYield :: typeof(pcall), +} diff --git a/packages/charm/test/wally.toml b/packages/charm/test/wally.toml new file mode 100644 index 0000000..cd94b61 --- /dev/null +++ b/packages/charm/test/wally.toml @@ -0,0 +1,8 @@ +[package] +name = "littensy/charm" +version = "0.10.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] + +[dependencies] diff --git a/packages/charm/tests/signals/spec.luau b/packages/charm/tests/signals/spec.luau deleted file mode 100644 index 7d5bdd7..0000000 --- a/packages/charm/tests/signals/spec.luau +++ /dev/null @@ -1,27 +0,0 @@ -local suite = require("@test/suite") - -suite.describe("signals", function() - suite.describe("computed", function() - require("./computed.spec") - end) - suite.describe("effect", function() - require("./effect.spec") - end) - suite.describe("effectScope", function() - require("./effectScope.spec") - end) - suite.describe("issue_48", function() - require("./issue_48.spec") - end) - suite.describe("gc", function() - require("./gc.spec") - end) - suite.describe("topology", function() - require("./topology.spec") - end) - suite.describe("untrack", function() - require("./untrack.spec") - end) -end) - -return {} diff --git a/packages/charm/tests/spec.luau b/packages/charm/tests/spec.luau deleted file mode 100644 index a2b8653..0000000 --- a/packages/charm/tests/spec.luau +++ /dev/null @@ -1,23 +0,0 @@ -local suite = require("@test/suite") - -require("./signals/spec") - -suite.describe("charm", function() - suite.describe("listen", function() - require("./listen.spec") - end) - suite.describe("mapped", function() - require("./mapped.spec") - end) - suite.describe("observe", function() - require("./observe.spec") - end) - suite.describe("subscribe", function() - require("./subscribe.spec") - end) - suite.describe("untracked", function() - require("./untracked.spec") - end) -end) - -return {} diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 1e798ac..cd94b61 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -3,6 +3,6 @@ name = "littensy/charm" version = "0.10.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["tests"] +exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] [dependencies] diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau new file mode 100644 index 0000000..6b6fb92 --- /dev/null +++ b/packages/react-charm/Charm.luau @@ -0,0 +1,6 @@ +local Charm = require(script.Parent.Parent.charm.src) + +export type Atom = Charm.Atom +export type Selector = Charm.Selector + +return Charm diff --git a/packages/react-charm/React.luau b/packages/react-charm/React.luau new file mode 100644 index 0000000..dd59e59 --- /dev/null +++ b/packages/react-charm/React.luau @@ -0,0 +1,12 @@ +type CoreReactBinding = { + getValue: (self: CoreReactBinding) -> T, + _source: string?, +} + +type ReactBindingMap = { + map: (self: CoreReactBinding & ReactBindingMap, (T) -> U) -> ReactBindingMap & CoreReactBinding, +} + +export type Binding = CoreReactBinding & ReactBindingMap + +return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React) diff --git a/packages/react-charm/ReactRoblox.luau b/packages/react-charm/ReactRoblox.luau new file mode 100644 index 0000000..0350e6b --- /dev/null +++ b/packages/react-charm/ReactRoblox.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").ReactRoblox) diff --git a/packages/react-charm/default.project.json b/packages/react-charm/default.project.json index c1ee26a..baf768c 100644 --- a/packages/react-charm/default.project.json +++ b/packages/react-charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "ReactCharm", + "name": "react-charm", "tree": { "$path": "src" } diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json new file mode 100644 index 0000000..eeaeefb --- /dev/null +++ b/packages/react-charm/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rbxts/react-charm", + "version": "0.3.0", + "description": "React bindings for Charm state management", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!**/__tests__" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:*" + }, + "peerDependencies": { + "@rbxts/react": "*" + } +} diff --git a/packages/react-charm/tests/helpers/renderHook.luau b/packages/react-charm/src/__tests__/helpers/renderHook.luau similarity index 79% rename from packages/react-charm/tests/helpers/renderHook.luau rename to packages/react-charm/src/__tests__/helpers/renderHook.luau index b4f7ac0..664b2fc 100644 --- a/packages/react-charm/tests/helpers/renderHook.luau +++ b/packages/react-charm/src/__tests__/helpers/renderHook.luau @@ -1,9 +1,7 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - _G.__ROACT_17_MOCK_SCHEDULER__ = true -local React = require(ReplicatedStorage.Packages.React) -local ReactRoblox = require(ReplicatedStorage.Packages.ReactRoblox) +local React = require("../../../React") +local ReactRoblox = require("../../../ReactRoblox") type RenderedHook = { result: Result, diff --git a/packages/react-charm/tests/spec.luau b/packages/react-charm/src/__tests__/spec.luau similarity index 63% rename from packages/react-charm/tests/spec.luau rename to packages/react-charm/src/__tests__/spec.luau index dabaab7..37326fa 100644 --- a/packages/react-charm/tests/spec.luau +++ b/packages/react-charm/src/__tests__/spec.luau @@ -1,8 +1,9 @@ -local ServerScriptService = game:GetService("ServerScriptService") - -local suite = require(ServerScriptService.Tests.suite) +local suite = require("../../../../test/suite") suite.describe("react-charm", function() + if not game then + return suite.skip() + end suite.describe("useAtom", function() require("./useAtom.spec") end) diff --git a/packages/react-charm/tests/useAtom.spec.luau b/packages/react-charm/src/__tests__/useAtom.spec.luau similarity index 72% rename from packages/react-charm/tests/useAtom.spec.luau rename to packages/react-charm/src/__tests__/useAtom.spec.luau index 3db371e..2df0f78 100644 --- a/packages/react-charm/tests/useAtom.spec.luau +++ b/packages/react-charm/src/__tests__/useAtom.spec.luau @@ -1,10 +1,7 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local ServerScriptService = game:GetService("ServerScriptService") - -local Charm = require(ReplicatedStorage.Packages.Charm) -local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm) -local renderHook = require(script.Parent.helpers.renderHook) -local suite = require(ServerScriptService.Tests.suite) +local Charm = require("../../Charm") +local ReactCharm = require("../") +local renderHook = require("./helpers/renderHook") +local suite = require("../../../../test/suite") local atom = Charm.atom local useAtom = ReactCharm.useAtom diff --git a/packages/react-charm/tests/useAtomBinding.spec.luau b/packages/react-charm/src/__tests__/useAtomBinding.spec.luau similarity index 74% rename from packages/react-charm/tests/useAtomBinding.spec.luau rename to packages/react-charm/src/__tests__/useAtomBinding.spec.luau index 28dcad2..e96d3b3 100644 --- a/packages/react-charm/tests/useAtomBinding.spec.luau +++ b/packages/react-charm/src/__tests__/useAtomBinding.spec.luau @@ -1,10 +1,7 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local ServerScriptService = game:GetService("ServerScriptService") - -local Charm = require(ReplicatedStorage.Packages.Charm) -local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm) -local renderHook = require(script.Parent.helpers.renderHook) -local suite = require(ServerScriptService.Tests.suite) +local Charm = require("../../Charm") +local ReactCharm = require("../") +local renderHook = require("./helpers/renderHook") +local suite = require("../../../../test/suite") local atom = Charm.atom local useAtomBinding = ReactCharm.useAtomBinding diff --git a/packages/react-charm/src/index.d.ts b/packages/react-charm/src/index.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/react-charm/src/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 8fabbb0..0ddbdd0 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -1,5 +1,5 @@ -local Charm = require(script.Parent.Charm) -local React = require(script.Parent.React) +local Charm = require("./Charm") +local React = require("./React") local effect = Charm.effect local useBinding = React.useBinding diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index d5dd56e..cc76252 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -3,7 +3,14 @@ name = "littensy/react-charm" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["tests"] +exclude = [ + "node_modules", + "src/__tests__", + "src/index.d.ts", + "package.json", + "React.luau", + "Charm.luau", +] [dependencies] Charm = "littensy/charm@^0.10" diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau new file mode 100644 index 0000000..6b6fb92 --- /dev/null +++ b/packages/vide-charm/Charm.luau @@ -0,0 +1,6 @@ +local Charm = require(script.Parent.Parent.charm.src) + +export type Atom = Charm.Atom +export type Selector = Charm.Selector + +return Charm diff --git a/packages/vide-charm/Vide.luau b/packages/vide-charm/Vide.luau new file mode 100644 index 0000000..0c1ac14 --- /dev/null +++ b/packages/vide-charm/Vide.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.vide.src) diff --git a/packages/vide-charm/default.project.json b/packages/vide-charm/default.project.json index 3a447c6..e2a35ad 100644 --- a/packages/vide-charm/default.project.json +++ b/packages/vide-charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "VideCharm", + "name": "vide-charm", "tree": { "$path": "src" } diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json new file mode 100644 index 0000000..4182564 --- /dev/null +++ b/packages/vide-charm/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rbxts/vide-charm", + "version": "0.3.0", + "description": "Vide bindings for Charm state management", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!**/__tests__" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:*" + }, + "peerDependencies": { + "@rbxts/vide": "*" + } +} diff --git a/packages/vide-charm/tests/spec.luau b/packages/vide-charm/src/__tests__/spec.luau similarity index 51% rename from packages/vide-charm/tests/spec.luau rename to packages/vide-charm/src/__tests__/spec.luau index 4583308..c445ac5 100644 --- a/packages/vide-charm/tests/spec.luau +++ b/packages/vide-charm/src/__tests__/spec.luau @@ -1,8 +1,9 @@ -local ServerScriptService = game:GetService("ServerScriptService") - -local suite = require(ServerScriptService.Tests.suite) +local suite = require("../../../../test/suite") suite.describe("vide-charm", function() + if not game then + return suite.skip() + end suite.describe("useAtom", function() require("./useAtom.spec") end) diff --git a/packages/vide-charm/tests/useAtom.spec.luau b/packages/vide-charm/src/__tests__/useAtom.spec.luau similarity index 64% rename from packages/vide-charm/tests/useAtom.spec.luau rename to packages/vide-charm/src/__tests__/useAtom.spec.luau index 84b202d..5f4a801 100644 --- a/packages/vide-charm/tests/useAtom.spec.luau +++ b/packages/vide-charm/src/__tests__/useAtom.spec.luau @@ -1,10 +1,7 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local ServerScriptService = game:GetService("ServerScriptService") - -local Charm = require(ReplicatedStorage.Packages.Charm) -local Vide = require(ReplicatedStorage.Packages.Vide) -local VideCharm = require(ReplicatedStorage.Packages.VideCharm) -local suite = require(ServerScriptService.Tests.suite) +local Charm = require("../../Charm") +local Vide = require("../../Vide") +local VideCharm = require("../") +local suite = require("../../../../test/suite") local atom = Charm.atom local root = Vide.root diff --git a/packages/vide-charm/src/index.d.ts b/packages/vide-charm/src/index.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/vide-charm/src/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index 805a634..766e57b 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -1,5 +1,5 @@ -local Charm = require(script.Parent.Charm) -local Vide = require(script.Parent.Vide) +local Charm = require("./Charm") +local Vide = require("./Vide") local subscribe = Charm.subscribe local source = Vide.source diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index dc9d909..9e88a0f 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -3,7 +3,14 @@ name = "littensy/vide-charm" version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["tests"] +exclude = [ + "node_modules", + "src/__tests__", + "src/index.d.ts", + "package.json", + "Vide.luau", + "Charm.luau", +] [dependencies] Charm = "littensy/charm@^0.10" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b51612c..ac32a87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,124 @@ importers: '@rbxts-js/react-roblox': specifier: 17.1.20-ts.1 version: 17.1.20-ts.1 + '@rbxts/compiler-types': + specifier: 3.0.0-types.0 + version: 3.0.0-types.0 + '@rbxts/react': + specifier: 17.3.0-alpha.1 + version: 17.3.0-alpha.1 + '@rbxts/react-roblox': + specifier: 17.3.0-alpha.1 + version: 17.3.0-alpha.1(@rbxts/react@17.3.0-alpha.1) + '@rbxts/types': + specifier: ^1.0.861 + version: 1.0.861 '@rbxts/vide': specifier: ^0.5.7 version: 0.5.7 + '@typescript-eslint/eslint-plugin': + specifier: ^8.35.1 + version: 8.35.1(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/parser': + specifier: ^8.35.1 + version: 8.35.1(eslint@8.57.1)(typescript@5.8.3) + eslint: + specifier: ^8.57.1 + version: 8.57.1 + eslint-config-prettier: + specifier: ^10.1.5 + version: 10.1.5(eslint@8.57.1) + eslint-plugin-prettier: + specifier: ^5.5.1 + version: 5.5.1(eslint-config-prettier@10.1.5(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2) + eslint-plugin-roblox-ts: + specifier: ^0.0.36 + version: 0.0.36(eslint@8.57.1) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + roblox-ts: + specifier: ^3.0.0 + version: 3.0.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + packages/charm: {} + + packages/charm-sync: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + + packages/react-charm: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/react': + specifier: '*' + version: 17.2.3 + + packages/vide-charm: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/vide': + specifier: '*' + version: 0.5.7 packages: + '@eslint-community/eslint-utils@4.7.0': + resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgr/core@0.2.7': + resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@rbxts-js/boolean@1.2.3-ts.1': resolution: {integrity: sha512-IlYFG9uatXQKXOLR1mdptVeEcxudwBCierO+Vu6VZRRaI+/+JDxUClm+QFZyce/kitwnDTmtNvhqVAUtFvCU0Q==} @@ -74,11 +186,808 @@ packages: '@rbxts-js/timers@1.2.3-ts.1': resolution: {integrity: sha512-HKP4ymFX3QLWbR/2Gy4LXwvkhjC9OzbwZ5bNunxu+M8Ocbi1cjRduiZK/y5ogLr9cNIfSoxs6e1DQ3soc8/hwA==} + '@rbxts/compiler-types@3.0.0-types.0': + resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} + + '@rbxts/react-roblox@17.3.0-alpha.1': + resolution: {integrity: sha512-P6vL3+LFC7YZxFGfx3hpVdd4bPBqOkJkDpr0aRpHmVj0OVoVCPiaPXe1qCPp5MciBRb4MQXauJCeiVVs4CvwJg==} + peerDependencies: + '@rbxts/react': ^17.3.0-alpha.1 + + '@rbxts/react-vendor@17.2.3': + resolution: {integrity: sha512-dWCiVelvTK6h920geaFH/OQ5zoWHyhyqyfH51hKx2/iVZ6OiiyNVJ5UqEIkJMG4HsJ8JnkTORizV3+MtQ41BtA==} + + '@rbxts/react@17.2.3': + resolution: {integrity: sha512-IoOB8e2W8MxT0MKWFf0Ls0vT61ImYL6ctHQgz5+oazma6z8bqFtJjgPxOuy6jmMSuyfBe7rYp/muou0kIjZT+Q==} + + '@rbxts/react@17.3.0-alpha.1': + resolution: {integrity: sha512-l2KYYxSj5pOKA2/QelbQRiqQjbI6SRMv8mrmIjc7xpAzuw728aumJ6V6Ol0lrQO/Qt5LVQ8En2L+6C6Lt4sLqA==} + + '@rbxts/types@1.0.861': + resolution: {integrity: sha512-WYIODP9WM7r2uc7+M6PA38yeSKf4bvIrh4MTbk4pvb1vzLwYlnJ3tq9q4SaI5e8BQsNWMSjXcI+s9p4tXY4v5A==} + '@rbxts/vide@0.5.7': resolution: {integrity: sha512-Q9R7ybzuHK1phtLicBHgAjQnlJ/dMsXDs2k296PIMLgLqK9aX/f1skOP7F1K/vo7IrSE0jCG3B3XTOtQL1Sb0Q==} + '@roblox-ts/luau-ast@2.0.0': + resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} + + '@roblox-ts/path-translator@1.1.0': + resolution: {integrity: sha512-D0akTmnNYqBw+ZIek5JxocT3BjmbgGOuOy0x1nIIxHBPNLGCpzseToY8jyYs/0mlvnN2xnSP/k8Tv+jvGOQSwQ==} + + '@roblox-ts/rojo-resolver@1.1.0': + resolution: {integrity: sha512-QmvVryu1EeME+3QUoG5j/gHGJoJUaffCgZ92mhlG7cJSd1uyhgpY4CNWriZAwZJYkTlzd5Htkpn+18yDFbOFXA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@20.19.4': + resolution: {integrity: sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==} + + '@types/semver@7.7.0': + resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} + + '@typescript-eslint/eslint-plugin@8.35.1': + resolution: {integrity: sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.35.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/experimental-utils@5.62.0': + resolution: {integrity: sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@typescript-eslint/parser@8.35.1': + resolution: {integrity: sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/project-service@8.35.1': + resolution: {integrity: sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/scope-manager@5.62.0': + resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/scope-manager@8.35.1': + resolution: {integrity: sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.35.1': + resolution: {integrity: sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/type-utils@8.35.1': + resolution: {integrity: sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/types@5.62.0': + resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/types@8.35.1': + resolution: {integrity: sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@5.62.0': + resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/typescript-estree@8.35.1': + resolution: {integrity: sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/utils@5.62.0': + resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@typescript-eslint/utils@8.35.1': + resolution: {integrity: sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/visitor-keys@5.62.0': + resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/visitor-keys@8.35.1': + resolution: {integrity: sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.5: + resolution: {integrity: sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.1: + resolution: {integrity: sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-roblox-ts@0.0.36: + resolution: {integrity: sha512-vlbgfHY1heWSyDunVX3gIOamCy28KqrJlDkmuDOyYVBo8uLqdvbFkbWkdGxn59Vky1m6pFcvZGU6TObfmwm5Pg==} + engines: {node: '>=0.10.0'} + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.0.6: + resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fs-extra@11.3.0: + resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} + engines: {node: '>=14.14'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roblox-ts@3.0.0: + resolution: {integrity: sha512-hwAC2frIFlLJOtHd6F+5opMEhBgfAMK9z5l1mP+bykLBbMO5cn1q5lIwhhXbeh9Pq07rlhF8uGHlmeRLPd/3AA==} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + synckit@0.11.8: + resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} + engines: {node: ^14.18.0 || >=16.0.0} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tsutils@3.21.0: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@5.5.3: + resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + snapshots: + '@eslint-community/eslint-utils@4.7.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.1 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@pkgr/core@0.2.7': {} + '@rbxts-js/boolean@1.2.3-ts.1': dependencies: '@rbxts-js/collections': 1.2.3-ts.1 @@ -161,4 +1070,774 @@ snapshots: dependencies: '@rbxts-js/collections': 1.2.3-ts.1 + '@rbxts/compiler-types@3.0.0-types.0': {} + + '@rbxts/react-roblox@17.3.0-alpha.1(@rbxts/react@17.3.0-alpha.1)': + dependencies: + '@rbxts-js/react-roblox': 17.1.20-ts.1 + '@rbxts/react': 17.3.0-alpha.1 + + '@rbxts/react-vendor@17.2.3': {} + + '@rbxts/react@17.2.3': + dependencies: + '@rbxts/react-vendor': 17.2.3 + + '@rbxts/react@17.3.0-alpha.1': + dependencies: + '@rbxts-js/react': 17.1.20-ts.1 + + '@rbxts/types@1.0.861': {} + '@rbxts/vide@0.5.7': {} + + '@roblox-ts/luau-ast@2.0.0': {} + + '@roblox-ts/path-translator@1.1.0': + dependencies: + ajv: 8.17.1 + fs-extra: 11.3.0 + + '@roblox-ts/rojo-resolver@1.1.0': + dependencies: + ajv: 8.17.1 + fs-extra: 11.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/node@20.19.4': + dependencies: + undici-types: 6.21.0 + + '@types/semver@7.7.0': {} + + '@typescript-eslint/eslint-plugin@8.35.1(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.35.1(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.35.1 + '@typescript-eslint/type-utils': 8.35.1(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/utils': 8.35.1(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.35.1 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.8.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.35.1 + '@typescript-eslint/types': 8.35.1 + '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.35.1 + debug: 4.4.1 + eslint: 8.57.1 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.35.1(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.35.1(typescript@5.8.3) + '@typescript-eslint/types': 8.35.1 + debug: 4.4.1 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@5.62.0': + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + + '@typescript-eslint/scope-manager@8.35.1': + dependencies: + '@typescript-eslint/types': 8.35.1 + '@typescript-eslint/visitor-keys': 8.35.1 + + '@typescript-eslint/tsconfig-utils@8.35.1(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + + '@typescript-eslint/type-utils@8.35.1(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3) + '@typescript-eslint/utils': 8.35.1(eslint@8.57.1)(typescript@5.8.3) + debug: 4.4.1 + eslint: 8.57.1 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@5.62.0': {} + + '@typescript-eslint/types@8.35.1': {} + + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + debug: 4.4.1 + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.7.2 + tsutils: 3.21.0(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.35.1(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.35.1(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.35.1(typescript@5.8.3) + '@typescript-eslint/types': 8.35.1 + '@typescript-eslint/visitor-keys': 8.35.1 + debug: 4.4.1 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.2 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.0 + '@typescript-eslint/scope-manager': 5.62.0 + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) + eslint: 8.57.1 + eslint-scope: 5.1.1 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/utils@8.35.1(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.35.1 + '@typescript-eslint/types': 8.35.1 + '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3) + eslint: 8.57.1 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@5.62.0': + dependencies: + '@typescript-eslint/types': 5.62.0 + eslint-visitor-keys: 3.4.3 + + '@typescript-eslint/visitor-keys@8.35.1': + dependencies: + '@typescript-eslint/types': 8.35.1 + eslint-visitor-keys: 4.2.1 + + '@ungap/structured-clone@1.3.0': {} + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + balanced-match@1.0.2: {} + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + emoji-regex@8.0.0: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.5(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-prettier@5.5.1(eslint-config-prettier@10.1.5(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2): + dependencies: + eslint: 8.57.1 + prettier: 3.6.2 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.8 + optionalDependencies: + eslint-config-prettier: 10.1.5(eslint@8.57.1) + + eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1): + dependencies: + '@types/node': 20.19.4 + '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - eslint + - supports-color + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + fs-extra@11.3.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + isexe@2.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + normalize-path@3.0.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + picomatch@2.3.1: {} + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + + prettier@3.6.2: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + roblox-ts@3.0.0: + dependencies: + '@roblox-ts/luau-ast': 2.0.0 + '@roblox-ts/path-translator': 1.1.0 + '@roblox-ts/rojo-resolver': 1.1.0 + chokidar: 3.6.0 + fs-extra: 11.3.0 + kleur: 4.1.5 + resolve: 1.22.10 + typescript: 5.5.3 + yargs: 17.7.2 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + semver@7.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + slash@3.0.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + synckit@0.11.8: + dependencies: + '@pkgr/core': 0.2.7 + + text-table@0.2.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.1.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + tslib@1.14.1: {} + + tsutils@3.21.0(typescript@5.8.3): + dependencies: + tslib: 1.14.1 + typescript: 5.8.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typescript@5.5.3: {} + + typescript@5.8.3: {} + + undici-types@6.21.0: {} + + universalify@2.0.1: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..924b55f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/rokit.toml b/rokit.toml index 9412a69..23db8e9 100644 --- a/rokit.toml +++ b/rokit.toml @@ -8,5 +8,6 @@ wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.5.1" stylua = "johnnymorganz/stylua@2.1.0" selene = "Kampfkarren/selene@0.28.0" -luau-lsp = "johnnymorganz/luau-lsp@1.47.0" -lune = "lune-org/lune@0.9.3" +luau-lsp = "johnnymorganz/luau-lsp@1.51.0" +lune = "lune-org/lune@0.9.4" +lute = "luau-lang/lute@0.1.0-nightly.20250701" diff --git a/selene.toml b/selene.toml index 1dab6d8..7da7261 100644 --- a/selene.toml +++ b/selene.toml @@ -1,4 +1,5 @@ std = "selene_defs" +exclude = ["**/node_modules/**"] [lints] global_usage = "allow" diff --git a/test/init.luau b/test/init.luau deleted file mode 100644 index 8048eba..0000000 --- a/test/init.luau +++ /dev/null @@ -1,9 +0,0 @@ -local suite = require("@self/suite") - -if not table.isfrozen(_G) then - _G.__DEV__ = true -end - -require("@packages/charm/tests/spec") - -suite.finish() diff --git a/test/spec.server.luau b/test/spec.server.luau index 6b6956b..b33b7b2 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -1,16 +1,11 @@ +local Charm = require("../packages/charm/src") local suite = require("./suite") -if not script then - return -end +Charm.strict.enabled = true -_G.__DEV__ = true +require("../packages/charm/src/__tests__/spec") +require("../packages/charm-sync/src/__tests__/spec") +require("../packages/react-charm/src/__tests__/spec") +require("../packages/vide-charm/src/__tests__/spec") -require(script.Parent.CharmSync.spec) -require(script.Parent.ReactCharm.spec) -require(script.Parent.VideCharm.spec) - -suite.finish({ - colors = false, - verbose = true, -}) +suite.finish() diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a323dec --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + // required + "allowSyntheticDefaultImports": true, + "downlevelIteration": true, + "jsx": "react", + "jsxFactory": "Roact.createElement", + "jsxFragmentFactory": "Roact.createFragment", + "module": "commonjs", + "moduleResolution": "Node", + "noLib": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "moduleDetection": "force", + "strict": true, + "target": "ESNext", + "typeRoots": ["node_modules/@rbxts"] + } +} From d2979ebd4cf60bfca0d0e0b16c7be152345a93d7 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 2 Jul 2025 09:28:38 -0700 Subject: [PATCH 036/160] ci: update test file path --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8c1ad..af91488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: run: sh bin/check.sh - name: Run tests - run: lune run test + run: lune run test/spec.server.luau - name: Build models run: sh bin/build.sh From 708bc55de7ae447f889bd66c4e63b066d0f2b389 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 2 Jul 2025 21:58:55 -0700 Subject: [PATCH 037/160] feat: add peek and roblox-ts types --- packages/charm-sync/src/__tests__/spec.luau | 37 +- packages/charm-sync/src/patch.luau | 4 +- .../charm/src/__tests__/core/gc.spec.luau | 4 +- packages/charm/src/__tests__/peek.spec.luau | 107 +++++ packages/charm/src/__tests__/spec.luau | 30 +- .../charm/src/__tests__/untracked.spec.luau | 21 + packages/charm/src/core.luau | 46 +- .../charm/src/{strict.luau => flags.luau} | 2 +- packages/charm/src/index.d.ts | 69 ++- packages/charm/src/init.luau | 22 +- packages/charm/src/listen.luau | 4 +- packages/charm/src/peek.luau | 18 + packages/charm/src/subscribe.luau | 4 +- packages/charm/src/untracked.luau | 8 +- packages/charm/test/default.project.json | 6 - packages/charm/test/src/batched.luau | 20 - packages/charm/test/src/core.luau | 445 ------------------ packages/charm/test/src/init.luau | 34 -- packages/charm/test/src/listen.luau | 32 -- packages/charm/test/src/mapped.luau | 59 --- packages/charm/test/src/observe.luau | 52 -- packages/charm/test/src/strict.luau | 8 - packages/charm/test/src/subscribe.luau | 26 - packages/charm/test/src/system.luau | 357 -------------- packages/charm/test/src/types.luau | 5 - packages/charm/test/src/untracked.luau | 18 - packages/charm/test/src/utils.luau | 18 - packages/charm/test/wally.toml | 8 - .../src/__tests__/helpers/renderHook.luau | 4 +- packages/react-charm/src/__tests__/spec.luau | 16 +- packages/react-charm/src/index.d.ts | 30 +- packages/react-charm/src/init.luau | 34 +- packages/vide-charm/src/__tests__/spec.luau | 9 +- packages/vide-charm/src/index.d.ts | 9 +- packages/vide-charm/src/init.luau | 8 +- test/spec.server.luau | 21 +- test/suite | 2 +- 37 files changed, 363 insertions(+), 1234 deletions(-) create mode 100644 packages/charm/src/__tests__/peek.spec.luau rename packages/charm/src/{strict.luau => flags.luau} (72%) create mode 100644 packages/charm/src/peek.luau delete mode 100644 packages/charm/test/default.project.json delete mode 100644 packages/charm/test/src/batched.luau delete mode 100644 packages/charm/test/src/core.luau delete mode 100644 packages/charm/test/src/init.luau delete mode 100644 packages/charm/test/src/listen.luau delete mode 100644 packages/charm/test/src/mapped.luau delete mode 100644 packages/charm/test/src/observe.luau delete mode 100644 packages/charm/test/src/strict.luau delete mode 100644 packages/charm/test/src/subscribe.luau delete mode 100644 packages/charm/test/src/system.luau delete mode 100644 packages/charm/test/src/types.luau delete mode 100644 packages/charm/test/src/untracked.luau delete mode 100644 packages/charm/test/src/utils.luau delete mode 100644 packages/charm/test/wally.toml diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau index 10f3bd9..84f7760 100644 --- a/packages/charm-sync/src/__tests__/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -1,24 +1,23 @@ local suite = require("../../../../test/suite") -suite.describe("charm-sync", function() - if not game then - return suite.skip() - end - suite.describe("client", function() - require("./client.spec") - end) - suite.describe("server", function() - require("./server.spec") - end) - suite.describe("flatten", function() - require("./flatten.spec") - end) - suite.describe("patch", function() - require("./patch.spec") - end) - suite.describe("validate", function() - require("./validate.spec") - end) +suite.describe("client", function() + require("./client.spec") +end) + +suite.describe("server", function() + require("./server.spec") +end) + +suite.describe("flatten", function() + require("./flatten.spec") +end) + +suite.describe("patch", function() + require("./patch.spec") +end) + +suite.describe("validate", function() + require("./validate.spec") end) return {} diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index f96f6f6..fd449af 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -1,6 +1,8 @@ local Charm = require("../Charm") local validate = require("./validate") +local flags = Charm.flags + --- A special symbol that denotes the absence of a value. Used to represent --- deleted values in patches. local None = { __none = "__none" } @@ -63,7 +65,7 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial patches = stringifySparseArray(patches) end - if serialize and Charm.strict.enabled then + if serialize and flags.strict then for key, value in next, patches do validate(value, key) end diff --git a/packages/charm/src/__tests__/core/gc.spec.luau b/packages/charm/src/__tests__/core/gc.spec.luau index 9914415..16bf679 100644 --- a/packages/charm/src/__tests__/core/gc.spec.luau +++ b/packages/charm/src/__tests__/core/gc.spec.luau @@ -23,13 +23,13 @@ local function gc() end end ---- In alien-core, a computed value unlinks from dependencies when its last +--- In alien-signals, a computed value unlinks from dependencies when its last --- parent scope is disposed. Calling a computed value outside of a scope links --- it to dependencies, but the dependencies hold strong references to the --- computed value, so it won't GC until called in a scope and disposed. --- --- This function releases a computed value by linking it to a scope and then ---- immediately closing it, causing alien-core to unlink the computed value +--- immediately closing it, causing alien-signals to unlink the computed value --- and allow it to be garbage collected. local function release(getter: () -> ()) effectScope(function() diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.spec.luau new file mode 100644 index 0000000..0e12b96 --- /dev/null +++ b/packages/charm/src/__tests__/peek.spec.luau @@ -0,0 +1,107 @@ +local Charm = require("../") +local suite = require("../../../../test/suite") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local onCleanup = Charm.onCleanup +local peek = Charm.peek +local test = suite.test + +test("should not run detached", function() + local a = atom(1) + local spyCalls = 0 + + effect(function() + a() + peek(function() + onCleanup(function() + spyCalls += 1 + end) + end) + end) + + assert(spyCalls == 0, "should not be called yet") + a(2) + assert(spyCalls == 1, "should be called once") + a(3) + assert(spyCalls == 2, "should be called twice") +end) + +test("should block tracking inside effects", function() + local a = atom(1) + local b = atom(2) + local spyCalls = 0 + + effect(function() + peek(function() + spyCalls += 1 + return a() + b() + end) + end) + assert(spyCalls == 1, "should be called once") + + a(10) + b(20) + assert(spyCalls == 1, "should be called once") +end) + +test("should block tracking even when run inside effect run inside peek", function() + local s = atom(1) + local spyCalls = 0 + + peek(function() + effect(function() + peek(function() + spyCalls += 1 + return s() + end) + end) + end) + assert(spyCalls == 1, "should be called once") + + s(2) + assert(spyCalls == 1, "should be called once") +end) + +test("should not cause atom assignments throw", function() + local a = atom(1) + local aChangedTime = atom(0) + + local dispose = effect(function() + a() + peek(function() + aChangedTime(aChangedTime() + 1) + end) + end) + + a(2) + assert(aChangedTime() == 2, "aChangedTime should be 2") + a(3) + assert(aChangedTime() == 3, "aChangedTime should be 3") + + dispose() +end) + +test("should block tracking inside computed atoms", function() + local a = atom(1) + local b = atom(2) + local spyCalls = 0 + local c = computed(function() + return peek(function() + spyCalls += 1 + return a() + b() + end) + end) + + assert(spyCalls == 0, "should not be called yet") + assert(c() == 3, "c should be 3") + a(10) + c() + b(20) + c() + assert(spyCalls == 1, "should be called once") + assert(c() == 3, "c should be 3") +end) + +return {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index 7d0d54a..4f63237 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -24,22 +24,20 @@ suite.describe("core", function() end) end) -suite.describe("charm", function() - suite.describe("listen", function() - require("./listen.spec") - end) - suite.describe("mapped", function() - require("./mapped.spec") - end) - suite.describe("observe", function() - require("./observe.spec") - end) - suite.describe("subscribe", function() - require("./subscribe.spec") - end) - suite.describe("untracked", function() - require("./untracked.spec") - end) +suite.describe("listen", function() + require("./listen.spec") +end) +suite.describe("mapped", function() + require("./mapped.spec") +end) +suite.describe("observe", function() + require("./observe.spec") +end) +suite.describe("subscribe", function() + require("./subscribe.spec") +end) +suite.describe("untracked", function() + require("./untracked.spec") end) return {} diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau index 37a40aa..9d56084 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -5,8 +5,29 @@ local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect local untracked = Charm.untracked +local onCleanup = Charm.onCleanup local test = suite.test +test("should run detached", function() + local a = atom(1) + local spyCalls = 0 + + effect(function() + a() + untracked(function() + onCleanup(function() + spyCalls += 1 + end, true) + end) + end) + + assert(spyCalls == 0, "should not be called") + a(2) + assert(spyCalls == 0, "rerun should not call cleanup") + a(3) + assert(spyCalls == 0, "rerun should not call cleanup") +end) + test("should block tracking inside effects", function() local a = atom(1) local b = atom(2) diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 75c8319..49ff50b 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -1,32 +1,4 @@ ---[[ - alien-signals 2.0.5 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. -]] - -local strict = require("./strict") +local config = require("./flags") local system = require("./system") local utils = require("./utils") @@ -103,15 +75,15 @@ local function setCurrentScope(scope: Effect | EffectScope?) end local function setCurrentTracking(tracking: boolean): boolean - local prevTracking = activeSubTracking + local prevSubTracking = activeSubTracking activeSubTracking = tracking - return prevTracking + return prevSubTracking end local function runCleanups(effect: EffectScope) for _, cleanup in effect.cleanups do local prev = setCurrentSub(nil) - local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup) + local success, result: unknown = (if config.strict then noYield else pcall)(cleanup) setCurrentSub(prev) if not success then error(result) @@ -136,7 +108,7 @@ local function run(effect: Effect | EffectScope, flags: ReactiveFlags): () local prev = setCurrentSub(effect) local prevTracking = setCurrentTracking(true) startTracking(effect) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)((effect :: Effect).fn) setCurrentSub(prev) endTracking(effect) setCurrentTracking(prevTracking) @@ -194,7 +166,7 @@ local function updateComputed(computed: Computed): boolean local prevTracking = setCurrentTracking(true) startTracking(computed) local oldValue = computed.value - local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue) + local success, result = (if config.strict then noYield else pcall)(computed.getter, oldValue) setCurrentSub(prevSub) endTracking(computed) setCurrentTracking(prevTracking) @@ -262,7 +234,7 @@ local function atomGetterSetter(atom: Atom): (...((T) -> T) | T) -> T local newValue = ... if type(newValue) == "function" then local prev = setCurrentSub(nil) - local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue) + local success, result = (if config.strict then noYield else pcall)(newValue, oldValue) setCurrentSub(prev) if not success then error(result) @@ -380,7 +352,7 @@ local function effect(fn: () -> ...(() -> ())?): () -> () end local prev = setCurrentSub(self) local prevTracking = setCurrentTracking(true) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) setCurrentSub(prev) setCurrentTracking(prevTracking) if not success then @@ -401,7 +373,7 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> end local prevSub = setCurrentSub(nil) local prevScope = setCurrentScope(self) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) setCurrentScope(prevScope) setCurrentSub(prevSub) if not success then diff --git a/packages/charm/src/strict.luau b/packages/charm/src/flags.luau similarity index 72% rename from packages/charm/src/strict.luau rename to packages/charm/src/flags.luau index 1955aa1..961dd6f 100644 --- a/packages/charm/src/strict.luau +++ b/packages/charm/src/flags.luau @@ -4,5 +4,5 @@ end return { -- Enable strict mode in O2 - enabled = inlineTest() == "inlineTest", + strict = inlineTest() == "inlineTest", } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index cb0ff5c..3193856 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1 +1,68 @@ -export {}; +type Key = string | number | symbol; +type Cleanup = () => void; + +export interface Atom { + (): T; + (value: T | ((value: T) => T)): T; +} + +export type Selector = () => T; + +export type Equals = (current: T, incoming: T) => boolean; + +export const flags: { + strict: boolean; +}; + +export function atom(value: T, equals?: Equals): Atom; +// Overload for undefined initial value +export function atom(): Atom; + +export function computed(getter: (previousValue?: T) => T, equals?: Equals): () => T; + +export function effect(callback: () => Cleanup | void): Cleanup; + +export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; + +export function listen( + callback: () => T, + listener: (value: T, previousValue: T | undefined, dispose: Cleanup) => void, +): Cleanup; + +export function subscribe(callback: () => T, listener: (value: T, previousValue: T) => void): Cleanup; + +export function mapped( + callback: () => Map | ReadonlyMap, + mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): () => ReadonlyMap; +// Overload for arrays +export function mapped( + callback: () => readonly VI[], + mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, +): () => K extends number ? readonly VO[] : { readonly [P in K]: VO }; +// Overload for objects +export function mapped( + callback: () => { readonly [K in KI]: VI }, + mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): () => { readonly [K in KO]: VO }; + +export function observe( + callback: () => Map | ReadonlyMap | readonly V[], + observer: (value: V, key: K) => Cleanup | void, +): Cleanup; +// Overload for objects +export function observe( + callback: () => { readonly [K in Key]: V }, + observer: (value: V, key: K) => Cleanup | void, +): Cleanup; + +export function onCleanup(callback: Cleanup, failSilently?: boolean): void; + +export function batched( + callback: (...args: Args) => Result, + ...args: Args +): (...args: Args) => Result; + +export function peek(callback: (...args: Args) => Result, ...args: Args): Result; + +export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index e583ae6..7897bd9 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,9 +1,10 @@ local batched = require("@self/batched") local core = require("@self/core") +local flags = require("@self/flags") local listen = require("@self/listen") local mapped = require("@self/mapped") local observe = require("@self/observe") -local strict = require("@self/strict") +local peek = require("@self/peek") local subscribe = require("@self/subscribe") local types = require("@self/types") local untracked = require("@self/untracked") @@ -11,24 +12,19 @@ local untracked = require("@self/untracked") export type Atom = types.Atom export type Selector = types.Selector -local atom = core.atom -local computed = core.computed -local effect = core.effect -local effectScope = core.effectScope -local onCleanup = core.onCleanup - return { core = core, - strict = strict, - atom = atom, + flags = flags, + atom = core.atom, + computed = core.computed, + effect = core.effect, + effectScope = core.effectScope, + onCleanup = core.onCleanup, batched = batched, - computed = computed, - effect = effect, - effectScope = effectScope, listen = listen, mapped = mapped, observe = observe, - onCleanup = onCleanup, + peek = peek, subscribe = subscribe, untracked = untracked, } diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 865e704..ed15d77 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -1,5 +1,5 @@ local core = require("./core") -local untracked = require("./untracked") +local peek = require("./peek") local computed = core.computed local effect = core.effect @@ -19,7 +19,7 @@ local function listen(selector: () -> T, listener: (state: T, previous: T?, d dispose = effect(function() local previous = current current = tracked() - untracked(listener, current, previous, dispose) + peek(listener, current, previous, dispose) end) if disposed then diff --git a/packages/charm/src/peek.luau b/packages/charm/src/peek.luau new file mode 100644 index 0000000..a8bc08f --- /dev/null +++ b/packages/charm/src/peek.luau @@ -0,0 +1,18 @@ +local core = require("./core") + +local setCurrentTracking = core.setCurrentTracking + +local function handlePeek(prevTracking: boolean, ok: boolean, ...) + setCurrentTracking(prevTracking) + if ok then + return ... + else + error((...)) + end +end + +local function peek(callback: (Args...) -> Results..., ...: Args...): Results... + return handlePeek(setCurrentTracking(false), pcall(callback, ...)) +end + +return peek diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index 1ceca66..bd2b8c1 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -1,5 +1,5 @@ local core = require("./core") -local untracked = require("./untracked") +local peek = require("./peek") local computed = core.computed local effect = core.effect @@ -18,7 +18,7 @@ local function subscribe(selector: () -> T, listener: (state: T, lastState: T if mounted then mounted = false else - untracked(listener, current, previous) + peek(listener, current, previous) end end) end diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau index b766aae..80c2671 100644 --- a/packages/charm/src/untracked.luau +++ b/packages/charm/src/untracked.luau @@ -1,9 +1,9 @@ local core = require("./core") -local setCurrentTracking = core.setCurrentTracking +local setCurrentSub = core.setCurrentSub -local function handleUntracked(prevTracking: boolean, ok: boolean, ...) - setCurrentTracking(prevTracking) +local function handleUntracked(prevSub: core.ReactiveNode?, ok: boolean, ...) + setCurrentSub(prevSub) if ok then return ... else @@ -12,7 +12,7 @@ local function handleUntracked(prevTracking: boolean, ok: boolean, ...) end local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - return handleUntracked(setCurrentTracking(false), pcall(callback, ...)) + return handleUntracked(setCurrentSub(nil), pcall(callback, ...)) end return untracked diff --git a/packages/charm/test/default.project.json b/packages/charm/test/default.project.json deleted file mode 100644 index 4cd33dd..0000000 --- a/packages/charm/test/default.project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "charm", - "tree": { - "$path": "src" - } -} \ No newline at end of file diff --git a/packages/charm/test/src/batched.luau b/packages/charm/test/src/batched.luau deleted file mode 100644 index 685365a..0000000 --- a/packages/charm/test/src/batched.luau +++ /dev/null @@ -1,20 +0,0 @@ -local core = require("./core") - -local startBatch = core.startBatch -local endBatch = core.endBatch - -local function handleBatched(ok: boolean, ...) - endBatch() - if ok then - return ... - else - error((...)) - end -end - -local function batched(callback: (Args...) -> Results..., ...: Args...): Results... - startBatch() - return handleBatched(pcall(callback, ...)) -end - -return batched diff --git a/packages/charm/test/src/core.luau b/packages/charm/test/src/core.luau deleted file mode 100644 index 75c8319..0000000 --- a/packages/charm/test/src/core.luau +++ /dev/null @@ -1,445 +0,0 @@ ---[[ - alien-signals 2.0.5 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. -]] - -local strict = require("./strict") -local system = require("./system") -local utils = require("./utils") - -export type ReactiveFlags = system.ReactiveFlags -export type ReactiveNode = system.ReactiveNode -export type Link = system.Link - -local ReactiveFlags = system.ReactiveFlags -local createReactiveSystem = system.createReactiveSystem -local noYield = utils.noYield - -local NONE = 0b0000000 -local MUTABLE = 0b0000001 -local WATCHING = 0b0000010 -local DIRTY = 0b0010000 -local PENDING = 0b0100000 -local QUEUED = 0b1000000 - -type EffectScope = ReactiveNode & { - cleanups: { () -> () }, -} - -type Effect = ReactiveNode & { - fn: () -> ...(() -> ())?, - cleanups: { () -> () }, -} - -type Computed = ReactiveNode & { - value: T?, - getter: (previousValue: T?) -> T, - equals: ((T, T) -> boolean)?, -} - -type Atom = ReactiveNode & { - previousValue: T, - value: T, - equals: ((T, T) -> boolean)?, -} - -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 queuedEffects: { Effect | EffectScope? } = {} -local queuedEffectsLength = 0 -local notifyIndex = 0 -local activeSubTracking = true -local activeSub: ReactiveNode? -local activeScope: Effect | EffectScope? - -local function getCurrentSub(): ReactiveNode? - return activeSub -end - -local function setCurrentSub(sub: ReactiveNode?) - local prevSub = activeSub - activeSub = sub - return prevSub -end - -local function getCurrentScope(): Effect | EffectScope? - return activeScope -end - -local function setCurrentScope(scope: Effect | EffectScope?) - local prevScope = activeScope - activeScope = scope - return prevScope -end - -local function setCurrentTracking(tracking: boolean): boolean - local prevTracking = activeSubTracking - activeSubTracking = tracking - return prevTracking -end - -local function runCleanups(effect: EffectScope) - for _, cleanup in effect.cleanups do - local prev = setCurrentSub(nil) - local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup) - setCurrentSub(prev) - if not success then - error(result) - end - end - table.clear(effect.cleanups) -end - -local function onCleanup(cleanup: () -> (), failSilently: boolean?) - if activeSub then - table.insert((activeSub :: Effect).cleanups, cleanup) - elseif activeScope then - table.insert(activeScope.cleanups, cleanup) - elseif not failSilently then - warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) - end -end - -local function run(effect: Effect | EffectScope, flags: ReactiveFlags): () - if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then - runCleanups(effect) - local prev = setCurrentSub(effect) - local prevTracking = setCurrentTracking(true) - startTracking(effect) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn) - setCurrentSub(prev) - endTracking(effect) - setCurrentTracking(prevTracking) - if not success then - error(result) - elseif result then - table.insert(effect.cleanups, result) - end - return - elseif bit32.btest(flags, PENDING) then - (effect :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING)) - end - local link = effect.deps - while link do - local dep = link.dep - local depFlags = dep.flags - if bit32.btest(depFlags, QUEUED) then - depFlags = bit32.band(depFlags, bit32.bnot(QUEUED)) - dep.flags = depFlags - run(dep :: Effect, depFlags) - end - link = link.nextDep - end -end - -local function flush(): () - while notifyIndex < queuedEffectsLength do - notifyIndex += 1 - local effect = queuedEffects[notifyIndex] :: Effect - 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(computed: Computed): boolean - local prevSub = setCurrentSub(computed) - local prevTracking = setCurrentTracking(true) - startTracking(computed) - local oldValue = computed.value - local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue) - setCurrentSub(prevSub) - endTracking(computed) - setCurrentTracking(prevTracking) - if not success then - error(result) - end - local equals = computed.equals - if if equals then not equals(oldValue, result) else oldValue ~= result then - computed.value = result - return true - else - return false - end -end - -local function updateAtom(atom: Atom, value: any): boolean - atom.flags = MUTABLE - if atom.previousValue ~= value then - atom.previousValue = value - return true - else - return false - end -end - -local function readAtom(atom: Atom): T - local value = atom.value - if bit32.btest(atom.flags, DIRTY) then - if updateAtom(atom, value) then - local subs = atom.subs - if subs then - shallowPropagate(subs) - end - end - end - if activeSub and activeSubTracking then - link(atom, activeSub) - end - return value -end - -local function writeAtom(atom: Atom, oldValue: T, newValue: T): T - local equals = atom.equals - if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then - atom.value = newValue - atom.flags = bit32.bor(MUTABLE, DIRTY) - local subs = atom.subs - if subs then - propagate(subs) - if batchDepth == 0 then - flush() - end - end - return newValue - end - return oldValue -end - -local function atomGetterSetter(atom: Atom): (...((T) -> T) | T) -> T - return function(...) - if select("#", ...) == 0 then - return readAtom(atom) - end - local oldValue = atom.value - local newValue = ... - if type(newValue) == "function" then - local prev = setCurrentSub(nil) - local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue) - setCurrentSub(prev) - if not success then - error(result) - end - newValue = result - end - return writeAtom(atom, oldValue, newValue :: T) - end -end - -local function computedGetter(computed: Computed): () -> T - return function() - local flags = computed.flags - if - bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(computed.deps :: Link, computed)) - then - if updateComputed(computed) then - local subs = computed.subs - if subs then - shallowPropagate(subs) - end - end - elseif bit32.btest(flags, PENDING) then - computed.flags = bit32.band(flags, bit32.bnot(PENDING)) - end - if activeSub then - if activeSubTracking then - link(computed, activeSub) - end - elseif activeScope then - link(computed, activeScope) - end - return computed.value :: T - end -end - -local function effectStop(self: Effect | EffectScope): () -> () - return function() - local dep = self.deps - while dep do - dep = unlink(dep, self) - end - local sub = self.subs - if sub then - unlink(sub) - end - (self :: Effect).flags = NONE - runCleanups(self) - end -end - -local function update(atom: Atom | Computed): boolean - if (atom :: Computed).getter then - return updateComputed(atom :: Computed) - else - return updateAtom(atom :: Atom, atom.value) - end -end - -local function notify(effect: Effect | EffectScope) - local flags = effect.flags - if not bit32.btest(flags, QUEUED) then - (effect :: Effect).flags = bit32.bor(flags, QUEUED) - local subs = effect.subs - if subs then - notify(subs.sub :: Effect) - else - queuedEffectsLength += 1 - queuedEffects[queuedEffectsLength] = effect - end - end -end - -local function unwatched(node: Atom | Computed | Effect | EffectScope) - if (node :: Computed).getter then - local toRemove = node.deps - if toRemove then - (node :: Computed).flags = bit32.bor(MUTABLE, DIRTY) - repeat - toRemove = unlink(toRemove, node) - until not toRemove - end - elseif (node :: Effect).cleanups then - effectStop(node :: Effect)() - end -end - -local function atom(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T - return atomGetterSetter({ - previousValue = initialValue, - value = initialValue, - flags = MUTABLE, - equals = equals, - }) -end - -local function computed(getter: (previousValue: T?) -> T, equals: ((T, T) -> boolean)?): () -> T - return computedGetter({ - flags = bit32.bor(MUTABLE, DIRTY), - getter = getter, - equals = equals, - }) -end - -local function effect(fn: () -> ...(() -> ())?): () -> () - local self: Effect = { - fn = fn, - flags = WATCHING, - cleanups = {}, - } - if activeSub then - link(self, activeSub) - elseif activeScope then - link(self, activeScope) - end - local prev = setCurrentSub(self) - local prevTracking = setCurrentTracking(true) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) - setCurrentSub(prev) - setCurrentTracking(prevTracking) - if not success then - error(result) - elseif result then - table.insert(self.cleanups, result) - end - return effectStop(self) -end - -local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () - local self: EffectScope = { - flags = NONE, - cleanups = {}, - } - if activeScope and not detached then - link(self, activeScope) - end - local prevSub = setCurrentSub(nil) - local prevScope = setCurrentScope(self) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) - setCurrentScope(prevScope) - setCurrentSub(prevSub) - if not success then - error(result) - elseif result then - table.insert(self.cleanups, result) - end - return effectStop(self) -end - -local reactiveSystem = createReactiveSystem({ - update = update :: (ReactiveNode) -> boolean, - notify = notify :: (ReactiveNode) -> (), - unwatched = unwatched :: (ReactiveNode) -> (), -}) - -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, - setCurrentTracking = setCurrentTracking, - onCleanup = onCleanup, - getBatchDepth = getBatchDepth, - startBatch = startBatch, - endBatch = endBatch, - atom = atom, - computed = computed, - effect = effect, - effectScope = effectScope, -} diff --git a/packages/charm/test/src/init.luau b/packages/charm/test/src/init.luau deleted file mode 100644 index e583ae6..0000000 --- a/packages/charm/test/src/init.luau +++ /dev/null @@ -1,34 +0,0 @@ -local batched = require("@self/batched") -local core = require("@self/core") -local listen = require("@self/listen") -local mapped = require("@self/mapped") -local observe = require("@self/observe") -local strict = require("@self/strict") -local subscribe = require("@self/subscribe") -local types = require("@self/types") -local untracked = require("@self/untracked") - -export type Atom = types.Atom -export type Selector = types.Selector - -local atom = core.atom -local computed = core.computed -local effect = core.effect -local effectScope = core.effectScope -local onCleanup = core.onCleanup - -return { - core = core, - strict = strict, - atom = atom, - batched = batched, - computed = computed, - effect = effect, - effectScope = effectScope, - listen = listen, - mapped = mapped, - observe = observe, - onCleanup = onCleanup, - subscribe = subscribe, - untracked = untracked, -} diff --git a/packages/charm/test/src/listen.luau b/packages/charm/test/src/listen.luau deleted file mode 100644 index 865e704..0000000 --- a/packages/charm/test/src/listen.luau +++ /dev/null @@ -1,32 +0,0 @@ -local core = require("./core") -local untracked = require("./untracked") - -local computed = core.computed -local effect = core.effect - -local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () - local tracked = computed(function() - return selector() - end) - - local current: T - local disposed = false - - local function dispose() - disposed = true - end - - dispose = effect(function() - local previous = current - current = tracked() - untracked(listener, current, previous, dispose) - end) - - if disposed then - dispose() - end - - return dispose -end - -return listen diff --git a/packages/charm/test/src/mapped.luau b/packages/charm/test/src/mapped.luau deleted file mode 100644 index f6289be..0000000 --- a/packages/charm/test/src/mapped.luau +++ /dev/null @@ -1,59 +0,0 @@ -local core = require("./core") - -local computed = core.computed -local setCurrentSub = core.setCurrentSub - -type Mapped = - & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) - & ((selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) - -local function mapped(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } - local currentData: { [KI]: VI } = {} - local keyCache: { [KI]: KO } = {} - - local function map(value: VI, key: KI): (VO, KO) - local currentSub = setCurrentSub(nil) - local success, valueOut, keyOut = pcall(mapper, value, key) - setCurrentSub(currentSub) - - if not success then - error(`mapped() transform function failed: {valueOut}`) - end - - return valueOut, if keyOut == nil then key else keyOut :: any - end - - return computed(function(previousOutput) - local currentOutput: { [KO]: VO } = previousOutput or {} - local previousData = currentData - currentData = selector() - - for key in previousData do - if currentData[key] ~= nil then - continue - end - local cachedKey = keyCache[key] - if cachedKey then - currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput - currentOutput[cachedKey] = nil - keyCache[key] = nil - end - end - - for key, value in currentData do - if value == previousData[key] then - continue - end - local valueOut, keyOut = map(value, key) - if currentOutput[keyOut] ~= valueOut then - currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput - currentOutput[keyOut] = valueOut - keyCache[key] = keyOut - end - end - - return currentOutput - end) -end - -return mapped :: Mapped diff --git a/packages/charm/test/src/observe.luau b/packages/charm/test/src/observe.luau deleted file mode 100644 index 7272584..0000000 --- a/packages/charm/test/src/observe.luau +++ /dev/null @@ -1,52 +0,0 @@ -local core = require("./core") - -local effect = core.effect -local effectScope = core.effectScope -local onCleanup = core.onCleanup - -local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () - local scopes: { [K]: () -> () } = {} - local currentItems: { [K]: V } = {} - - local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V }) - for key in previousItems do - local dispose = scopes[key] - if currentItems[key] == nil and dispose then - dispose() - scopes[key] = nil - end - end - - for key, value in currentItems do - if previousItems[key] == nil then - scopes[key] = effectScope(function() - return observer(value, key) - end, true) - end - end - end - - local function disposeScopes() - for _, dispose in scopes do - dispose() - end - table.clear(scopes) - end - - local disposeEffect = effect(function() - local previousItems = currentItems - currentItems = selector() - if currentItems ~= previousItems then - updateScopes(currentItems, previousItems) - end - end) - - onCleanup(disposeScopes, true) - - return function() - disposeEffect() - disposeScopes() - end -end - -return observe diff --git a/packages/charm/test/src/strict.luau b/packages/charm/test/src/strict.luau deleted file mode 100644 index 1955aa1..0000000 --- a/packages/charm/test/src/strict.luau +++ /dev/null @@ -1,8 +0,0 @@ -local function inlineTest(): string - return debug.info(1, "n") -end - -return { - -- Enable strict mode in O2 - enabled = inlineTest() == "inlineTest", -} diff --git a/packages/charm/test/src/subscribe.luau b/packages/charm/test/src/subscribe.luau deleted file mode 100644 index 1ceca66..0000000 --- a/packages/charm/test/src/subscribe.luau +++ /dev/null @@ -1,26 +0,0 @@ -local core = require("./core") -local untracked = require("./untracked") - -local computed = core.computed -local effect = core.effect - -local function subscribe(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () - local tracked = computed(function() - return selector() - end) - - local current: T - local mounted = true - - return effect(function() - local previous = current - current = tracked() - if mounted then - mounted = false - else - untracked(listener, current, previous) - end - end) -end - -return subscribe diff --git a/packages/charm/test/src/system.luau b/packages/charm/test/src/system.luau deleted file mode 100644 index d3d5692..0000000 --- a/packages/charm/test/src/system.luau +++ /dev/null @@ -1,357 +0,0 @@ ---[[ - 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.btest(sub.flags, RECURSED_CHECK) - 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.btest(flags, bit32.bor(MUTABLE, WATCHING)) then - if not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) then - sub.flags = bit32.bor(flags, PENDING) - elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then - flags = NONE - elseif not bit32.btest(flags, RECURSED_CHECK) then - sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) - elseif not bit32.btest(flags, bit32.bor(DIRTY, PENDING)) 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.btest(flags, WATCHING) then - notify(sub) - end - - if bit32.btest(flags, MUTABLE) 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.btest(subFlags, WATCHING) 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.btest(sub.flags, DIRTY) 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/test/src/types.luau b/packages/charm/test/src/types.luau deleted file mode 100644 index d5c0314..0000000 --- a/packages/charm/test/src/types.luau +++ /dev/null @@ -1,5 +0,0 @@ -type Update = ((previous: T) -> T) | T -export type Atom = (...Update) -> T -export type Selector = () -> T - -return {} diff --git a/packages/charm/test/src/untracked.luau b/packages/charm/test/src/untracked.luau deleted file mode 100644 index b766aae..0000000 --- a/packages/charm/test/src/untracked.luau +++ /dev/null @@ -1,18 +0,0 @@ -local core = require("./core") - -local setCurrentTracking = core.setCurrentTracking - -local function handleUntracked(prevTracking: boolean, ok: boolean, ...) - setCurrentTracking(prevTracking) - if ok then - return ... - else - error((...)) - end -end - -local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - return handleUntracked(setCurrentTracking(false), pcall(callback, ...)) -end - -return untracked diff --git a/packages/charm/test/src/utils.luau b/packages/charm/test/src/utils.luau deleted file mode 100644 index 536e086..0000000 --- a/packages/charm/test/src/utils.luau +++ /dev/null @@ -1,18 +0,0 @@ -local function handleNoYield(thread: thread, ok: boolean, ...) - if not ok then - return false, debug.traceback(thread, tostring((...))) - elseif coroutine.status(thread) ~= "dead" then - return false, debug.traceback(thread, "Attempted to yield!") - else - return true, ... - end -end - -local function noYield(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...) - local thread = coroutine.create(callback) - return handleNoYield(thread, coroutine.resume(thread, ...)) -end - -return { - noYield = noYield :: typeof(pcall), -} diff --git a/packages/charm/test/wally.toml b/packages/charm/test/wally.toml deleted file mode 100644 index cd94b61..0000000 --- a/packages/charm/test/wally.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "littensy/charm" -version = "0.10.0" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" -exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] - -[dependencies] diff --git a/packages/react-charm/src/__tests__/helpers/renderHook.luau b/packages/react-charm/src/__tests__/helpers/renderHook.luau index 664b2fc..6a3ea48 100644 --- a/packages/react-charm/src/__tests__/helpers/renderHook.luau +++ b/packages/react-charm/src/__tests__/helpers/renderHook.luau @@ -13,11 +13,11 @@ local function renderHook(callback: (Props) -> Result, props: Pro local root = ReactRoblox.createLegacyRoot(Instance.new("Folder")) local hook = {} - local function Component(props) + local function Component(props: Props) hook.result = callback(props) end - function hook.rerender(newProps) + function hook.rerender(newProps: Props?) props = newProps or props ReactRoblox.act(function() root:render(React.createElement(Component, props)) diff --git a/packages/react-charm/src/__tests__/spec.luau b/packages/react-charm/src/__tests__/spec.luau index 37326fa..2dcdfe7 100644 --- a/packages/react-charm/src/__tests__/spec.luau +++ b/packages/react-charm/src/__tests__/spec.luau @@ -1,15 +1,11 @@ local suite = require("../../../../test/suite") -suite.describe("react-charm", function() - if not game then - return suite.skip() - end - suite.describe("useAtom", function() - require("./useAtom.spec") - end) - suite.describe("useAtomBinding", function() - require("./useAtomBinding.spec") - end) +suite.describe("useAtom", function() + require("./useAtom.spec") +end) + +suite.describe("useAtomBinding", function() + require("./useAtomBinding.spec") end) return {} diff --git a/packages/react-charm/src/index.d.ts b/packages/react-charm/src/index.d.ts index cb0ff5c..f24d4af 100644 --- a/packages/react-charm/src/index.d.ts +++ b/packages/react-charm/src/index.d.ts @@ -1 +1,29 @@ -export {}; +import { Binding } from "@rbxts/react"; + +/** + * A hook that subscribes to changes in the given atom or selector. The + * component is re-rendered with the new state after an update. + * + * If the `dependencies` array is provided, a new effect is created for the + * current `callback` when the dependencies change. Otherwise, the effect is + * created only once, when the component is mounted. + * + * @param callback The atom or selector to watch. + * @param dependencies An array of values used to memoize the callback. + * @returns The current state. + */ +export function useAtom(callback: () => T, dependencies?: any[]): T; + +/** + * A hook that subscribes to changes in the given atom or selector. Returns + * a binding that updates when the state changes. + * + * If the `dependencies` array is provided, a new effect is created for the + * current `callback` when the dependencies change. Otherwise, the effect is + * created only once, when the component is mounted. + * + * @param callback The atom or selector to watch. + * @param dependencies An array of values used to memoize the callback. + * @returns A binding that stores the current state. + */ +export function useAtomBinding(callback: () => T, dependencies?: any[]): Binding; diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 0ddbdd0..02976a2 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -8,21 +8,21 @@ local useState = React.useState local useMemo = React.useMemo --- A hook that subscribes to changes in the given atom or selector. The ---- component is re-rendered whenever the state changes. +--- component is re-rendered with the new state after an update. --- ---- If the `dependencies` array is provided, the effect is re-created whenever ---- the dependencies change. Otherwise, the subscription is created once when ---- the component is mounted. +--- If the `dependencies` array is provided, a new effect is created for the +--- current `callback` when the dependencies change. Otherwise, the effect is +--- created only once, when the component is mounted. --- ---- @param selector The atom or selector to subscribe to. ---- @param dependencies An array of values that the subscription depends on. +--- @param callback The atom or selector to watch. +--- @param dependencies An array of values used to memoize the callback. --- @return The current state. -local function useAtom(selector: () -> State, dependencies: { any }?): State - local state, setState = useState(selector) +local function useAtom(callback: () -> T, dependencies: { any }?): T + local state, setState = useState(callback) useEffect(function() return effect(function() - setState(selector()) + setState(callback()) end) end, dependencies or {}) @@ -32,20 +32,20 @@ end --- A hook that subscribes to changes in the given atom or selector. Returns --- a binding that updates when the state changes. --- ---- If the `dependencies` array is provided, the effect is re-created whenever ---- the dependencies change. Otherwise, the subscription is created once when ---- the component is mounted. +--- If the `dependencies` array is provided, a new effect is created for the +--- current `callback` when the dependencies change. Otherwise, the effect is +--- created only once, when the component is mounted. --- ---- @param selector The atom or selector to subscribe to. ---- @param dependencies An array of values that the subscription depends on. +--- @param callback The atom or selector to watch. +--- @param dependencies An array of values used to memoize the callback. --- @return A binding that stores the current state. -local function useAtomBinding(selector: () -> State, dependencies: { any }?) - local initialState = useMemo(selector, {}) +local function useAtomBinding(callback: () -> T, dependencies: { any }?) + local initialState = useMemo(callback, {}) local binding, setBinding = useBinding(initialState) useEffect(function() return effect(function() - setBinding(selector()) + setBinding(callback()) end) end, dependencies or {}) diff --git a/packages/vide-charm/src/__tests__/spec.luau b/packages/vide-charm/src/__tests__/spec.luau index c445ac5..fddcda9 100644 --- a/packages/vide-charm/src/__tests__/spec.luau +++ b/packages/vide-charm/src/__tests__/spec.luau @@ -1,12 +1,7 @@ local suite = require("../../../../test/suite") -suite.describe("vide-charm", function() - if not game then - return suite.skip() - end - suite.describe("useAtom", function() - require("./useAtom.spec") - end) +suite.describe("useAtom", function() + require("./useAtom.spec") end) return {} diff --git a/packages/vide-charm/src/index.d.ts b/packages/vide-charm/src/index.d.ts index cb0ff5c..f9541ab 100644 --- a/packages/vide-charm/src/index.d.ts +++ b/packages/vide-charm/src/index.d.ts @@ -1 +1,8 @@ -export {}; +/** + * Subscribes to the state of an atom or selector. Returns a source that + * updates when the state changes. + * + * @param callback The atom or selector to subscribe to. + * @returns The reactive source. + */ +export function useAtom(callback: () => T, dependencies?: any[]): () => T; diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index 766e57b..ddbb727 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -8,11 +8,11 @@ local cleanup = Vide.cleanup --- Subscribes to the state of an atom or selector. Returns a source that --- updates when the state changes. --- ---- @param selector The atom or selector to subscribe to. +--- @param callback The atom or selector to subscribe to. --- @return The reactive source. -local function useAtom(selector: () -> State): () -> State - local state = source(selector()) - cleanup(subscribe(selector, state)) +local function useAtom(callback: () -> State): () -> State + local state = source(callback()) + cleanup(subscribe(callback, state)) return state end diff --git a/test/spec.server.luau b/test/spec.server.luau index b33b7b2..46abc3b 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -1,11 +1,22 @@ local Charm = require("../packages/charm/src") local suite = require("./suite") -Charm.strict.enabled = true +Charm.flags.strict = true -require("../packages/charm/src/__tests__/spec") -require("../packages/charm-sync/src/__tests__/spec") -require("../packages/react-charm/src/__tests__/spec") -require("../packages/vide-charm/src/__tests__/spec") +suite.describe("charm", function() + require("../packages/charm/src/__tests__/spec") +end) + +suite.describe.ingame("charm-sync", function() + require("../packages/charm-sync/src/__tests__/spec") +end) + +suite.describe.ingame("react-charm", function() + require("../packages/react-charm/src/__tests__/spec") +end) + +suite.describe.ingame("vide-charm", function() + require("../packages/vide-charm/src/__tests__/spec") +end) suite.finish() diff --git a/test/suite b/test/suite index 2692eb0..eb95781 160000 --- a/test/suite +++ b/test/suite @@ -1 +1 @@ -Subproject commit 2692eb055839cd56d0075ca72482658a5bdaddb9 +Subproject commit eb95781a86e505cb6bff83ca568a67c7295ebb38 From 77c98ca94ae21cdd15daa591c5f2c1bb17f0e098 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 5 Jul 2025 13:38:18 -0700 Subject: [PATCH 038/160] feat: add deep freeze flag, misc refactors --- benches/patch.bench.luau | 4 +- .../charm/src/__tests__/core/atom.spec.luau | 40 +++++++ .../src/__tests__/core/computed.spec.luau | 26 +++- .../charm/src/__tests__/core/gc.spec.luau | 28 ++--- .../src/__tests__/core/issue_48.spec.luau | 113 ------------------ .../src/__tests__/core/untrack.spec.luau | 23 ---- packages/charm/src/__tests__/listen.spec.luau | 17 +++ packages/charm/src/__tests__/peek.spec.luau | 107 ----------------- packages/charm/src/__tests__/spec.luau | 9 +- .../charm/src/__tests__/subscribe.spec.luau | 19 ++- .../charm/src/__tests__/untracked.spec.luau | 17 +-- packages/charm/src/core.luau | 11 ++ packages/charm/src/flags.luau | 10 +- packages/charm/src/index.d.ts | 13 +- packages/charm/src/init.luau | 10 +- packages/charm/src/listen.luau | 4 +- packages/charm/src/peek.luau | 18 --- packages/charm/src/subscribe.luau | 4 +- packages/charm/src/system.luau | 30 +---- packages/charm/src/untracked.luau | 8 +- packages/charm/src/utils.luau | 10 ++ test/spec.server.luau | 1 + 22 files changed, 185 insertions(+), 337 deletions(-) create mode 100644 packages/charm/src/__tests__/core/atom.spec.luau delete mode 100644 packages/charm/src/__tests__/core/issue_48.spec.luau delete mode 100644 packages/charm/src/__tests__/core/untrack.spec.luau delete mode 100644 packages/charm/src/__tests__/peek.spec.luau delete mode 100644 packages/charm/src/peek.luau diff --git a/benches/patch.bench.luau b/benches/patch.bench.luau index a4c5829..dc8f918 100644 --- a/benches/patch.bench.luau +++ b/benches/patch.bench.luau @@ -1,4 +1,6 @@ -local CharmSync = require("../packages/charm-sync/src") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local CharmSync = require(ReplicatedStorage.packages["charm-sync"].src) local patch = CharmSync.patch diff --git a/packages/charm/src/__tests__/core/atom.spec.luau b/packages/charm/src/__tests__/core/atom.spec.luau new file mode 100644 index 0000000..1c8f8d0 --- /dev/null +++ b/packages/charm/src/__tests__/core/atom.spec.luau @@ -0,0 +1,40 @@ +local core = require("../../core") +local suite = require("../../../../../test/suite") + +local atom = core.atom +local effect = core.effect +local test = suite.test + +test("should accept equality function", function() + local calls = {} + local src = atom(0, function(current, incoming) + return current == incoming or incoming % 2 == 1 + end) + + effect(function() + table.insert(calls, src()) + end) + + src(2) + src(3) + assert(src() == 2, "should skip odd value") + + src(4) + src(5) + assert(src() == 4, "should skip odd value") + assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) +end) + +test("should enforce immutability", function() + local src = atom({ nested = {} }) + assert(not pcall(function() + src().nested.a = 1 + end), "should not allow mutation of initial value") + + src({ nested = { a = 1 } }) + assert(not pcall(function() + src().nested.a = 2 + end), "should not allow mutation of new value") +end) + +return {} diff --git a/packages/charm/src/__tests__/core/computed.spec.luau b/packages/charm/src/__tests__/core/computed.spec.luau index c5f37d3..9c6e078 100644 --- a/packages/charm/src/__tests__/core/computed.spec.luau +++ b/packages/charm/src/__tests__/core/computed.spec.luau @@ -1,8 +1,9 @@ local core = require("../../core") local suite = require("../../../../../test/suite") -local computed = core.computed local atom = core.atom +local computed = core.computed +local effect = core.effect local test = suite.test test("should correctly propagate changes through computed core", function() @@ -80,4 +81,27 @@ test("should not update if the atom value is reverted", function() assert(times == 1, "computed ran when it should not") end) +test("should accept equality function", function() + local calls = {} + local src = atom(0) + local c1 = computed(function() + return src() + end, function(current, incoming) + return current == incoming or incoming % 2 == 1 + end) + + effect(function() + table.insert(calls, c1()) + end) + + src(2) + src(3) + assert(c1() == 2, "should skip odd value") + + src(4) + src(5) + assert(c1() == 4, "should skip odd value") + assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) +end) + return {} diff --git a/packages/charm/src/__tests__/core/gc.spec.luau b/packages/charm/src/__tests__/core/gc.spec.luau index 16bf679..d5b9bfc 100644 --- a/packages/charm/src/__tests__/core/gc.spec.luau +++ b/packages/charm/src/__tests__/core/gc.spec.luau @@ -8,15 +8,11 @@ local effectScope = core.effectScope local test = suite.test --- Creates a weak reference to a value. ---- @return Function to dereference the value. -local function weakRef(value: T): () -> T? - local ref = setmetatable({ value = value }, { __mode = "v" }) - return function() - return ref.value - end +local function weakRef(value: T): { value: T? } + return setmetatable({ value = value }, { __mode = "v" }) :: any end ---- Triggers garbage collection by allocating excessive memory. +--- Triggers garbage collection by creating a large number of tables. local function gc() for _ = 1, 1000 do local _ = table.create(100, 0) @@ -24,14 +20,14 @@ local function gc() end --- In alien-signals, a computed value unlinks from dependencies when its last ---- parent scope is disposed. Calling a computed value outside of a scope links ---- it to dependencies, but the dependencies hold strong references to the ---- computed value, so it won't GC until called in a scope and disposed. +--- parent scope is disposed. However, calling a computed value outside of a +--- scope still links it to dependencies, which act as strong references. +--- Computed values called in this way won't GC until unlinked from a scope. --- --- This function releases a computed value by linking it to a scope and then --- immediately closing it, causing alien-signals to unlink the computed value ---- and allow it to be garbage collected. -local function release(getter: () -> ()) +--- from its dependencies and allow it to be garbage collected. +local function unlink(getter: () -> ()) effectScope(function() getter() end)() @@ -39,18 +35,17 @@ end test("should release computed cache", function() local srcRef = weakRef({}) - local src = atom(srcRef()) - + local src = atom(srcRef.value) local c = computed(function() return src() end) c() -- cache src value src(nil) -- release value - c = release(c) -- release computed + c = unlink(c) -- release computed gc() - assert(srcRef() == nil, "should release computed cache") + assert(srcRef.value == nil, "should release computed cache") end) test("should not release effect for atom", function() @@ -61,7 +56,6 @@ test("should not release effect for atom", function() effectCalls += 1 src() end) - assert(effectCalls == 1, "should call effect once") gc() diff --git a/packages/charm/src/__tests__/core/issue_48.spec.luau b/packages/charm/src/__tests__/core/issue_48.spec.luau deleted file mode 100644 index ba67e4e..0000000 --- a/packages/charm/src/__tests__/core/issue_48.spec.luau +++ /dev/null @@ -1,113 +0,0 @@ -local core = require("../../core") -local suite = require("../../../../../test/suite") - -local computed = core.computed -local effect = core.effect -local setCurrentSub = core.setCurrentSub -local atom = core.atom -local test = suite.test - -type ReactionOptions = { - fireImmediately: boolean?, - equals: ((a: T, b: T?) -> boolean)?, - onError: ((error: unknown) -> ())?, - scheduler: ((fn: () -> ()) -> ())?, - once: boolean?, -} - -local function untracked(callback: () -> T): T - local currentSub = setCurrentSub(nil) - local success, result = pcall(callback) - setCurrentSub(currentSub) - 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 = atom(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/packages/charm/src/__tests__/core/untrack.spec.luau b/packages/charm/src/__tests__/core/untrack.spec.luau deleted file mode 100644 index 177c3d7..0000000 --- a/packages/charm/src/__tests__/core/untrack.spec.luau +++ /dev/null @@ -1,23 +0,0 @@ -local core = require("../../core") -local suite = require("../../../../../test/suite") - -local computed = core.computed -local setCurrentSub = core.setCurrentSub -local atom = core.atom -local test = suite.test - -test("should pause tracking", function() - local src = atom(0) - local c = computed(function() - local currentSub = setCurrentSub(nil) - local value = src() - setCurrentSub(currentSub) - return value - end) - assert(c() == 0, "should not track src") - - src(1) - assert(c() == 0, "should not track src") -end) - -return {} diff --git a/packages/charm/src/__tests__/listen.spec.luau b/packages/charm/src/__tests__/listen.spec.luau index 097d2aa..d8c1764 100644 --- a/packages/charm/src/__tests__/listen.spec.luau +++ b/packages/charm/src/__tests__/listen.spec.luau @@ -98,4 +98,21 @@ test("should track inner subs", function() assert(table.concat(order) == "", `bad order: {table.concat(order)}`) end) +test("should skip redundant updates", function() + local order = {} + local a = atom(0) + + listen(function(): "even" | "odd" + return if a() % 2 == 0 then "even" else "odd" + end, function(value) + table.insert(order, value) + end) + + a(1) + a(3) + a(4) + a(6) + assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`) +end) + return {} diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.spec.luau deleted file mode 100644 index 0e12b96..0000000 --- a/packages/charm/src/__tests__/peek.spec.luau +++ /dev/null @@ -1,107 +0,0 @@ -local Charm = require("../") -local suite = require("../../../../test/suite") - -local atom = Charm.atom -local computed = Charm.computed -local effect = Charm.effect -local onCleanup = Charm.onCleanup -local peek = Charm.peek -local test = suite.test - -test("should not run detached", function() - local a = atom(1) - local spyCalls = 0 - - effect(function() - a() - peek(function() - onCleanup(function() - spyCalls += 1 - end) - end) - end) - - assert(spyCalls == 0, "should not be called yet") - a(2) - assert(spyCalls == 1, "should be called once") - a(3) - assert(spyCalls == 2, "should be called twice") -end) - -test("should block tracking inside effects", function() - local a = atom(1) - local b = atom(2) - local spyCalls = 0 - - effect(function() - peek(function() - spyCalls += 1 - return a() + b() - end) - end) - assert(spyCalls == 1, "should be called once") - - a(10) - b(20) - assert(spyCalls == 1, "should be called once") -end) - -test("should block tracking even when run inside effect run inside peek", function() - local s = atom(1) - local spyCalls = 0 - - peek(function() - effect(function() - peek(function() - spyCalls += 1 - return s() - end) - end) - end) - assert(spyCalls == 1, "should be called once") - - s(2) - assert(spyCalls == 1, "should be called once") -end) - -test("should not cause atom assignments throw", function() - local a = atom(1) - local aChangedTime = atom(0) - - local dispose = effect(function() - a() - peek(function() - aChangedTime(aChangedTime() + 1) - end) - end) - - a(2) - assert(aChangedTime() == 2, "aChangedTime should be 2") - a(3) - assert(aChangedTime() == 3, "aChangedTime should be 3") - - dispose() -end) - -test("should block tracking inside computed atoms", function() - local a = atom(1) - local b = atom(2) - local spyCalls = 0 - local c = computed(function() - return peek(function() - spyCalls += 1 - return a() + b() - end) - end) - - assert(spyCalls == 0, "should not be called yet") - assert(c() == 3, "c should be 3") - a(10) - c() - b(20) - c() - assert(spyCalls == 1, "should be called once") - assert(c() == 3, "c should be 3") -end) - -return {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index 4f63237..46bd916 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -1,6 +1,9 @@ local suite = require("../../../../test/suite") suite.describe("core", function() + suite.describe("atom", function() + require("./core/atom.spec") + end) suite.describe("computed", function() require("./core/computed.spec") end) @@ -10,18 +13,12 @@ suite.describe("core", function() suite.describe("effectScope", function() require("./core/effectScope.spec") end) - suite.describe("issue_48", function() - require("./core/issue_48.spec") - end) suite.describe("gc", function() require("./core/gc.spec") end) suite.describe("topology", function() require("./core/topology.spec") end) - suite.describe("untrack", function() - require("./core/untrack.spec") - end) end) suite.describe("listen", function() diff --git a/packages/charm/src/__tests__/subscribe.spec.luau b/packages/charm/src/__tests__/subscribe.spec.luau index d91a952..6d14a4d 100644 --- a/packages/charm/src/__tests__/subscribe.spec.luau +++ b/packages/charm/src/__tests__/subscribe.spec.luau @@ -103,8 +103,6 @@ test("should track inner subs", function() end) end) - assert(table.concat(order) == "", "should not run immediately") - a(a() + 1) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) @@ -118,4 +116,21 @@ test("should track inner subs", function() assert(table.concat(order) == "", `bad order: {table.concat(order)}`) end) +test("should skip redundant updates", function() + local order = {} + local a = atom(0) + + subscribe(function(): "even" | "odd" + return if a() % 2 == 0 then "even" else "odd" + end, function(value) + table.insert(order, value) + end) + + a(1) + a(3) + a(4) + a(6) + assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`) +end) + return {} diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau index 9d56084..7bc7f9f 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -5,27 +5,28 @@ local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect local untracked = Charm.untracked -local onCleanup = Charm.onCleanup local test = suite.test -test("should run detached", function() +test("should not run detached", function() local a = atom(1) local spyCalls = 0 effect(function() a() untracked(function() - onCleanup(function() - spyCalls += 1 - end, true) + effect(function() + return function() + spyCalls += 1 + end + end) end) end) - assert(spyCalls == 0, "should not be called") + assert(spyCalls == 0, "should not be called yet") a(2) - assert(spyCalls == 0, "rerun should not call cleanup") + assert(spyCalls == 1, "should be called once") a(3) - assert(spyCalls == 0, "rerun should not call cleanup") + assert(spyCalls == 2, "should be called twice") end) test("should block tracking inside effects", function() diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 49ff50b..4138aad 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -1,3 +1,7 @@ +-- Adapted from Alien Signals +-- https://github.com/stackblitz/alien-signals +-- https://github.com/Nicell/alien-signals-luau + local config = require("./flags") local system = require("./system") local utils = require("./utils") @@ -8,6 +12,7 @@ export type Link = system.Link local ReactiveFlags = system.ReactiveFlags local createReactiveSystem = system.createReactiveSystem +local deepFreeze = utils.deepFreeze local noYield = utils.noYield local NONE = 0b0000000 @@ -211,6 +216,9 @@ end local function writeAtom(atom: Atom, oldValue: T, newValue: T): T local equals = atom.equals if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then + if config.frozen then + deepFreeze(newValue) + end atom.value = newValue atom.flags = bit32.bor(MUTABLE, DIRTY) local subs = atom.subs @@ -323,6 +331,9 @@ local function unwatched(node: Atom | Computed | Effect | EffectScope) end local function atom(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T + if config.frozen then + deepFreeze(initialValue) + end return atomGetterSetter({ previousValue = initialValue, value = initialValue, diff --git a/packages/charm/src/flags.luau b/packages/charm/src/flags.luau index 961dd6f..b9d43d9 100644 --- a/packages/charm/src/flags.luau +++ b/packages/charm/src/flags.luau @@ -2,7 +2,13 @@ local function inlineTest(): string return debug.info(1, "n") end +local isO2 = inlineTest() ~= "inlineTest" + return { - -- Enable strict mode in O2 - strict = inlineTest() == "inlineTest", + --- Enforces synchronous, non-yielding behavior in atoms and effects. + --- Enabled if the optimize level is below 2, which is the case in studio. + strict = not isO2, + --- Enforces immutability of atoms by deep-freezing table values. Disabled + --- by default, but can be enabled to detect accidental mutations. + frozen = false, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 3193856..6246761 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -12,6 +12,7 @@ export type Equals = (current: T, incoming: T) => boolean; export const flags: { strict: boolean; + frozen: boolean; }; export function atom(value: T, equals?: Equals): Atom; @@ -63,6 +64,14 @@ export function batched( ...args: Args ): (...args: Args) => Result; -export function peek(callback: (...args: Args) => Result, ...args: Args): Result; - export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; + +/** + * @deprecated Use `batched()` instead. + */ +export const batch: typeof batched; + +/** + * @deprecated Use `untracked()` instead. + */ +export const peek: typeof untracked; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 7897bd9..b388e5d 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -4,7 +4,6 @@ local flags = require("@self/flags") local listen = require("@self/listen") local mapped = require("@self/mapped") local observe = require("@self/observe") -local peek = require("@self/peek") local subscribe = require("@self/subscribe") local types = require("@self/types") local untracked = require("@self/untracked") @@ -12,6 +11,11 @@ local untracked = require("@self/untracked") export type Atom = types.Atom export type Selector = types.Selector +--- @deprecated Function `batch()` is deprecated, use `batched()` instead. +local batch = batched +--- @deprecated Function `peek()` is deprecated, use `untracked()` instead. +local peek = untracked + return { core = core, flags = flags, @@ -24,7 +28,9 @@ return { listen = listen, mapped = mapped, observe = observe, - peek = peek, subscribe = subscribe, untracked = untracked, + -- Temporary aliases for backwards compatibility + batch = batch, + peek = peek, } diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index ed15d77..865e704 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -1,5 +1,5 @@ local core = require("./core") -local peek = require("./peek") +local untracked = require("./untracked") local computed = core.computed local effect = core.effect @@ -19,7 +19,7 @@ local function listen(selector: () -> T, listener: (state: T, previous: T?, d dispose = effect(function() local previous = current current = tracked() - peek(listener, current, previous, dispose) + untracked(listener, current, previous, dispose) end) if disposed then diff --git a/packages/charm/src/peek.luau b/packages/charm/src/peek.luau deleted file mode 100644 index a8bc08f..0000000 --- a/packages/charm/src/peek.luau +++ /dev/null @@ -1,18 +0,0 @@ -local core = require("./core") - -local setCurrentTracking = core.setCurrentTracking - -local function handlePeek(prevTracking: boolean, ok: boolean, ...) - setCurrentTracking(prevTracking) - if ok then - return ... - else - error((...)) - end -end - -local function peek(callback: (Args...) -> Results..., ...: Args...): Results... - return handlePeek(setCurrentTracking(false), pcall(callback, ...)) -end - -return peek diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index bd2b8c1..1ceca66 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -1,5 +1,5 @@ local core = require("./core") -local peek = require("./peek") +local untracked = require("./untracked") local computed = core.computed local effect = core.effect @@ -18,7 +18,7 @@ local function subscribe(selector: () -> T, listener: (state: T, lastState: T if mounted then mounted = false else - peek(listener, current, previous) + untracked(listener, current, previous) end end) end diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index d3d5692..dbd5e2c 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -1,30 +1,6 @@ ---[[ - 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. -]] +-- Adapted from Alien Signals +-- https://github.com/stackblitz/alien-signals +-- https://github.com/Nicell/alien-signals-luau export type ReactiveNode = { deps: Link?, diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau index 80c2671..b766aae 100644 --- a/packages/charm/src/untracked.luau +++ b/packages/charm/src/untracked.luau @@ -1,9 +1,9 @@ local core = require("./core") -local setCurrentSub = core.setCurrentSub +local setCurrentTracking = core.setCurrentTracking -local function handleUntracked(prevSub: core.ReactiveNode?, ok: boolean, ...) - setCurrentSub(prevSub) +local function handleUntracked(prevTracking: boolean, ok: boolean, ...) + setCurrentTracking(prevTracking) if ok then return ... else @@ -12,7 +12,7 @@ local function handleUntracked(prevSub: core.ReactiveNode?, ok: boolean, ...) end local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - return handleUntracked(setCurrentSub(nil), pcall(callback, ...)) + return handleUntracked(setCurrentTracking(false), pcall(callback, ...)) end return untracked diff --git a/packages/charm/src/utils.luau b/packages/charm/src/utils.luau index 536e086..ce71ef5 100644 --- a/packages/charm/src/utils.luau +++ b/packages/charm/src/utils.luau @@ -13,6 +13,16 @@ local function noYield(callback: (Args...) -> Results..., . return handleNoYield(thread, coroutine.resume(thread, ...)) end +local function deepFreeze(object: any) + if type(object) == "table" and not table.isfrozen(object) then + table.freeze(object) + for _, value in object do + deepFreeze(value) + end + end +end + return { noYield = noYield :: typeof(pcall), + deepFreeze = deepFreeze, } diff --git a/test/spec.server.luau b/test/spec.server.luau index 46abc3b..ab0f072 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -2,6 +2,7 @@ local Charm = require("../packages/charm/src") local suite = require("./suite") Charm.flags.strict = true +Charm.flags.frozen = true suite.describe("charm", function() require("../packages/charm/src/__tests__/spec") From a8051257e5f515ea65ea18bf8ce89ba2600bde93 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 20 Jul 2025 20:52:17 -0700 Subject: [PATCH 039/160] refactor: reduce type casting, housekeeping --- packages/charm/src/core.luau | 84 +++++++++++++++---------------- packages/charm/src/flags.luau | 9 ++-- packages/charm/src/index.d.ts | 4 +- packages/charm/src/mapped.luau | 4 ++ packages/charm/src/untracked.luau | 8 +-- 5 files changed, 57 insertions(+), 52 deletions(-) diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 4138aad..403e101 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -52,12 +52,14 @@ local startTracking: (sub: ReactiveNode) -> () local shallowPropagate: (link: Link) -> () local batchDepth = 0 -local queuedEffects: { Effect | EffectScope? } = {} + +local queuedEffects: { Effect } = {} local queuedEffectsLength = 0 local notifyIndex = 0 -local activeSubTracking = true -local activeSub: ReactiveNode? + local activeScope: Effect | EffectScope? +local activeSub: ReactiveNode? +local activeSubWatching = true local function getCurrentSub(): ReactiveNode? return activeSub @@ -79,16 +81,16 @@ local function setCurrentScope(scope: Effect | EffectScope?) return prevScope end -local function setCurrentTracking(tracking: boolean): boolean - local prevSubTracking = activeSubTracking - activeSubTracking = tracking - return prevSubTracking +local function setCurrentWatching(watching: boolean): boolean + local prevSubWatching = activeSubWatching + activeSubWatching = watching + return prevSubWatching end local function runCleanups(effect: EffectScope) for _, cleanup in effect.cleanups do local prev = setCurrentSub(nil) - local success, result: unknown = (if config.strict then noYield else pcall)(cleanup) + local success, result: any = (config.strict and noYield or pcall)(cleanup) setCurrentSub(prev) if not success then error(result) @@ -98,7 +100,7 @@ local function runCleanups(effect: EffectScope) end local function onCleanup(cleanup: () -> (), failSilently: boolean?) - if activeSub then + if activeSub and (activeSub :: Effect).cleanups then table.insert((activeSub :: Effect).cleanups, cleanup) elseif activeScope then table.insert(activeScope.cleanups, cleanup) @@ -107,16 +109,16 @@ local function onCleanup(cleanup: () -> (), failSilently: boolean?) end end -local function run(effect: Effect | EffectScope, flags: ReactiveFlags): () +local function run(effect: Effect, flags: ReactiveFlags): () if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then runCleanups(effect) local prev = setCurrentSub(effect) - local prevTracking = setCurrentTracking(true) + local prevTracking = setCurrentWatching(true) startTracking(effect) - local success, result: (() -> ())? = (if config.strict then noYield else pcall)((effect :: Effect).fn) + local success, result: (() -> ())? = (config.strict and noYield or pcall)(effect.fn) setCurrentSub(prev) endTracking(effect) - setCurrentTracking(prevTracking) + setCurrentWatching(prevTracking) if not success then error(result) elseif result then @@ -124,7 +126,7 @@ local function run(effect: Effect | EffectScope, flags: ReactiveFlags): () end return elseif bit32.btest(flags, PENDING) then - (effect :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING)) + effect.flags = bit32.band(flags, bit32.bnot(PENDING)) end local link = effect.deps while link do @@ -142,7 +144,7 @@ end local function flush(): () while notifyIndex < queuedEffectsLength do notifyIndex += 1 - local effect = queuedEffects[notifyIndex] :: Effect + local effect = queuedEffects[notifyIndex] queuedEffects[notifyIndex] = nil effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED)) run(effect, effect.flags) @@ -168,13 +170,13 @@ end local function updateComputed(computed: Computed): boolean local prevSub = setCurrentSub(computed) - local prevTracking = setCurrentTracking(true) + local prevWatching = setCurrentWatching(true) startTracking(computed) local oldValue = computed.value - local success, result = (if config.strict then noYield else pcall)(computed.getter, oldValue) + local success, result = (config.strict and noYield or pcall)(computed.getter, oldValue) setCurrentSub(prevSub) endTracking(computed) - setCurrentTracking(prevTracking) + setCurrentWatching(prevWatching) if not success then error(result) end @@ -207,7 +209,7 @@ local function readAtom(atom: Atom): T end end end - if activeSub and activeSubTracking then + if activeSub and activeSubWatching then link(atom, activeSub) end return value @@ -215,7 +217,7 @@ end local function writeAtom(atom: Atom, oldValue: T, newValue: T): T local equals = atom.equals - if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then + if if equals then not equals(oldValue, newValue) else oldValue ~= newValue then if config.frozen then deepFreeze(newValue) end @@ -242,7 +244,7 @@ local function atomGetterSetter(atom: Atom): (...((T) -> T) | T) -> T local newValue = ... if type(newValue) == "function" then local prev = setCurrentSub(nil) - local success, result = (if config.strict then noYield else pcall)(newValue, oldValue) + local success, result = (config.strict and noYield or pcall)(newValue, oldValue) setCurrentSub(prev) if not success then error(result) @@ -268,10 +270,8 @@ local function computedGetter(computed: Computed): () -> T elseif bit32.btest(flags, PENDING) then computed.flags = bit32.band(flags, bit32.bnot(PENDING)) end - if activeSub then - if activeSubTracking then - link(computed, activeSub) - end + if activeSub and activeSubWatching then + link(computed, activeSub) elseif activeScope then link(computed, activeScope) end @@ -279,7 +279,7 @@ local function computedGetter(computed: Computed): () -> T end end -local function effectStop(self: Effect | EffectScope): () -> () +local function effectStop(self: EffectScope): () -> () return function() local dep = self.deps while dep do @@ -289,38 +289,38 @@ local function effectStop(self: Effect | EffectScope): () -> () if sub then unlink(sub) end - (self :: Effect).flags = NONE + self.flags = NONE runCleanups(self) end end -local function update(atom: Atom | Computed): boolean +local function update(atom: ReactiveNode): boolean if (atom :: Computed).getter then return updateComputed(atom :: Computed) else - return updateAtom(atom :: Atom, atom.value) + return updateAtom(atom :: Atom, (atom :: Atom).value) end end -local function notify(effect: Effect | EffectScope) +local function notify(effect: ReactiveNode) local flags = effect.flags if not bit32.btest(flags, QUEUED) then - (effect :: Effect).flags = bit32.bor(flags, QUEUED) + effect.flags = bit32.bor(flags, QUEUED) local subs = effect.subs if subs then notify(subs.sub :: Effect) else queuedEffectsLength += 1 - queuedEffects[queuedEffectsLength] = effect + queuedEffects[queuedEffectsLength] = effect :: Effect end end end -local function unwatched(node: Atom | Computed | Effect | EffectScope) +local function unwatched(node: ReactiveNode) if (node :: Computed).getter then local toRemove = node.deps if toRemove then - (node :: Computed).flags = bit32.bor(MUTABLE, DIRTY) + node.flags = bit32.bor(MUTABLE, DIRTY) repeat toRemove = unlink(toRemove, node) until not toRemove @@ -362,10 +362,10 @@ local function effect(fn: () -> ...(() -> ())?): () -> () link(self, activeScope) end local prev = setCurrentSub(self) - local prevTracking = setCurrentTracking(true) - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) + local prevWatching = setCurrentWatching(true) + local success, result: (() -> ())? = (config.strict and noYield or pcall)(fn) setCurrentSub(prev) - setCurrentTracking(prevTracking) + setCurrentWatching(prevWatching) if not success then error(result) elseif result then @@ -384,7 +384,7 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> end local prevSub = setCurrentSub(nil) local prevScope = setCurrentScope(self) - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) + local success, result: (() -> ())? = (config.strict and noYield or pcall)(fn) setCurrentScope(prevScope) setCurrentSub(prevSub) if not success then @@ -396,9 +396,9 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> end local reactiveSystem = createReactiveSystem({ - update = update :: (ReactiveNode) -> boolean, - notify = notify :: (ReactiveNode) -> (), - unwatched = unwatched :: (ReactiveNode) -> (), + update = update, + notify = notify, + unwatched = unwatched, }) link = reactiveSystem.link @@ -416,7 +416,7 @@ return { setCurrentSub = setCurrentSub, getCurrentScope = getCurrentScope, setCurrentScope = setCurrentScope, - setCurrentTracking = setCurrentTracking, + setCurrentWatching = setCurrentWatching, onCleanup = onCleanup, getBatchDepth = getBatchDepth, startBatch = startBatch, diff --git a/packages/charm/src/flags.luau b/packages/charm/src/flags.luau index b9d43d9..3900394 100644 --- a/packages/charm/src/flags.luau +++ b/packages/charm/src/flags.luau @@ -6,9 +6,10 @@ local isO2 = inlineTest() ~= "inlineTest" return { --- Enforces synchronous, non-yielding behavior in atoms and effects. - --- Enabled if the optimize level is below 2, which is the case in studio. + --- Also enables state validation in Charm Sync to catch sync errors early. + --- Enabled if the optimization level is below 2, which is true in studio. strict = not isO2, - --- Enforces immutability of atoms by deep-freezing table values. Disabled - --- by default, but can be enabled to detect accidental mutations. - frozen = false, + --- Enforces immutability of atoms by deep-freezing table values. Enabled + --- if the optimization level is below 2, which is true in studio. + frozen = not isO2, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 6246761..ab8f02e 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -57,8 +57,6 @@ export function observe( observer: (value: V, key: K) => Cleanup | void, ): Cleanup; -export function onCleanup(callback: Cleanup, failSilently?: boolean): void; - export function batched( callback: (...args: Args) => Result, ...args: Args @@ -66,6 +64,8 @@ export function batched( export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; +export function onCleanup(callback: Cleanup, failSilently?: boolean): void; + /** * @deprecated Use `batched()` instead. */ diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index f6289be..2c213fc 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -32,7 +32,9 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite if currentData[key] ~= nil then continue end + local cachedKey = keyCache[key] + if cachedKey then currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput currentOutput[cachedKey] = nil @@ -44,7 +46,9 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite if value == previousData[key] then continue end + local valueOut, keyOut = map(value, key) + if currentOutput[keyOut] ~= valueOut then currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput currentOutput[keyOut] = valueOut diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau index b766aae..3c5121f 100644 --- a/packages/charm/src/untracked.luau +++ b/packages/charm/src/untracked.luau @@ -1,9 +1,9 @@ local core = require("./core") -local setCurrentTracking = core.setCurrentTracking +local setCurrentWatching = core.setCurrentWatching -local function handleUntracked(prevTracking: boolean, ok: boolean, ...) - setCurrentTracking(prevTracking) +local function handleUntracked(prevWatching: boolean, ok: boolean, ...) + setCurrentWatching(prevWatching) if ok then return ... else @@ -12,7 +12,7 @@ local function handleUntracked(prevTracking: boolean, ok: boolean, ...) end local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - return handleUntracked(setCurrentTracking(false), pcall(callback, ...)) + return handleUntracked(setCurrentWatching(false), pcall(callback, ...)) end return untracked From d0b7cff9436bf8c45c86f585a34775213b78de8e Mon Sep 17 00:00:00 2001 From: alihsaas <40891677+alihsaas@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:35:39 +0300 Subject: [PATCH 040/160] fix: add cleanup parameter to effect type (#52) --- packages/charm/src/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index da1c5fa..8336177 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -86,7 +86,7 @@ declare namespace Charm { * @param callback The function to run. * @returns A function that unsubscribes the callback. */ - function effect(callback: () => Cleanup | void): Cleanup; + function effect(callback: (cleanup: Cleanup) => Cleanup | void): Cleanup; /** * Returns the result of the function without subscribing to changes. If a From cb52ece4d3f4e1a3f15b72142c117be717b9e116 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:50:39 -0700 Subject: [PATCH 041/160] refactor: rewrite untracked, update alien-signals --- benches/propagate.luau | 20 +++ package.json | 2 +- packages/charm-sync/src/types.luau | 16 +- .../charm/src/__tests__/core/effect.spec.luau | 21 +++ packages/charm/src/__tests__/listen.spec.luau | 21 +++ .../charm/src/__tests__/observe.spec.luau | 32 ++++ .../charm/src/__tests__/untracked.spec.luau | 22 +++ packages/charm/src/batched.luau | 5 +- packages/charm/src/core.luau | 40 ++--- packages/charm/src/init.luau | 2 + packages/charm/src/observe.luau | 6 +- packages/charm/src/system.luau | 137 +++++++++--------- packages/charm/src/untracked.luau | 17 ++- rokit.toml | 10 +- selene.toml | 2 +- 15 files changed, 239 insertions(+), 114 deletions(-) diff --git a/benches/propagate.luau b/benches/propagate.luau index 9d40d9b..7473b6d 100644 --- a/benches/propagate.luau +++ b/benches/propagate.luau @@ -4,6 +4,7 @@ local bench = require("./bench") local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect +local listen = Charm.listen for _, w in { 10, 100 } do for _, h in { 10, 100 } do @@ -25,3 +26,22 @@ for _, w in { 10, 100 } do end) end end + +for _, w in { 10, 100 } do + for _, h in { 10, 100 } do + bench.run(`propagate (listen) {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 + listen(last, function() end) + end + src(src() + 1) + end) + end +end diff --git a/package.json b/package.json index 49c9f01..2646252 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.12.4", + "packageManager": "pnpm@10.17.1", "devDependencies": { "@rbxts-js/react": "17.1.20-ts.1", "@rbxts-js/react-roblox": "17.1.20-ts.1", diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 791707f..3ccd746 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -4,13 +4,15 @@ type Atom = Charm.Atom --- A payload that can be sent from the server to the client to synchronize --- state between the two. -export type SyncPayload = { - type: "init", - data: { [string | number]: any }, -} | { - type: "patch", - data: { [string | number]: any }, -} +export type SyncPayload = + | { + type: "init", + data: { [string | number]: any }, + } + | { + type: "patch", + data: { [string | number]: any }, + } export type AtomMap = { [string | number]: Atom, diff --git a/packages/charm/src/__tests__/core/effect.spec.luau b/packages/charm/src/__tests__/core/effect.spec.luau index e02512a..ab3db2f 100644 --- a/packages/charm/src/__tests__/core/effect.spec.luau +++ b/packages/charm/src/__tests__/core/effect.spec.luau @@ -356,4 +356,25 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) +test("should allow self cleanup", function() + local calls = 0 + local cleanups = 0 + local a = atom(0) + + effect(function(stopEffect) + onCleanup(function() + cleanups += 1 + end) + stopEffect() + a() + calls += 1 + end) + + assert(calls == 1, "should call effect once") + assert(cleanups == 1, "should call cleanup once") + a(1) + assert(calls == 1, "should not call effect after a update") + assert(cleanups == 1, "should not call cleanup after a update") +end) + return {} diff --git a/packages/charm/src/__tests__/listen.spec.luau b/packages/charm/src/__tests__/listen.spec.luau index d8c1764..d73bf23 100644 --- a/packages/charm/src/__tests__/listen.spec.luau +++ b/packages/charm/src/__tests__/listen.spec.luau @@ -4,6 +4,7 @@ local suite = require("../../../../test/suite") local atom = Charm.atom local listen = Charm.listen local effect = Charm.effect +local onCleanup = Charm.onCleanup local test = suite.test test("should listen to selector", function() @@ -115,4 +116,24 @@ test("should skip redundant updates", function() assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`) end) +test("should allow self cleanup", function() + local calls = 0 + local cleanups = 0 + local a = atom(0) + + listen(a, function(_, _, stopEffect) + onCleanup(function() + cleanups += 1 + end) + stopEffect() + calls += 1 + end) + + assert(calls == 1, "should call effect once") + assert(cleanups == 1, "should call cleanup once") + a(1) + assert(calls == 1, "should not call effect after a update") + assert(cleanups == 1, "should not call cleanup after a update") +end) + return {} diff --git a/packages/charm/src/__tests__/observe.spec.luau b/packages/charm/src/__tests__/observe.spec.luau index ed392f4..ed2bdda 100644 --- a/packages/charm/src/__tests__/observe.spec.luau +++ b/packages/charm/src/__tests__/observe.spec.luau @@ -61,4 +61,36 @@ test("should dispose scopes", function() assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`) end) +test("should queue addition", function() + local items = atom({ 1 }) + local edits = {} + + observe(items, function(item) + items({ 1, 2 }) + items({ 1, 2, 3, 4 }) + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4", `bad edits: {table.concat(edits)}`) +end) + +test("should queue removal", function() + local items = atom({ 1, 2, 3, 4 }) + local edits = {} + + observe(items, function(item) + items({ 1, 2, 3 }) + items({ 1 }) + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`) +end) + return {} diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau index 7bc7f9f..6d7fb55 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -105,4 +105,26 @@ test("should block tracking inside computed atoms", function() assert(c() == 3, "c should be 3") end) +test("should track outside untracked", function() + local a = atom(1) + local b = atom(2) + local c = atom(3) + local spyCalls = 0 + + effect(function() + a() + untracked(c) + b() + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + a(10) + assert(spyCalls == 2, "should be called twice") + b(20) + assert(spyCalls == 3, "should be called three times") + c(30) + assert(spyCalls == 3, "should not run on c change") +end) + return {} diff --git a/packages/charm/src/batched.luau b/packages/charm/src/batched.luau index 685365a..29a3d28 100644 --- a/packages/charm/src/batched.luau +++ b/packages/charm/src/batched.luau @@ -1,7 +1,10 @@ local core = require("./core") +local flags = require("./flags") +local utils = require("./utils") local startBatch = core.startBatch local endBatch = core.endBatch +local noYield = utils.noYield local function handleBatched(ok: boolean, ...) endBatch() @@ -14,7 +17,7 @@ end local function batched(callback: (Args...) -> Results..., ...: Args...): Results... startBatch() - return handleBatched(pcall(callback, ...)) + return handleBatched((if flags.strict then noYield else pcall)(callback, ...)) end return batched diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 403e101..81296cc 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -21,14 +21,16 @@ local WATCHING = 0b0000010 local DIRTY = 0b0010000 local PENDING = 0b0100000 local QUEUED = 0b1000000 +local UNTRACKED = 0b10000000 type EffectScope = ReactiveNode & { cleanups: { () -> () }, } type Effect = ReactiveNode & { - fn: () -> ...(() -> ())?, + fn: (dispose: () -> ()) -> ...(() -> ())?, cleanups: { () -> () }, + cleanup: () -> (), } type Computed = ReactiveNode & { @@ -59,7 +61,6 @@ local notifyIndex = 0 local activeScope: Effect | EffectScope? local activeSub: ReactiveNode? -local activeSubWatching = true local function getCurrentSub(): ReactiveNode? return activeSub @@ -81,16 +82,10 @@ local function setCurrentScope(scope: Effect | EffectScope?) return prevScope end -local function setCurrentWatching(watching: boolean): boolean - local prevSubWatching = activeSubWatching - activeSubWatching = watching - return prevSubWatching -end - local function runCleanups(effect: EffectScope) for _, cleanup in effect.cleanups do local prev = setCurrentSub(nil) - local success, result: any = (config.strict and noYield or pcall)(cleanup) + local success, result: any = (if config.strict then noYield else pcall)(cleanup) setCurrentSub(prev) if not success then error(result) @@ -113,12 +108,10 @@ local function run(effect: Effect, flags: ReactiveFlags): () if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then runCleanups(effect) local prev = setCurrentSub(effect) - local prevTracking = setCurrentWatching(true) startTracking(effect) - local success, result: (() -> ())? = (config.strict and noYield or pcall)(effect.fn) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)(effect.fn, effect.cleanup) setCurrentSub(prev) endTracking(effect) - setCurrentWatching(prevTracking) if not success then error(result) elseif result then @@ -170,13 +163,11 @@ end local function updateComputed(computed: Computed): boolean local prevSub = setCurrentSub(computed) - local prevWatching = setCurrentWatching(true) startTracking(computed) local oldValue = computed.value - local success, result = (config.strict and noYield or pcall)(computed.getter, oldValue) + local success, result = (if config.strict then noYield else pcall)(computed.getter, oldValue) setCurrentSub(prevSub) endTracking(computed) - setCurrentWatching(prevWatching) if not success then error(result) end @@ -209,7 +200,7 @@ local function readAtom(atom: Atom): T end end end - if activeSub and activeSubWatching then + if activeSub and not bit32.btest(activeSub.flags, UNTRACKED) then link(atom, activeSub) end return value @@ -244,7 +235,7 @@ local function atomGetterSetter(atom: Atom): (...((T) -> T) | T) -> T local newValue = ... if type(newValue) == "function" then local prev = setCurrentSub(nil) - local success, result = (config.strict and noYield or pcall)(newValue, oldValue) + local success, result = (if config.strict then noYield else pcall)(newValue, oldValue) setCurrentSub(prev) if not success then error(result) @@ -270,7 +261,7 @@ local function computedGetter(computed: Computed): () -> T elseif bit32.btest(flags, PENDING) then computed.flags = bit32.band(flags, bit32.bnot(PENDING)) end - if activeSub and activeSubWatching then + if activeSub and not bit32.btest(activeSub.flags, UNTRACKED) then link(computed, activeSub) elseif activeScope then link(computed, activeScope) @@ -350,28 +341,28 @@ local function computed(getter: (previousValue: T?) -> T, equals: ((T, T) -> }) end -local function effect(fn: () -> ...(() -> ())?): () -> () +local function effect(fn: (dispose: () -> ()) -> ...(() -> ())?): () -> () local self: Effect = { fn = fn, flags = WATCHING, cleanups = {}, + cleanup = nil :: any, } + self.cleanup = effectStop(self) if activeSub then link(self, activeSub) elseif activeScope then link(self, activeScope) end local prev = setCurrentSub(self) - local prevWatching = setCurrentWatching(true) - local success, result: (() -> ())? = (config.strict and noYield or pcall)(fn) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn, self.cleanup) setCurrentSub(prev) - setCurrentWatching(prevWatching) if not success then error(result) elseif result then table.insert(self.cleanups, result) end - return effectStop(self) + return self.cleanup end local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () @@ -384,7 +375,7 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> end local prevSub = setCurrentSub(nil) local prevScope = setCurrentScope(self) - local success, result: (() -> ())? = (config.strict and noYield or pcall)(fn) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) setCurrentScope(prevScope) setCurrentSub(prevSub) if not success then @@ -416,7 +407,6 @@ return { setCurrentSub = setCurrentSub, getCurrentScope = getCurrentScope, setCurrentScope = setCurrentScope, - setCurrentWatching = setCurrentWatching, onCleanup = onCleanup, getBatchDepth = getBatchDepth, startBatch = startBatch, diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index b388e5d..36236b2 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,3 +1,5 @@ +--!nolint DeprecatedApi + local batched = require("@self/batched") local core = require("@self/core") local flags = require("@self/flags") diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index 7272584..219ae72 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -3,6 +3,8 @@ local core = require("./core") local effect = core.effect local effectScope = core.effectScope local onCleanup = core.onCleanup +local startBatch = core.startBatch +local endBatch = core.endBatch local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () local scopes: { [K]: () -> () } = {} @@ -18,7 +20,7 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key end for key, value in currentItems do - if previousItems[key] == nil then + if previousItems[key] == nil and scopes[key] == nil then scopes[key] = effectScope(function() return observer(value, key) end, true) @@ -37,7 +39,9 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key local previousItems = currentItems currentItems = selector() if currentItems ~= previousItems then + startBatch() updateScopes(currentItems, previousItems) + endBatch() end end) diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index dbd5e2c..ee80b9b 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -11,6 +11,7 @@ export type ReactiveNode = { } export type Link = { + version: number, dep: ReactiveNode, sub: ReactiveNode, prevSub: Link?, @@ -26,13 +27,14 @@ type 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 NONE = 0b00000000 +local MUTABLE = 0b00000001 +local WATCHING = 0b00000010 +local RECURSED_CHECK = 0b00000100 +local RECURSED = 0b00001000 +local DIRTY = 0b00010000 +local PENDING = 0b00100000 +local UNTRACKED = 0b10000000 local ReactiveFlags = { None = NONE, @@ -42,6 +44,7 @@ local ReactiveFlags = { Recursed = RECURSED, Dirty = DIRTY, Pending = PENDING, + Untracked = UNTRACKED, } local function createReactiveSystem(props: { @@ -52,20 +55,15 @@ local function createReactiveSystem(props: { local update = props.update local notify = props.notify local unwatched = props.unwatched + local currentVersion = 0 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 + local link = sub.depsTail + while link do + if link == checkLink then + return true + end + link = link.prevDep end return false end @@ -75,20 +73,18 @@ local function createReactiveSystem(props: { if prevDep and prevDep.dep == dep then return end - local nextDep: Link? - local recursedCheck = bit32.btest(sub.flags, RECURSED_CHECK) - if recursedCheck then - nextDep = if prevDep then prevDep.nextDep else sub.deps - if nextDep and nextDep.dep == dep then - sub.depsTail = nextDep - return - end + local nextDep = if prevDep then prevDep.nextDep else sub.deps + if nextDep and nextDep.dep == dep then + nextDep.version = currentVersion + sub.depsTail = nextDep + return end local prevSub = dep.subsTail - if prevSub and prevSub.sub == sub and (not recursedCheck or isValidLink(prevSub, sub)) then + if prevSub and prevSub.version == currentVersion and prevSub.sub == sub then return end local newLink: Link = { + version = currentVersion, dep = dep, sub = sub, prevDep = prevDep, @@ -154,35 +150,34 @@ local function createReactiveSystem(props: { local flags = sub.flags - if bit32.btest(flags, bit32.bor(MUTABLE, WATCHING)) then - if not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) then - sub.flags = bit32.bor(flags, PENDING) - elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then - flags = NONE - elseif not bit32.btest(flags, RECURSED_CHECK) then - sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) - elseif not bit32.btest(flags, bit32.bor(DIRTY, PENDING)) 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 not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) then + sub.flags = bit32.bor(flags, PENDING) + elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then + flags = NONE + elseif not bit32.btest(flags, RECURSED_CHECK) then + sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) + elseif not bit32.btest(flags, bit32.bor(DIRTY, PENDING)) 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.btest(flags, WATCHING) then - notify(sub) - end + if bit32.btest(flags, WATCHING) then + notify(sub) + end - if bit32.btest(flags, MUTABLE) then - local subSubs = sub.subs - if subSubs then - link = subSubs - if subSubs.nextSub then - stack = { value = next, prev = stack } - next = link.nextSub - end - continue + if bit32.btest(flags, MUTABLE) then + local subSubs = sub.subs + if subSubs then + link = subSubs + local nextSub = link.nextSub + if nextSub then + stack = { value = next, prev = stack } + next = nextSub end + continue end end @@ -214,6 +209,7 @@ local function createReactiveSystem(props: { end local function startTracking(sub: ReactiveNode): () + currentVersion += 1 sub.depsTail = nil sub.flags = bit32.bor(bit32.band(sub.flags, bit32.bnot(bit32.bor(RECURSED, DIRTY, PENDING))), RECURSED_CHECK) end @@ -230,10 +226,10 @@ local function createReactiveSystem(props: { 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.btest(subFlags, WATCHING) then + local flags = sub.flags + if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then + sub.flags = bit32.bor(flags, DIRTY) + if bit32.btest(flags, WATCHING) then notify(sub) end end @@ -244,16 +240,15 @@ local function createReactiveSystem(props: { local function checkDirty(link: Link, sub: ReactiveNode): boolean local stack: Stack? local checkDepth = 0 + local dirty = false repeat local dep = link.dep - local depFlags = dep.flags - - local dirty = false + local flags = dep.flags if bit32.btest(sub.flags, DIRTY) then dirty = true - elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then + elseif bit32.band(flags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then if update(dep) then local subs = dep.subs :: Link if subs.nextSub then @@ -261,7 +256,7 @@ local function createReactiveSystem(props: { end dirty = true end - elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then + elseif bit32.band(flags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then if link.nextSub or link.prevSub then stack = { value = link, prev = stack } end @@ -271,9 +266,12 @@ local function createReactiveSystem(props: { continue end - if not dirty and link.nextDep then - link = link.nextDep - continue + if not dirty then + local nextDep = link.nextDep + if nextDep then + link = nextDep + continue + end end local continueTop = false @@ -296,16 +294,17 @@ local function createReactiveSystem(props: { sub = link.sub continue end + dirty = false else sub.flags = bit32.band(sub.flags, bit32.bnot(PENDING)) end sub = link.sub - if link.nextDep then - link = link.nextDep + local nextDep = link.nextDep + if nextDep then + link = nextDep continueTop = true break end - dirty = false end if continueTop then diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau index 3c5121f..9661fa3 100644 --- a/packages/charm/src/untracked.luau +++ b/packages/charm/src/untracked.luau @@ -1,9 +1,12 @@ local core = require("./core") -local setCurrentWatching = core.setCurrentWatching +local UNTRACKED = core.ReactiveFlags.Untracked +local getCurrentSub = core.getCurrentSub -local function handleUntracked(prevWatching: boolean, ok: boolean, ...) - setCurrentWatching(prevWatching) +local function handleUntracked(sub: core.ReactiveNode?, ok: boolean, ...) + if sub then + sub.flags = bit32.band(sub.flags, bit32.bnot(UNTRACKED)) + end if ok then return ... else @@ -12,7 +15,13 @@ local function handleUntracked(prevWatching: boolean, ok: boolean, ...) end local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - return handleUntracked(setCurrentWatching(false), pcall(callback, ...)) + local sub = getCurrentSub() + if not sub or bit32.btest(sub.flags, UNTRACKED) then + return callback(...) + else + sub.flags = bit32.bor(sub.flags, UNTRACKED) + end + return handleUntracked(sub, pcall(callback, ...)) end return untracked diff --git a/rokit.toml b/rokit.toml index 23db8e9..1b1c5d7 100644 --- a/rokit.toml +++ b/rokit.toml @@ -6,8 +6,8 @@ [tools] wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.5.1" -stylua = "johnnymorganz/stylua@2.1.0" -selene = "Kampfkarren/selene@0.28.0" -luau-lsp = "johnnymorganz/luau-lsp@1.51.0" -lune = "lune-org/lune@0.9.4" -lute = "luau-lang/lute@0.1.0-nightly.20250701" +stylua = "johnnymorganz/stylua@2.2.0" +selene = "Kampfkarren/selene@0.29.0" +luau-lsp = "johnnymorganz/luau-lsp@1.53.5" +lune = "lune-org/lune@0.10.2" +lute = "luau-lang/lute@0.1.0-nightly.20250923" diff --git a/selene.toml b/selene.toml index 7da7261..1b04ccf 100644 --- a/selene.toml +++ b/selene.toml @@ -1,5 +1,5 @@ std = "selene_defs" -exclude = ["**/node_modules/**"] +exclude = ["*/node_modules/*"] [lints] global_usage = "allow" From 4a07297a13369fbe84d135e17dcc27c729bc1bd4 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:17:08 -0700 Subject: [PATCH 042/160] chore: update eslint, housekeeping --- .eslintrc | 28 - eslint.config.ts | 20 + package.json | 25 +- packages/charm/src/index.d.ts | 14 +- packages/charm/src/listen.luau | 4 +- packages/charm/src/mapped.luau | 8 +- packages/charm/src/observe.luau | 4 +- packages/charm/src/subscribe.luau | 4 +- .../src/__tests__/useAtom.spec.luau | 1 + .../src/__tests__/useAtomBinding.spec.luau | 1 + packages/react-charm/src/init.luau | 42 +- packages/vide-charm/src/init.luau | 12 +- pnpm-lock.yaml | 1009 ++++++++--------- tsconfig.json | 1 + 14 files changed, 554 insertions(+), 619 deletions(-) delete mode 100644 .eslintrc create mode 100644 eslint.config.ts diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 5fe4250..0000000 --- a/.eslintrc +++ /dev/null @@ -1,28 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "jsx": true, - "useJSXTextNode": true, - "ecmaVersion": 2018, - "sourceType": "module", - "project": [ - "./tsconfig.json", - "./examples/ts/tsconfig.json" - ] - }, - "plugins": [ - "@typescript-eslint", - "roblox-ts", - "prettier" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:roblox-ts/recommended", - "plugin:prettier/recommended" - ], - "rules": { - "prettier/prettier": "warn", - "@typescript-eslint/no-explicit-any": "off" - } -} diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..21ece76 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,20 @@ +import eslint from "@eslint/js"; +import { defineConfig, globalIgnores } from "eslint/config"; +import prettierRecommended from "eslint-plugin-prettier/recommended"; +import roblox from "eslint-plugin-roblox-ts"; +import tseslint from "typescript-eslint"; + +export default defineConfig( + eslint.configs.recommended, + tseslint.configs.recommended, + roblox.configs.tsRecommendedCompat, + roblox.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + "roblox-ts/no-any": "off", + }, + }, + globalIgnores(["out/**"]), + prettierRecommended, +); diff --git a/package.json b/package.json index 2646252..86aa760 100644 --- a/package.json +++ b/package.json @@ -9,21 +9,22 @@ }, "packageManager": "pnpm@10.17.1", "devDependencies": { - "@rbxts-js/react": "17.1.20-ts.1", - "@rbxts-js/react-roblox": "17.1.20-ts.1", + "@eslint/js": "^9.36.0", "@rbxts/compiler-types": "3.0.0-types.0", - "@rbxts/react": "17.3.0-alpha.1", - "@rbxts/react-roblox": "17.3.0-alpha.1", - "@rbxts/types": "^1.0.861", + "@rbxts/react": "17.3.5-alpha.1", + "@rbxts/react-roblox": "17.3.5-alpha.1", + "@rbxts/types": "^1.0.883", "@rbxts/vide": "^0.5.7", - "@typescript-eslint/eslint-plugin": "^8.35.1", - "@typescript-eslint/parser": "^8.35.1", - "eslint": "^8.57.1", - "eslint-config-prettier": "^10.1.5", - "eslint-plugin-prettier": "^5.5.1", - "eslint-plugin-roblox-ts": "^0.0.36", + "@typescript-eslint/eslint-plugin": "^8.44.1", + "@typescript-eslint/parser": "^8.44.1", + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-roblox-ts": "^1.2.1", + "jiti": "^2.6.0", "prettier": "^3.6.2", "roblox-ts": "^3.0.0", - "typescript": "^5.8.3" + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1" } } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index ab8f02e..8be022f 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -26,34 +26,34 @@ export function effect(callback: () => Cleanup | void): Cleanup; export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; export function listen( - callback: () => T, + getter: () => T, listener: (value: T, previousValue: T | undefined, dispose: Cleanup) => void, ): Cleanup; -export function subscribe(callback: () => T, listener: (value: T, previousValue: T) => void): Cleanup; +export function subscribe(getter: () => T, listener: (value: T, previousValue: T) => void): Cleanup; export function mapped( - callback: () => Map | ReadonlyMap, + getter: () => Map | ReadonlyMap, mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): () => ReadonlyMap; // Overload for arrays export function mapped( - callback: () => readonly VI[], + getter: () => readonly VI[], mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, ): () => K extends number ? readonly VO[] : { readonly [P in K]: VO }; // Overload for objects export function mapped( - callback: () => { readonly [K in KI]: VI }, + getter: () => { readonly [K in KI]: VI }, mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): () => { readonly [K in KO]: VO }; export function observe( - callback: () => Map | ReadonlyMap | readonly V[], + getter: () => Map | ReadonlyMap | readonly V[], observer: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( - callback: () => { readonly [K in Key]: V }, + getter: () => { readonly [K in Key]: V }, observer: (value: V, key: K) => Cleanup | void, ): Cleanup; diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 865e704..4d3db95 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -4,9 +4,9 @@ local untracked = require("./untracked") local computed = core.computed local effect = core.effect -local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () +local function listen(getter: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () local tracked = computed(function() - return selector() + return getter() end) local current: T diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index 2c213fc..2447adb 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -4,10 +4,10 @@ local computed = core.computed local setCurrentSub = core.setCurrentSub type Mapped = - & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) - & ((selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) + & ((getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) + & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) -local function mapped(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } +local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } local currentData: { [KI]: VI } = {} local keyCache: { [KI]: KO } = {} @@ -26,7 +26,7 @@ local function mapped(selector: () -> { [KI]: VI }, mapper: (ite return computed(function(previousOutput) local currentOutput: { [KO]: VO } = previousOutput or {} local previousData = currentData - currentData = selector() + currentData = getter() for key in previousData do if currentData[key] ~= nil then diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index 219ae72..bf7a9b8 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -6,7 +6,7 @@ local onCleanup = core.onCleanup local startBatch = core.startBatch local endBatch = core.endBatch -local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () +local function observe(getter: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () local scopes: { [K]: () -> () } = {} local currentItems: { [K]: V } = {} @@ -37,7 +37,7 @@ local function observe(selector: () -> { [K]: V }, observer: (item: V, key local disposeEffect = effect(function() local previousItems = currentItems - currentItems = selector() + currentItems = getter() if currentItems ~= previousItems then startBatch() updateScopes(currentItems, previousItems) diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index 1ceca66..c8ee09b 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -4,9 +4,9 @@ local untracked = require("./untracked") local computed = core.computed local effect = core.effect -local function subscribe(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () +local function subscribe(getter: () -> T, listener: (state: T, lastState: T) -> ()): () -> () local tracked = computed(function() - return selector() + return getter() end) local current: T diff --git a/packages/react-charm/src/__tests__/useAtom.spec.luau b/packages/react-charm/src/__tests__/useAtom.spec.luau index 2df0f78..c695f9f 100644 --- a/packages/react-charm/src/__tests__/useAtom.spec.luau +++ b/packages/react-charm/src/__tests__/useAtom.spec.luau @@ -37,6 +37,7 @@ test("tracks dependencies", function() assert(hook.result == 0, "should return 0") state(1) assert(hook.result == 1, "should return 1") + state = atom(0) hook.rerender() assert(hook.result == 0, "should return 0") diff --git a/packages/react-charm/src/__tests__/useAtomBinding.spec.luau b/packages/react-charm/src/__tests__/useAtomBinding.spec.luau index e96d3b3..d1fa23d 100644 --- a/packages/react-charm/src/__tests__/useAtomBinding.spec.luau +++ b/packages/react-charm/src/__tests__/useAtomBinding.spec.luau @@ -37,6 +37,7 @@ test("tracks dependencies", function() assert(hook.result:getValue() == 0, "should return 0") state(1) assert(hook.result:getValue() == 1, "should return 1") + state = atom(0) hook.rerender() assert(hook.result:getValue() == 0, "should return 0") diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 02976a2..b7598f0 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -1,28 +1,23 @@ local Charm = require("./Charm") local React = require("./React") -local effect = Charm.effect -local useBinding = React.useBinding -local useEffect = React.useEffect -local useState = React.useState -local useMemo = React.useMemo - --- A hook that subscribes to changes in the given atom or selector. The --- component is re-rendered with the new state after an update. --- --- If the `dependencies` array is provided, a new effect is created for the ---- current `callback` when the dependencies change. Otherwise, the effect is +--- current `getter` when the dependencies change. Otherwise, the effect is --- created only once, when the component is mounted. --- ---- @param callback The atom or selector to watch. ---- @param dependencies An array of values used to memoize the callback. +--- @param getter The atom or selector to watch. +--- @param dependencies An array of values used to memoize the getter. --- @return The current state. -local function useAtom(callback: () -> T, dependencies: { any }?): T - local state, setState = useState(callback) +local function useAtom(getter: () -> T, dependencies: { any }?): T + local state, setState = React.useState(getter) - useEffect(function() - return effect(function() - setState(callback()) + React.useLayoutEffect(function() + setState(getter()) + return Charm.effect(function() + setState(getter()) end) end, dependencies or {}) @@ -33,19 +28,20 @@ end --- a binding that updates when the state changes. --- --- If the `dependencies` array is provided, a new effect is created for the ---- current `callback` when the dependencies change. Otherwise, the effect is +--- current `getter` when the dependencies change. Otherwise, the effect is --- created only once, when the component is mounted. --- ---- @param callback The atom or selector to watch. ---- @param dependencies An array of values used to memoize the callback. +--- @param getter The atom or selector to watch. +--- @param dependencies An array of values used to memoize the getter. --- @return A binding that stores the current state. -local function useAtomBinding(callback: () -> T, dependencies: { any }?) - local initialState = useMemo(callback, {}) - local binding, setBinding = useBinding(initialState) +local function useAtomBinding(getter: () -> T, dependencies: { any }?) + local initialState = React.useMemo(getter, {}) + local binding, setBinding = React.useBinding(initialState) - useEffect(function() - return effect(function() - setBinding(callback()) + React.useEffect(function() + setBinding(getter()) + return Charm.effect(function() + setBinding(getter()) end) end, dependencies or {}) diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index ddbb727..c6326dd 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -5,14 +5,14 @@ local subscribe = Charm.subscribe local source = Vide.source local cleanup = Vide.cleanup ---- Subscribes to the state of an atom or selector. Returns a source that ---- updates when the state changes. +--- Returns a reactive source that updates when the given getter function's +--- value changes. --- ---- @param callback The atom or selector to subscribe to. +--- @param getter The atom or selector to subscribe to. --- @return The reactive source. -local function useAtom(callback: () -> State): () -> State - local state = source(callback()) - cleanup(subscribe(callback, state)) +local function useAtom(getter: () -> State): () -> State + local state = source(getter()) + cleanup(subscribe(getter, state)) return state end diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac32a87..312a389 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,45 +8,45 @@ importers: .: devDependencies: - '@rbxts-js/react': - specifier: 17.1.20-ts.1 - version: 17.1.20-ts.1 - '@rbxts-js/react-roblox': - specifier: 17.1.20-ts.1 - version: 17.1.20-ts.1 + '@eslint/js': + specifier: ^9.36.0 + version: 9.36.0 '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 '@rbxts/react': - specifier: 17.3.0-alpha.1 - version: 17.3.0-alpha.1 + specifier: 17.3.5-alpha.1 + version: 17.3.5-alpha.1 '@rbxts/react-roblox': - specifier: 17.3.0-alpha.1 - version: 17.3.0-alpha.1(@rbxts/react@17.3.0-alpha.1) + specifier: 17.3.5-alpha.1 + version: 17.3.5-alpha.1(@rbxts/react@17.3.5-alpha.1) '@rbxts/types': - specifier: ^1.0.861 - version: 1.0.861 + specifier: ^1.0.883 + version: 1.0.883 '@rbxts/vide': specifier: ^0.5.7 version: 0.5.7 '@typescript-eslint/eslint-plugin': - specifier: ^8.35.1 - version: 8.35.1(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + specifier: ^8.44.1 + version: 8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) '@typescript-eslint/parser': - specifier: ^8.35.1 - version: 8.35.1(eslint@8.57.1)(typescript@5.8.3) + specifier: ^8.44.1 + version: 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) eslint: - specifier: ^8.57.1 - version: 8.57.1 + specifier: ^9.36.0 + version: 9.36.0(jiti@2.6.0) eslint-config-prettier: - specifier: ^10.1.5 - version: 10.1.5(eslint@8.57.1) + specifier: ^10.1.8 + version: 10.1.8(eslint@9.36.0(jiti@2.6.0)) eslint-plugin-prettier: - specifier: ^5.5.1 - version: 5.5.1(eslint-config-prettier@10.1.5(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2) + specifier: ^5.5.4 + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.36.0(jiti@2.6.0)))(eslint@9.36.0(jiti@2.6.0))(prettier@3.6.2) eslint-plugin-roblox-ts: - specifier: ^0.0.36 - version: 0.0.36(eslint@8.57.1) + specifier: ^1.2.1 + version: 1.2.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + jiti: + specifier: ^2.6.0 + version: 2.6.0 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -54,8 +54,11 @@ importers: specifier: ^3.0.0 version: 3.0.0 typescript: - specifier: ^5.8.3 - version: 5.8.3 + specifier: ^5.9.2 + version: 5.9.2 + typescript-eslint: + specifier: ^8.44.1 + version: 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) packages/charm: {} @@ -85,8 +88,8 @@ importers: packages: - '@eslint-community/eslint-utils@4.7.0': - resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -95,26 +98,49 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-array@0.21.0': + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-helpers@0.3.1': + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead + '@eslint/core@0.15.2': + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.36.0': + resolution: {integrity: sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.3.5': + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -128,8 +154,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@pkgr/core@0.2.7': - resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} '@rbxts-js/boolean@1.2.3-ts.1': @@ -156,26 +182,29 @@ packages: '@rbxts-js/number@1.2.3-ts.1': resolution: {integrity: sha512-tPdt2s7fEBSxImVFBUvuSMtBKIvaZUCtlOjBl58Y0+GKB15VPXFIRW/5biVruXPnv9d4sDE3mh986ZxxN4UQ5w==} - '@rbxts-js/react-reconciler@17.1.20-ts.1': - resolution: {integrity: sha512-t4vY4nHeM+Scrs+9FbgNsuhK0QR6lg3SNWUhjbzvptNP+IA4+4F8TkHQghR2SSTxQDhegU02BTR7NBpk+AQcUw==} + '@rbxts-js/react-globals@17.3.5-ts.1': + resolution: {integrity: sha512-dbQ4E9WFtGg93cwElXk64KEYyd9gX2SCkTmYcm+Wty3qQdPkTWcaxu7c4SIMJpsvDTkt1nl+D0PdvVmQErcheg==} - '@rbxts-js/react-roblox@17.1.20-ts.1': - resolution: {integrity: sha512-Jv0mq+9txXptgsJ6MDWHgCTwsohaon9HhmllkUe29zkaMwE+di4cHqzF1tgV8VHYGG/LQoitXDddK03CA3L+2g==} + '@rbxts-js/react-reconciler@17.3.5-ts.1': + resolution: {integrity: sha512-Hy/Z4K5epqRFjchd7RAKHnSinAp6vviqqPFmwIoW8RJDwxkvcu7GeXmh6SkFv0FeRatYIic6vTry/0cu/AluYg==} - '@rbxts-js/react@17.1.20-ts.1': - resolution: {integrity: sha512-u/dNRTNj5AaVPuqJLza7FEJ24lyRfJ0CdjflbIS8JiUS1iXiO7yZeHQWwPk6V3oHuAOckv6tA4FN/Eju2rPMrQ==} + '@rbxts-js/react-roblox@17.3.5-ts.1': + resolution: {integrity: sha512-7AvkYe9lpIGcO+uHOnoqze46/8qMTpwKrqt+55gg6GLoWelVzseTkib5gGFRU4gi7CBpXvltiB8fY+T+IcPPOQ==} + + '@rbxts-js/react@17.3.5-ts.1': + resolution: {integrity: sha512-Ln5r32y72s83T6qdpWX2hzd1sxoMf9GNx4PJnVVdlV+a3HJRC16huThTgMXyVD3mXlOnQwUKHs9ZLTFrSRNPUA==} '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': resolution: {integrity: sha512-H8fxKVJkRnM60yTZ/riO4X43GZD/MViFm+ftCzzAALkAhE4vSaBDNv9E7IUjZZoWi21bG6vZ8mGIiYfavaljvw==} - '@rbxts-js/safe-flags@17.1.20-ts.1': - resolution: {integrity: sha512-k+16jmCP63LT8sNfhLoytUrp3UB+CGHH7s54BFUAHte+zCANK3wfAKXbpT36NqnUfRqgKDM8grO4lWp6awuacQ==} + '@rbxts-js/safe-flags@17.3.5-ts.1': + resolution: {integrity: sha512-lzuUf2TNdmgxLeZuw9gfbrzNjju7LrmGwI4gQQxgXR3M+QhjjRaaXiLrHFkC83+VdBHJht70Re5gBK/7VtNOHg==} - '@rbxts-js/scheduler@17.1.20-ts.1': - resolution: {integrity: sha512-RoXs6bAW3LIB6HL/voMilR2hjcGAtutaVsr0yumu0hUYVFY2FwIkbOVepsdYWDrYhVqsk5zrj8CcVGgUxDldGg==} + '@rbxts-js/scheduler@17.3.5-ts.1': + resolution: {integrity: sha512-IT3dWnruzhEp7iShNOIsyVdy1jCCnHGV1B7Hhgusr7viwH8u5UrXZcOXazVuJM4ImdfPl1Yi9Bee+RKhW4zbuQ==} - '@rbxts-js/shared@17.1.20-ts.1': - resolution: {integrity: sha512-/3K1BnWV519+any6JMWniIDx1rtq20H6RVgJStSCj4XofrM63kzju0EzRQg1af4lnXCfZNZBusRW8IGQ14xuZQ==} + '@rbxts-js/shared@17.3.5-ts.1': + resolution: {integrity: sha512-0iwX5dD+gpwoRPBH0fpRCL3MVtnMrQqKX4IOg4xqeLewMzGXwP00+uXvewSN+eIPtLisMYjqH9GdGNUJOO7oGw==} '@rbxts-js/string@1.2.3-ts.1': resolution: {integrity: sha512-Je/WKgZbXF/sAHEsQPuns5n4rWzux327zjr0KRf9Gy62ieUzl2FRZZHjVYce3xdMtHif2JstIf/J3p513TUtRg==} @@ -189,10 +218,10 @@ packages: '@rbxts/compiler-types@3.0.0-types.0': resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} - '@rbxts/react-roblox@17.3.0-alpha.1': - resolution: {integrity: sha512-P6vL3+LFC7YZxFGfx3hpVdd4bPBqOkJkDpr0aRpHmVj0OVoVCPiaPXe1qCPp5MciBRb4MQXauJCeiVVs4CvwJg==} + '@rbxts/react-roblox@17.3.5-alpha.1': + resolution: {integrity: sha512-U7gb08OCDZTFp178gTVrgE5S1Ee6VBa3aHeIVcGhK1EJr8kn3dst2ugOtgeZIUQKPSBfxh885sKKcOfFScW5oA==} peerDependencies: - '@rbxts/react': ^17.3.0-alpha.1 + '@rbxts/react': ^17.3.5-alpha.1 '@rbxts/react-vendor@17.2.3': resolution: {integrity: sha512-dWCiVelvTK6h920geaFH/OQ5zoWHyhyqyfH51hKx2/iVZ6OiiyNVJ5UqEIkJMG4HsJ8JnkTORizV3+MtQ41BtA==} @@ -200,11 +229,11 @@ packages: '@rbxts/react@17.2.3': resolution: {integrity: sha512-IoOB8e2W8MxT0MKWFf0Ls0vT61ImYL6ctHQgz5+oazma6z8bqFtJjgPxOuy6jmMSuyfBe7rYp/muou0kIjZT+Q==} - '@rbxts/react@17.3.0-alpha.1': - resolution: {integrity: sha512-l2KYYxSj5pOKA2/QelbQRiqQjbI6SRMv8mrmIjc7xpAzuw728aumJ6V6Ol0lrQO/Qt5LVQ8En2L+6C6Lt4sLqA==} + '@rbxts/react@17.3.5-alpha.1': + resolution: {integrity: sha512-qYhZZtfH0neWmzfQvo05sQFx9EYNQCH8aZomqDdoX40bbnB9XlK1P5kl98xYXCX1uBZCAjj6cWPIUCCfZVE8hw==} - '@rbxts/types@1.0.861': - resolution: {integrity: sha512-WYIODP9WM7r2uc7+M6PA38yeSKf4bvIrh4MTbk4pvb1vzLwYlnJ3tq9q4SaI5e8BQsNWMSjXcI+s9p4tXY4v5A==} + '@rbxts/types@1.0.883': + resolution: {integrity: sha512-X/SgSPqES8awoqSehdEfvOCQjFPcTGA4EflfWZ/FXyWvlfd7Y5/DWa3UfUJFGDHZu1D0iDIL66q17UYQbufpMg==} '@rbxts/vide@0.5.7': resolution: {integrity: sha512-Q9R7ybzuHK1phtLicBHgAjQnlJ/dMsXDs2k296PIMLgLqK9aX/f1skOP7F1K/vo7IrSE0jCG3B3XTOtQL1Sb0Q==} @@ -218,109 +247,114 @@ packages: '@roblox-ts/rojo-resolver@1.1.0': resolution: {integrity: sha512-QmvVryu1EeME+3QUoG5j/gHGJoJUaffCgZ92mhlG7cJSd1uyhgpY4CNWriZAwZJYkTlzd5Htkpn+18yDFbOFXA==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@20.19.4': - resolution: {integrity: sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==} - - '@types/semver@7.7.0': - resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} - - '@typescript-eslint/eslint-plugin@8.35.1': - resolution: {integrity: sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==} + '@typescript-eslint/eslint-plugin@8.44.1': + resolution: {integrity: sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.35.1 + '@typescript-eslint/parser': ^8.44.1 eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/experimental-utils@5.62.0': - resolution: {integrity: sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@typescript-eslint/parser@8.35.1': - resolution: {integrity: sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==} + '@typescript-eslint/parser@8.44.1': + resolution: {integrity: sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.35.1': - resolution: {integrity: sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==} + '@typescript-eslint/project-service@8.44.0': + resolution: {integrity: sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@5.62.0': - resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@typescript-eslint/scope-manager@8.35.1': - resolution: {integrity: sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.35.1': - resolution: {integrity: sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==} + '@typescript-eslint/project-service@8.44.1': + resolution: {integrity: sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.35.1': - resolution: {integrity: sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==} + '@typescript-eslint/scope-manager@8.44.0': + resolution: {integrity: sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.44.1': + resolution: {integrity: sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.44.0': + resolution: {integrity: sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.44.1': + resolution: {integrity: sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.44.0': + resolution: {integrity: sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@5.62.0': - resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@typescript-eslint/types@8.35.1': - resolution: {integrity: sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@5.62.0': - resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/typescript-estree@8.35.1': - resolution: {integrity: sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <5.9.0' - - '@typescript-eslint/utils@5.62.0': - resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@typescript-eslint/utils@8.35.1': - resolution: {integrity: sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==} + '@typescript-eslint/type-utils@8.44.1': + resolution: {integrity: sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@5.62.0': - resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@typescript-eslint/visitor-keys@8.35.1': - resolution: {integrity: sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==} + '@typescript-eslint/types@8.44.0': + resolution: {integrity: sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@typescript-eslint/types@8.44.1': + resolution: {integrity: sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.44.0': + resolution: {integrity: sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.44.1': + resolution: {integrity: sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.44.0': + resolution: {integrity: sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.44.1': + resolution: {integrity: sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.44.0': + resolution: {integrity: sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.44.1': + resolution: {integrity: sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -353,10 +387,6 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -404,8 +434,8 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -416,14 +446,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -435,14 +457,14 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-prettier@10.1.5: - resolution: {integrity: sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' - eslint-plugin-prettier@5.5.1: - resolution: {integrity: sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==} + eslint-plugin-prettier@5.5.4: + resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' @@ -455,17 +477,17 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-roblox-ts@0.0.36: - resolution: {integrity: sha512-vlbgfHY1heWSyDunVX3gIOamCy28KqrJlDkmuDOyYVBo8uLqdvbFkbWkdGxn59Vky1m6pFcvZGU6TObfmwm5Pg==} - engines: {node: '>=0.10.0'} + eslint-plugin-roblox-ts@1.2.1: + resolution: {integrity: sha512-B6PyXsz2urPJIr2TxZBD3qQwCM0FN6lUOoddJTk1AcdRkWSTe1Vc6VuNP593u7Tbq9sStjW2k2iYjI1G5CFNiQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=22.5.1} + peerDependencies: + '@typescript-eslint/parser': '>=6.0.0' + eslint: '>=8.0.0' + typescript: '>=5.2.0' - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} @@ -475,15 +497,19 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + eslint@9.36.0: + resolution: {integrity: sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} esquery@1.6.0: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} @@ -493,10 +519,6 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -521,15 +543,15 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.0.6: - resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} @@ -539,20 +561,17 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - fs-extra@11.3.0: - resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} engines: {node: '>=14.14'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -573,17 +592,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -615,13 +626,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -646,13 +650,13 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.6.0: + resolution: {integrity: sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==} + hasBin: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -669,8 +673,8 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -715,9 +719,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -738,10 +739,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -749,10 +746,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -802,11 +795,6 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - roblox-ts@3.0.0: resolution: {integrity: sha512-hwAC2frIFlLJOtHd6F+5opMEhBgfAMK9z5l1mP+bykLBbMO5cn1q5lIwhhXbeh9Pq07rlhF8uGHlmeRLPd/3AA==} hasBin: true @@ -827,10 +815,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -851,13 +835,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.8: - resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} + synckit@0.11.11: + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -868,36 +849,27 @@ packages: peerDependencies: typescript: '>=4.8.4' - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - - tsutils@3.21.0: - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} + typescript-eslint@8.44.1: + resolution: {integrity: sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' typescript@5.5.3: resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} engines: {node: '>=14.17'} hasBin: true - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -918,9 +890,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -939,19 +908,33 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.7.0(eslint@8.57.1)': + '@eslint-community/eslint-utils@4.9.0(eslint@9.36.0(jiti@2.6.0))': dependencies: - eslint: 8.57.1 + eslint: 9.36.0(jiti@2.6.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} - '@eslint/eslintrc@2.1.4': + '@eslint/config-array@0.21.0': + dependencies: + '@eslint/object-schema': 2.1.6 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.3.1': {} + + '@eslint/core@0.15.2': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.1 - espree: 9.6.1 - globals: 13.24.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.0 @@ -960,19 +943,25 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@8.57.1': {} + '@eslint/js@9.36.0': {} - '@humanwhocodes/config-array@0.13.0': + '@eslint/object-schema@2.1.6': {} + + '@eslint/plugin-kit@0.3.5': dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.1 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color + '@eslint/core': 0.15.2 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/object-schema@2.0.3': {} + '@humanwhocodes/retry@0.4.3': {} '@nodelib/fs.scandir@2.1.5': dependencies: @@ -986,7 +975,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@pkgr/core@0.2.7': {} + '@pkgr/core@0.2.9': {} '@rbxts-js/boolean@1.2.3-ts.1': dependencies: @@ -1022,42 +1011,51 @@ snapshots: '@rbxts-js/number@1.2.3-ts.1': {} - '@rbxts-js/react-reconciler@17.1.20-ts.1': + '@rbxts-js/react-globals@17.3.5-ts.1': + dependencies: + '@rbxts-js/safe-flags': 17.3.5-ts.1 + + '@rbxts-js/react-reconciler@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react': 17.1.20-ts.1 + '@rbxts-js/react': 17.3.5-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 '@rbxts-js/roblox-lua-promise': 3.5.0-ts.2 - '@rbxts-js/safe-flags': 17.1.20-ts.1 - '@rbxts-js/scheduler': 17.1.20-ts.1 - '@rbxts-js/shared': 17.1.20-ts.1 + '@rbxts-js/safe-flags': 17.3.5-ts.1 + '@rbxts-js/scheduler': 17.3.5-ts.1 + '@rbxts-js/shared': 17.3.5-ts.1 - '@rbxts-js/react-roblox@17.1.20-ts.1': + '@rbxts-js/react-roblox@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react': 17.1.20-ts.1 - '@rbxts-js/react-reconciler': 17.1.20-ts.1 - '@rbxts-js/scheduler': 17.1.20-ts.1 - '@rbxts-js/shared': 17.1.20-ts.1 + '@rbxts-js/react': 17.3.5-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 + '@rbxts-js/react-reconciler': 17.3.5-ts.1 + '@rbxts-js/scheduler': 17.3.5-ts.1 + '@rbxts-js/shared': 17.3.5-ts.1 - '@rbxts-js/react@17.1.20-ts.1': + '@rbxts-js/react@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/shared': 17.1.20-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 + '@rbxts-js/shared': 17.3.5-ts.1 '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': {} - '@rbxts-js/safe-flags@17.1.20-ts.1': {} + '@rbxts-js/safe-flags@17.3.5-ts.1': {} - '@rbxts-js/scheduler@17.1.20-ts.1': + '@rbxts-js/scheduler@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/safe-flags': 17.1.20-ts.1 - '@rbxts-js/shared': 17.1.20-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 + '@rbxts-js/safe-flags': 17.3.5-ts.1 + '@rbxts-js/shared': 17.3.5-ts.1 - '@rbxts-js/shared@17.1.20-ts.1': + '@rbxts-js/shared@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/safe-flags': 17.1.20-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 + '@rbxts-js/safe-flags': 17.3.5-ts.1 '@rbxts-js/string@1.2.3-ts.1': dependencies: @@ -1072,10 +1070,10 @@ snapshots: '@rbxts/compiler-types@3.0.0-types.0': {} - '@rbxts/react-roblox@17.3.0-alpha.1(@rbxts/react@17.3.0-alpha.1)': + '@rbxts/react-roblox@17.3.5-alpha.1(@rbxts/react@17.3.5-alpha.1)': dependencies: - '@rbxts-js/react-roblox': 17.1.20-ts.1 - '@rbxts/react': 17.3.0-alpha.1 + '@rbxts-js/react-roblox': 17.3.5-ts.1 + '@rbxts/react': 17.3.5-alpha.1 '@rbxts/react-vendor@17.2.3': {} @@ -1083,11 +1081,11 @@ snapshots: dependencies: '@rbxts/react-vendor': 17.2.3 - '@rbxts/react@17.3.0-alpha.1': + '@rbxts/react@17.3.5-alpha.1': dependencies: - '@rbxts-js/react': 17.1.20-ts.1 + '@rbxts-js/react': 17.3.5-ts.1 - '@rbxts/types@1.0.861': {} + '@rbxts/types@1.0.883': {} '@rbxts/vide@0.5.7': {} @@ -1096,163 +1094,173 @@ snapshots: '@roblox-ts/path-translator@1.1.0': dependencies: ajv: 8.17.1 - fs-extra: 11.3.0 + fs-extra: 11.3.2 '@roblox-ts/rojo-resolver@1.1.0': dependencies: ajv: 8.17.1 - fs-extra: 11.3.0 + fs-extra: 11.3.2 + + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} - '@types/node@20.19.4': - dependencies: - undici-types: 6.21.0 - - '@types/semver@7.7.0': {} - - '@typescript-eslint/eslint-plugin@8.35.1(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.35.1(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/scope-manager': 8.35.1 - '@typescript-eslint/type-utils': 8.35.1(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/utils': 8.35.1(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.35.1 - eslint: 8.57.1 + '@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/scope-manager': 8.44.1 + '@typescript-eslint/type-utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.44.1 + eslint: 9.36.0(jiti@2.6.0) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.8.3) - eslint: 8.57.1 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.8.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.35.1 - '@typescript-eslint/types': 8.35.1 - '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.35.1 - debug: 4.4.1 - eslint: 8.57.1 - typescript: 5.8.3 + '@typescript-eslint/scope-manager': 8.44.1 + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.44.1 + debug: 4.4.3 + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.35.1(typescript@5.8.3)': + '@typescript-eslint/project-service@8.44.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.35.1(typescript@5.8.3) - '@typescript-eslint/types': 8.35.1 - debug: 4.4.1 - typescript: 5.8.3 + '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2) + '@typescript-eslint/types': 8.44.0 + debug: 4.4.3 + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@5.62.0': + '@typescript-eslint/project-service@8.44.1(typescript@5.9.2)': dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - - '@typescript-eslint/scope-manager@8.35.1': - dependencies: - '@typescript-eslint/types': 8.35.1 - '@typescript-eslint/visitor-keys': 8.35.1 - - '@typescript-eslint/tsconfig-utils@8.35.1(typescript@5.8.3)': - dependencies: - typescript: 5.8.3 - - '@typescript-eslint/type-utils@8.35.1(eslint@8.57.1)(typescript@5.8.3)': - dependencies: - '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3) - '@typescript-eslint/utils': 8.35.1(eslint@8.57.1)(typescript@5.8.3) - debug: 4.4.1 - eslint: 8.57.1 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 + '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.2) + '@typescript-eslint/types': 8.44.1 + debug: 4.4.3 + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@5.62.0': {} - - '@typescript-eslint/types@8.35.1': {} - - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.8.3)': + '@typescript-eslint/scope-manager@8.44.0': dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.1 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.7.2 - tsutils: 3.21.0(typescript@5.8.3) - optionalDependencies: - typescript: 5.8.3 + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/visitor-keys': 8.44.0 + + '@typescript-eslint/scope-manager@8.44.1': + dependencies: + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/visitor-keys': 8.44.1 + + '@typescript-eslint/tsconfig-utils@8.44.0(typescript@5.9.2)': + dependencies: + typescript: 5.9.2 + + '@typescript-eslint/tsconfig-utils@8.44.1(typescript@5.9.2)': + dependencies: + typescript: 5.9.2 + + '@typescript-eslint/type-utils@8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': + dependencies: + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + debug: 4.4.3 + eslint: 9.36.0(jiti@2.6.0) + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.35.1(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: - '@typescript-eslint/project-service': 8.35.1(typescript@5.8.3) - '@typescript-eslint/tsconfig-utils': 8.35.1(typescript@5.8.3) - '@typescript-eslint/types': 8.35.1 - '@typescript-eslint/visitor-keys': 8.35.1 - debug: 4.4.1 + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + debug: 4.4.3 + eslint: 9.36.0(jiti@2.6.0) + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.44.0': {} + + '@typescript-eslint/types@8.44.1': {} + + '@typescript-eslint/typescript-estree@8.44.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/project-service': 8.44.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2) + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/visitor-keys': 8.44.0 + debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@8.44.1(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.7.0 - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) - eslint: 8.57.1 - eslint-scope: 5.1.1 + '@typescript-eslint/project-service': 8.44.1(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.2) + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/visitor-keys': 8.44.1 + debug: 4.4.3 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/utils@8.35.1(eslint@8.57.1)(typescript@5.8.3)': - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) - '@typescript-eslint/scope-manager': 8.35.1 - '@typescript-eslint/types': 8.35.1 - '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3) - eslint: 8.57.1 - typescript: 5.8.3 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@5.62.0': + '@typescript-eslint/utils@8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: - '@typescript-eslint/types': 5.62.0 - eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0)) + '@typescript-eslint/scope-manager': 8.44.0 + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2) + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color - '@typescript-eslint/visitor-keys@8.35.1': + '@typescript-eslint/utils@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: - '@typescript-eslint/types': 8.35.1 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0)) + '@typescript-eslint/scope-manager': 8.44.1 + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.44.0': + dependencies: + '@typescript-eslint/types': 8.44.0 eslint-visitor-keys: 4.2.1 - '@ungap/structured-clone@1.3.0': {} + '@typescript-eslint/visitor-keys@8.44.1': + dependencies: + '@typescript-eslint/types': 8.44.1 + eslint-visitor-keys: 4.2.1 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -1270,7 +1278,7 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.0.6 + fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -1287,8 +1295,6 @@ snapshots: argparse@2.0.1: {} - array-union@2.1.0: {} - balanced-match@1.0.2: {} binary-extensions@2.3.0: {} @@ -1345,54 +1351,42 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - debug@4.4.1: + debug@4.4.3: dependencies: ms: 2.1.3 deep-is@0.1.4: {} - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - emoji-regex@8.0.0: {} escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.5(eslint@8.57.1): + eslint-config-prettier@10.1.8(eslint@9.36.0(jiti@2.6.0)): dependencies: - eslint: 8.57.1 + eslint: 9.36.0(jiti@2.6.0) - eslint-plugin-prettier@5.5.1(eslint-config-prettier@10.1.5(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2): + eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.36.0(jiti@2.6.0)))(eslint@9.36.0(jiti@2.6.0))(prettier@3.6.2): dependencies: - eslint: 8.57.1 + eslint: 9.36.0(jiti@2.6.0) prettier: 3.6.2 prettier-linter-helpers: 1.0.0 - synckit: 0.11.8 + synckit: 0.11.11 optionalDependencies: - eslint-config-prettier: 10.1.5(eslint@8.57.1) + eslint-config-prettier: 10.1.8(eslint@9.36.0(jiti@2.6.0)) - eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1): + eslint-plugin-roblox-ts@1.2.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2): dependencies: - '@types/node': 20.19.4 - '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.8.3) - typescript: 5.8.3 + '@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/type-utils': 8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 transitivePeerDependencies: - - eslint - supports-color - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - - eslint-scope@7.2.2: + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 @@ -1401,54 +1395,53 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@8.57.1: + eslint@9.36.0(jiti@2.6.0): dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0)) '@eslint-community/regexpp': 4.12.1 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 + '@eslint/config-array': 0.21.0 + '@eslint/config-helpers': 0.3.1 + '@eslint/core': 0.15.2 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.36.0 + '@eslint/plugin-kit': 0.3.5 + '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.3.0 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1 - doctrine: 3.0.0 + debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 esquery: 1.6.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 + file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.0 json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 lodash.merge: 4.6.2 minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 + optionalDependencies: + jiti: 2.6.0 transitivePeerDependencies: - supports-color - espree@9.6.1: + espree@10.4.0: dependencies: acorn: 8.15.0 acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 3.4.3 + eslint-visitor-keys: 4.2.1 esquery@1.6.0: dependencies: @@ -1458,8 +1451,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} esutils@2.0.3: {} @@ -1480,15 +1471,15 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.0.6: {} + fast-uri@3.1.0: {} fastq@1.19.1: dependencies: reusify: 1.1.0 - file-entry-cache@6.0.1: + file-entry-cache@8.0.0: dependencies: - flat-cache: 3.2.0 + flat-cache: 4.0.1 fill-range@7.1.1: dependencies: @@ -1499,22 +1490,19 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@3.2.0: + flat-cache@4.0.1: dependencies: flatted: 3.3.3 keyv: 4.5.4 - rimraf: 3.0.2 flatted@3.3.3: {} - fs-extra@11.3.0: + fs-extra@11.3.2: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.1.0 + jsonfile: 6.2.0 universalify: 2.0.1 - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true @@ -1530,27 +1518,7 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - globals@13.24.0: - dependencies: - type-fest: 0.20.2 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 + globals@14.0.0: {} graceful-fs@4.2.11: {} @@ -1573,13 +1541,6 @@ snapshots: imurmurhash@0.1.4: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -1598,10 +1559,10 @@ snapshots: is-number@7.0.0: {} - is-path-inside@3.0.3: {} - isexe@2.0.0: {} + jiti@2.6.0: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -1614,7 +1575,7 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - jsonfile@6.1.0: + jsonfile@6.2.0: dependencies: universalify: 2.0.1 optionalDependencies: @@ -1658,10 +1619,6 @@ snapshots: normalize-path@3.0.0: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1685,14 +1642,10 @@ snapshots: path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-parse@1.0.7: {} - path-type@4.0.0: {} - picomatch@2.3.1: {} prelude-ls@1.2.1: {} @@ -1725,17 +1678,13 @@ snapshots: reusify@1.1.0: {} - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - roblox-ts@3.0.0: dependencies: '@roblox-ts/luau-ast': 2.0.0 '@roblox-ts/path-translator': 1.1.0 '@roblox-ts/rojo-resolver': 1.1.0 chokidar: 3.6.0 - fs-extra: 11.3.0 + fs-extra: 11.3.2 kleur: 4.1.5 resolve: 1.22.10 typescript: 5.5.3 @@ -1753,8 +1702,6 @@ snapshots: shebang-regex@3.0.0: {} - slash@3.0.0: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -1773,38 +1720,36 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.8: + synckit@0.11.11: dependencies: - '@pkgr/core': 0.2.7 - - text-table@0.2.0: {} + '@pkgr/core': 0.2.9 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ts-api-utils@2.1.0(typescript@5.8.3): + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: - typescript: 5.8.3 - - tslib@1.14.1: {} - - tsutils@3.21.0(typescript@5.8.3): - dependencies: - tslib: 1.14.1 - typescript: 5.8.3 + typescript: 5.9.2 type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - type-fest@0.20.2: {} + typescript-eslint@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2): + dependencies: + '@typescript-eslint/eslint-plugin': 8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color typescript@5.5.3: {} - typescript@5.8.3: {} - - undici-types@6.21.0: {} + typescript@5.9.2: {} universalify@2.0.1: {} @@ -1824,8 +1769,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrappy@1.0.2: {} - y18n@5.0.8: {} yargs-parser@21.1.1: {} diff --git a/tsconfig.json b/tsconfig.json index a323dec..c0db2ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "jsxFragmentFactory": "Roact.createFragment", "module": "commonjs", "moduleResolution": "Node", + "ignoreDeprecations": "6.0", "noLib": true, "resolveJsonModule": true, "experimentalDecorators": true, From 079abaacc12d5c25e7c34ba5f8f450bed7fc346d Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:11:39 -0700 Subject: [PATCH 043/160] refactor: start charm-sync rewrite --- packages/charm-sync/Charm.luau | 4 + .../charm-sync/src/__tests__/client.spec.luau | 69 ++- .../src/__tests__/flatten.spec.luau | 3 +- .../charm-sync/src/__tests__/patch.spec.luau | 64 +-- .../charm-sync/src/__tests__/server.spec.luau | 99 ---- packages/charm-sync/src/__tests__/spec.luau | 4 - .../src/__tests__/validate.spec.luau | 3 +- packages/charm-sync/src/client.luau | 117 +++-- packages/charm-sync/src/config.luau | 20 + packages/charm-sync/src/flatten.luau | 10 +- packages/charm-sync/src/init.luau | 7 +- packages/charm-sync/src/interval.luau | 26 - packages/charm-sync/src/patch.luau | 46 +- packages/charm-sync/src/server.luau | 473 +++++++++--------- packages/charm-sync/src/types.luau | 16 +- packages/charm-sync/src/validate.luau | 53 +- packages/charm/src/init.luau | 12 +- test/spec.server.luau | 2 +- 18 files changed, 468 insertions(+), 560 deletions(-) delete mode 100644 packages/charm-sync/src/__tests__/server.spec.luau create mode 100644 packages/charm-sync/src/config.luau delete mode 100644 packages/charm-sync/src/interval.luau diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index 6b6fb92..0070134 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -1,3 +1,7 @@ +if not script then + return require("../charm/src") +end + local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index eaebab2..33f3013 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -1,19 +1,22 @@ local Charm = require("../../Charm") -local CharmSync = require("../") +local client = require("../client") local suite = require("../../../../test/suite") local atom = Charm.atom local subscribe = Charm.subscribe -local client = CharmSync.client local test = suite.test +local afterEach = suite.afterEach + +afterEach(function() + client.clearAtoms() +end) test("should sync state payload", function() local a = atom(1) - local client = client({ atoms = { a = a } }) - client:sync({ - type = "init", - data = { a = 2 }, + client.registerAtoms({ a = a }) + client.applyPayloads({ + { type = "init", data = { a = 2 } }, }) assert(a() == 2, "should set a to the initial value") @@ -22,18 +25,16 @@ 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() }, + client.registerAtoms({ a = a }) + client.applyPayloads({ + { type = "init", data = { a = a() } }, }) assert(a() == previous, "should set a to the initial value") - client:sync({ - type = "patch", - data = { a = { b = 3 } }, + client.applyPayloads({ + { type = "patch", data = { a = { b = 3 } } }, }) assert(a() ~= previous, "should apply patch") @@ -43,19 +44,17 @@ 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() }, + client.registerAtoms({ a = a }) + client.applyPayloads({ + { 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 } }, + client.applyPayloads({ + { type = "patch", data = { a = { ["2"] = 2 } } }, }) assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers") @@ -64,24 +63,21 @@ end) test("should apply multiple patches", function() local a = atom(1) - local client = client({ atoms = { a = a } }) local calls = {} + client.registerAtoms({ a = a }) + subscribe(a, function(value) table.insert(calls, value) end) - client:sync({ - type = "init", - data = { a = a() }, + client.applyPayloads({ + { type = "init", data = { a = a() } }, }) - client:sync({ - type = "patch", - data = { a = 2 }, - }, { - type = "patch", - data = { a = 3 }, + client.applyPayloads({ + { type = "patch", data = { a = 2 } }, + { type = "patch", data = { a = 3 } }, }) assert(a() == 3, "should apply the last patch") @@ -91,9 +87,10 @@ end) test("should batch patches", function() local a = atom(0) local b = atom(0) - local client = client({ atoms = { a = a, b = b } }) local states = {} + client.registerAtoms({ a = a, b = b }) + subscribe(a, function() states.b = b() end) @@ -102,14 +99,12 @@ test("should batch patches", function() states.a = a() end) - client:sync({ - type = "init", - data = { a = a(), b = b() }, + client.applyPayloads({ + { type = "init", data = { a = a(), b = b() } }, }) - client:sync({ - type = "patch", - data = { a = 1, b = 2 }, + client.applyPayloads({ + { type = "patch", data = { a = 1, b = 2 } }, }) assert(a() == 1, "should set a to the new value") diff --git a/packages/charm-sync/src/__tests__/flatten.spec.luau b/packages/charm-sync/src/__tests__/flatten.spec.luau index 2507b17..ff4848d 100644 --- a/packages/charm-sync/src/__tests__/flatten.spec.luau +++ b/packages/charm-sync/src/__tests__/flatten.spec.luau @@ -1,9 +1,8 @@ local Charm = require("../../Charm") -local CharmSync = require("../") +local flatten = require("../flatten") local suite = require("../../../../test/suite") local atom = Charm.atom -local flatten = CharmSync.flatten local test = suite.test test("should flatten atoms", function() diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index 05bfef5..4a68750 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -1,15 +1,33 @@ -local HttpService = game:GetService("HttpService") - -local CharmSync = require("../") +local config = require("../config") +local patch = require("../patch") local suite = require("../../../../test/suite") -local patch = CharmSync.patch local test = suite.test +local afterEach = suite.afterEach -local function serdes(value: { [any]: any }) - return HttpService:JSONDecode(HttpService:JSONEncode(value)) +local function mockArraySerdes(object: { [any]: any }): { [any]: any } + if object[1] then + local copy = {} + for index = 1, #object do + copy[index] = object[index] + end + return copy + elseif table.maxn(object) ~= #object then + local copy = {} + for key, value in object do + copy[tostring(key)] = value + end + return copy + else + return object + end end +afterEach(function() + config.autoSerialize = true + config.validatePatches = true +end) + test("returns a patch", function() local prevState = { foo = "bar", baz = "qux" } local nextState = { foo = "bar", baz = "quux" } @@ -60,7 +78,7 @@ test("preserves array density after serdes", function() local prevState = { "a", "b", "c", "d", "e" } local nextState = { "A", "b", "c", "D" } - local patches = serdes(patch.diff(prevState, nextState)) + local patches = mockArraySerdes(patch.diff(prevState, nextState)) local applied = patch.apply(prevState, patches) for index in next, prevState do @@ -72,27 +90,11 @@ test("preserves array density after serdes", function() 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], `expected {applied[index]} to be {nextState[index]}`) - end -end) - test("cannot preserve sparse arrays", function() local prevState = {} local nextState = { [2] = "foo" } - local patches = serdes(patch.diff(prevState, nextState)) + local patches = mockArraySerdes(patch.diff(prevState, nextState)) local applied = patch.apply(prevState, patches) -- Not enough information is available to determine whether 'patches' @@ -102,24 +104,30 @@ test("cannot preserve sparse arrays", function() end) test("optionally fixes sparse arrays", function() - for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do + config.autoSerialize = true + + for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "string", `expected {type(index)} {index} to be string`) end - for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do + config.autoSerialize = false + + for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "number", `expected {type(index)} {index} to be number`) end end) test("optionally validates state", function() local success = pcall(function() - patch.diff({}, { [0.1] = true }, true) + config.validatePatches = true + patch.diff({}, { [0.1] = true }) end) assert(not success, "should not allow non-integer keys") success = pcall(function() - patch.diff({}, { [0.1] = true }, false) + config.validatePatches = false + patch.diff({}, { [0.1] = true }) end) assert(success, "should skip validation if not requested") diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau deleted file mode 100644 index d88b36f..0000000 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ /dev/null @@ -1,99 +0,0 @@ -local Charm = require("../../Charm") -local CharmSync = require("../") -local suite = require("../../../../test/suite") - -type SyncPayload = CharmSync.SyncPayload - -local atom = Charm.atom -local server = CharmSync.server -local patch = CharmSync.patch -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, "should send to the target player") - assert(payload.type == "init", "should be an init payload") - assert(payload.data.a == 1, "should send the initial state") - - 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, "should send to the target player") - assert(payload.type == "patch", "should be a patch payload") - assert(payload.data.a.b == nil, "should not send unchanged values") - assert(payload.data.a.c == 5, "should send changed values") - - a({ b = 1, d = 1 }) - server:_sendPatch(target) - - assert(payload.data.a.b == nil, "should not send unchanged values") - assert(patch.isNone(payload.data.a.c) == true, "should send None for removed values") - assert(payload.data.a.d == 1, "should send new values") - - 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, "should only have one payload") - assert(payloads[1].type == "patch", "should be a patch payload") - assert(payloads[1].data.a == 2, "should send the new value of a") - assert(payloads[1].data.b == 2, "should send the new value of b") - - a(3) - a(4) - b(3) - server:_sendPatch(target) - - assert(#payloads == 2, "should have two payloads") - assert(payloads[1].type == "patch", "1 should be a patch payload") - assert(payloads[1].data.a == 3, "1 should send the new value of a") - assert(payloads[1].data.b == nil, "1 should not send unchanged values") - assert(payloads[2].type == "patch", "2 should be a patch payload") - assert(payloads[2].data.a == 4, "2 should send the new value of a") - assert(payloads[2].data.b == 3, "2 should send the new value of b") - - disconnect() -end) - -return {} diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau index 84f7760..2fde10b 100644 --- a/packages/charm-sync/src/__tests__/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -4,10 +4,6 @@ suite.describe("client", function() require("./client.spec") end) -suite.describe("server", function() - require("./server.spec") -end) - suite.describe("flatten", function() require("./flatten.spec") end) diff --git a/packages/charm-sync/src/__tests__/validate.spec.luau b/packages/charm-sync/src/__tests__/validate.spec.luau index 86d2edb..5d8b99a 100644 --- a/packages/charm-sync/src/__tests__/validate.spec.luau +++ b/packages/charm-sync/src/__tests__/validate.spec.luau @@ -1,9 +1,8 @@ local Charm = require("../../Charm") -local CharmSync = require("../") local suite = require("../../../../test/suite") +local validate = require("../validate") local atom = Charm.atom -local validate = CharmSync.validate local test = suite.test test("validates inputs", function() diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 3bb778d..40edc76 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,75 +2,72 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") -type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload -type ClientOptions = { - --- The atoms to synchronize with the server. - atoms: AtomMap, - --- Whether to ignore patches sent before the client has been hydrated. - --- Default is `true`. - ignoreUnhydrated: boolean?, -} +local atomRegister: { [string]: Charm.Atom } = {} -type ClientSyncer = { - --- Applies a patch or initializes the state of the atoms with the given - --- payload from the server. - --- - --- @param ...payloads The patches or hydration payloads to apply. - sync: (self: ClientSyncer, ...SyncPayload) -> (), -} +--- @client +local function registerAtoms(atoms: { [string]: Charm.Atom }) + for key, atom in atoms do + if atomRegister[key] and atomRegister[key] ~= atom then + warn("[Charm] Attempted to sync different atoms with the same key:", key) + continue + end ---- Creates a `ClientSyncer` object that receives patches from the server and ---- applies them to the local state. ---- ---- @param options The atoms to synchronize with the server. ---- @return A `ClientSyncer` object. -local function client(options: ClientOptions): ClientSyncer - local atoms = options.atoms - local ignoreUnhydrated = options.ignoreUnhydrated ~= false + atomRegister[key] = atom + end +end - local self = {} :: ClientSyncer - local hydrated = false +--- @client +local function clearAtoms() + table.clear(atomRegister) +end - local function hydrate(state: { [string | number]: any }) - hydrated = true +--- @client +local function unregisterAtoms(...: string) + for index = 1, select("#", ...) do + local key = select(index, ...) + atomRegister[key] = nil + end +end - for key, value in state do - local atom = atoms[key] +local function applyInitPayload(data: { [string]: any }) + for key, value in data do + local atom = atomRegister[key] + if atom then atom(value) end end - - local function apply(data: { [string | number]: any }) - local target = {} - - for key, atom in atoms do - target[key] = atom() - end - - target = patch.apply(target, data) - - for key, atom in atoms do - atom(target[key]) - end - end - - function self:sync(...) - for index = 1, select("#", ...) do - local payload: SyncPayload = select(index, ...) - - Charm.batched(function() - if payload.type == "init" then - hydrate(payload.data) - elseif not ignoreUnhydrated or hydrated then - apply(payload.data) - end - end) - end - end - - return self end -return client +local function applyPatchPayload(data: { [string | number]: any }) + local target = {} + + for key, atom in atomRegister do + target[key] = atom() + end + + target = patch.apply(target, data) + + for key, atom in atomRegister do + atom(target[key]) + end +end + +--- @client +local function applyPayloads(payloads: { SyncPayload }) + for _, payload in payloads do + if payload.type == "init" then + Charm.batched(applyInitPayload, payload.data) + elseif payload.type == "patch" then + Charm.batched(applyPatchPayload, payload.data) + end + end +end + +return { + registerAtoms = registerAtoms, + clearAtoms = clearAtoms, + unregisterAtoms = unregisterAtoms, + applyPayloads = applyPayloads, +} diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau new file mode 100644 index 0000000..3023971 --- /dev/null +++ b/packages/charm-sync/src/config.luau @@ -0,0 +1,20 @@ +return { + --- The interval at which to send patches to the client, in seconds. + --- Defaults to `0` (patches are sent up to once per frame). Set to a + --- negative value to disable automatic syncing. + interval = 0, + --- Whether the history of state changes since the client's last update + --- should be preserved. This is useful for values that change multiple + --- times per frame, where each individual change is important. Defaults + --- to `false`. + preserveHistory = false, + --- When `true`, Charm will apply validation and serialize unsafe arrays + --- to address remote event argument limitations. Defaults to `true`. + --- This option should be disabled if your network library uses a custom + --- serialization method (i.e. Zap, ByteNet) to prevent interference. + autoSerialize = true, + --- When `true`, patches will be validated before being sent to clients to + --- enforce remote event limitations. Only checked in strict mode and if + --- `autoSerialize` is enabled. Defaults to `true`. + validatePatches = true, +} diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau index 5d4a60d..23a4e5d 100644 --- a/packages/charm-sync/src/flatten.luau +++ b/packages/charm-sync/src/flatten.luau @@ -1,13 +1,9 @@ -local types = require("./types") - -type AtomMap = types.AtomMap - type NestedAtomMap = { - [string]: NestedAtomMap | () -> (), + [string]: NestedAtomMap | () -> any, } -local function flatten(atoms: NestedAtomMap): AtomMap - local result: AtomMap = {} +local function flatten(atoms: NestedAtomMap): { [string]: () -> any } + local result: { [string]: () -> any } = {} local function visit(node: NestedAtomMap, path: string) for key, value in node do diff --git a/packages/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau index 249922f..f9c1a8e 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -1,17 +1,18 @@ local client = require("@self/client") +local config = require("@self/config") local flatten = require("@self/flatten") local patch = require("@self/patch") local server = require("@self/server") local types = require("@self/types") -local validate = require("@self/validate") +export type None = types.None export type SyncPayload = types.SyncPayload return { + config = config, client = client, server = server, - flatten = flatten, - validate = validate, patch = patch, isNone = patch.isNone, + flatten = flatten, } diff --git a/packages/charm-sync/src/interval.luau b/packages/charm-sync/src/interval.luau deleted file mode 100644 index 1225ecb..0000000 --- a/packages/charm-sync/src/interval.luau +++ /dev/null @@ -1,26 +0,0 @@ ---- Schedules a function to be called at a fixed interval of `seconds`. ---- ---- @param callback The function to call. ---- @param seconds The interval in seconds. ---- @return A function that cancels the interval. -local function interval(callback: () -> (), seconds: number): () -> () - if seconds < 0 then - return function() end - end - - local clock = 0 - - local connection = game:GetService("RunService").Heartbeat:Connect(function(delta) - clock += delta - if clock >= seconds then - clock = 0 - callback() - end - end) - - return function() - connection:Disconnect() - end -end - -return interval diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index fd449af..859cddc 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -1,16 +1,28 @@ local Charm = require("../Charm") +local config = require("./config") +local types = require("./types") local validate = require("./validate") local flags = Charm.flags +export type None = types.None + --- A special symbol that denotes the absence of a value. Used to represent --- deleted values in patches. -local None = { __none = "__none" } +local None: None = { __none = "__none" } +--- Checks if a value is the special `None` symbol. local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" end +--- Converts `nil` values to the special `None` symbol. This is useful when +--- preparing data for serialization, since `nil` can represent both the absence +--- of a value and the removal of a value in patches. +local function nilToNone(value: T?): T | None + return if value == nil then None else value +end + --- JSON serialization can drop the last values of a sparse array. For example, --- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the --- serialization of sparse arrays can be resolved by converting them to @@ -29,7 +41,7 @@ local function stringifySparseArray(object: { any }): { [string | number]: any } local dictionary = {} - for index, value in next, object do + for index, value in object do dictionary[tostring(index)] = value end @@ -41,14 +53,11 @@ end --- --- @param prevState The previous state. --- @param nextState The next state. ---- @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`. --- @returns A patch that can be applied to the previous state. -local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?) - serialize = serialize ~= false - +local function diff(prevState: { [any]: any }, nextState: { [any]: any }) local patches = table.clone(nextState) - for key, previous in next, prevState do + for key, previous in prevState do local current = nextState[key] if previous == current then @@ -56,18 +65,20 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial elseif current == nil then patches[key] = None elseif type(previous) == "table" and type(current) == "table" then - patches[key] = diff(previous, current, serialize) + patches[key] = diff(previous, current) end end - -- Coerce sparse array patches into dictionaries - if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then - patches = stringifySparseArray(patches) - end + if config.autoSerialize then + -- Coerce sparse array patches into dictionaries + if prevState[1] ~= nil or nextState[1] ~= nil then + patches = stringifySparseArray(patches) + end - if serialize and flags.strict then - for key, value in next, patches do - validate(value, key) + if flags.strict and config.validatePatches then + for key, value in patches do + validate(value, key) + end end end @@ -80,7 +91,7 @@ end --- @param patches The patches to apply. --- @returns The next state. local function apply(state: any, patches: any): any - if type(patches) == "table" and patches.__none == "__none" then + if isNone(patches) then return nil elseif type(state) ~= "table" or type(patches) ~= "table" then return patches @@ -89,7 +100,7 @@ local function apply(state: any, patches: any): any local nextState = table.clone(state) local stateIsArray = state[1] ~= nil - for key, patch in next, patches do + for key, patch in patches do -- Diff-checking an array produces a sparse array, which will not be -- preserved when converted to JSON. To prevent this, we turn string -- keys back into numeric keys. @@ -105,6 +116,7 @@ end return { isNone = isNone, + nilToNone = nilToNone, diff = diff, apply = apply, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 880944a..355ea11 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -1,250 +1,265 @@ local Charm = require("../Charm") -local interval = require("./interval") +local config = require("./config") local patch = require("./patch") local types = require("./types") -type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload -type ServerOptions = { - --- The atoms to synchronize with the client. - atoms: AtomMap, - --- The interval at which to send patches to the client, in seconds. - --- Defaults to `0` (patches are sent up to once per frame). Set to a - --- negative value to disable automatic syncing. - interval: number?, - --- Whether the history of state changes since the client's last update - --- should be preserved. This is useful for values that change multiple times - --- per frame, where each individual change is important. Defaults to `false`. - preserveHistory: boolean?, - --- When `true`, Charm will apply validation and serialize unsafe arrays - --- to address remote event argument limitations. Defaults to `true`. - --- - --- This option should be disabled if your network library uses a custom - --- serialization method (i.e. Zap, ByteNet) to prevent interference. - autoSerialize: boolean?, +type RegisteredAtom = { + atom: () -> T, + value: T, + valueLastSynced: T, + stop: () -> (), } -type ServerSyncer = { - --- Sets up a subscription to each atom that schedules a patch to be sent to - --- the client whenever the state changes. When a change occurs, the `callback` - --- is called with the player and the payload to send. - --- - --- Note that the `payload` object should not be mutated. If you need to - --- modify the payload, apply the changes to a copy of the object. - --- - --- @param callback The function to call when the state changes. - --- @return A cleanup function that unsubscribes all listeners. - connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (), - --- Hydrates the client's state with the server's state. This should be - --- called when a player joins the game and requires the server's state. - --- - --- @param player The player to hydrate. - hydrate: (self: ServerSyncer, player: Player) -> (), - --- @deprecated For internal use only. - _sendPatch: (self: ServerSyncer, player: Player) -> (), -} +type SyncStatus = number ---- Creates a `ServerSyncer` object that sends patches to the client and ---- hydrates the client's state. ---- ---- @param options The atoms to synchronize with the client. ---- @return A `ServerSyncer` object. -local function server(options: ServerOptions): ServerSyncer - local atoms = options.atoms - local autoSerialize = options.autoSerialize ~= false - local preserveHistory = options.preserveHistory - local syncRate = options.interval or 0 +local PENDING_INITIAL_STATE: SyncStatus = 0 +local LISTENING_FOR_CHANGES: SyncStatus = 1 - local self = {} :: ServerSyncer - local sync: (player: Player, payload: SyncPayload) -> () +local atomRegister: { [string]: RegisteredAtom } = {} +local clientRegister: { [any]: { [string]: SyncStatus } } = {} +local globalUpdates: { { key: string, value: any, previousValue: any } } = {} +local connection: RBXScriptConnection? - local function createSnapshot() - local snapshot = {} - - for key, atom in atoms do - snapshot[key] = atom() +local function registerAtom(key: string, atom: () -> T) + if atomRegister[key] then + if atomRegister[key].atom ~= atom then + warn("[Charm] Attempted to sync different atoms with the same key:", key) end - - return snapshot + return end - -- Send the initial state to a player when they join the server. - function self:hydrate(player) - assert(sync, "connect() must be called before hydrate()") + local registeredAtom: RegisteredAtom = { + atom = atom, + value = atom(), + valueLastSynced = atom(), + stop = function() end, + } - sync(player, { - type = "init", - data = createSnapshot(), - }) - end + registeredAtom.stop = Charm.effect(function() + local value = atom() - -- If the history of state changes should be preserved, keep a list of - -- previous snapshots and diff-check each one from the last. - if preserveHistory then - local snapshots = { createSnapshot() } - local changed = false - - function self:connect(callback) - local subscriptions = {} - - sync = callback - - local function pushSnapshot(key: string | number, current: any, previous: any) - local lastSnapshot = snapshots[#snapshots] - local previousSnapshot = snapshots[#snapshots - 1] - - -- Optimize snapshots by updating the most recent snapshot if the - -- previous and current values are the same, since this allows us - -- to group multiple changes into a single snapshot. - if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then - lastSnapshot[key] = current - else - local nextSnapshot = table.clone(lastSnapshot) - nextSnapshot[key] = current - table.insert(snapshots, nextSnapshot) - end - end - - -- Populate the snapshot with the initial state of each atom. - -- Subscribe to each atom and update the state when it changes. - for key, atom in atoms do - subscriptions[key] = Charm.subscribe(atom, function(current, previous) - pushSnapshot(key, current, previous) - changed = true - end) - end - - local disconnect = interval(function() - if not changed then - return - end - - local payloads: { SyncPayload } = {} - local lastSnapshot - - for index, snapshot in snapshots do - lastSnapshot = snapshot - - if index == 1 then - continue - end - - table.insert(payloads, { - type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, autoSerialize), - }) - end - - snapshots = { lastSnapshot } - changed = false - - for _, player in game:GetService("Players"):GetPlayers() do - callback(player, unpack(payloads)) - end - end, syncRate) - - return function() - disconnect() - - for _, unsubscribe in subscriptions do - unsubscribe() - end - end + if config.preserveHistory and registeredAtom.value ~= value then + table.insert(globalUpdates, { + key = key, + value = value, + previousValue = registeredAtom.value, + }) end - function self:_sendPatch(player: Player) - assert(sync, "connect() must be called before _sendPatch()") + registeredAtom.value = value + end) - if not changed then - return - end - - local payloads: { SyncPayload } = {} - - for index, snapshot in snapshots do - if index == 1 then - continue - end - - table.insert(payloads, { - type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, autoSerialize), - }) - end - - snapshots = { snapshots[#snapshots] } - changed = false - - sync(player, unpack(payloads)) - end - - return self - end - - local previousSnapshot = createSnapshot() - local currentSnapshot = table.clone(previousSnapshot) - local changed = false - - function self:connect(callback) - local subscriptions = {} - - sync = callback - - -- Subscribe to each atom and update the state when one changes. - for key, atom in atoms do - subscriptions[key] = Charm.subscribe(atom, function(state) - currentSnapshot[key] = state - changed = true - end) - end - - local disconnect = interval(function() - if not changed then - return - end - - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), - } - - previousSnapshot = table.clone(currentSnapshot) - changed = false - - for _, player in game:GetService("Players"):GetPlayers() do - callback(player, payload) - end - end, syncRate) - - return function() - disconnect() - - for _, unsubscribe in subscriptions do - unsubscribe() - end - end - end - - function self:_sendPatch(player) - assert(sync, "connect() must be called before _sendPatch()") - - if not changed then - return - end - - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), - } - - previousSnapshot = table.clone(currentSnapshot) - changed = false - - sync(player, payload) - end - - return self + atomRegister[key] = registeredAtom end -return server +local function unregisterAtom(key: string) + local registeredAtom = atomRegister[key] + + if registeredAtom then + registeredAtom.stop() + atomRegister[key] = nil + end +end + +local function unregisterUnusedAtoms() + local used: { [string]: boolean } = {} + + for _, clientAtoms in clientRegister do + for key in clientAtoms do + used[key] = true + end + end + + for key in atomRegister do + if not used[key] then + unregisterAtom(key) + end + end +end + +local function createHistoricalPatches(): { any } + local patchCount = #globalUpdates + local patches: { any } = table.create(patchCount) + + for index, update in globalUpdates do + patches[index] = patch.diff(update.previousValue, update.value) + end + + return patches +end + +local function createHistoricalPayloadsForClient(client: any, patches: { any }): { SyncPayload } + local syncedAtoms = clientRegister[client] + + if not syncedAtoms then + return {} + end + + local payloads: { SyncPayload } = {} + + for index, update in globalUpdates do + local patchData = patches[index] + + if not patchData then + continue + end + + local key = update.key + local status = syncedAtoms[key] + + if status == LISTENING_FOR_CHANGES then + table.insert(payloads, { + type = "patch", + data = { [key] = patchData }, + }) + elseif status == PENDING_INITIAL_STATE then + table.insert(payloads, { + type = "init", + data = { [key] = update.value }, + }) + + syncedAtoms[key] = LISTENING_FOR_CHANGES + end + end + + return payloads +end + +local function createPatch(): any + local current: { [string]: any } = {} + local previous: { [string]: any } = {} + + for key, registeredAtom in atomRegister do + current[key] = registeredAtom.value + previous[key] = registeredAtom.valueLastSynced + registeredAtom.valueLastSynced = registeredAtom.value + end + + return patch.diff(previous, current) +end + +local function createPayloadsForClient(client: any, patchData: any): { SyncPayload } + local syncedAtoms = clientRegister[client] + local payloads: { SyncPayload } = {} + + if not syncedAtoms then + return payloads + end + + local shouldSendInitialState = false + local initPayload: { [string]: any } = {} + local patchPayload: { [string]: any } = {} + + for key, status in syncedAtoms do + local registeredAtom = atomRegister[key] + + if status == LISTENING_FOR_CHANGES then + patchPayload[key] = patchData[key] + elseif status == PENDING_INITIAL_STATE then + initPayload[key] = registeredAtom.value + syncedAtoms[key] = LISTENING_FOR_CHANGES + shouldSendInitialState = true + end + end + + if shouldSendInitialState then + table.insert(payloads, { type = "init", data = initPayload }) + end + + if next(patchPayload) ~= nil then + table.insert(payloads, { type = "patch", data = patchPayload }) + end + + return payloads +end + +--- @server +local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) + if not clientRegister[client] then + clientRegister[client] = {} + end + + local syncedAtoms = clientRegister[client] + + for key, atom in atoms do + registerAtom(key, atom) + + if not syncedAtoms[key] then + syncedAtoms[key] = PENDING_INITIAL_STATE + end + end +end + +--- @server +local function unsyncClient(client: any) + clientRegister[client] = nil +end + +--- @server +local function unsyncAtomsFromClient(client: any, ...: string) + local syncedAtoms = clientRegister[client] + + if not syncedAtoms then + return + end + + for index = 1, select("#", ...) do + local key = select(index, ...) + syncedAtoms[key] = nil + end + + if next(syncedAtoms) == nil then + clientRegister[client] = nil + end +end + +--- @server +local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) + if connection then + connection:Disconnect() + end + + local elapsedTime = 0 + + local function sync() + local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() + + if Charm.flags.frozen then + Charm.deepFreeze(patches) + end + + for client in clientRegister do + local payloads: { SyncPayload } = if config.preserveHistory + then createHistoricalPayloadsForClient(client, patches) + else createPayloadsForClient(client, patches) + + if payloads[1] then + task.spawn(onSync, client, payloads) + end + end + + unregisterUnusedAtoms() + table.clear(globalUpdates) + end + + connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime: number) + elapsedTime += deltaTime + + if elapsedTime >= config.interval then + elapsedTime %= config.interval + + debug.profilebegin("CharmSync") + sync() + debug.profileend() + end + end) +end + +return { + syncAtomsToClient = syncAtomsToClient, + unsyncClient = unsyncClient, + unsyncAtomsFromClient = unsyncAtomsFromClient, + startSync = startSync, +} diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 3ccd746..482f91d 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -1,21 +1,17 @@ -local Charm = require("../Charm") - -type Atom = Charm.Atom - --- A payload that can be sent from the server to the client to synchronize ---- state between the two. +--- the states of the client's atoms with the server. export type SyncPayload = | { type: "init", - data: { [string | number]: any }, + data: { [string]: any }, } | { type: "patch", - data: { [string | number]: any }, + data: { [string]: any }, } -export type AtomMap = { - [string | number]: Atom, -} +--- A special type that represents a removed value in a patch. This is used to +--- differentiate between a value that was set to `nil` and an unchanged value. +export type None = { __none: "__none" } return nil diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index 769f410..6d747ce 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -2,25 +2,26 @@ local SAFE_KEYS = { string = true, number = true } local UNSAFE_VALUES = { ["function"] = true, thread = true } local function isTableUnsafe(object: { [any]: any }) - local keyType = nil - local objectSize = 0 + local key = next(object) - -- All keys must have the same type - for key in next, object do - local currentType = type(key) - - if not keyType and SAFE_KEYS[currentType] then - keyType = currentType - elseif keyType ~= currentType then - return true - end - - objectSize += 1 + if key == nil then + -- An empty table is always safe + return false end - -- If there are more keys than the length of the array, it's an array with - -- non-sequential keys. - if objectSize > #object and keyType == "number" then + local keyType = type(key) + local keyCount = 0 + + -- Keys must either be all strings or all numbers + for key in object do + if type(key) ~= keyType then + return true + end + keyCount += 1 + end + + -- Array keys must be sequential + if keyType == "number" and keyCount > #object then return true end @@ -33,19 +34,21 @@ end --- @param key The key of the value in the table. --- @error Throws an error if the value cannot be synced. local function validate(value: any, key: any) - local typeOfKey = type(key) - local typeOfValue = type(value) + local typeKey = type(key) + local typeValue = type(value) - if not SAFE_KEYS[typeOfKey] then - error(`Invalid key type '{typeOfKey}' at key '{key}'`) - elseif UNSAFE_VALUES[typeOfValue] then - error(`Invalid value type '{typeOfValue}' at key '{key}'`) - elseif typeOfValue == "table" then + if not SAFE_KEYS[typeKey] then + error(`Invalid key type '{typeKey}' at key '{key}'`) + elseif UNSAFE_VALUES[typeValue] then + error(`Invalid value type '{typeValue}' at key '{key}'`) + end + + if typeValue == "table" then if getmetatable(value) ~= nil then error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) elseif isTableUnsafe(value) then error( - `Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n` + `Cannot sync tables unsupported by remote events! The table was found at the key '{key}'.\n\n` .. "This can be for the following reasons:\n" .. "1. The object is an array with non-sequential keys\n" .. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n" @@ -54,7 +57,7 @@ local function validate(value: any, key: any) end end - if typeOfKey == "number" then + if typeKey == "number" then if key == math.huge or key == -math.huge then error("Cannot sync infinity as key") elseif key ~= math.floor(key) then diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 36236b2..e718af4 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,5 +1,3 @@ ---!nolint DeprecatedApi - local batched = require("@self/batched") local core = require("@self/core") local flags = require("@self/flags") @@ -9,15 +7,11 @@ local observe = require("@self/observe") local subscribe = require("@self/subscribe") local types = require("@self/types") local untracked = require("@self/untracked") +local utils = require("@self/utils") export type Atom = types.Atom export type Selector = types.Selector ---- @deprecated Function `batch()` is deprecated, use `batched()` instead. -local batch = batched ---- @deprecated Function `peek()` is deprecated, use `untracked()` instead. -local peek = untracked - return { core = core, flags = flags, @@ -32,7 +26,5 @@ return { observe = observe, subscribe = subscribe, untracked = untracked, - -- Temporary aliases for backwards compatibility - batch = batch, - peek = peek, + deepFreeze = utils.deepFreeze, } diff --git a/test/spec.server.luau b/test/spec.server.luau index ab0f072..dfb6d0b 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -8,7 +8,7 @@ suite.describe("charm", function() require("../packages/charm/src/__tests__/spec") end) -suite.describe.ingame("charm-sync", function() +suite.describe("charm-sync", function() require("../packages/charm-sync/src/__tests__/spec") end) From 0dbc0587a00e74c81998e528314b01f4a8acbc53 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:30:35 -0700 Subject: [PATCH 044/160] fix: send init payload even if atom did not update --- packages/charm-sync/src/client.luau | 4 ---- packages/charm-sync/src/server.luau | 37 ++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 40edc76..3beb209 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -6,7 +6,6 @@ type SyncPayload = types.SyncPayload local atomRegister: { [string]: Charm.Atom } = {} ---- @client local function registerAtoms(atoms: { [string]: Charm.Atom }) for key, atom in atoms do if atomRegister[key] and atomRegister[key] ~= atom then @@ -18,12 +17,10 @@ local function registerAtoms(atoms: { [string]: Charm.Atom }) end end ---- @client local function clearAtoms() table.clear(atomRegister) end ---- @client local function unregisterAtoms(...: string) for index = 1, select("#", ...) do local key = select(index, ...) @@ -54,7 +51,6 @@ local function applyPatchPayload(data: { [string | number]: any }) end end ---- @client local function applyPayloads(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 355ea11..b645e8e 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -40,6 +40,8 @@ local function registerAtom(key: string, atom: () -> T) registeredAtom.stop = Charm.effect(function() local value = atom() + -- When preserving history, we need to keep track of previous values + -- so we can create granular patches for each change. if config.preserveHistory and registeredAtom.value ~= value then table.insert(globalUpdates, { key = key, @@ -81,7 +83,7 @@ end local function createHistoricalPatches(): { any } local patchCount = #globalUpdates - local patches: { any } = table.create(patchCount) + local patches = table.create(patchCount) for index, update in globalUpdates do patches[index] = patch.diff(update.previousValue, update.value) @@ -99,6 +101,8 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }): local payloads: { SyncPayload } = {} + -- Iterate over all global updates and see if the client is interested in + -- any of them. If so, we create a patch payload for the update. for index, update in globalUpdates do local patchData = patches[index] @@ -124,6 +128,23 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }): end end + -- If a pending atom was never updated, we still need to send its initial + -- state. This can happen if the atom was registered after its last change. + local initPayload: { [string]: any } = {} + + for key, status in syncedAtoms do + local registeredAtom = atomRegister[key] + + if status == PENDING_INITIAL_STATE and registeredAtom then + initPayload[key] = registeredAtom.value + syncedAtoms[key] = LISTENING_FOR_CHANGES + end + end + + if next(initPayload) ~= nil then + table.insert(payloads, 1, { type = "init" :: "init", data = initPayload }) + end + return payloads end @@ -148,10 +169,11 @@ local function createPayloadsForClient(client: any, patchData: any): { SyncPaylo return payloads end - local shouldSendInitialState = false local initPayload: { [string]: any } = {} local patchPayload: { [string]: any } = {} + -- Create patches for initialized atoms that changed, and add atoms that + -- are still pending initial state to the init payload. for key, status in syncedAtoms do local registeredAtom = atomRegister[key] @@ -160,11 +182,10 @@ local function createPayloadsForClient(client: any, patchData: any): { SyncPaylo elseif status == PENDING_INITIAL_STATE then initPayload[key] = registeredAtom.value syncedAtoms[key] = LISTENING_FOR_CHANGES - shouldSendInitialState = true end end - if shouldSendInitialState then + if next(initPayload) ~= nil then table.insert(payloads, { type = "init", data = initPayload }) end @@ -175,7 +196,6 @@ local function createPayloadsForClient(client: any, patchData: any): { SyncPaylo return payloads end ---- @server local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) if not clientRegister[client] then clientRegister[client] = {} @@ -187,17 +207,17 @@ local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) registerAtom(key, atom) if not syncedAtoms[key] then + -- Mark the atom as pending initial state so we send its full value + -- the next time we sync. syncedAtoms[key] = PENDING_INITIAL_STATE end end end ---- @server local function unsyncClient(client: any) clientRegister[client] = nil end ---- @server local function unsyncAtomsFromClient(client: any, ...: string) local syncedAtoms = clientRegister[client] @@ -215,7 +235,6 @@ local function unsyncAtomsFromClient(client: any, ...: string) end end ---- @server local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) if connection then connection:Disconnect() @@ -226,6 +245,8 @@ local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) local function sync() local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() + -- While payload objects are technically mutable as they are unique to + -- each client, the data inside them is not safe to mutate. if Charm.flags.frozen then Charm.deepFreeze(patches) end From 5a05f012237175820d612630f7e067cc12ca7836 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 1 Oct 2025 01:30:31 -0700 Subject: [PATCH 045/160] test: add all sync tests --- .../charm-sync/src/__tests__/e2e.spec.luau | 96 ++++++ .../charm-sync/src/__tests__/server.spec.luau | 306 ++++++++++++++++++ packages/charm-sync/src/__tests__/spec.luau | 8 + packages/charm-sync/src/patch.luau | 42 +-- packages/charm-sync/src/server.luau | 93 ++++-- 5 files changed, 492 insertions(+), 53 deletions(-) create mode 100644 packages/charm-sync/src/__tests__/e2e.spec.luau create mode 100644 packages/charm-sync/src/__tests__/server.spec.luau diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau new file mode 100644 index 0000000..e386e85 --- /dev/null +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -0,0 +1,96 @@ +local Charm = require("../../Charm") +local client = require("../client") +local config = require("../config") +local server = require("../server") +local suite = require("../../../../test/suite") + +local afterEach = suite.afterEach +local test = suite.test + +afterEach(function() + server.reset() + client.clearAtoms() + config.preserveHistory = false +end) + +test("should replicate server state to client", function() + local target = {} + local serverA = Charm.atom({ count = 0 }) + local serverB = Charm.atom({ count = 0 }) + local clientA = Charm.atom({ count = -1 }) + local clientB = Charm.atom({ count = -1 }) + + client.registerAtoms({ a = clientA, b = clientB }) + server.syncAtomsToClient(target, { a = serverA, b = serverB }) + server.startSync(function(syncTarget, payloads) + if syncTarget == target then + client.applyPayloads(payloads) + end + end) + + server.flush() + assert(clientA().count == 0, "Client atom A should be initialized to 0") + assert(clientB().count == 0, "Client atom B should be initialized to 0") + + serverA({ count = 1 }) + serverB({ count = 1 }) + server.flush() + assert(clientA().count == 1, "Client atom A should be updated to 1") + assert(clientB().count == 1, "Client atom B should be updated to 1") + + local prevClientB = clientB() + serverA({ count = 2 }) + server.flush() + assert(clientA().count == 2, "Client atom A should be updated to 2") + assert(clientB() == prevClientB, "Client atom B should not be updated") +end) + +test("should replicate historical server state to client", function() + local target = {} + local serverA = Charm.atom({ count = 0 }) + local serverB = Charm.atom({ count = 0 }) + local clientA = Charm.atom({ count = -1 }) + local clientB = Charm.atom({ count = -1 }) + + config.preserveHistory = true + + client.registerAtoms({ a = clientA, b = clientB }) + server.syncAtomsToClient(target, { a = serverA, b = serverB }) + server.startSync(function(syncTarget, payloads) + if syncTarget == target then + client.applyPayloads(payloads) + end + end) + + local expectedA = { -1, 0, 1, 2, 3, 4 } + local expectedB = { -1, 0, 1, 2 } + + Charm.effect(function() + local expected = table.remove(expectedA, 1) + assert(expected == clientA().count, `Client atom A should be {expected}`) + end) + + Charm.effect(function() + local expected = table.remove(expectedB, 1) + assert(expected == clientB().count, `Client atom B should be {expected}`) + end) + + server.flush() + + serverA({ count = 1 }) + serverA({ count = 2 }) + serverB({ count = 1 }) + serverB({ count = 2 }) + server.flush() + assert(clientA().count == 2, "Client atom A should be updated to 2") + assert(clientB().count == 2, "Client atom B should be updated to 2") + + local prevClientB = clientB() + serverA({ count = 3 }) + serverA({ count = 4 }) + server.flush() + assert(clientA().count == 4, "Client atom A should be updated to 4") + assert(clientB() == prevClientB, "Client atom B should not be updated") +end) + +return {} diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau new file mode 100644 index 0000000..744c75e --- /dev/null +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -0,0 +1,306 @@ +local Charm = require("../../Charm") +local config = require("../config") +local patch = require("../patch") +local server = require("../server") +local suite = require("../../../../test/suite") + +local afterEach = suite.afterEach +local beforeEach = suite.beforeEach +local describe = suite.describe +local test = suite.test + +afterEach(function() + server.reset() +end) + +test("should replicate state changes", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local c = Charm.atom(0) + local target, payloads + + server.syncAtomsToClient(client, { a = a, b = b, c = c }) + server.startSync(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == a(), "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'") + + a(1) + b(2) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == a(), "Expected patch payload to contain atom 'a'") + assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'") +end) + +test("should replicate partial state patches", function() + local client = {} + local a = Charm.atom({ x = 0, y = 0 }) + local b = Charm.atom(0 :: number?) + local target, payloads + + server.syncAtomsToClient(client, { a = a, b = b }) + server.startSync(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a.x == a().x, "Expected init payload to contain atom 'a.x'") + assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + + a({ x = 1, y = 0 }) + b(nil) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a.x == 1, "Expected patch payload to contain atom 'a.x'") + assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'") + assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None") +end) + +test("should init new clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local payloadsA, payloadsB + + server.syncAtomsToClient(clientA, { a = a }) + server.startSync(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + a(1) + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") + + server.syncAtomsToClient(clientB, { a = a }) + a(2) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B") +end) + +test("should init new atoms", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local target, payloads + + server.syncAtomsToClient(client, { a = a }) + server.startSync(function(...) + target, payloads = ... + end) + + server.flush() + server.syncAtomsToClient(client, { b = b }) + a(1) + b(1) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + assert(payloads[2].type == "patch", "Expected second payload to be patch") + assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'") +end) + +test("should only replicate to synced clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local payloadsA, payloadsB + + server.syncAtomsToClient(clientA, { a = a }) + server.syncAtomsToClient(clientB, { b = b }) + server.startSync(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") + assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B") + assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B") + + payloadsA = {} + payloadsB = {} + a(1) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A") + assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A") + assert(#payloadsB == 0, "Expected no payloads for client B") + + payloadsA = {} + payloadsB = {} + b(1) + server.flush() + assert(#payloadsA == 0, "Expected no payloads for client A") + assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B") + assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B") + assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B") +end) + +test("should validate state", function() + local client = {} + local a = Charm.atom({}) + + server.syncAtomsToClient(client, { a = a }) + server.startSync(function() end) + + a({ [0.5] = true }) + assert(not pcall(server.flush), "Expected server.flush to fail") + + a({ [0.5] = true, valid = true }) + server.flush() +end) + +test("should serialize state", function() + local client = {} + local a = Charm.atom({ 1, 2, 3, 4, 5 }) + local payloads + + server.syncAtomsToClient(client, { a = a }) + server.startSync(function(_, p) + payloads = p + end) + server.flush() + + a({ 1, 2, 0, 4 }) + server.flush() + assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key") + assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key") +end) + +describe("historical", function() + beforeEach(function() + config.preserveHistory = true + end) + + afterEach(function() + config.preserveHistory = false + end) + + test("should replicate granular state changes", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local c = Charm.atom(0) + local target, payloads + + server.syncAtomsToClient(client, { a = a, b = b, c = c }) + server.startSync(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'") + + a(1) + a(2) + b(2) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'") + assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'") + end) + + test("should init new clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local payloadsA, payloadsB + + server.syncAtomsToClient(clientA, { a = a }) + server.startSync(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + a(1) + a(2) + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A") + assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A") + assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A") + + server.syncAtomsToClient(clientB, { a = a }) + a(3) + a(4) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A") + assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B") + assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B") + assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B") + end) + + test("should init new atoms", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local payloads + + server.syncAtomsToClient(client, { a = a }) + server.startSync(function(_, ...) + payloads = ... + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + server.syncAtomsToClient(client, { b = b }) + a(1) + a(2) + b(1) + b(2) + server.flush() + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'") + assert(payloads[3].type == "init", "Expected third payload to be init") + assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'") + assert(payloads[4].type == "patch", "Expected fourth payload to be patch") + assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'") + end) +end) + +return {} diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau index 2fde10b..2b6363e 100644 --- a/packages/charm-sync/src/__tests__/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -4,6 +4,10 @@ suite.describe("client", function() require("./client.spec") end) +suite.describe("e2e", function() + require("./e2e.spec") +end) + suite.describe("flatten", function() require("./flatten.spec") end) @@ -12,6 +16,10 @@ suite.describe("patch", function() require("./patch.spec") end) +suite.describe("server", function() + require("./server.spec") +end) + suite.describe("validate", function() require("./validate.spec") end) diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 859cddc..617efad 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -54,19 +54,19 @@ end --- @param prevState The previous state. --- @param nextState The next state. --- @returns A patch that can be applied to the previous state. -local function diff(prevState: { [any]: any }, nextState: { [any]: any }) - local patches = table.clone(nextState) +local function diff(prevState: any, nextState: any): any + if prevState == nextState then + return nil + elseif nextState == nil then + return None + elseif type(prevState) ~= "table" or type(nextState) ~= "table" then + return nextState + end + + local patches: { [any]: any } = table.clone(nextState) for key, previous in prevState do - local current = nextState[key] - - if previous == current then - patches[key] = nil - elseif current == nil then - patches[key] = None - elseif type(previous) == "table" and type(current) == "table" then - patches[key] = diff(previous, current) - end + patches[key] = diff(previous, nextState[key]) end if config.autoSerialize then @@ -87,20 +87,20 @@ end --- Applies a patch to a state and returns the next state. --- ---- @param state The current state. ---- @param patches The patches to apply. +--- @param prevState The current state. +--- @param statePatch The patches to apply. --- @returns The next state. -local function apply(state: any, patches: any): any - if isNone(patches) then +local function apply(prevState: any, statePatch: any): any + if isNone(statePatch) then return nil - elseif type(state) ~= "table" or type(patches) ~= "table" then - return patches + elseif type(prevState) ~= "table" or type(statePatch) ~= "table" then + return statePatch end - local nextState = table.clone(state) - local stateIsArray = state[1] ~= nil + local nextState = table.clone(prevState) + local stateIsArray = prevState[1] ~= nil - for key, patch in patches do + for key, value in statePatch do -- Diff-checking an array produces a sparse array, which will not be -- preserved when converted to JSON. To prevent this, we turn string -- keys back into numeric keys. @@ -108,7 +108,7 @@ local function apply(state: any, patches: any): any key = tonumber(key) or key end - nextState[key] = apply(nextState[key], patch) + nextState[key] = apply(nextState[key], value) end return nextState diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index b645e8e..55f94b6 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -235,47 +235,73 @@ local function unsyncAtomsFromClient(client: any, ...: string) end end -local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) +local function onSyncCallback(_client: any, _payloads: { SyncPayload }) + warn("[Charm] Override the onSync function by passing a custom function to startSync") +end + +local function flush() + local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() + + -- While payload objects are technically mutable as they are unique to + -- each client, the data inside them is not safe to mutate. + if Charm.flags.frozen then + Charm.deepFreeze(patches) + end + + for client in clientRegister do + local payloads: { SyncPayload } = if config.preserveHistory + then createHistoricalPayloadsForClient(client, patches) + else createPayloadsForClient(client, patches) + + if payloads[1] then + if task then + task.spawn(onSyncCallback, client, payloads) + else + onSyncCallback(client, payloads) + end + end + end + + unregisterUnusedAtoms() + table.clear(globalUpdates) +end + +local function stopSync() + if connection then + connection:Disconnect() + connection = nil + end +end + +local function reset() + table.clear(atomRegister) + table.clear(clientRegister) + table.clear(globalUpdates) + stopSync() +end + +local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()): () -> () if connection then connection:Disconnect() end - local elapsedTime = 0 + onSyncCallback = onSync - local function sync() - local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() + if game then + local elapsedTime = 0 - -- While payload objects are technically mutable as they are unique to - -- each client, the data inside them is not safe to mutate. - if Charm.flags.frozen then - Charm.deepFreeze(patches) - end - - for client in clientRegister do - local payloads: { SyncPayload } = if config.preserveHistory - then createHistoricalPayloadsForClient(client, patches) - else createPayloadsForClient(client, patches) - - if payloads[1] then - task.spawn(onSync, client, payloads) + connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) + elapsedTime += deltaTime + if elapsedTime >= config.interval then + elapsedTime %= config.interval + debug.profilebegin("CharmSync") + flush() + debug.profileend() end - end - - unregisterUnusedAtoms() - table.clear(globalUpdates) + end) end - connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime: number) - elapsedTime += deltaTime - - if elapsedTime >= config.interval then - elapsedTime %= config.interval - - debug.profilebegin("CharmSync") - sync() - debug.profileend() - end - end) + return stopSync end return { @@ -283,4 +309,7 @@ return { unsyncClient = unsyncClient, unsyncAtomsFromClient = unsyncAtomsFromClient, startSync = startSync, + stopSync = stopSync, + flush = flush, + reset = reset, } From f5709c3112bc377ddddc8260e86b86f74a196696 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 2 Oct 2025 01:43:28 -0700 Subject: [PATCH 046/160] feat: prepare for alpha release --- .../charm-sync/src/__tests__/client.spec.luau | 12 +- .../charm-sync/src/__tests__/e2e.spec.luau | 6 +- packages/charm-sync/src/client.luau | 18 +- packages/charm-sync/src/index.d.ts | 189 +++++++++++++++++- packages/charm-sync/src/server.luau | 31 +-- packages/charm-sync/src/validate.luau | 4 +- .../charm/src/__tests__/untracked.spec.luau | 17 ++ packages/charm/src/index.d.ts | 12 +- packages/react-charm/src/init.luau | 4 +- packages/vide-charm/src/init.luau | 12 +- 10 files changed, 251 insertions(+), 54 deletions(-) diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index 33f3013..d761ca0 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -8,13 +8,13 @@ local test = suite.test local afterEach = suite.afterEach afterEach(function() - client.clearAtoms() + client.reset() end) test("should sync state payload", function() local a = atom(1) - client.registerAtoms({ a = a }) + client.syncAtoms({ a = a }) client.applyPayloads({ { type = "init", data = { a = 2 } }, }) @@ -26,7 +26,7 @@ test("should sync state patch", function() local a = atom({ b = 1, c = 2 }) local previous = a() - client.registerAtoms({ a = a }) + client.syncAtoms({ a = a }) client.applyPayloads({ { type = "init", data = { a = a() } }, }) @@ -45,7 +45,7 @@ end) test("should fix sparse array patches", function() local a = atom({ 1 }) - client.registerAtoms({ a = a }) + client.syncAtoms({ a = a }) client.applyPayloads({ { type = "init", data = { a = a() } }, }) @@ -65,7 +65,7 @@ test("should apply multiple patches", function() local a = atom(1) local calls = {} - client.registerAtoms({ a = a }) + client.syncAtoms({ a = a }) subscribe(a, function(value) table.insert(calls, value) @@ -89,7 +89,7 @@ test("should batch patches", function() local b = atom(0) local states = {} - client.registerAtoms({ a = a, b = b }) + client.syncAtoms({ a = a, b = b }) subscribe(a, function() states.b = b() diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau index e386e85..acc75e8 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -9,7 +9,7 @@ local test = suite.test afterEach(function() server.reset() - client.clearAtoms() + client.reset() config.preserveHistory = false end) @@ -20,7 +20,7 @@ test("should replicate server state to client", function() local clientA = Charm.atom({ count = -1 }) local clientB = Charm.atom({ count = -1 }) - client.registerAtoms({ a = clientA, b = clientB }) + client.syncAtoms({ a = clientA, b = clientB }) server.syncAtomsToClient(target, { a = serverA, b = serverB }) server.startSync(function(syncTarget, payloads) if syncTarget == target then @@ -54,7 +54,7 @@ test("should replicate historical server state to client", function() config.preserveHistory = true - client.registerAtoms({ a = clientA, b = clientB }) + client.syncAtoms({ a = clientA, b = clientB }) server.syncAtomsToClient(target, { a = serverA, b = serverB }) server.startSync(function(syncTarget, payloads) if syncTarget == target then diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 3beb209..3610056 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -6,7 +6,7 @@ type SyncPayload = types.SyncPayload local atomRegister: { [string]: Charm.Atom } = {} -local function registerAtoms(atoms: { [string]: Charm.Atom }) +local function syncAtoms(atoms: { [string]: Charm.Atom }) for key, atom in atoms do if atomRegister[key] and atomRegister[key] ~= atom then warn("[Charm] Attempted to sync different atoms with the same key:", key) @@ -17,17 +17,17 @@ local function registerAtoms(atoms: { [string]: Charm.Atom }) end end -local function clearAtoms() - table.clear(atomRegister) -end - -local function unregisterAtoms(...: string) +local function unsyncAtoms(...: string) for index = 1, select("#", ...) do local key = select(index, ...) atomRegister[key] = nil end end +local function reset() + table.clear(atomRegister) +end + local function applyInitPayload(data: { [string]: any }) for key, value in data do local atom = atomRegister[key] @@ -62,8 +62,8 @@ local function applyPayloads(payloads: { SyncPayload }) end return { - registerAtoms = registerAtoms, - clearAtoms = clearAtoms, - unregisterAtoms = unregisterAtoms, + syncAtoms = syncAtoms, + unsyncAtoms = unsyncAtoms, + reset = reset, applyPayloads = applyPayloads, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index cb0ff5c..ca736db 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -1 +1,188 @@ -export {}; +import { Atom } from "@rbxts/charm"; + +type Cleanup = () => void; + +type Key = string | number | symbol; + +/** + * Represents the removal of a value from the state. + */ +export interface None { + readonly __none: "__none"; +} + +export type MaybeNone = undefined extends T ? None : never; + +type SelectorMap = Record any>; + +/** + * Infers the type of the return values produced by a map of functions. + */ +type StateOfMap = { + readonly [P in keyof T]: T[P] extends () => infer State ? State : never; +}; + +type DataTypes = { + [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; +}; + +/** + * A type that should not be made partial in patches. + */ +type DataType = DataTypes[keyof DataTypes]; + +/** + * A partial patch that can be applied to the state to update it. Represents + * the difference between the current state and the next state. + * + * If a value was removed, it is replaced with `None`. This can be checked + * using the `isNone` function. + */ +export type SyncPatch = MaybeNone | State extends + | ReadonlyMap + | Map + ? ReadonlyMap | None> + : State extends Set | ReadonlySet + ? ReadonlyMap + : State extends readonly (infer T)[] + ? Serialize extends true + ? ReadonlyMap | None> + : readonly (SyncPatch | None | undefined)[] + : State extends DataType + ? State + : State extends object + ? { readonly [P in keyof State]?: SyncPatch } + : State; + +/** + * A payload that can be sent from the server to the client to synchronize + * state between the two. + */ +export type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch, Serialize> }; + +/** + * Checks whether a value is `None`. If `true`, the value is scheduled to be + * removed from the state when the patch is applied. + * + * @param value The value to check. + * @returns `true` if the value is `None`, otherwise `false`. + */ +export function isNone(value: unknown): value is None; + +export const config: { + /** + * The interval at which to send patches to the client, in seconds. + * Defaults to `0` (patches are sent up to once per frame). Set to a + * negative value to disable automatic syncing. + * + * @server + */ + interval: number; + /** + * Whether the history of state changes since the client's last update + * should be preserved. This is useful for values that change multiple + * times per frame, where each individual change is important. Defaults + * to `false`. + * + * @server + */ + preserveHistory: boolean; + /** + * When `true`, Charm will apply validation and serialize unsafe arrays + * to address remote event argument limitations. Defaults to `true`. + * This option should be disabled if your network library uses a custom + * serialization method (i.e. Zap, ByteNet) to prevent interference. + * + * @server + */ + autoSerialize: boolean; + /** + * When `true`, patches will be validated before being sent to clients to + * enforce remote event limitations. Only checked in strict mode and if + * `autoSerialize` is enabled. Defaults to `true`. + * + * @server + */ + validatePatches: boolean; +}; + +export namespace client { + export function syncAtoms(atoms: { [key: string]: Atom }): void; + + export function unsyncAtoms(...keys: string[]): void; + + export function reset(): void; + + export function applyPayloads }, Serialize extends boolean>( + payloads: SyncPayload[], + ): void; +} + +export namespace server { + export function syncAtomsToClient(client: Player, atoms: { [key: string]: Atom }): void; + + export function unsyncAtomsFromClient(client: Player, ...keys: string[]): void; + + export function unsyncClient(client: Player): void; + + export function startSync }, Serialize extends boolean>( + onSync: (client: Player, payload: SyncPayload) => void, + ): Cleanup; + + export function stopSync(): void; + + export function flush(): void; + + export function reset(): void; +} + +export namespace patch { + export function isNone(value: unknown): value is None; + + export function nilToNone(value: T | undefined): T | None; + + export function diff( + currentState: State, + nextState: State, + ): SyncPatch; + + export function apply( + state: State, + patch: SyncPatch, + ): State; +} + +interface NestedAtomMap { + readonly [key: string]: NestedAtomMap | Atom; +} + +type AppendPath = undefined extends Path + ? Name + : Name extends string + ? Path extends string + ? `${Path}/${Name}` + : never + : never; + +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; + +type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; + +type FlattenNestedAtoms = IntersectValues<{ + readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom + ? { readonly [K in AppendPath]: Atoms[Name] } + : Atoms[Name] extends NestedAtomMap + ? FlattenNestedAtoms> + : never; +}>; + +/** + * Flattens a nested atom map into a single object with slash-separated keys. + * Useful for recursively collecting atoms returned by modules. + * + * @param atoms The nested atom map to flatten. + * @return A flattened atom map. + */ +export function flatten(atoms: Atoms): FlattenNestedAtoms; diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 55f94b6..2d3789d 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -287,20 +287,27 @@ local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) onSyncCallback = onSync - if game then - local elapsedTime = 0 - - connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) - elapsedTime += deltaTime - if elapsedTime >= config.interval then - elapsedTime %= config.interval - debug.profilebegin("CharmSync") - flush() - debug.profileend() - end - end) + if not game then + return stopSync end + local timer = 0 + + connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) + if config.interval < 0 then + return + end + + timer += deltaTime + + if timer >= config.interval then + timer %= config.interval + debug.profilebegin("CharmSync") + flush() + debug.profileend() + end + end) + return stopSync end diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index 6d747ce..64d7caf 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -41,9 +41,7 @@ local function validate(value: any, key: any) error(`Invalid key type '{typeKey}' at key '{key}'`) elseif UNSAFE_VALUES[typeValue] then error(`Invalid value type '{typeValue}' at key '{key}'`) - end - - if typeValue == "table" then + elseif typeValue == "table" then if getmetatable(value) ~= nil then error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) elseif isTableUnsafe(value) then diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau index 6d7fb55..b32fd8c 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -127,4 +127,21 @@ test("should track outside untracked", function() assert(spyCalls == 3, "should not run on c change") end) +test("should stack untracked", function() + local a = atom(1) + local spyCalls = 0 + + effect(function() + untracked(function() + untracked(a) + return a() + end) + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + a(2) + assert(spyCalls == 1, "should not be called twice") +end) + return {} diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 8be022f..533b052 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -21,7 +21,7 @@ export function atom(): Atom; export function computed(getter: (previousValue?: T) => T, equals?: Equals): () => T; -export function effect(callback: () => Cleanup | void): Cleanup; +export function effect(callback: (dispose: Cleanup) => Cleanup | void): Cleanup; export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; @@ -65,13 +65,3 @@ export function batched( export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; export function onCleanup(callback: Cleanup, failSilently?: boolean): void; - -/** - * @deprecated Use `batched()` instead. - */ -export const batch: typeof batched; - -/** - * @deprecated Use `untracked()` instead. - */ -export const peek: typeof untracked; diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index b7598f0..c81d7dd 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -15,7 +15,6 @@ local function useAtom(getter: () -> T, dependencies: { any }?): T local state, setState = React.useState(getter) React.useLayoutEffect(function() - setState(getter()) return Charm.effect(function() setState(getter()) end) @@ -38,8 +37,7 @@ local function useAtomBinding(getter: () -> T, dependencies: { any }?) local initialState = React.useMemo(getter, {}) local binding, setBinding = React.useBinding(initialState) - React.useEffect(function() - setBinding(getter()) + React.useLayoutEffect(function() return Charm.effect(function() setBinding(getter()) end) diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index c6326dd..b2d27d8 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -1,18 +1,18 @@ local Charm = require("./Charm") local Vide = require("./Vide") -local subscribe = Charm.subscribe -local source = Vide.source -local cleanup = Vide.cleanup - --- Returns a reactive source that updates when the given getter function's --- value changes. --- --- @param getter The atom or selector to subscribe to. --- @return The reactive source. local function useAtom(getter: () -> State): () -> State - local state = source(getter()) - cleanup(subscribe(getter, state)) + local state = Vide.source(getter()) + + Vide.cleanup(Charm.effect(function() + state(getter()) + end)) + return state end From 1657e2cb747d8b369106e8ca2dc16e3a8528a427 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 2 Oct 2025 19:29:04 -0700 Subject: [PATCH 047/160] feat: add peek, make untracked escape scope --- packages/charm/src/__tests__/listen.spec.luau | 2 +- packages/charm/src/__tests__/peek.spec.luau | 147 ++++++++++++++++++ packages/charm/src/__tests__/spec.luau | 3 + .../charm/src/__tests__/subscribe.spec.luau | 2 +- .../charm/src/__tests__/untracked.spec.luau | 50 ++++-- packages/charm/src/core.luau | 4 +- packages/charm/src/index.d.ts | 4 +- packages/charm/src/init.luau | 2 + packages/charm/src/listen.luau | 4 +- packages/charm/src/peek.luau | 27 ++++ packages/charm/src/subscribe.luau | 4 +- packages/charm/src/untracked.luau | 27 ++-- 12 files changed, 248 insertions(+), 28 deletions(-) create mode 100644 packages/charm/src/__tests__/peek.spec.luau create mode 100644 packages/charm/src/peek.luau diff --git a/packages/charm/src/__tests__/listen.spec.luau b/packages/charm/src/__tests__/listen.spec.luau index d73bf23..b529cde 100644 --- a/packages/charm/src/__tests__/listen.spec.luau +++ b/packages/charm/src/__tests__/listen.spec.luau @@ -28,7 +28,7 @@ test("should listen to selector", function() assert(current == 2 and previous == 1, "should not run after unsub") end) -test("should run in untracked", function() +test("should run in peek", function() local runs = 0 local cleanups = 0 local a = atom(0) diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.spec.luau new file mode 100644 index 0000000..0c9cc5b --- /dev/null +++ b/packages/charm/src/__tests__/peek.spec.luau @@ -0,0 +1,147 @@ +local Charm = require("../") +local suite = require("../../../../test/suite") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local peek = Charm.peek +local test = suite.test + +test("should not run detached", function() + local a = atom(1) + local spyCalls = 0 + + effect(function() + a() + peek(function() + effect(function() + return function() + spyCalls += 1 + end + end) + end) + end) + + assert(spyCalls == 0, "should not be called yet") + a(2) + assert(spyCalls == 1, "should be called once") + a(3) + assert(spyCalls == 2, "should be called twice") +end) + +test("should block tracking inside effects", function() + local a = atom(1) + local b = atom(2) + local spyCalls = 0 + + effect(function() + peek(function() + spyCalls += 1 + return a() + b() + end) + end) + assert(spyCalls == 1, "should be called once") + + a(10) + b(20) + assert(spyCalls == 1, "should be called once") +end) + +test("should block tracking even when run inside effect run inside peek", function() + local s = atom(1) + local spyCalls = 0 + + peek(function() + effect(function() + peek(function() + spyCalls += 1 + return s() + end) + end) + end) + assert(spyCalls == 1, "should be called once") + + s(2) + assert(spyCalls == 1, "should be called once") +end) + +test("should not cause atom assignments throw", function() + local a = atom(1) + local aChangedTime = atom(0) + + local dispose = effect(function() + a() + peek(function() + aChangedTime(aChangedTime() + 1) + end) + end) + + a(2) + assert(aChangedTime() == 2, "aChangedTime should be 2") + a(3) + assert(aChangedTime() == 3, "aChangedTime should be 3") + + dispose() +end) + +test("should block tracking inside computed atoms", function() + local a = atom(1) + local b = atom(2) + local spyCalls = 0 + local c = computed(function() + return peek(function() + spyCalls += 1 + return a() + b() + end) + end) + + assert(spyCalls == 0, "should not be called yet") + assert(c() == 3, "c should be 3") + a(10) + c() + b(20) + c() + assert(spyCalls == 1, "should be called once") + assert(c() == 3, "c should be 3") +end) + +test("should track outside peek", function() + local a = atom(1) + local b = atom(2) + local c = atom(3) + local spyCalls = 0 + + effect(function() + a() + peek(c) + b() + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + a(10) + assert(spyCalls == 2, "should be called twice") + b(20) + assert(spyCalls == 3, "should be called three times") + c(30) + assert(spyCalls == 3, "should not run on c change") +end) + +test("should stack peek", function() + local a = atom(1) + local spyCalls = 0 + + effect(function() + peek(function() + peek(a) + return a() + end) + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + a(2) + assert(spyCalls == 1, "should not be called twice") +end) + +return {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index 46bd916..8273125 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -36,5 +36,8 @@ end) suite.describe("untracked", function() require("./untracked.spec") end) +suite.describe("peek", function() + require("./peek.spec") +end) return {} diff --git a/packages/charm/src/__tests__/subscribe.spec.luau b/packages/charm/src/__tests__/subscribe.spec.luau index 6d14a4d..3ebb003 100644 --- a/packages/charm/src/__tests__/subscribe.spec.luau +++ b/packages/charm/src/__tests__/subscribe.spec.luau @@ -31,7 +31,7 @@ test("should subscribe to selector", function() assert(current == 2 and previous == 1, "should not run after unsub") end) -test("should run in untracked", function() +test("should run in peek", function() local runs = 0 local cleanups = 0 local a = atom(0) diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau index b32fd8c..f5c5f07 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -4,29 +4,59 @@ local suite = require("../../../../test/suite") local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect +local effectScope = Charm.effectScope local untracked = Charm.untracked local test = suite.test -test("should not run detached", function() +test("should run detached from effect", function() local a = atom(1) - local spyCalls = 0 + local effects = 0 + local innerEffects = {} - effect(function() + local stopEffect = effect(function() a() untracked(function() - effect(function() + local stopInnerEffect = effect(function() + effects += 1 return function() - spyCalls += 1 + effects -= 1 + end + end) + table.insert(innerEffects, stopInnerEffect) + end) + end) + + assert(effects == 1, "should have one effect") + a(2) + assert(effects == 2, "should have two effects") + stopEffect() + assert(effects == 2, "should still have two effects") + for _, stopInnerEffect in innerEffects do + stopInnerEffect() + end + assert(effects == 0, "should have zero effects") +end) + +test("should run detached from effect scope", function() + local effects = 0 + local stopEffect + + local stopScope = effectScope(function() + untracked(function() + stopEffect = effect(function() + effects += 1 + return function() + effects -= 1 end end) end) end) - assert(spyCalls == 0, "should not be called yet") - a(2) - assert(spyCalls == 1, "should be called once") - a(3) - assert(spyCalls == 2, "should be called twice") + assert(effects == 1, "should have one effect") + stopScope() + assert(effects == 1, "should still have one effect") + stopEffect() + assert(effects == 0, "should have zero effects") end) test("should block tracking inside effects", function() diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 81296cc..dffba9a 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -23,11 +23,11 @@ local PENDING = 0b0100000 local QUEUED = 0b1000000 local UNTRACKED = 0b10000000 -type EffectScope = ReactiveNode & { +export type EffectScope = ReactiveNode & { cleanups: { () -> () }, } -type Effect = ReactiveNode & { +export type Effect = ReactiveNode & { fn: (dispose: () -> ()) -> ...(() -> ())?, cleanups: { () -> () }, cleanup: () -> (), diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 533b052..a8cabeb 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -2,8 +2,8 @@ type Key = string | number | symbol; type Cleanup = () => void; export interface Atom { - (): T; (value: T | ((value: T) => T)): T; + (): T; } export type Selector = () => T; @@ -64,4 +64,6 @@ export function batched( export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; +export function peek(callback: (...args: Args) => Result, ...args: Args): Result; + export function onCleanup(callback: Cleanup, failSilently?: boolean): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index e718af4..50350b5 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -4,6 +4,7 @@ local flags = require("@self/flags") local listen = require("@self/listen") local mapped = require("@self/mapped") local observe = require("@self/observe") +local peek = require("@self/peek") local subscribe = require("@self/subscribe") local types = require("@self/types") local untracked = require("@self/untracked") @@ -24,6 +25,7 @@ return { listen = listen, mapped = mapped, observe = observe, + peek = peek, subscribe = subscribe, untracked = untracked, deepFreeze = utils.deepFreeze, diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 4d3db95..90a6b4f 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -1,5 +1,5 @@ local core = require("./core") -local untracked = require("./untracked") +local peek = require("./peek") local computed = core.computed local effect = core.effect @@ -19,7 +19,7 @@ local function listen(getter: () -> T, listener: (state: T, previous: T?, dis dispose = effect(function() local previous = current current = tracked() - untracked(listener, current, previous, dispose) + peek(listener, current, previous, dispose) end) if disposed then diff --git a/packages/charm/src/peek.luau b/packages/charm/src/peek.luau new file mode 100644 index 0000000..27c7e5a --- /dev/null +++ b/packages/charm/src/peek.luau @@ -0,0 +1,27 @@ +local core = require("./core") + +local UNTRACKED = core.ReactiveFlags.Untracked +local getCurrentSub = core.getCurrentSub + +local function handlePeek(sub: core.ReactiveNode?, ok: boolean, ...) + if sub then + sub.flags = bit32.band(sub.flags, bit32.bnot(UNTRACKED)) + end + if ok then + return ... + else + error((...)) + end +end + +local function peek(callback: (Args...) -> Results..., ...: Args...): Results... + local sub = getCurrentSub() + if not sub or bit32.btest(sub.flags, UNTRACKED) then + return callback(...) + else + sub.flags = bit32.bor(sub.flags, UNTRACKED) + end + return handlePeek(sub, pcall(callback, ...)) +end + +return peek diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index c8ee09b..9c46c0a 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -1,5 +1,5 @@ local core = require("./core") -local untracked = require("./untracked") +local peek = require("./peek") local computed = core.computed local effect = core.effect @@ -18,7 +18,7 @@ local function subscribe(getter: () -> T, listener: (state: T, lastState: T) if mounted then mounted = false else - untracked(listener, current, previous) + peek(listener, current, previous) end end) end diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau index 9661fa3..6038fec 100644 --- a/packages/charm/src/untracked.luau +++ b/packages/charm/src/untracked.luau @@ -1,11 +1,20 @@ local core = require("./core") -local UNTRACKED = core.ReactiveFlags.Untracked +local setCurrentSub = core.setCurrentSub +local setCurrentScope = core.setCurrentScope local getCurrentSub = core.getCurrentSub +local getCurrentScope = core.getCurrentScope -local function handleUntracked(sub: core.ReactiveNode?, ok: boolean, ...) - if sub then - sub.flags = bit32.band(sub.flags, bit32.bnot(UNTRACKED)) +local function handleUntracked( + prevSub: core.ReactiveNode?, + prevScope: core.Effect | core.EffectScope?, + ok: boolean, + ... +) + if prevSub then + setCurrentSub(prevSub) + elseif prevScope then + setCurrentScope(prevScope) end if ok then return ... @@ -15,13 +24,13 @@ local function handleUntracked(sub: core.ReactiveNode?, ok: boolean, ...) end local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - local sub = getCurrentSub() - if not sub or bit32.btest(sub.flags, UNTRACKED) then - return callback(...) + if getCurrentSub() then + return handleUntracked(setCurrentSub(nil), nil, pcall(callback, ...)) + elseif getCurrentScope() then + return handleUntracked(nil, setCurrentScope(nil), pcall(callback, ...)) else - sub.flags = bit32.bor(sub.flags, UNTRACKED) + return callback(...) end - return handleUntracked(sub, pcall(callback, ...)) end return untracked From 87e7260c1613daee06f56c5f2e6ef44957abe04e Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 2 Oct 2025 19:30:40 -0700 Subject: [PATCH 048/160] fix: wrap hooks in untracked, update types --- packages/charm-sync/src/index.d.ts | 6 +-- packages/charm-sync/src/server.luau | 2 +- packages/react-charm/src/init.luau | 4 +- .../src/__tests__/useAtom.spec.luau | 42 +++++++++++++++---- packages/vide-charm/src/init.luau | 2 +- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index ca736db..edbfc39 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -115,7 +115,7 @@ export namespace client { export function reset(): void; - export function applyPayloads }, Serialize extends boolean>( + export function applyPayloads }, Serialize extends boolean = true>( payloads: SyncPayload[], ): void; } @@ -127,8 +127,8 @@ export namespace server { export function unsyncClient(client: Player): void; - export function startSync }, Serialize extends boolean>( - onSync: (client: Player, payload: SyncPayload) => void, + export function startSync }, Serialize extends boolean = true>( + onSync: (client: Player, payloads: SyncPayload[]) => void, ): Cleanup; export function stopSync(): void; diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 2d3789d..d9009b1 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -301,7 +301,7 @@ local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) timer += deltaTime if timer >= config.interval then - timer %= config.interval + timer = 0 debug.profilebegin("CharmSync") flush() debug.profileend() diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index c81d7dd..44f43f9 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -16,7 +16,7 @@ local function useAtom(getter: () -> T, dependencies: { any }?): T React.useLayoutEffect(function() return Charm.effect(function() - setState(getter()) + Charm.untracked(setState, getter()) end) end, dependencies or {}) @@ -39,7 +39,7 @@ local function useAtomBinding(getter: () -> T, dependencies: { any }?) React.useLayoutEffect(function() return Charm.effect(function() - setBinding(getter()) + Charm.untracked(setBinding, getter()) end) end, dependencies or {}) diff --git a/packages/vide-charm/src/__tests__/useAtom.spec.luau b/packages/vide-charm/src/__tests__/useAtom.spec.luau index 5f4a801..88c6b5b 100644 --- a/packages/vide-charm/src/__tests__/useAtom.spec.luau +++ b/packages/vide-charm/src/__tests__/useAtom.spec.luau @@ -3,16 +3,13 @@ local Vide = require("../../Vide") local VideCharm = require("../") local suite = require("../../../../test/suite") -local atom = Charm.atom -local root = Vide.root -local useAtom = VideCharm.useAtom local test = suite.test test("tracks atom state", function() - local state = atom(0) + local state = Charm.atom(0) local source - local cleanup = root(function() - source = useAtom(state) + local cleanup = Vide.root(function() + source = VideCharm.useAtom(state) end) assert(source() == 0, "should return 0") state(1) @@ -21,10 +18,10 @@ test("tracks atom state", function() end) test("accepts a selector", function() - local state = atom(0) + local state = Charm.atom(0) local source - local cleanup = root(function() - source = useAtom(function() + local cleanup = Vide.root(function() + source = VideCharm.useAtom(function() return 2 * state() end) end) @@ -34,4 +31,31 @@ test("accepts a selector", function() cleanup() end) +test("updates source in untracked", function() + local outerAtom = Charm.atom(nil :: any) + local innerAtom = Charm.atom(0) + local spyCalls = 0 + local cleanup = Vide.root(function() + return Vide.show(VideCharm.useAtom(outerAtom), function() + -- Charm effects created in Vide scopes triggered by useAtom might + -- incorrectly get treated as an inner effect in useAtom. This test + -- ensures that doesn't happen. + Vide.cleanup(Charm.effect(function() + innerAtom() + spyCalls += 1 + end)) + return nil + end) + end) + assert(spyCalls == 0, "should not call spy initially") + outerAtom(true) + assert(spyCalls == 1, "should call spy when visible") + innerAtom(1) + assert(spyCalls == 2, "spy should run when counter updates") + outerAtom({}) -- set to a truthy value to trigger an outer effect re-run + innerAtom(2) + assert(spyCalls == 3, "spy should still run when counter updates") + cleanup() +end) + return {} diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index b2d27d8..bf59c2e 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -10,7 +10,7 @@ local function useAtom(getter: () -> State): () -> State local state = Vide.source(getter()) Vide.cleanup(Charm.effect(function() - state(getter()) + Charm.untracked(state :: (State) -> State, getter()) end)) return state From 027c5bf8f89e1f7b8e22f15f7018052459bcb087 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 2 Oct 2025 19:37:03 -0700 Subject: [PATCH 049/160] feat: add flag for inner effect tracking --- .gitignore | 1 + packages/charm/src/core.luau | 2 +- packages/charm/src/flags.luau | 4 ++++ packages/charm/src/index.d.ts | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 815441d..52e0af1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ node_modules *.tsbuildinfo *.rbxm +*.tgz .DS_Store roblox.d.luau diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index dffba9a..f430260 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -349,7 +349,7 @@ local function effect(fn: (dispose: () -> ()) -> ...(() -> ())?): () -> () cleanup = nil :: any, } self.cleanup = effectStop(self) - if activeSub then + if activeSub and config.trackInnerEffects then link(self, activeSub) elseif activeScope then link(self, activeScope) diff --git a/packages/charm/src/flags.luau b/packages/charm/src/flags.luau index 3900394..43475be 100644 --- a/packages/charm/src/flags.luau +++ b/packages/charm/src/flags.luau @@ -12,4 +12,8 @@ return { --- Enforces immutability of atoms by deep-freezing table values. Enabled --- if the optimization level is below 2, which is true in studio. frozen = not isO2, + --- Whether inner effects should clean up after themselves when their + --- parent effect re-runs. This is usually desirable, but can be disabled + --- to revert to the old behavior. Defaults to `true`. + trackInnerEffects = true, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index a8cabeb..cfffd62 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -13,6 +13,7 @@ export type Equals = (current: T, incoming: T) => boolean; export const flags: { strict: boolean; frozen: boolean; + trackInnerEffects: boolean; }; export function atom(value: T, equals?: Equals): Atom; From 7a639932930facc46316d51f64a84d566a95df13 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:45:01 -0700 Subject: [PATCH 050/160] refactor: add docs, remove some modules --- packages/charm-sync/src/client.luau | 17 ++ packages/charm-sync/src/flatten.luau | 5 + packages/charm-sync/src/index.d.ts | 128 +++++++++++-- packages/charm-sync/src/patch.luau | 36 ++-- packages/charm-sync/src/server.luau | 40 +++- packages/charm-sync/src/types.luau | 2 +- .../src/__tests__/core/computed.spec.luau | 9 +- .../charm/src/{system.luau => alien.luau} | 0 packages/charm/src/batched.luau | 23 --- packages/charm/src/core.luau | 165 ++++++++++++++--- packages/charm/src/index.d.ts | 172 +++++++++++++++++- packages/charm/src/init.luau | 13 +- packages/charm/src/listen.luau | 14 +- packages/charm/src/mapped.luau | 19 +- packages/charm/src/observe.luau | 16 +- packages/charm/src/peek.luau | 27 --- packages/charm/src/subscribe.luau | 14 +- packages/charm/src/types.luau | 2 + packages/charm/src/untracked.luau | 36 ---- 19 files changed, 558 insertions(+), 180 deletions(-) rename packages/charm/src/{system.luau => alien.luau} (100%) delete mode 100644 packages/charm/src/batched.luau delete mode 100644 packages/charm/src/peek.luau delete mode 100644 packages/charm/src/untracked.luau diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 3610056..5b40ba0 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -6,6 +6,10 @@ type SyncPayload = types.SyncPayload local atomRegister: { [string]: Charm.Atom } = {} +--- Registers atoms to be kept in sync with the server. Atoms with matching +--- keys on the server will be synced to these atoms. +--- +--- @param atoms An object mapping string keys to atoms to be synced. local function syncAtoms(atoms: { [string]: Charm.Atom }) for key, atom in atoms do if atomRegister[key] and atomRegister[key] ~= atom then @@ -17,6 +21,11 @@ local function syncAtoms(atoms: { [string]: Charm.Atom }) end end +--- Unregisters one or more atoms from being kept in sync with the server. +--- The atoms will retain their current values, but will no longer receive +--- updates from the server. +--- +--- @param keys The keys of the atoms to stop syncing. local function unsyncAtoms(...: string) for index = 1, select("#", ...) do local key = select(index, ...) @@ -24,6 +33,9 @@ local function unsyncAtoms(...: string) end end +--- Unregisters all atoms from being kept in sync with the server. The +--- atoms will retain their current values, but will no longer receive +--- updates from the server. local function reset() table.clear(atomRegister) end @@ -51,6 +63,11 @@ local function applyPatchPayload(data: { [string | number]: any }) end end +--- Applies one or more payloads received from the server to the synced +--- atoms. Payloads are state patches or initial state updates sent by the +--- server, and are usually received via a remote event. +--- +--- @param payloads The payloads to apply. local function applyPayloads(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau index 23a4e5d..06335f4 100644 --- a/packages/charm-sync/src/flatten.luau +++ b/packages/charm-sync/src/flatten.luau @@ -2,6 +2,11 @@ type NestedAtomMap = { [string]: NestedAtomMap | () -> any, } +--- Flattens a nested atom map into a single object with slash-separated keys. +--- Useful for recursively collecting atoms returned by modules. +--- +--- @param atoms The nested atom map to flatten. +--- @return A flattened atom map. local function flatten(atoms: NestedAtomMap): { [string]: () -> any } local result: { [string]: () -> any } = {} diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index edbfc39..7f565ed 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -26,9 +26,6 @@ type DataTypes = { [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; }; -/** - * A type that should not be made partial in patches. - */ type DataType = DataTypes[keyof DataTypes]; /** @@ -63,14 +60,8 @@ export type SyncPayload, Serialize> }; /** - * Checks whether a value is `None`. If `true`, the value is scheduled to be - * removed from the state when the patch is applied. - * - * @param value The value to check. - * @returns `true` if the value is `None`, otherwise `false`. + * Global configuration options that affect the behavior of Charm Sync. */ -export function isNone(value: unknown): value is None; - export const config: { /** * The interval at which to send patches to the client, in seconds. @@ -108,49 +99,142 @@ export const config: { validatePatches: boolean; }; +/** + * @client + */ export namespace client { + /** + * Registers atoms to be kept in sync with the server. Atoms with matching + * keys on the server will be synced to these atoms. + * + * @param atoms An object mapping string keys to atoms to be synced. + */ export function syncAtoms(atoms: { [key: string]: Atom }): void; + /** + * Unregisters one or more atoms from being kept in sync with the server. + * The atoms will retain their current values, but will no longer receive + * updates from the server. + * + * @param keys The keys of the atoms to stop syncing. + */ export function unsyncAtoms(...keys: string[]): void; + /** + * Unregisters all atoms from being kept in sync with the server. The + * atoms will retain their current values, but will no longer receive + * updates from the server. + */ export function reset(): void; + /** + * Applies one or more payloads received from the server to the synced + * atoms. Payloads are state patches or initial state updates sent by the + * server, and are usually received via a remote event. + * + * @param payloads The payloads to apply. + */ export function applyPayloads }, Serialize extends boolean = true>( payloads: SyncPayload[], ): void; } +/** + * @server + */ export namespace server { + /** + * Registers atoms to be kept in sync with a specific client. Should be + * called when a player joins the game to start syncing atoms to them. Call + * `unsyncAtomsFromClient` or `unsyncClient` to stop syncing. + * + * Each atom should be assigned a unique key, allowing the server to re-use + * patches between multiple clients. + * + * @param client The player to sync atoms to. + * @param atoms An object mapping string keys to atoms to be synced. + */ export function syncAtomsToClient(client: Player, atoms: { [key: string]: Atom }): void; + /** + * Unregisters one or more atoms from being kept in sync with a specific + * client. + * + * @param client The player to stop syncing atoms to. + * @param keys The keys of the atoms to stop syncing. + */ export function unsyncAtomsFromClient(client: Player, ...keys: string[]): void; + /** + * Unregisters all atoms from being kept in sync with a specific client. + * Should be called when a player leaves the game. + * + * @param client The player to stop syncing atoms to. + */ export function unsyncClient(client: Player): void; + /** + * Starts automatically syncing registered atoms to clients. Patches will + * be sent to clients whenever the atoms are updated. + * + * @param onSync The function to call when patches can be sent to a client. + * @returns `stopSync()` + */ export function startSync }, Serialize extends boolean = true>( onSync: (client: Player, payloads: SyncPayload[]) => void, ): Cleanup; + /** + * Stops automatically syncing registered atoms to clients. Patches will + * no longer be sent to clients until `startSync` is called again. + */ export function stopSync(): void; + /** + * Immediately sends pending updates and initial states to all clients. + * Normally, updates are sent at the interval specified in `config.interval`. + * Calling this function forces all pending updates to be sent immediately. + */ export function flush(): void; + /** + * Unregisters all atoms from being kept in sync with all clients, and + * stops automatic syncing. + */ export function reset(): void; } export namespace patch { - export function isNone(value: unknown): value is None; - + /** + * Converts `nil` values to the special `None` symbol. This is useful when + * preparing data for serialization, since `nil` can represent both the absence + * of a value and the removal of a value in patches. + */ export function nilToNone(value: T | undefined): T | None; + /** + * Compares two states and returns a patch that can be applied to the + * current state to produce the next state. + * + * @param currentState The current state. + * @param nextState The next state. + * @returns A patch that can be applied to the current state. + */ export function diff( currentState: State, nextState: State, ): SyncPatch; + /** + * Applies a patch to a state and returns the resulting value. + * + * @param currentState The current state. + * @param statePatch The patches to apply. + * @returns The new state with the patch applied. + */ export function apply( - state: State, - patch: SyncPatch, + currentState: State, + statePatch: SyncPatch, ): State; } @@ -170,7 +254,10 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; -type FlattenNestedAtoms = IntersectValues<{ +export type FlattenNestedAtoms< + Atoms extends NestedAtomMap, + Path extends Key | undefined = undefined, +> = IntersectValues<{ readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom ? { readonly [K in AppendPath]: Atoms[Name] } : Atoms[Name] extends NestedAtomMap @@ -183,6 +270,15 @@ type FlattenNestedAtoms(atoms: Atoms): FlattenNestedAtoms; + +/** + * Checks whether a value is `None`. If `true`, the value is scheduled to be + * removed from the state when the patch is applied. + * + * @param value The value to check. + * @returns `true` if the value is `None`, otherwise `false`. + */ +export function isNone(value: unknown): value is None; diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 617efad..7f017ee 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -11,7 +11,11 @@ export type None = types.None --- deleted values in patches. local None: None = { __none = "__none" } ---- Checks if a value is the special `None` symbol. +--- Checks whether a value is `None`. If `true`, the value is scheduled to be +--- removed from the state when the patch is applied. +--- +--- @param value The value to check. +--- @return `true` if the value is `None`, otherwise `false`. local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" end @@ -48,30 +52,30 @@ local function stringifySparseArray(object: { any }): { [string | number]: any } return dictionary end ---- Compares two states and returns a patch that can be applied to the previous +--- Compares two states and returns a patch that can be applied to the current --- state to produce the next state. --- ---- @param prevState The previous state. +--- @param currentState The current state. --- @param nextState The next state. ---- @returns A patch that can be applied to the previous state. -local function diff(prevState: any, nextState: any): any - if prevState == nextState then +--- @return A patch that can be applied to the current state. +local function diff(currentState: any, nextState: any): any + if currentState == nextState then return nil elseif nextState == nil then return None - elseif type(prevState) ~= "table" or type(nextState) ~= "table" then + elseif type(currentState) ~= "table" or type(nextState) ~= "table" then return nextState end local patches: { [any]: any } = table.clone(nextState) - for key, previous in prevState do + for key, previous in currentState do patches[key] = diff(previous, nextState[key]) end if config.autoSerialize then -- Coerce sparse array patches into dictionaries - if prevState[1] ~= nil or nextState[1] ~= nil then + if currentState[1] ~= nil or nextState[1] ~= nil then patches = stringifySparseArray(patches) end @@ -85,20 +89,20 @@ local function diff(prevState: any, nextState: any): any return patches end ---- Applies a patch to a state and returns the next state. +--- Applies a patch to a state and returns the resulting value. --- ---- @param prevState The current state. +--- @param currentState The current state. --- @param statePatch The patches to apply. ---- @returns The next state. -local function apply(prevState: any, statePatch: any): any +--- @return The new state with the patch applied. +local function apply(currentState: any, statePatch: any): any if isNone(statePatch) then return nil - elseif type(prevState) ~= "table" or type(statePatch) ~= "table" then + elseif type(currentState) ~= "table" or type(statePatch) ~= "table" then return statePatch end - local nextState = table.clone(prevState) - local stateIsArray = prevState[1] ~= nil + local nextState = table.clone(currentState) + local stateIsArray = currentState[1] ~= nil for key, value in statePatch do -- Diff-checking an array produces a sparse array, which will not be diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index d9009b1..5f628d7 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -20,7 +20,9 @@ local LISTENING_FOR_CHANGES: SyncStatus = 1 local atomRegister: { [string]: RegisteredAtom } = {} local clientRegister: { [any]: { [string]: SyncStatus } } = {} local globalUpdates: { { key: string, value: any, previousValue: any } } = {} + local connection: RBXScriptConnection? +local onSyncCallback: (client: any, payloads: { SyncPayload }) -> ()? local function registerAtom(key: string, atom: () -> T) if atomRegister[key] then @@ -196,6 +198,15 @@ local function createPayloadsForClient(client: any, patchData: any): { SyncPaylo return payloads end +--- Registers atoms to be kept in sync with a specific client. Should be +--- called when a player joins the game to start syncing atoms to them. Call +--- `unsyncAtomsFromClient` or `unsyncClient` to stop syncing. +--- +--- Each atom should be assigned a unique key, allowing the server to re-use +--- patches between multiple clients. +--- +--- @param client The player to sync atoms to. +--- @param atoms An object mapping string keys to atoms to be synced. local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) if not clientRegister[client] then clientRegister[client] = {} @@ -214,10 +225,19 @@ local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) end end +--- Unregisters all atoms from being kept in sync with a specific client. +--- Should be called when a player leaves the game. +--- +--- @param client The player to stop syncing atoms to. local function unsyncClient(client: any) clientRegister[client] = nil end +--- Unregisters one or more atoms from being kept in sync with a specific +--- client. +--- +--- @param client The player to stop syncing atoms to. +--- @param keys The keys of the atoms to stop syncing. local function unsyncAtomsFromClient(client: any, ...: string) local syncedAtoms = clientRegister[client] @@ -235,11 +255,14 @@ local function unsyncAtomsFromClient(client: any, ...: string) end end -local function onSyncCallback(_client: any, _payloads: { SyncPayload }) - warn("[Charm] Override the onSync function by passing a custom function to startSync") -end - +--- Immediately sends pending updates and initial states to all clients. +--- Normally, updates are sent at the interval specified in `config.interval`. +--- Calling this function forces all pending updates to be sent immediately. local function flush() + if not onSyncCallback then + return + end + local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() -- While payload objects are technically mutable as they are unique to @@ -266,6 +289,8 @@ local function flush() table.clear(globalUpdates) end +--- Stops automatically syncing registered atoms to clients. Patches will +--- no longer be sent to clients until `startSync` is called again. local function stopSync() if connection then connection:Disconnect() @@ -273,6 +298,8 @@ local function stopSync() end end +--- Unregisters all atoms from being kept in sync with all clients, and +--- stops automatic syncing. local function reset() table.clear(atomRegister) table.clear(clientRegister) @@ -280,6 +307,11 @@ local function reset() stopSync() end +--- Starts automatically syncing registered atoms to clients. Patches will +--- be sent to clients whenever the atoms are updated. +--- +--- @param onSync The function to call when patches can be sent to a client. +--- @return `stopSync()` local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()): () -> () if connection then connection:Disconnect() diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 482f91d..ac8ffb4 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -14,4 +14,4 @@ export type SyncPayload = --- differentiate between a value that was set to `nil` and an unchanged value. export type None = { __none: "__none" } -return nil +return {} diff --git a/packages/charm/src/__tests__/core/computed.spec.luau b/packages/charm/src/__tests__/core/computed.spec.luau index 9c6e078..43c5fad 100644 --- a/packages/charm/src/__tests__/core/computed.spec.luau +++ b/packages/charm/src/__tests__/core/computed.spec.luau @@ -81,13 +81,12 @@ test("should not update if the atom value is reverted", function() assert(times == 1, "computed ran when it should not") end) -test("should accept equality function", function() +test("should receive previous value", function() local calls = {} local src = atom(0) - local c1 = computed(function() - return src() - end, function(current, incoming) - return current == incoming or incoming % 2 == 1 + local c1 = computed(function(current): number + local incoming = src() + return if current == incoming or incoming % 2 == 1 then current or incoming else incoming end) effect(function() diff --git a/packages/charm/src/system.luau b/packages/charm/src/alien.luau similarity index 100% rename from packages/charm/src/system.luau rename to packages/charm/src/alien.luau diff --git a/packages/charm/src/batched.luau b/packages/charm/src/batched.luau deleted file mode 100644 index 29a3d28..0000000 --- a/packages/charm/src/batched.luau +++ /dev/null @@ -1,23 +0,0 @@ -local core = require("./core") -local flags = require("./flags") -local utils = require("./utils") - -local startBatch = core.startBatch -local endBatch = core.endBatch -local noYield = utils.noYield - -local function handleBatched(ok: boolean, ...) - endBatch() - if ok then - return ... - else - error((...)) - end -end - -local function batched(callback: (Args...) -> Results..., ...: Args...): Results... - startBatch() - return handleBatched((if flags.strict then noYield else pcall)(callback, ...)) -end - -return batched diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index f430260..3ff1604 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -2,16 +2,16 @@ -- https://github.com/stackblitz/alien-signals -- https://github.com/Nicell/alien-signals-luau +local alien = require("./alien") local config = require("./flags") -local system = require("./system") local utils = require("./utils") -export type ReactiveFlags = system.ReactiveFlags -export type ReactiveNode = system.ReactiveNode -export type Link = system.Link +export type ReactiveFlags = alien.ReactiveFlags +export type ReactiveNode = alien.ReactiveNode +export type Link = alien.Link -local ReactiveFlags = system.ReactiveFlags -local createReactiveSystem = system.createReactiveSystem +local ReactiveFlags = alien.ReactiveFlags +local createReactiveSystem = alien.createReactiveSystem local deepFreeze = utils.deepFreeze local noYield = utils.noYield @@ -23,11 +23,11 @@ local PENDING = 0b0100000 local QUEUED = 0b1000000 local UNTRACKED = 0b10000000 -export type EffectScope = ReactiveNode & { +type EffectScope = ReactiveNode & { cleanups: { () -> () }, } -export type Effect = ReactiveNode & { +type Effect = ReactiveNode & { fn: (dispose: () -> ()) -> ...(() -> ())?, cleanups: { () -> () }, cleanup: () -> (), @@ -36,7 +36,6 @@ export type Effect = ReactiveNode & { type Computed = ReactiveNode & { value: T?, getter: (previousValue: T?) -> T, - equals: ((T, T) -> boolean)?, } type Atom = ReactiveNode & { @@ -94,16 +93,6 @@ local function runCleanups(effect: EffectScope) table.clear(effect.cleanups) end -local function onCleanup(cleanup: () -> (), failSilently: boolean?) - if activeSub and (activeSub :: Effect).cleanups then - table.insert((activeSub :: Effect).cleanups, cleanup) - elseif activeScope then - table.insert(activeScope.cleanups, cleanup) - elseif not failSilently then - warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) - end -end - local function run(effect: Effect, flags: ReactiveFlags): () if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then runCleanups(effect) @@ -171,8 +160,7 @@ local function updateComputed(computed: Computed): boolean if not success then error(result) end - local equals = computed.equals - if if equals then not equals(oldValue, result) else oldValue ~= result then + if oldValue ~= result then computed.value = result return true else @@ -321,26 +309,63 @@ local function unwatched(node: ReactiveNode) end end -local function atom(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T +--- Creates a reactive atom that acts as both a getter and setter. Calling the +--- atom with an argument sets its value, while calling it with no arguments +--- returns its current value. +--- +--- @param value The initial value of the atom. If empty, the atom's value will be `undefined`. +--- @param equals A comparator function to determine if the atom's value has changed. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options +local function atom(value: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T if config.frozen then - deepFreeze(initialValue) + deepFreeze(value) end return atomGetterSetter({ - previousValue = initialValue, - value = initialValue, + previousValue = value, + value = value, flags = MUTABLE, equals = equals, }) end -local function computed(getter: (previousValue: T?) -> T, equals: ((T, T) -> boolean)?): () -> T +--- Creates a new atom that is computed based on the values of other atoms. +--- The returned atom is read-only, and its value is automatically updated when +--- any atoms accessed from within the getter function change. +--- +--- If an initial value is provided, the getter will receive this value as its +--- first argument on the initial run, instead of `undefined`. +--- +--- @example +--- ```ts +--- const count = atom(1); +--- const plusOne = computed(() => count() + 1); +--- print(plusOne()); // 2 +--- ``` +--- +--- @param getter A function that produces the next value. +--- @param value An optional initial value for the selector. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options +local function computed(getter: (previousValue: T?) -> T, value: T?): () -> T return computedGetter({ flags = bit32.bor(MUTABLE, DIRTY), getter = getter, - equals = equals, + value = value, }) end +--- Creates an effect that runs a callback in response to atom state changes. +--- An effect tracks which atoms are accessed during its execution, and re-runs +--- the callback when those atoms change. +--- +--- The effect callback may return a cleanup function, which gets called once, +--- either before the effect re-runs or when it is disposed. +--- +--- If the effect is called within another effect, it will be disposed when the +--- outer effect re-runs. +--- +--- @param fn The function to run when dependencies change. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback local function effect(fn: (dispose: () -> ()) -> ...(() -> ())?): () -> () local self: Effect = { fn = fn, @@ -365,6 +390,14 @@ local function effect(fn: (dispose: () -> ()) -> ...(() -> ())?): () -> () return self.cleanup end +--- Creates an effect scope that can capture multiple reactive effects (i.e. +--- computed, effect, subscribe) created within it so that they can be disposed +--- together. +--- +--- @param fn A function that may create reactive effects. +--- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. +--- @return A function for disposing all effects created within the scope. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () local self: EffectScope = { flags = NONE, @@ -386,6 +419,81 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> return effectStop(self) end +--- Registers a cleanup function to the current active effect or effect scope. +--- If there is no active effect or scope, a warning is logged and the callback +--- is not registered, unless `failSilently` is set to true. +--- +--- @param callback The cleanup function to register. +--- @param failSilently If true, suppresses the warning when there is no active effect or scope. Defaults to false. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently +local function onCleanup(cleanup: () -> (), failSilently: boolean?) + if activeSub and (activeSub :: Effect).cleanups then + table.insert((activeSub :: Effect).cleanups, cleanup) + elseif activeScope then + table.insert(activeScope.cleanups, cleanup) + elseif not failSilently then + warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) + end +end + +--- Runs the callback without subscribing to atom updates or capturing inner +--- effects in the parent effect or scope. +--- +--- For an alternative that still cleans up inner effects, use `peek` instead. +--- +--- @param callback The callback function. +--- @param args Arguments to pass to the callback. +--- @return The return value of the callback. +local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... + local prevSub = setCurrentSub(nil) + local prevScope = setCurrentScope(nil) + local results: { any } = { pcall(callback, ...) } + setCurrentSub(prevSub) + setCurrentScope(prevScope) + if not results[1] then + error(results[2]) + end + return unpack(results, 2) +end + +--- Runs the callback without subscribing to atom updates, but still captures +--- inner effects so that they dispose with the parent effect. +--- +--- To avoid capturing inner effects, use `untracked` instead. +--- +--- @param callback The callback function. +--- @param args Arguments to pass to the callback. +--- @return The return value of the callback. +local function peek(callback: (Args...) -> Results..., ...: Args...): Results... + if not activeSub or bit32.btest(activeSub.flags, UNTRACKED) then + return callback(...) + else + activeSub.flags = bit32.bor(activeSub.flags, UNTRACKED) + local results: { any } = { pcall(callback, ...) } + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(UNTRACKED)) + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + end +end + +--- Combines multiple atom updates into a single "commit" that runs effects at +--- the end of the provided callback. +--- +--- @param callback A function that performs multiple atom updates. +--- @param args Arguments to pass to the callback. +--- @return The return value of the callback. +local function batched(callback: (Args...) -> Results..., ...: Args...): Results... + startBatch() + local results: { any } = { (if config.strict then noYield else pcall)(callback, ...) } + endBatch() + if not results[1] then + error(results[2]) + end + return unpack(results, 2) +end + local reactiveSystem = createReactiveSystem({ update = update, notify = notify, @@ -415,4 +523,7 @@ return { computed = computed, effect = effect, effectScope = effectScope, + untracked = untracked, + peek = peek, + batched = batched, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index cfffd62..30d5410 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1,6 +1,11 @@ type Key = string | number | symbol; type Cleanup = () => void; +/** + * A reactive atom that acts as both a getter and setter. Calling the atom with + * an argument sets its value, while calling it with no arguments returns its + * current value. + */ export interface Atom { (value: T | ((value: T) => T)): T; (): T; @@ -10,29 +15,130 @@ export type Selector = () => T; export type Equals = (current: T, incoming: T) => boolean; +/** + * Global flags that modify the behavior of Charm's reactive system. + */ export const flags: { + /** + * Enforces synchronous, non-yielding behavior in atoms and effects. + * Also enables state validation in Charm Sync to catch sync errors early. + * Enabled if the optimization level is below 2, which is true in studio. + */ strict: boolean; + /** + * Enforces immutability of atoms by deep-freezing table values. Enabled + * if the optimization level is below 2, which is true in studio. + */ frozen: boolean; + /** + * Whether inner effects should clean up after themselves when their + * parent effect re-runs. This is usually desirable, but can be disabled + * to revert to the old behavior. Defaults to `true`. + */ trackInnerEffects: boolean; }; +/** + * Creates a reactive atom that acts as both a getter and setter. Calling the + * atom with an argument sets its value, while calling it with no arguments + * returns its current value. + * + * @param value The initial value of the atom. If empty, the atom's value will be `undefined`. + * @param equals A comparator function to determine if the atom's value has changed. + * @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options + */ export function atom(value: T, equals?: Equals): Atom; -// Overload for undefined initial value export function atom(): Atom; -export function computed(getter: (previousValue?: T) => T, equals?: Equals): () => T; +/** + * Creates a new atom that is computed based on the values of other atoms. + * The returned atom is read-only, and its value is automatically updated when + * any atoms accessed from within the getter function change. + * + * If an initial value is provided, the getter will receive this value as its + * first argument on the initial run, instead of `undefined`. + * + * @example + * ```ts + * const count = atom(1); + * const plusOne = computed(() => count() + 1); + * print(plusOne()); // 2 + * ``` + * + * @param getter A function that produces the next value. + * @param value An optional initial value for the selector. + * @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options + */ +export function computed(getter: (previousValue?: T) => T): () => T; +export function computed(getter: (previousValue: T) => T, value: T): () => T; +/** + * Creates an effect that runs a callback in response to atom state changes. + * An effect tracks which atoms are accessed during its execution, and re-runs + * the callback when those atoms change. + * + * The effect callback may return a cleanup function, which gets called once, + * either before the effect re-runs or when it is disposed. + * + * If the effect is called within another effect, it will be disposed when the + * outer effect re-runs. + * + * @param callback The function to run when dependencies change. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback + */ export function effect(callback: (dispose: Cleanup) => Cleanup | void): Cleanup; +/** + * Creates an effect scope that can capture multiple reactive effects (i.e. + * computed, effect, subscribe) created within it so that they can be disposed + * together. + * + * @param callback A function that may create reactive effects. + * @param detached If true, the scope will not be automatically disposed of + * when the parent scope is disposed. Defaults to false. + * @returns A function for disposing all effects created within the scope. + * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached + */ export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; +/** + * Creates an effect that only runs the callback when the value returned by the + * getter changes. The callback receives the new value and the previous value + * as arguments. + * + * @param getter A function that returns the value to watch. + * @param callback The function to run when the value changes. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback + */ +export function subscribe(getter: () => T, callback: (value: T, previousValue: T) => void): Cleanup; + +/** + * Creates an effect that runs the callback one immediately, and then again + * when the value returned by the getter changes. The callback receives the new + * value, the previous value, and a cleanup function as arguments. + * + * @param getter A function that returns the value to watch. + * @param callback The function to run when the value changes. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback + */ export function listen( getter: () => T, - listener: (value: T, previousValue: T | undefined, dispose: Cleanup) => void, + callback: (value: T, previousValue: T | undefined, dispose: Cleanup) => void, ): Cleanup; -export function subscribe(getter: () => T, listener: (value: T, previousValue: T) => void): Cleanup; - +/** + * Creates a new atom that is computed by mapping over the entries of an + * existing atom. The returned atom is read-only, and its value is + * automatically updated when the source atom changes. + * + * @param getter A function that returns the table to map over. + * @param mapper A function that transforms each value-key pair from the source + * into a new value or value-key pair for the resulting table. + * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper + */ export function mapped( getter: () => Map | ReadonlyMap, mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, @@ -48,6 +154,19 @@ export function mapped( mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): () => { readonly [K in KO]: VO }; +/** + * Calls the observer function for each unique key in the table returned by the + * getter. The observer is called once for each key, and again when a new key + * is added. + * + * The observer may return a cleanup function, which will be called when the + * key is removed or when the entire observer is disposed. + * + * @param getter A function that returns the table to observe. + * @param observer A function that is called for each key in the table. + * @returns A function for disposing the observer. + * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer + */ export function observe( getter: () => Map | ReadonlyMap | readonly V[], observer: (value: V, key: K) => Cleanup | void, @@ -58,13 +177,48 @@ export function observe( observer: (value: V, key: K) => Cleanup | void, ): Cleanup; -export function batched( - callback: (...args: Args) => Result, - ...args: Args -): (...args: Args) => Result; +/** + * Combines multiple atom updates into a single "commit" that runs effects at + * the end of the provided callback. + * + * @param callback A function that performs multiple atom updates. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + */ +export function batched(callback: (...args: Args) => Result, ...args: Args): Result; +/** + * Runs the callback without subscribing to atom updates or capturing inner + * effects in the parent effect or scope. + * + * For an alternative that still cleans up inner effects, use `peek` instead. + * + * @param callback The callback function. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + */ export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; +/** + * Runs the callback without subscribing to atom updates, but still captures + * inner effects so that they dispose with the parent effect. + * + * To avoid capturing inner effects, use `untracked` instead. + * + * @param callback The callback function. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + */ export function peek(callback: (...args: Args) => Result, ...args: Args): Result; +/** + * Registers a cleanup function to the current active effect or effect scope. + * If there is no active effect or scope, a warning is logged and the callback + * is not registered, unless `failSilently` is set to true. + * + * @param callback The cleanup function to register. + * @param failSilently If true, suppresses the warning when there is no active + * effect or scope. Defaults to false. + * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently + */ export function onCleanup(callback: Cleanup, failSilently?: boolean): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 50350b5..470312a 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,32 +1,33 @@ -local batched = require("@self/batched") local core = require("@self/core") local flags = require("@self/flags") local listen = require("@self/listen") local mapped = require("@self/mapped") local observe = require("@self/observe") -local peek = require("@self/peek") local subscribe = require("@self/subscribe") local types = require("@self/types") -local untracked = require("@self/untracked") local utils = require("@self/utils") +--- A reactive atom that acts as both a getter and setter. Calling the atom with +--- an argument sets its value, while calling it with no arguments returns its +--- current value. export type Atom = types.Atom + export type Selector = types.Selector return { core = core, flags = flags, atom = core.atom, + batched = core.batched, computed = core.computed, effect = core.effect, effectScope = core.effectScope, onCleanup = core.onCleanup, - batched = batched, + peek = core.peek, + untracked = core.untracked, listen = listen, mapped = mapped, observe = observe, - peek = peek, subscribe = subscribe, - untracked = untracked, deepFreeze = utils.deepFreeze, } diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 90a6b4f..9489297 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -1,10 +1,18 @@ local core = require("./core") -local peek = require("./peek") local computed = core.computed local effect = core.effect +local peek = core.peek -local function listen(getter: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () +--- Creates an effect that runs the callback one immediately, and then again +--- when the value returned by the getter changes. The callback receives the new +--- value, the previous value, and a cleanup function as arguments. +--- +--- @param getter A function that returns the value to watch. +--- @param callback The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +local function listen(getter: () -> T, callback: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () local tracked = computed(function() return getter() end) @@ -19,7 +27,7 @@ local function listen(getter: () -> T, listener: (state: T, previous: T?, dis dispose = effect(function() local previous = current current = tracked() - peek(listener, current, previous, dispose) + peek(callback, current, previous, dispose) end) if disposed then diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index 2447adb..ece9ebd 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -36,7 +36,9 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: local cachedKey = keyCache[key] if cachedKey then - currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) + end currentOutput[cachedKey] = nil keyCache[key] = nil end @@ -50,7 +52,9 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: local valueOut, keyOut = map(value, key) if currentOutput[keyOut] ~= valueOut then - currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) + end currentOutput[keyOut] = valueOut keyCache[key] = keyOut end @@ -60,4 +64,13 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: end) end -return mapped :: Mapped +--- Creates a new atom that is computed by mapping over the entries of an +--- existing atom. The returned atom is read-only, and its value is +--- automatically updated when the source atom changes. +--- +--- @param getter A function that returns the table to map over. +--- @param mapper A function that transforms each value-key pair from the source into a new value or value-key pair for the resulting table. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper +local mapped = mapped :: Mapped + +return mapped diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index bf7a9b8..bbdf359 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -1,4 +1,5 @@ local core = require("./core") +local flags = require("./flags") local effect = core.effect local effectScope = core.effectScope @@ -6,6 +7,17 @@ local onCleanup = core.onCleanup local startBatch = core.startBatch local endBatch = core.endBatch +--- Calls the observer function for each unique key in the table returned by the +--- getter. The observer is called once for each key, and again when a new key +--- is added. +--- +--- The observer may return a cleanup function, which will be called when the +--- key is removed or when the entire observer is disposed. +--- +--- @param getter A function that returns the table to observe. +--- @param observer A function that is called for each key in the table. +--- @return A function for disposing the observer. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer local function observe(getter: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () local scopes: { [K]: () -> () } = {} local currentItems: { [K]: V } = {} @@ -45,7 +57,9 @@ local function observe(getter: () -> { [K]: V }, observer: (item: V, key: end end) - onCleanup(disposeScopes, true) + if flags.trackInnerEffects then + onCleanup(disposeScopes, true) + end return function() disposeEffect() diff --git a/packages/charm/src/peek.luau b/packages/charm/src/peek.luau deleted file mode 100644 index 27c7e5a..0000000 --- a/packages/charm/src/peek.luau +++ /dev/null @@ -1,27 +0,0 @@ -local core = require("./core") - -local UNTRACKED = core.ReactiveFlags.Untracked -local getCurrentSub = core.getCurrentSub - -local function handlePeek(sub: core.ReactiveNode?, ok: boolean, ...) - if sub then - sub.flags = bit32.band(sub.flags, bit32.bnot(UNTRACKED)) - end - if ok then - return ... - else - error((...)) - end -end - -local function peek(callback: (Args...) -> Results..., ...: Args...): Results... - local sub = getCurrentSub() - if not sub or bit32.btest(sub.flags, UNTRACKED) then - return callback(...) - else - sub.flags = bit32.bor(sub.flags, UNTRACKED) - end - return handlePeek(sub, pcall(callback, ...)) -end - -return peek diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index 9c46c0a..3d4ec41 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -1,10 +1,18 @@ local core = require("./core") -local peek = require("./peek") local computed = core.computed local effect = core.effect +local peek = core.peek -local function subscribe(getter: () -> T, listener: (state: T, lastState: T) -> ()): () -> () +--- Creates an effect that only runs the callback when the value returned by the +--- getter changes. The callback receives the new value and the previous value +--- as arguments. +--- +--- @param getter A function that returns the value to watch. +--- @param callback The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback +local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () local tracked = computed(function() return getter() end) @@ -18,7 +26,7 @@ local function subscribe(getter: () -> T, listener: (state: T, lastState: T) if mounted then mounted = false else - peek(listener, current, previous) + peek(callback, current, previous) end end) end diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau index d5c0314..4769ad2 100644 --- a/packages/charm/src/types.luau +++ b/packages/charm/src/types.luau @@ -1,5 +1,7 @@ type Update = ((previous: T) -> T) | T + export type Atom = (...Update) -> T + export type Selector = () -> T return {} diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau deleted file mode 100644 index 6038fec..0000000 --- a/packages/charm/src/untracked.luau +++ /dev/null @@ -1,36 +0,0 @@ -local core = require("./core") - -local setCurrentSub = core.setCurrentSub -local setCurrentScope = core.setCurrentScope -local getCurrentSub = core.getCurrentSub -local getCurrentScope = core.getCurrentScope - -local function handleUntracked( - prevSub: core.ReactiveNode?, - prevScope: core.Effect | core.EffectScope?, - ok: boolean, - ... -) - if prevSub then - setCurrentSub(prevSub) - elseif prevScope then - setCurrentScope(prevScope) - end - if ok then - return ... - else - error((...)) - end -end - -local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - if getCurrentSub() then - return handleUntracked(setCurrentSub(nil), nil, pcall(callback, ...)) - elseif getCurrentScope() then - return handleUntracked(nil, setCurrentScope(nil), pcall(callback, ...)) - else - return callback(...) - end -end - -return untracked From bf92413a977a43223fd54fd547d162e7e4ea19e2 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:59:34 -0700 Subject: [PATCH 051/160] refactor: align with alien-signals 3.0 --- .../charm/src/__tests__/core/effect.spec.luau | 6 +- packages/charm/src/alien.luau | 37 +- packages/charm/src/core.luau | 480 +++++++++--------- packages/charm/src/mapped.luau | 6 +- packages/charm/src/utils.luau | 4 +- 5 files changed, 254 insertions(+), 279 deletions(-) diff --git a/packages/charm/src/__tests__/core/effect.spec.luau b/packages/charm/src/__tests__/core/effect.spec.luau index ab3db2f..89e4240 100644 --- a/packages/charm/src/__tests__/core/effect.spec.luau +++ b/packages/charm/src/__tests__/core/effect.spec.luau @@ -5,7 +5,7 @@ local computed = core.computed local effect = core.effect local effectScope = core.effectScope local endBatch = core.endBatch -local setCurrentSub = core.setCurrentSub +local setActiveSub = core.setActiveSub local onCleanup = core.onCleanup local atom = core.atom local startBatch = core.startBatch @@ -202,9 +202,9 @@ test("should duplicate subscribers do not affect the notify order", function() effect(function() table.insert(order, "a") - local currentSub = setCurrentSub(nil) + local currentSub = setActiveSub(nil) local isOne = src2() == 1 - setCurrentSub(currentSub) + setActiveSub(currentSub) if isOne then src1() end diff --git a/packages/charm/src/alien.luau b/packages/charm/src/alien.luau index ee80b9b..b7604f3 100644 --- a/packages/charm/src/alien.luau +++ b/packages/charm/src/alien.luau @@ -1,7 +1,3 @@ --- Adapted from Alien Signals --- https://github.com/stackblitz/alien-signals --- https://github.com/Nicell/alien-signals-luau - export type ReactiveNode = { deps: Link?, depsTail: Link?, @@ -34,7 +30,7 @@ local RECURSED_CHECK = 0b00000100 local RECURSED = 0b00001000 local DIRTY = 0b00010000 local PENDING = 0b00100000 -local UNTRACKED = 0b10000000 +local PEEKING = 0b10000000 local ReactiveFlags = { None = NONE, @@ -44,7 +40,6 @@ local ReactiveFlags = { Recursed = RECURSED, Dirty = DIRTY, Pending = PENDING, - Untracked = UNTRACKED, } local function createReactiveSystem(props: { @@ -55,7 +50,6 @@ local function createReactiveSystem(props: { local update = props.update local notify = props.notify local unwatched = props.unwatched - local currentVersion = 0 local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean local link = sub.depsTail @@ -68,23 +62,27 @@ local function createReactiveSystem(props: { return false end - local function link(dep: ReactiveNode, sub: ReactiveNode): () + local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): () + -- DEVIATION: Do not link signals to sub while peeking + if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then + return + end local prevDep = sub.depsTail if prevDep and prevDep.dep == dep then return end local nextDep = if prevDep then prevDep.nextDep else sub.deps if nextDep and nextDep.dep == dep then - nextDep.version = currentVersion + nextDep.version = version sub.depsTail = nextDep return end local prevSub = dep.subsTail - if prevSub and prevSub.version == currentVersion and prevSub.sub == sub then + if prevSub and prevSub.version == version and prevSub.sub == sub then return end local newLink: Link = { - version = currentVersion, + version = version, dep = dep, sub = sub, prevDep = prevDep, @@ -208,21 +206,6 @@ local function createReactiveSystem(props: { until false end - local function startTracking(sub: ReactiveNode): () - currentVersion += 1 - 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 @@ -320,8 +303,6 @@ local function createReactiveSystem(props: { unlink = unlink, propagate = propagate, checkDirty = checkDirty, - endTracking = endTracking, - startTracking = startTracking, shallowPropagate = shallowPropagate, } end diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 3ff1604..7a9409a 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -1,7 +1,3 @@ --- Adapted from Alien Signals --- https://github.com/stackblitz/alien-signals --- https://github.com/Nicell/alien-signals-luau - local alien = require("./alien") local config = require("./flags") local utils = require("./utils") @@ -15,22 +11,23 @@ local createReactiveSystem = alien.createReactiveSystem local deepFreeze = utils.deepFreeze local noYield = utils.noYield -local NONE = 0b0000000 -local MUTABLE = 0b0000001 -local WATCHING = 0b0000010 -local DIRTY = 0b0010000 -local PENDING = 0b0100000 -local QUEUED = 0b1000000 -local UNTRACKED = 0b10000000 +local NONE = 0b00000000 +local MUTABLE = 0b00000001 +local WATCHING = 0b00000010 +local RECURSED_CHECK = 0b00000100 +local DIRTY = 0b00010000 +local PENDING = 0b00100000 +local QUEUED = 0b01000000 +local PEEKING = 0b10000000 type EffectScope = ReactiveNode & { cleanups: { () -> () }, } type Effect = ReactiveNode & { - fn: (dispose: () -> ()) -> ...(() -> ())?, + fn: (stop: () -> ()) -> (() -> ())?, cleanups: { () -> () }, - cleanup: () -> (), + stop: () -> (), } type Computed = ReactiveNode & { @@ -38,54 +35,47 @@ type Computed = ReactiveNode & { getter: (previousValue: T?) -> T, } -type Atom = ReactiveNode & { - previousValue: T, - value: T, - equals: ((T, T) -> boolean)?, +type Signal = ReactiveNode & { + currentValue: T, + pendingValue: T, } -local link: (dep: ReactiveNode, sub: ReactiveNode) -> () +local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () 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 cycle = 0 local batchDepth = 0 - -local queuedEffects: { Effect } = {} -local queuedEffectsLength = 0 local notifyIndex = 0 - -local activeScope: Effect | EffectScope? +local queuedEffects: { Effect | EffectScope? } = {} +local queuedEffectsLength = 0 local activeSub: ReactiveNode? -local function getCurrentSub(): ReactiveNode? +local function getActiveSub(): ReactiveNode? return activeSub end -local function setCurrentSub(sub: ReactiveNode?) +local function setActiveSub(sub: ReactiveNode?) local prevSub = activeSub activeSub = sub return prevSub end -local function getCurrentScope(): Effect | EffectScope? - return activeScope -end - -local function setCurrentScope(scope: Effect | EffectScope?) - local prevScope = activeScope - activeScope = scope - return prevScope +local function purgeDeps(sub: ReactiveNode) + local depsTail = sub.depsTail + local dep = if depsTail then depsTail.nextDep else sub.deps + while dep do + dep = unlink(dep, sub) + end end local function runCleanups(effect: EffectScope) for _, cleanup in effect.cleanups do - local prev = setCurrentSub(nil) + local prevSub = setActiveSub(nil) local success, result: any = (if config.strict then noYield else pcall)(cleanup) - setCurrentSub(prev) + activeSub = prevSub if not success then error(result) end @@ -93,40 +83,49 @@ local function runCleanups(effect: EffectScope) table.clear(effect.cleanups) end -local function run(effect: Effect, flags: ReactiveFlags): () - if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then - runCleanups(effect) - local prev = setCurrentSub(effect) - startTracking(effect) - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(effect.fn, effect.cleanup) - setCurrentSub(prev) - endTracking(effect) +local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) + sub.flags = bit32.band(flags, bit32.bnot(PENDING)) +end + +local function run(e: Effect | EffectScope, flags: ReactiveFlags): () + if + bit32.btest(flags, DIRTY) + or (bit32.btest(flags, PENDING) and (checkDirty(e.deps :: Link, e) or setNotPending(e, flags))) + then + runCleanups(e) + local e = e :: Effect + cycle += 1 + e.depsTail = nil + e.flags = bit32.bor(WATCHING, RECURSED_CHECK) + local prevSub = setActiveSub(e) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)(e.fn, e.stop) + activeSub = prevSub + e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(e) if not success then error(result) elseif result then - table.insert(effect.cleanups, result) + table.insert(e.cleanups, result) end - return - elseif bit32.btest(flags, PENDING) then - effect.flags = bit32.band(flags, bit32.bnot(PENDING)) - end - local link = effect.deps - while link do - local dep = link.dep - local depFlags = dep.flags - if bit32.btest(depFlags, QUEUED) then - depFlags = bit32.band(depFlags, bit32.bnot(QUEUED)) - dep.flags = depFlags - run(dep :: Effect, depFlags) + else + local link = e.deps + while link do + local dep = link.dep + local depFlags = dep.flags + if bit32.btest(depFlags, QUEUED) then + depFlags = bit32.band(depFlags, bit32.bnot(QUEUED)) + dep.flags = depFlags + run(dep :: Effect, depFlags) + end + link = link.nextDep end - link = link.nextDep end end local function flush(): () while notifyIndex < queuedEffectsLength do notifyIndex += 1 - local effect = queuedEffects[notifyIndex] + local effect = queuedEffects[notifyIndex] :: Effect queuedEffects[notifyIndex] = nil effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED)) run(effect, effect.flags) @@ -150,165 +149,136 @@ local function endBatch() end end -local function updateComputed(computed: Computed): boolean - local prevSub = setCurrentSub(computed) - startTracking(computed) - local oldValue = computed.value - local success, result = (if config.strict then noYield else pcall)(computed.getter, oldValue) - setCurrentSub(prevSub) - endTracking(computed) +local function updateComputed(c: Computed): boolean + cycle += 1 + c.depsTail = nil + c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + local prevSub = setActiveSub(c) + local oldValue = c.value + local success, result: unknown = (if config.strict then noYield else pcall)(c.getter, oldValue) + activeSub = prevSub + c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(c) if not success then error(result) end if oldValue ~= result then - computed.value = result - return true - else - return false - end -end - -local function updateAtom(atom: Atom, value: any): boolean - atom.flags = MUTABLE - if atom.previousValue ~= value then - atom.previousValue = value - return true - else - return false - end -end - -local function readAtom(atom: Atom): T - local value = atom.value - if bit32.btest(atom.flags, DIRTY) then - if updateAtom(atom, value) then - local subs = atom.subs - if subs then - shallowPropagate(subs) - end - end - end - if activeSub and not bit32.btest(activeSub.flags, UNTRACKED) then - link(atom, activeSub) - end - return value -end - -local function writeAtom(atom: Atom, oldValue: T, newValue: T): T - local equals = atom.equals - if if equals then not equals(oldValue, newValue) else oldValue ~= newValue then if config.frozen then - deepFreeze(newValue) + deepFreeze(result) end - atom.value = newValue - atom.flags = bit32.bor(MUTABLE, DIRTY) - local subs = atom.subs - if subs then - propagate(subs) - if batchDepth == 0 then - flush() - end - end - return newValue - end - return oldValue -end - -local function atomGetterSetter(atom: Atom): (...((T) -> T) | T) -> T - return function(...) - if select("#", ...) == 0 then - return readAtom(atom) - end - local oldValue = atom.value - local newValue = ... - if type(newValue) == "function" then - local prev = setCurrentSub(nil) - local success, result = (if config.strict then noYield else pcall)(newValue, oldValue) - setCurrentSub(prev) - if not success then - error(result) - end - newValue = result - end - return writeAtom(atom, oldValue, newValue :: T) + c.value = result + return true + else + return false end end -local function computedGetter(computed: Computed): () -> T - return function() - local flags = computed.flags +local function updateSignal(s: Signal): boolean + s.flags = MUTABLE + local pendingValue = s.pendingValue + if s.currentValue ~= pendingValue then + s.currentValue = pendingValue + return true + else + return false + end +end + +local function computedOper(this: Computed): () -> T + return function(): T + local flags = this.flags if - bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(computed.deps :: Link, computed)) + bit32.btest(flags, DIRTY) + or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags))) then - if updateComputed(computed) then - local subs = computed.subs + if updateComputed(this) then + local subs = this.subs if subs then shallowPropagate(subs) end end - elseif bit32.btest(flags, PENDING) then - computed.flags = bit32.band(flags, bit32.bnot(PENDING)) end - if activeSub and not bit32.btest(activeSub.flags, UNTRACKED) then - link(computed, activeSub) - elseif activeScope then - link(computed, activeScope) + if activeSub then + link(this, activeSub, cycle) end - return computed.value :: T + return this.value :: T end end -local function effectStop(self: EffectScope): () -> () - return function() - local dep = self.deps - while dep do - dep = unlink(dep, self) - end - local sub = self.subs - if sub then - unlink(sub) - end - self.flags = NONE - runCleanups(self) - end -end - -local function update(atom: ReactiveNode): boolean - if (atom :: Computed).getter then - return updateComputed(atom :: Computed) - else - return updateAtom(atom :: Atom, (atom :: Atom).value) - end -end - -local function notify(effect: ReactiveNode) - local flags = effect.flags - if not bit32.btest(flags, QUEUED) then - effect.flags = bit32.bor(flags, QUEUED) - local subs = effect.subs - if subs then - notify(subs.sub :: Effect) +local function signalOper(this: Signal, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T + return function(...: ((T) -> T) | T): T + if select("#", ...) ~= 0 then + local update = ... + local value: T + if type(update) == "function" then + local prevSub = setActiveSub(nil) + local success, result: T = (if config.strict then noYield else pcall)(update, this.currentValue) + activeSub = prevSub + if not success then + error(result) + end + value = result + else + value = update + end + if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then + if config.frozen then + deepFreeze(value) + end + this.pendingValue = value + this.flags = bit32.bor(MUTABLE, DIRTY) + local subs = this.subs + if subs then + propagate(subs) + if batchDepth == 0 then + flush() + end + end + return value + end + return this.currentValue else - queuedEffectsLength += 1 - queuedEffects[queuedEffectsLength] = effect :: Effect + if bit32.btest(this.flags, DIRTY) then + if updateSignal(this) then + local subs = this.subs + if subs then + shallowPropagate(subs) + end + end + end + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(this, sub, cycle) + break + end + local subs = sub.subs + if subs then + sub = subs.sub + end + end + return this.currentValue end end end -local function unwatched(node: ReactiveNode) - if (node :: Computed).getter then - local toRemove = node.deps - if toRemove then - node.flags = bit32.bor(MUTABLE, DIRTY) - repeat - toRemove = unlink(toRemove, node) - until not toRemove +local function effectScopeOper(this: EffectScope): () -> () + return function() + this.depsTail = nil + this.flags = NONE + purgeDeps(this) + local sub = this.subs + if sub then + unlink(sub, this) end - elseif (node :: Effect).cleanups then - effectStop(node :: Effect)() + runCleanups(this) end end +local function effectOper(this: Effect): () -> () + return effectScopeOper(this) +end + --- Creates a reactive atom that acts as both a getter and setter. Calling the --- atom with an argument sets its value, while calling it with no arguments --- returns its current value. @@ -316,16 +286,15 @@ end --- @param value The initial value of the atom. If empty, the atom's value will be `undefined`. --- @param equals A comparator function to determine if the atom's value has changed. --- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options -local function atom(value: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T +local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T if config.frozen then - deepFreeze(value) + deepFreeze(initialValue) end - return atomGetterSetter({ - previousValue = value, - value = value, + return signalOper({ + currentValue = initialValue, + pendingValue = initialValue, flags = MUTABLE, - equals = equals, - }) + }, equals) end --- Creates a new atom that is computed based on the values of other atoms. @@ -345,11 +314,10 @@ end --- @param getter A function that produces the next value. --- @param value An optional initial value for the selector. --- @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options -local function computed(getter: (previousValue: T?) -> T, value: T?): () -> T - return computedGetter({ +local function computed(getter: (previousValue: T?) -> T): () -> T + return computedOper({ flags = bit32.bor(MUTABLE, DIRTY), getter = getter, - value = value, }) end @@ -366,28 +334,32 @@ end --- @param fn The function to run when dependencies change. --- @return A function for disposing the effect. --- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback -local function effect(fn: (dispose: () -> ()) -> ...(() -> ())?): () -> () - local self: Effect = { +local function effect(fn: (stop: () -> ()) -> ()): () -> () + local e: Effect = { fn = fn, flags = WATCHING, cleanups = {}, - cleanup = nil :: any, + stop = nil :: any, } - self.cleanup = effectStop(self) - if activeSub and config.trackInnerEffects then - link(self, activeSub) - elseif activeScope then - link(self, activeScope) + e.stop = effectOper(e) + local prevSub = setActiveSub(e) + if prevSub and (config.trackInnerEffects or prevSub.flags == NONE) then + link(e, prevSub, 0) end - local prev = setCurrentSub(self) - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn, self.cleanup) - setCurrentSub(prev) + local stopped = false + local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn, function() + stopped = true + end) + activeSub = prevSub if not success then error(result) elseif result then - table.insert(self.cleanups, result) + table.insert(e.cleanups, result) end - return self.cleanup + if stopped then + e.stop() + end + return e.stop end --- Creates an effect scope that can capture multiple reactive effects (i.e. @@ -398,25 +370,23 @@ end --- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. --- @return A function for disposing all effects created within the scope. --- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached -local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () - local self: EffectScope = { +local function effectScope(fn: () -> (), detached: boolean?): () -> () + local e: EffectScope = { flags = NONE, cleanups = {}, } - if activeScope and not detached then - link(self, activeScope) + local prevSub = setActiveSub(e) + if prevSub and not detached then + link(e, prevSub, 0) end - local prevSub = setCurrentSub(nil) - local prevScope = setCurrentScope(self) local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) - setCurrentScope(prevScope) - setCurrentSub(prevSub) + activeSub = prevSub if not success then error(result) elseif result then - table.insert(self.cleanups, result) + table.insert(e.cleanups, result) end - return effectStop(self) + return effectScopeOper(e) end --- Registers a cleanup function to the current active effect or effect scope. @@ -429,8 +399,6 @@ end local function onCleanup(cleanup: () -> (), failSilently: boolean?) if activeSub and (activeSub :: Effect).cleanups then table.insert((activeSub :: Effect).cleanups, cleanup) - elseif activeScope then - table.insert(activeScope.cleanups, cleanup) elseif not failSilently then warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) end @@ -445,11 +413,9 @@ end --- @param args Arguments to pass to the callback. --- @return The return value of the callback. local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - local prevSub = setCurrentSub(nil) - local prevScope = setCurrentScope(nil) + local prevSub = setActiveSub(nil) local results: { any } = { pcall(callback, ...) } - setCurrentSub(prevSub) - setCurrentScope(prevScope) + activeSub = prevSub if not results[1] then error(results[2]) end @@ -465,16 +431,20 @@ end --- @param args Arguments to pass to the callback. --- @return The return value of the callback. local function peek(callback: (Args...) -> Results..., ...: Args...): Results... - if not activeSub or bit32.btest(activeSub.flags, UNTRACKED) then - return callback(...) - else - activeSub.flags = bit32.bor(activeSub.flags, UNTRACKED) + if + activeSub + and bit32.band(activeSub.flags, PEEKING) == 0 + and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE)) + then + activeSub.flags = bit32.bor(activeSub.flags, PEEKING) local results: { any } = { pcall(callback, ...) } - activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(UNTRACKED)) + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING)) if not results[1] then error(results[2]) end return unpack(results, 2) + else + return callback(...) end end @@ -494,28 +464,51 @@ local function batched(callback: (Args...) -> Results..., . return unpack(results, 2) end +local function notify(e: ReactiveNode) + local flags = e.flags + if not bit32.btest(flags, QUEUED) then + e.flags = bit32.bor(flags, QUEUED) + local subs = e.subs + if subs then + notify(subs.sub) + else + queuedEffectsLength += 1 + queuedEffects[queuedEffectsLength] = e :: Effect + end + end +end + local reactiveSystem = createReactiveSystem({ - update = update, + update = function(node: ReactiveNode): boolean + if node.depsTail then + return updateComputed(node :: Computed) + else + return updateSignal(node :: Signal) + end + end, notify = notify, - unwatched = unwatched, + unwatched = function(node: ReactiveNode) + if bit32.band(node.flags, MUTABLE) == 0 then + effectScopeOper(node :: EffectScope)() + elseif node.depsTail then + node.depsTail = nil + node.flags = bit32.bor(MUTABLE, DIRTY) + purgeDeps(node) + end + end, }) 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, - onCleanup = onCleanup, + getActiveSub = getActiveSub, + setActiveSub = setActiveSub, getBatchDepth = getBatchDepth, startBatch = startBatch, endBatch = endBatch, @@ -523,6 +516,7 @@ return { computed = computed, effect = effect, effectScope = effectScope, + onCleanup = onCleanup, untracked = untracked, peek = peek, batched = batched, diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index ece9ebd..8f9b528 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -1,7 +1,7 @@ local core = require("./core") local computed = core.computed -local setCurrentSub = core.setCurrentSub +local setActiveSub = core.setActiveSub type Mapped = & ((getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) @@ -12,9 +12,9 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: local keyCache: { [KI]: KO } = {} local function map(value: VI, key: KI): (VO, KO) - local currentSub = setCurrentSub(nil) + local prevSub = setActiveSub(nil) local success, valueOut, keyOut = pcall(mapper, value, key) - setCurrentSub(currentSub) + setActiveSub(prevSub) if not success then error(`mapped() transform function failed: {valueOut}`) diff --git a/packages/charm/src/utils.luau b/packages/charm/src/utils.luau index ce71ef5..1f19410 100644 --- a/packages/charm/src/utils.luau +++ b/packages/charm/src/utils.luau @@ -1,4 +1,4 @@ -local function handleNoYield(thread: thread, ok: boolean, ...) +local function noYieldHandler(thread: thread, ok: boolean, ...) if not ok then return false, debug.traceback(thread, tostring((...))) elseif coroutine.status(thread) ~= "dead" then @@ -10,7 +10,7 @@ end local function noYield(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...) local thread = coroutine.create(callback) - return handleNoYield(thread, coroutine.resume(thread, ...)) + return noYieldHandler(thread, coroutine.resume(thread, ...)) end local function deepFreeze(object: any) From 1e0b10aef6853a00e43209df0e71dd84732a19b3 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 31 Oct 2025 00:36:55 -0700 Subject: [PATCH 052/160] feat: add signal api --- benches/mapped.luau | 16 +- benches/propagate.luau | 10 +- .../charm-sync/src/__tests__/patch.spec.luau | 6 +- packages/charm-sync/src/client.luau | 1 - packages/charm-sync/src/config.luau | 4 +- packages/charm-sync/src/index.d.ts | 11 +- packages/charm-sync/src/patch.luau | 6 +- packages/charm-sync/src/server.luau | 154 +++++++------ .../core/{atom.spec.luau => signal.spec.luau} | 16 +- packages/charm/src/__tests__/spec.luau | 6 +- packages/charm/src/core.luau | 208 ++++++++++++------ .../charm/src/{flags.luau => globals.luau} | 11 +- packages/charm/src/index.d.ts | 27 ++- packages/charm/src/init.luau | 18 +- packages/charm/src/listen.luau | 40 ---- packages/charm/src/observe.luau | 11 +- packages/charm/src/subscribe.luau | 34 --- packages/charm/src/types.luau | 7 - packages/react-charm/src/init.luau | 2 + packages/vide-charm/src/init.luau | 1 + test/spec.server.luau | 4 +- 21 files changed, 300 insertions(+), 293 deletions(-) rename packages/charm/src/__tests__/core/{atom.spec.luau => signal.spec.luau} (79%) rename packages/charm/src/{flags.luau => globals.luau} (63%) delete mode 100644 packages/charm/src/listen.luau delete mode 100644 packages/charm/src/subscribe.luau delete mode 100644 packages/charm/src/types.luau diff --git a/benches/mapped.luau b/benches/mapped.luau index 539310c..d9758fc 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -1,10 +1,10 @@ local Charm = require("../packages/charm/src") local bench = require("./bench") -local atom = Charm.atom local computed = Charm.computed local mapped = Charm.mapped -local setCurrentSub = Charm.core.setCurrentSub +local setActiveSub = Charm.core.setActiveSub +local signal = Charm.signal type Entry = { number } @@ -25,9 +25,9 @@ local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> local nextState = {} for index, value in state do - local currentSub = setCurrentSub(nil) + local currentSub = setActiveSub(nil) local success, valueOut, keyOut = pcall(mapper, value, index) - setCurrentSub(currentSub) + setActiveSub(currentSub) if success then nextState[keyOut] = valueOut @@ -52,26 +52,26 @@ end for _, percent in { 0.1, 0.5, 1 } do bench.run(`mapped {percent}`, function() - local src = atom(initialState) + local src, setSrc = signal(initialState) local out = mapped(src, mapper) bench.profilebegin("init") out() bench.profileend() - src(updatePercentage(percent)) + setSrc(updatePercentage(percent)) bench.profilebegin("update") out() bench.profileend() end) bench.run(`control {percent}`, function() - local src = atom(initialState) + local src, setSrc = signal(initialState) local out = simpleMapped(src, mapper) bench.profilebegin("init") out() bench.profileend() - src(updatePercentage(percent)) + setSrc(updatePercentage(percent)) bench.profilebegin("update") out() bench.profileend() diff --git a/benches/propagate.luau b/benches/propagate.luau index 7473b6d..463ee2e 100644 --- a/benches/propagate.luau +++ b/benches/propagate.luau @@ -1,15 +1,15 @@ local Charm = require("../packages/charm/src") local bench = require("./bench") -local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect local listen = Charm.listen +local signal = Charm.signal for _, w in { 10, 100 } do for _, h in { 10, 100 } do bench.run(`propagate {w}*{h}`, function() - local src = atom(1) + local src, setSrc = signal(1) for _ = 1, w do local last = src for _ = 1, h do @@ -22,7 +22,7 @@ for _, w in { 10, 100 } do last() end) end - src(src() + 1) + setSrc(src() + 1) end) end end @@ -30,7 +30,7 @@ end for _, w in { 10, 100 } do for _, h in { 10, 100 } do bench.run(`propagate (listen) {w}*{h}`, function() - local src = atom(1) + local src, setSrc = signal(1) for _ = 1, w do local last = src for _ = 1, h do @@ -41,7 +41,7 @@ for _, w in { 10, 100 } do end listen(last, function() end) end - src(src() + 1) + setSrc(src() + 1) end) end end diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index 4a68750..fbf5607 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -24,7 +24,7 @@ local function mockArraySerdes(object: { [any]: any }): { [any]: any } end afterEach(function() - config.autoSerialize = true + config.fixArrays = true config.validatePatches = true end) @@ -104,13 +104,13 @@ test("cannot preserve sparse arrays", function() end) test("optionally fixes sparse arrays", function() - config.autoSerialize = true + config.fixArrays = true for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "string", `expected {type(index)} {index} to be string`) end - config.autoSerialize = false + config.fixArrays = false for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "number", `expected {type(index)} {index} to be number`) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 5b40ba0..57b03ec 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -16,7 +16,6 @@ local function syncAtoms(atoms: { [string]: Charm.Atom }) warn("[Charm] Attempted to sync different atoms with the same key:", key) continue end - atomRegister[key] = atom end end diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau index 3023971..6a71e12 100644 --- a/packages/charm-sync/src/config.luau +++ b/packages/charm-sync/src/config.luau @@ -12,9 +12,9 @@ return { --- to address remote event argument limitations. Defaults to `true`. --- This option should be disabled if your network library uses a custom --- serialization method (i.e. Zap, ByteNet) to prevent interference. - autoSerialize = true, + fixArrays = true, --- When `true`, patches will be validated before being sent to clients to --- enforce remote event limitations. Only checked in strict mode and if - --- `autoSerialize` is enabled. Defaults to `true`. + --- `fixArrays` is enabled. Defaults to `true`. validatePatches = true, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 7f565ed..bbd4923 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -1,7 +1,5 @@ import { Atom } from "@rbxts/charm"; -type Cleanup = () => void; - type Key = string | number | symbol; /** @@ -18,7 +16,7 @@ type SelectorMap = Record any>; /** * Infers the type of the return values produced by a map of functions. */ -type StateOfMap = { +export type StateOfMap = { readonly [P in keyof T]: T[P] extends () => infer State ? State : never; }; @@ -88,11 +86,11 @@ export const config: { * * @server */ - autoSerialize: boolean; + fixArrays: boolean; /** * When `true`, patches will be validated before being sent to clients to * enforce remote event limitations. Only checked in strict mode and if - * `autoSerialize` is enabled. Defaults to `true`. + * `fixArrays` is enabled. Defaults to `true`. * * @server */ @@ -178,11 +176,10 @@ export namespace server { * be sent to clients whenever the atoms are updated. * * @param onSync The function to call when patches can be sent to a client. - * @returns `stopSync()` */ export function startSync }, Serialize extends boolean = true>( onSync: (client: Player, payloads: SyncPayload[]) => void, - ): Cleanup; + ): void; /** * Stops automatically syncing registered atoms to clients. Patches will diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 7f017ee..88f414b 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -3,7 +3,7 @@ local config = require("./config") local types = require("./types") local validate = require("./validate") -local flags = Charm.flags +local globals = Charm.globals export type None = types.None @@ -73,13 +73,13 @@ local function diff(currentState: any, nextState: any): any patches[key] = diff(previous, nextState[key]) end - if config.autoSerialize then + if config.fixArrays then -- Coerce sparse array patches into dictionaries if currentState[1] ~= nil or nextState[1] ~= nil then patches = stringifySparseArray(patches) end - if flags.strict and config.validatePatches then + if globals.strict and config.validatePatches then for key, value in patches do validate(value, key) end diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 5f628d7..ba021f6 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -5,11 +5,13 @@ local types = require("./types") type SyncPayload = types.SyncPayload +local globals = Charm.globals + type RegisteredAtom = { - atom: () -> T, - value: T, - valueLastSynced: T, - stop: () -> (), + callback: () -> T, + currentValue: T, + previousValue: T, + dispose: () -> (), } type SyncStatus = number @@ -19,82 +21,82 @@ local LISTENING_FOR_CHANGES: SyncStatus = 1 local atomRegister: { [string]: RegisteredAtom } = {} local clientRegister: { [any]: { [string]: SyncStatus } } = {} -local globalUpdates: { { key: string, value: any, previousValue: any } } = {} +local globalUpdates: { { key: string, currentValue: any, previousValue: any } } = {} local connection: RBXScriptConnection? local onSyncCallback: (client: any, payloads: { SyncPayload }) -> ()? -local function registerAtom(key: string, atom: () -> T) +local function syncGlobalAtom(key: string, callback: () -> T) if atomRegister[key] then - if atomRegister[key].atom ~= atom then + if atomRegister[key].callback ~= callback then warn("[Charm] Attempted to sync different atoms with the same key:", key) end return end local registeredAtom: RegisteredAtom = { - atom = atom, - value = atom(), - valueLastSynced = atom(), - stop = function() end, + callback = callback, + currentValue = callback(), + previousValue = callback(), + dispose = function() end, } - registeredAtom.stop = Charm.effect(function() - local value = atom() + registeredAtom.dispose = Charm.effect(function() + local newValue = callback() -- When preserving history, we need to keep track of previous values -- so we can create granular patches for each change. - if config.preserveHistory and registeredAtom.value ~= value then + if config.preserveHistory and registeredAtom.currentValue ~= newValue then table.insert(globalUpdates, { key = key, - value = value, - previousValue = registeredAtom.value, + currentValue = newValue, + previousValue = registeredAtom.currentValue, }) end - registeredAtom.value = value + registeredAtom.currentValue = newValue end) atomRegister[key] = registeredAtom end -local function unregisterAtom(key: string) +local function unsyncGlobalAtom(key: string) local registeredAtom = atomRegister[key] if registeredAtom then - registeredAtom.stop() + registeredAtom.dispose() atomRegister[key] = nil end end -local function unregisterUnusedAtoms() - local used: { [string]: boolean } = {} +local function unsyncStaleGlobalAtoms() + local active: { [string]: boolean } = {} for _, clientAtoms in clientRegister do for key in clientAtoms do - used[key] = true + active[key] = true end end for key in atomRegister do - if not used[key] then - unregisterAtom(key) + if not active[key] then + unsyncGlobalAtom(key) end end end -local function createHistoricalPatches(): { any } +local function createGlobalPatchHistory(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) for index, update in globalUpdates do - patches[index] = patch.diff(update.previousValue, update.value) + patches[index] = patch.diff(update.previousValue, update.currentValue) end return patches end -local function createHistoricalPayloadsForClient(client: any, patches: { any }): { SyncPayload } +local function createPayloadHistoryForClient(client: any, patches: { any }): { SyncPayload } local syncedAtoms = clientRegister[client] if not syncedAtoms then @@ -123,9 +125,8 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }): elseif status == PENDING_INITIAL_STATE then table.insert(payloads, { type = "init", - data = { [key] = update.value }, + data = { [key] = update.currentValue }, }) - syncedAtoms[key] = LISTENING_FOR_CHANGES end end @@ -138,7 +139,7 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }): local registeredAtom = atomRegister[key] if status == PENDING_INITIAL_STATE and registeredAtom then - initPayload[key] = registeredAtom.value + initPayload[key] = registeredAtom.currentValue syncedAtoms[key] = LISTENING_FOR_CHANGES end end @@ -150,20 +151,20 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }): return payloads end -local function createPatch(): any +local function createGlobalPatch(): any local current: { [string]: any } = {} local previous: { [string]: any } = {} for key, registeredAtom in atomRegister do - current[key] = registeredAtom.value - previous[key] = registeredAtom.valueLastSynced - registeredAtom.valueLastSynced = registeredAtom.value + current[key] = registeredAtom.currentValue + previous[key] = registeredAtom.previousValue + registeredAtom.previousValue = registeredAtom.currentValue end return patch.diff(previous, current) end -local function createPayloadsForClient(client: any, patchData: any): { SyncPayload } +local function createPayloadForClient(client: any, patchData: any): { SyncPayload } local syncedAtoms = clientRegister[client] local payloads: { SyncPayload } = {} @@ -182,7 +183,7 @@ local function createPayloadsForClient(client: any, patchData: any): { SyncPaylo if status == LISTENING_FOR_CHANGES then patchPayload[key] = patchData[key] elseif status == PENDING_INITIAL_STATE then - initPayload[key] = registeredAtom.value + initPayload[key] = registeredAtom.currentValue syncedAtoms[key] = LISTENING_FOR_CHANGES end end @@ -215,7 +216,7 @@ local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) local syncedAtoms = clientRegister[client] for key, atom in atoms do - registerAtom(key, atom) + syncGlobalAtom(key, atom) if not syncedAtoms[key] then -- Mark the atom as pending initial state so we send its full value @@ -263,18 +264,18 @@ local function flush() return end - local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() + local patches = if config.preserveHistory then createGlobalPatchHistory() else createGlobalPatch() -- While payload objects are technically mutable as they are unique to -- each client, the data inside them is not safe to mutate. - if Charm.flags.frozen then + if globals.frozen then Charm.deepFreeze(patches) end for client in clientRegister do local payloads: { SyncPayload } = if config.preserveHistory - then createHistoricalPayloadsForClient(client, patches) - else createPayloadsForClient(client, patches) + then createPayloadHistoryForClient(client, patches) + else createPayloadForClient(client, patches) if payloads[1] then if task then @@ -285,10 +286,43 @@ local function flush() end end - unregisterUnusedAtoms() + unsyncStaleGlobalAtoms() table.clear(globalUpdates) end +--- Starts automatically syncing registered atoms to clients. Patches will +--- be sent to clients whenever the atoms are updated. +--- +--- @param onSync The function to call when patches can be sent to a client. +local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) + if connection then + connection:Disconnect() + end + + onSyncCallback = onSync + + if not game then + return + end + + local timer = 0 + + connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) + if config.interval < 0 then + return + end + + timer += deltaTime + + if timer >= config.interval then + timer = 0 + debug.profilebegin("CharmSync") + flush() + debug.profileend() + end + end) +end + --- Stops automatically syncing registered atoms to clients. Patches will --- no longer be sent to clients until `startSync` is called again. local function stopSync() @@ -307,42 +341,6 @@ local function reset() stopSync() end ---- Starts automatically syncing registered atoms to clients. Patches will ---- be sent to clients whenever the atoms are updated. ---- ---- @param onSync The function to call when patches can be sent to a client. ---- @return `stopSync()` -local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()): () -> () - if connection then - connection:Disconnect() - end - - onSyncCallback = onSync - - if not game then - return stopSync - end - - local timer = 0 - - connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) - if config.interval < 0 then - return - end - - timer += deltaTime - - if timer >= config.interval then - timer = 0 - debug.profilebegin("CharmSync") - flush() - debug.profileend() - end - end) - - return stopSync -end - return { syncAtomsToClient = syncAtomsToClient, unsyncClient = unsyncClient, diff --git a/packages/charm/src/__tests__/core/atom.spec.luau b/packages/charm/src/__tests__/core/signal.spec.luau similarity index 79% rename from packages/charm/src/__tests__/core/atom.spec.luau rename to packages/charm/src/__tests__/core/signal.spec.luau index 1c8f8d0..e63b4f6 100644 --- a/packages/charm/src/__tests__/core/atom.spec.luau +++ b/packages/charm/src/__tests__/core/signal.spec.luau @@ -1,13 +1,13 @@ local core = require("../../core") local suite = require("../../../../../test/suite") -local atom = core.atom +local signal = core.signal local effect = core.effect local test = suite.test test("should accept equality function", function() local calls = {} - local src = atom(0, function(current, incoming) + local src, setSrc = signal(0, function(current, incoming) return current == incoming or incoming % 2 == 1 end) @@ -15,23 +15,23 @@ test("should accept equality function", function() table.insert(calls, src()) end) - src(2) - src(3) + setSrc(2) + setSrc(3) assert(src() == 2, "should skip odd value") - src(4) - src(5) + setSrc(4) + setSrc(5) assert(src() == 4, "should skip odd value") assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) end) test("should enforce immutability", function() - local src = atom({ nested = {} }) + local src, setSrc = signal({ nested = {} }) assert(not pcall(function() src().nested.a = 1 end), "should not allow mutation of initial value") - src({ nested = { a = 1 } }) + setSrc({ nested = { a = 1 } }) assert(not pcall(function() src().nested.a = 2 end), "should not allow mutation of new value") diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index 8273125..4560796 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -1,9 +1,6 @@ local suite = require("../../../../test/suite") suite.describe("core", function() - suite.describe("atom", function() - require("./core/atom.spec") - end) suite.describe("computed", function() require("./core/computed.spec") end) @@ -16,6 +13,9 @@ suite.describe("core", function() suite.describe("gc", function() require("./core/gc.spec") end) + suite.describe("signal", function() + require("./core/signal.spec") + end) suite.describe("topology", function() require("./core/topology.spec") end) diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 7a9409a..410f4ff 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -1,5 +1,5 @@ local alien = require("./alien") -local config = require("./flags") +local globals = require("./globals") local utils = require("./utils") export type ReactiveFlags = alien.ReactiveFlags @@ -74,7 +74,7 @@ end local function runCleanups(effect: EffectScope) for _, cleanup in effect.cleanups do local prevSub = setActiveSub(nil) - local success, result: any = (if config.strict then noYield else pcall)(cleanup) + local success, result: any = (if globals.strict then noYield else pcall)(cleanup) activeSub = prevSub if not success then error(result) @@ -87,18 +87,17 @@ local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) sub.flags = bit32.band(flags, bit32.bnot(PENDING)) end -local function run(e: Effect | EffectScope, flags: ReactiveFlags): () +local function run(e: Effect, flags: ReactiveFlags): () if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and (checkDirty(e.deps :: Link, e) or setNotPending(e, flags))) then runCleanups(e) - local e = e :: Effect cycle += 1 e.depsTail = nil e.flags = bit32.bor(WATCHING, RECURSED_CHECK) local prevSub = setActiveSub(e) - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(e.fn, e.stop) + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn, e.stop) activeSub = prevSub e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) purgeDeps(e) @@ -155,7 +154,7 @@ local function updateComputed(c: Computed): boolean c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) local prevSub = setActiveSub(c) local oldValue = c.value - local success, result: unknown = (if config.strict then noYield else pcall)(c.getter, oldValue) + local success, result: unknown = (if globals.strict then noYield else pcall)(c.getter, oldValue) activeSub = prevSub c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) purgeDeps(c) @@ -163,7 +162,7 @@ local function updateComputed(c: Computed): boolean error(result) end if oldValue ~= result then - if config.frozen then + if globals.frozen then deepFreeze(result) end c.value = result @@ -205,61 +204,65 @@ local function computedOper(this: Computed): () -> T end end -local function signalOper(this: Signal, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T - return function(...: ((T) -> T) | T): T - if select("#", ...) ~= 0 then - local update = ... - local value: T - if type(update) == "function" then - local prevSub = setActiveSub(nil) - local success, result: T = (if config.strict then noYield else pcall)(update, this.currentValue) - activeSub = prevSub - if not success then - error(result) - end - value = result - else - value = update +local function signalOper( + this: Signal, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) + local function setter(update: ((T) -> T) | T): T + local value: T + if type(update) == "function" then + local prevSub = setActiveSub(nil) + local success, result: T = (if globals.strict then noYield else pcall)(update, this.currentValue) + activeSub = prevSub + if not success then + error(result) end - if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then - if config.frozen then - deepFreeze(value) + value = result + else + value = update + end + if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then + if globals.frozen then + deepFreeze(value) + end + this.pendingValue = value + this.flags = bit32.bor(MUTABLE, DIRTY) + local subs = this.subs + if subs then + propagate(subs) + if batchDepth == 0 then + flush() end - this.pendingValue = value - this.flags = bit32.bor(MUTABLE, DIRTY) + end + return value + end + return this.currentValue + end + + local function getter(): T + if bit32.btest(this.flags, DIRTY) then + if updateSignal(this) then local subs = this.subs if subs then - propagate(subs) - if batchDepth == 0 then - flush() - end - end - return value - end - return this.currentValue - else - if bit32.btest(this.flags, DIRTY) then - if updateSignal(this) then - local subs = this.subs - if subs then - shallowPropagate(subs) - end + shallowPropagate(subs) end end - local sub = activeSub - while sub do - if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then - link(this, sub, cycle) - break - end - local subs = sub.subs - if subs then - sub = subs.sub - end - end - return this.currentValue end + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(this, sub, cycle) + break + end + local subs = sub.subs + if subs then + sub = subs.sub + end + end + return this.currentValue end + + return getter, setter end local function effectScopeOper(this: EffectScope): () -> () @@ -279,15 +282,18 @@ local function effectOper(this: Effect): () -> () return effectScopeOper(this) end ---- Creates a reactive atom that acts as both a getter and setter. Calling the ---- atom with an argument sets its value, while calling it with no arguments ---- returns its current value. +--- Creates a reactive signal that stores a value and notifies subscribers when +--- the value changes. The signal provides both a getter and a setter. --- ---- @param value The initial value of the atom. If empty, the atom's value will be `undefined`. ---- @param equals A comparator function to determine if the atom's value has changed. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options -local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T - if config.frozen then +--- @param initialValue The initial value of the signal. +--- @param equals A comparator function to determine if the signal's value has changed. +--- @return A tuple containing the getter and setter functions for the signal. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals +local function signal( + initialValue: T, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) + if globals.frozen then deepFreeze(initialValue) end return signalOper({ @@ -297,6 +303,20 @@ local function atom(initialValue: T, equals: ((current: T, incoming: T) -> bo }, equals) end +--- Creates a reactive atom that acts as both a getter and setter. Calling the +--- atom with an argument sets its value, while calling it with no arguments +--- returns its current value. +--- +--- @param value The initial value of the atom. If empty, the atom's value will be `undefined`. +--- @param equals A comparator function to determine if the atom's value has changed. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals +local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T + local getter, setter = signal(initialValue, equals) + return function(...: ((T) -> T) | T): T + return if select("#", ...) == 0 then getter() else setter((...)) + end +end + --- Creates a new atom that is computed based on the values of other atoms. --- The returned atom is read-only, and its value is automatically updated when --- any atoms accessed from within the getter function change. @@ -343,11 +363,11 @@ local function effect(fn: (stop: () -> ()) -> ()): () -> () } e.stop = effectOper(e) local prevSub = setActiveSub(e) - if prevSub and (config.trackInnerEffects or prevSub.flags == NONE) then + if prevSub and (globals.trackInnerEffects or prevSub.flags == NONE) then link(e, prevSub, 0) end local stopped = false - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn, function() + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn, function() stopped = true end) activeSub = prevSub @@ -379,7 +399,7 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> () if prevSub and not detached then link(e, prevSub, 0) end - local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn) activeSub = prevSub if not success then error(result) @@ -456,7 +476,7 @@ end --- @return The return value of the callback. local function batched(callback: (Args...) -> Results..., ...: Args...): Results... startBatch() - local results: { any } = { (if config.strict then noYield else pcall)(callback, ...) } + local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) } endBatch() if not results[1] then error(results[2]) @@ -464,6 +484,59 @@ local function batched(callback: (Args...) -> Results..., . return unpack(results, 2) end +--- Creates an effect that only runs the callback when the value returned by the +--- getter changes. The callback receives the new value and the previous value +--- as arguments. +--- +--- @param getter A function that returns the value to watch. +--- @param callback The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback +local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () + local tracked = computed(function() + return getter() + end) + local current: T + local mounted = true + return effect(function() + local previous = current + current = tracked() + if mounted then + mounted = false + else + peek(callback, current, previous) + end + end) +end + +--- Creates an effect that runs the callback one immediately, and then again +--- when the value returned by the getter changes. The callback receives the new +--- value, the previous value, and a cleanup function as arguments. +--- +--- @param getter A function that returns the value to watch. +--- @param callback The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +local function listen(getter: () -> T, callback: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () + local tracked = computed(function() + return getter() + end) + local current: T + local disposed = false + local dispose = function() + disposed = true + end + dispose = effect(function() + local previous = current + current = tracked() + peek(callback, current, previous, dispose) + end) + if disposed then + dispose() + end + return dispose +end + local function notify(e: ReactiveNode) local flags = e.flags if not bit32.btest(flags, QUEUED) then @@ -513,6 +586,7 @@ return { startBatch = startBatch, endBatch = endBatch, atom = atom, + signal = signal, computed = computed, effect = effect, effectScope = effectScope, @@ -520,4 +594,6 @@ return { untracked = untracked, peek = peek, batched = batched, + subscribe = subscribe, + listen = listen, } diff --git a/packages/charm/src/flags.luau b/packages/charm/src/globals.luau similarity index 63% rename from packages/charm/src/flags.luau rename to packages/charm/src/globals.luau index 43475be..d1c1b17 100644 --- a/packages/charm/src/flags.luau +++ b/packages/charm/src/globals.luau @@ -3,17 +3,18 @@ local function inlineTest(): string end local isO2 = inlineTest() ~= "inlineTest" +local isDev = not isO2 or _G.__DEV__ == true return { --- Enforces synchronous, non-yielding behavior in atoms and effects. --- Also enables state validation in Charm Sync to catch sync errors early. --- Enabled if the optimization level is below 2, which is true in studio. - strict = not isO2, + strict = isDev, --- Enforces immutability of atoms by deep-freezing table values. Enabled --- if the optimization level is below 2, which is true in studio. - frozen = not isO2, - --- Whether inner effects should clean up after themselves when their - --- parent effect re-runs. This is usually desirable, but can be disabled - --- to revert to the old behavior. Defaults to `true`. + frozen = isDev, + --- Whether parent effects should track inner effects and clean them up + --- when the parent effect re-runs. This is usually desirable, but can be + --- disabled to revert to the old behavior. Defaults to `true`. trackInnerEffects = true, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 30d5410..03f37c3 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -7,7 +7,7 @@ type Cleanup = () => void; * current value. */ export interface Atom { - (value: T | ((value: T) => T)): T; + (newValue: T | ((currentValue: T) => T)): T; (): T; } @@ -18,7 +18,7 @@ export type Equals = (current: T, incoming: T) => boolean; /** * Global flags that modify the behavior of Charm's reactive system. */ -export const flags: { +export const globals: { /** * Enforces synchronous, non-yielding behavior in atoms and effects. * Also enables state validation in Charm Sync to catch sync errors early. @@ -31,13 +31,30 @@ export const flags: { */ frozen: boolean; /** - * Whether inner effects should clean up after themselves when their - * parent effect re-runs. This is usually desirable, but can be disabled - * to revert to the old behavior. Defaults to `true`. + * Whether parent effects should track inner effects and clean them up + * when the parent effect re-runs. This is usually desirable, but can be + * disabled to revert to the old behavior. Defaults to `true`. */ trackInnerEffects: boolean; }; +/** + * Creates a reactive signal that stores a value and notifies subscribers when + * the value changes. The signal provides both a getter and a setter. + * + * @param initialValue The initial value of the signal. + * @param equals A comparator function to determine if the signal's value has changed. + * @return A tuple containing the getter and setter functions for the signal. + * @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals + */ +export function signal( + value: T, + equals?: Equals, +): LuaTuple<[() => T, (newValue: T | ((currentValue: T) => T)) => T]>; +export function signal(): LuaTuple< + [() => T | undefined, (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined] +>; + /** * Creates a reactive atom that acts as both a getter and setter. Calling the * atom with an argument sets its value, while calling it with no arguments diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 470312a..1275509 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,33 +1,33 @@ local core = require("@self/core") -local flags = require("@self/flags") -local listen = require("@self/listen") +local globals = require("@self/globals") local mapped = require("@self/mapped") local observe = require("@self/observe") -local subscribe = require("@self/subscribe") -local types = require("@self/types") local utils = require("@self/utils") +type Update = ((previous: T) -> T) | T + --- A reactive atom that acts as both a getter and setter. Calling the atom with --- an argument sets its value, while calling it with no arguments returns its --- current value. -export type Atom = types.Atom +export type Atom = (...Update) -> T -export type Selector = types.Selector +export type Selector = () -> T return { core = core, - flags = flags, + globals = globals, atom = core.atom, batched = core.batched, computed = core.computed, effect = core.effect, effectScope = core.effectScope, + listen = core.listen, onCleanup = core.onCleanup, peek = core.peek, + signal = core.signal, + subscribe = core.subscribe, untracked = core.untracked, - listen = listen, mapped = mapped, observe = observe, - subscribe = subscribe, deepFreeze = utils.deepFreeze, } diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau deleted file mode 100644 index 9489297..0000000 --- a/packages/charm/src/listen.luau +++ /dev/null @@ -1,40 +0,0 @@ -local core = require("./core") - -local computed = core.computed -local effect = core.effect -local peek = core.peek - ---- Creates an effect that runs the callback one immediately, and then again ---- when the value returned by the getter changes. The callback receives the new ---- value, the previous value, and a cleanup function as arguments. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback -local function listen(getter: () -> T, callback: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () - local tracked = computed(function() - return getter() - end) - - local current: T - local disposed = false - - local function dispose() - disposed = true - end - - dispose = effect(function() - local previous = current - current = tracked() - peek(callback, current, previous, dispose) - end) - - if disposed then - dispose() - end - - return dispose -end - -return listen diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index bbdf359..5f5f9ba 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -1,11 +1,10 @@ local core = require("./core") -local flags = require("./flags") +local globals = require("./globals") +local batched = core.batched local effect = core.effect local effectScope = core.effectScope local onCleanup = core.onCleanup -local startBatch = core.startBatch -local endBatch = core.endBatch --- Calls the observer function for each unique key in the table returned by the --- getter. The observer is called once for each key, and again when a new key @@ -51,13 +50,11 @@ local function observe(getter: () -> { [K]: V }, observer: (item: V, key: local previousItems = currentItems currentItems = getter() if currentItems ~= previousItems then - startBatch() - updateScopes(currentItems, previousItems) - endBatch() + batched(updateScopes, currentItems, previousItems) end end) - if flags.trackInnerEffects then + if globals.trackInnerEffects then onCleanup(disposeScopes, true) end diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau deleted file mode 100644 index 3d4ec41..0000000 --- a/packages/charm/src/subscribe.luau +++ /dev/null @@ -1,34 +0,0 @@ -local core = require("./core") - -local computed = core.computed -local effect = core.effect -local peek = core.peek - ---- Creates an effect that only runs the callback when the value returned by the ---- getter changes. The callback receives the new value and the previous value ---- as arguments. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback -local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () - local tracked = computed(function() - return getter() - end) - - local current: T - local mounted = true - - return effect(function() - local previous = current - current = tracked() - if mounted then - mounted = false - else - peek(callback, current, previous) - end - end) -end - -return subscribe diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau deleted file mode 100644 index 4769ad2..0000000 --- a/packages/charm/src/types.luau +++ /dev/null @@ -1,7 +0,0 @@ -type Update = ((previous: T) -> T) | T - -export type Atom = (...Update) -> T - -export type Selector = () -> T - -return {} diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 44f43f9..a923ff5 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -16,6 +16,7 @@ local function useAtom(getter: () -> T, dependencies: { any }?): T React.useLayoutEffect(function() return Charm.effect(function() + -- Wrap in untracked to avoid tracking inner effect and atom calls Charm.untracked(setState, getter()) end) end, dependencies or {}) @@ -39,6 +40,7 @@ local function useAtomBinding(getter: () -> T, dependencies: { any }?) React.useLayoutEffect(function() return Charm.effect(function() + -- Wrap in untracked to avoid tracking inner effect and atom calls Charm.untracked(setBinding, getter()) end) end, dependencies or {}) diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index bf59c2e..d6d27ae 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -10,6 +10,7 @@ local function useAtom(getter: () -> State): () -> State local state = Vide.source(getter()) Vide.cleanup(Charm.effect(function() + -- Wrap in untracked to avoid tracking inner effect and atom calls Charm.untracked(state :: (State) -> State, getter()) end)) diff --git a/test/spec.server.luau b/test/spec.server.luau index dfb6d0b..bb1f7aa 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -1,8 +1,8 @@ local Charm = require("../packages/charm/src") local suite = require("./suite") -Charm.flags.strict = true -Charm.flags.frozen = true +Charm.globals.strict = true +Charm.globals.frozen = true suite.describe("charm", function() require("../packages/charm/src/__tests__/spec") From 32788b19e15b33d64b961728020e441d7beeb58f Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 31 Oct 2025 21:56:02 -0700 Subject: [PATCH 053/160] refactor: update charm sync api --- .../charm-sync/src/__tests__/client.spec.luau | 54 ++-- .../charm-sync/src/__tests__/e2e.spec.luau | 16 +- .../charm-sync/src/__tests__/server.spec.luau | 50 ++-- packages/charm-sync/src/__tests__/spec.luau | 4 +- .../{flatten.spec.luau => utils.spec.luau} | 7 +- packages/charm-sync/src/client.luau | 60 ++-- packages/charm-sync/src/flatten.luau | 30 -- packages/charm-sync/src/index.d.ts | 130 ++++----- packages/charm-sync/src/init.luau | 5 +- packages/charm-sync/src/server.luau | 273 +++++++++--------- packages/charm-sync/src/utils.luau | 52 ++++ packages/charm/src/core.luau | 11 +- packages/charm/src/index.d.ts | 2 - 13 files changed, 359 insertions(+), 335 deletions(-) rename packages/charm-sync/src/__tests__/{flatten.spec.luau => utils.spec.luau} (78%) delete mode 100644 packages/charm-sync/src/flatten.luau create mode 100644 packages/charm-sync/src/utils.luau diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index d761ca0..d3a9fb9 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -2,8 +2,6 @@ local Charm = require("../../Charm") local client = require("../client") local suite = require("../../../../test/suite") -local atom = Charm.atom -local subscribe = Charm.subscribe local test = suite.test local afterEach = suite.afterEach @@ -11,29 +9,29 @@ afterEach(function() client.reset() end) -test("should sync state payload", function() - local a = atom(1) +test("should hydrate atom", function() + local a = Charm.atom(1) - client.syncAtoms({ a = a }) - client.applyPayloads({ + client.register({ a = a }) + client.hydrate({ { type = "init", data = { a = 2 } }, }) assert(a() == 2, "should set a to the initial value") end) -test("should sync state patch", function() - local a = atom({ b = 1, c = 2 }) +test("should merge patch", function() + local a = Charm.atom({ b = 1, c = 2 }) local previous = a() - client.syncAtoms({ a = a }) - client.applyPayloads({ + client.register({ a = a }) + client.hydrate({ { type = "init", data = { a = a() } }, }) assert(a() == previous, "should set a to the initial value") - client.applyPayloads({ + client.hydrate({ { type = "patch", data = { a = { b = 3 } } }, }) @@ -43,17 +41,17 @@ test("should sync state patch", function() end) test("should fix sparse array patches", function() - local a = atom({ 1 }) + local a = Charm.atom({ 1 }) - client.syncAtoms({ a = a }) - client.applyPayloads({ + client.register({ a = a }) + client.hydrate({ { 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.applyPayloads({ + client.hydrate({ { type = "patch", data = { a = { ["2"] = 2 } } }, }) @@ -61,21 +59,21 @@ test("should fix sparse array patches", function() assert(a()[2] == 2, "should convert serialized arrays to use numbers") end) -test("should apply multiple patches", function() - local a = atom(1) +test("should merge multiple patches", function() + local a = Charm.atom(1) local calls = {} - client.syncAtoms({ a = a }) + client.register({ a = a }) - subscribe(a, function(value) + Charm.subscribe(a, function(value) table.insert(calls, value) end) - client.applyPayloads({ + client.hydrate({ { type = "init", data = { a = a() } }, }) - client.applyPayloads({ + client.hydrate({ { type = "patch", data = { a = 2 } }, { type = "patch", data = { a = 3 } }, }) @@ -85,25 +83,25 @@ test("should apply multiple patches", function() end) test("should batch patches", function() - local a = atom(0) - local b = atom(0) + local a = Charm.atom(0) + local b = Charm.atom(0) local states = {} - client.syncAtoms({ a = a, b = b }) + client.register({ a = a, b = b }) - subscribe(a, function() + Charm.subscribe(a, function() states.b = b() end) - subscribe(b, function() + Charm.subscribe(b, function() states.a = a() end) - client.applyPayloads({ + client.hydrate({ { type = "init", data = { a = a(), b = b() } }, }) - client.applyPayloads({ + client.hydrate({ { type = "patch", data = { a = 1, b = 2 } }, }) diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau index acc75e8..2f2c647 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -20,11 +20,11 @@ test("should replicate server state to client", function() local clientA = Charm.atom({ count = -1 }) local clientB = Charm.atom({ count = -1 }) - client.syncAtoms({ a = clientA, b = clientB }) - server.syncAtomsToClient(target, { a = serverA, b = serverB }) - server.startSync(function(syncTarget, payloads) + client.register({ a = clientA, b = clientB }) + server.subscribeClient(target, { a = serverA, b = serverB }) + server.connect(function(syncTarget, payloads) if syncTarget == target then - client.applyPayloads(payloads) + client.hydrate(payloads) end end) @@ -54,11 +54,11 @@ test("should replicate historical server state to client", function() config.preserveHistory = true - client.syncAtoms({ a = clientA, b = clientB }) - server.syncAtomsToClient(target, { a = serverA, b = serverB }) - server.startSync(function(syncTarget, payloads) + client.register({ a = clientA, b = clientB }) + server.subscribeClient(target, { a = serverA, b = serverB }) + server.connect(function(syncTarget, payloads) if syncTarget == target then - client.applyPayloads(payloads) + client.hydrate(payloads) end end) diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau index 744c75e..19e60cd 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -20,8 +20,8 @@ test("should replicate state changes", function() local c = Charm.atom(0) local target, payloads - server.syncAtomsToClient(client, { a = a, b = b, c = c }) - server.startSync(function(...) + server.subscribeClient(client, { a = a, b = b, c = c }) + server.connect(function(...) target, payloads = ... end) @@ -47,8 +47,8 @@ test("should replicate partial state patches", function() local b = Charm.atom(0 :: number?) local target, payloads - server.syncAtomsToClient(client, { a = a, b = b }) - server.startSync(function(...) + server.subscribeClient(client, { a = a, b = b }) + server.connect(function(...) target, payloads = ... end) @@ -75,8 +75,8 @@ test("should init new clients", function() local a = Charm.atom(0) local payloadsA, payloadsB - server.syncAtomsToClient(clientA, { a = a }) - server.startSync(function(target, payloads) + server.subscribeClient(clientA, { a = a }) + server.connect(function(target, payloads) if target == clientA then payloadsA = payloads elseif target == clientB then @@ -89,7 +89,7 @@ test("should init new clients", function() assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") - server.syncAtomsToClient(clientB, { a = a }) + server.subscribeClient(clientB, { a = a }) a(2) server.flush() assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") @@ -104,13 +104,13 @@ test("should init new atoms", function() local b = Charm.atom(0) local target, payloads - server.syncAtomsToClient(client, { a = a }) - server.startSync(function(...) + server.subscribeClient(client, { a = a }) + server.connect(function(...) target, payloads = ... end) server.flush() - server.syncAtomsToClient(client, { b = b }) + server.subscribeClient(client, { b = b }) a(1) b(1) server.flush() @@ -129,9 +129,9 @@ test("should only replicate to synced clients", function() local b = Charm.atom(0) local payloadsA, payloadsB - server.syncAtomsToClient(clientA, { a = a }) - server.syncAtomsToClient(clientB, { b = b }) - server.startSync(function(target, payloads) + server.subscribeClient(clientA, { a = a }) + server.subscribeClient(clientB, { b = b }) + server.connect(function(target, payloads) if target == clientA then payloadsA = payloads elseif target == clientB then @@ -170,8 +170,8 @@ test("should validate state", function() local client = {} local a = Charm.atom({}) - server.syncAtomsToClient(client, { a = a }) - server.startSync(function() end) + server.subscribeClient(client, { a = a }) + server.connect(function() end) a({ [0.5] = true }) assert(not pcall(server.flush), "Expected server.flush to fail") @@ -185,8 +185,8 @@ test("should serialize state", function() local a = Charm.atom({ 1, 2, 3, 4, 5 }) local payloads - server.syncAtomsToClient(client, { a = a }) - server.startSync(function(_, p) + server.subscribeClient(client, { a = a }) + server.connect(function(_, p) payloads = p end) server.flush() @@ -213,8 +213,8 @@ describe("historical", function() local c = Charm.atom(0) local target, payloads - server.syncAtomsToClient(client, { a = a, b = b, c = c }) - server.startSync(function(...) + server.subscribeClient(client, { a = a, b = b, c = c }) + server.connect(function(...) target, payloads = ... end) @@ -242,8 +242,8 @@ describe("historical", function() local a = Charm.atom(0) local payloadsA, payloadsB - server.syncAtomsToClient(clientA, { a = a }) - server.startSync(function(target, payloads) + server.subscribeClient(clientA, { a = a }) + server.connect(function(target, payloads) if target == clientA then payloadsA = payloads elseif target == clientB then @@ -259,7 +259,7 @@ describe("historical", function() assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A") assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A") - server.syncAtomsToClient(clientB, { a = a }) + server.subscribeClient(clientB, { a = a }) a(3) a(4) server.flush() @@ -278,8 +278,8 @@ describe("historical", function() local b = Charm.atom(0) local payloads - server.syncAtomsToClient(client, { a = a }) - server.startSync(function(_, ...) + server.subscribeClient(client, { a = a }) + server.connect(function(_, ...) payloads = ... end) @@ -287,7 +287,7 @@ describe("historical", function() assert(payloads[1].type == "init", "Expected first payload to be init") assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") - server.syncAtomsToClient(client, { b = b }) + server.subscribeClient(client, { b = b }) a(1) a(2) b(1) diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau index 2b6363e..5f8513c 100644 --- a/packages/charm-sync/src/__tests__/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -8,8 +8,8 @@ suite.describe("e2e", function() require("./e2e.spec") end) -suite.describe("flatten", function() - require("./flatten.spec") +suite.describe("utils", function() + require("./utils.spec") end) suite.describe("patch", function() diff --git a/packages/charm-sync/src/__tests__/flatten.spec.luau b/packages/charm-sync/src/__tests__/utils.spec.luau similarity index 78% rename from packages/charm-sync/src/__tests__/flatten.spec.luau rename to packages/charm-sync/src/__tests__/utils.spec.luau index ff4848d..e4215e7 100644 --- a/packages/charm-sync/src/__tests__/flatten.spec.luau +++ b/packages/charm-sync/src/__tests__/utils.spec.luau @@ -1,17 +1,18 @@ local Charm = require("../../Charm") -local flatten = require("../flatten") local suite = require("../../../../test/suite") +local utils = require("../utils") local atom = Charm.atom local test = suite.test test("should flatten atoms", function() + local getF, setF = Charm.signal(0) local input = { a = atom(0), b = { c = atom(0) }, - d = { e = { f = atom(0) } }, + d = { e = { f = utils.signalToAtom(getF, setF) } }, } - local result = flatten(input) + local result = utils.flattenState(input) assert(result.a == input.a, "should flatten the first level") assert(result["b/c"] == input.b.c, "should flatten the second level") diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 57b03ec..6b4ad08 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,46 +2,43 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") +type Atom = Charm.Atom type SyncPayload = types.SyncPayload -local atomRegister: { [string]: Charm.Atom } = {} +local trackedAtoms: { [string]: Atom } = {} ---- Registers atoms to be kept in sync with the server. Atoms with matching ---- keys on the server will be synced to these atoms. +--- Registers a map of atoms to the state with the corresponding keys on the +--- server. When the server sends updates, these atoms will be hydrated with +--- the new values. --- ---- @param atoms An object mapping string keys to atoms to be synced. -local function syncAtoms(atoms: { [string]: Charm.Atom }) +--- If syncing signals instead of atoms, you can use `signalToAtom` to sync +--- signal getters and setters. +--- +--- @param atoms A map of atoms to sync with the server. +local function register(atoms: { [string]: Atom }) for key, atom in atoms do - if atomRegister[key] and atomRegister[key] ~= atom then - warn("[Charm] Attempted to sync different atoms with the same key:", key) - continue - end - atomRegister[key] = atom + trackedAtoms[key] = atom end end ---- Unregisters one or more atoms from being kept in sync with the server. ---- The atoms will retain their current values, but will no longer receive ---- updates from the server. ---- ---- @param keys The keys of the atoms to stop syncing. -local function unsyncAtoms(...: string) +--- Unregisters from server state updates for the given keys. The atoms +--- will retain their current values, but will no longer receive updates +--- from the server. +local function unregister(...: string) for index = 1, select("#", ...) do local key = select(index, ...) - atomRegister[key] = nil + trackedAtoms[key] = nil end end ---- Unregisters all atoms from being kept in sync with the server. The ---- atoms will retain their current values, but will no longer receive ---- updates from the server. +--- Unsubscribes from all server state updates. local function reset() - table.clear(atomRegister) + table.clear(trackedAtoms) end local function applyInitPayload(data: { [string]: any }) for key, value in data do - local atom = atomRegister[key] + local atom = trackedAtoms[key] if atom then atom(value) end @@ -51,23 +48,22 @@ end local function applyPatchPayload(data: { [string | number]: any }) local target = {} - for key, atom in atomRegister do + for key, atom in trackedAtoms do target[key] = atom() end target = patch.apply(target, data) - for key, atom in atomRegister do + for key, atom in trackedAtoms do atom(target[key]) end end ---- Applies one or more payloads received from the server to the synced ---- atoms. Payloads are state patches or initial state updates sent by the ---- server, and are usually received via a remote event. +--- Merges the server's state patches into the client's state, either +--- initializing it with the full state or merging a partial patch. --- ---- @param payloads The payloads to apply. -local function applyPayloads(payloads: { SyncPayload }) +--- @param payloads The state updates received from the server. +local function hydrate(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then Charm.batched(applyInitPayload, payload.data) @@ -78,8 +74,8 @@ local function applyPayloads(payloads: { SyncPayload }) end return { - syncAtoms = syncAtoms, - unsyncAtoms = unsyncAtoms, + register = register, + unregister = unregister, reset = reset, - applyPayloads = applyPayloads, + hydrate = hydrate, } diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau deleted file mode 100644 index 06335f4..0000000 --- a/packages/charm-sync/src/flatten.luau +++ /dev/null @@ -1,30 +0,0 @@ -type NestedAtomMap = { - [string]: NestedAtomMap | () -> any, -} - ---- Flattens a nested atom map into a single object with slash-separated keys. ---- Useful for recursively collecting atoms returned by modules. ---- ---- @param atoms The nested atom map to flatten. ---- @return A flattened atom map. -local function flatten(atoms: NestedAtomMap): { [string]: () -> any } - local result: { [string]: () -> any } = {} - - local function visit(node: NestedAtomMap, path: string) - for key, value in node do - local location = if path == "" then key else path .. "/" .. key - - if type(value) == "table" then - visit(value, location) - else - result[location] = value - end - end - end - - visit(atoms, "") - - return result -end - -return flatten diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index bbd4923..70bdda9 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -33,29 +33,29 @@ type DataType = DataTypes[keyof DataTypes]; * If a value was removed, it is replaced with `None`. This can be checked * using the `isNone` function. */ -export type SyncPatch = MaybeNone | State extends +export type SyncPatch = MaybeNone | State extends | ReadonlyMap | Map - ? ReadonlyMap | None> + ? ReadonlyMap | None> : State extends Set | ReadonlySet ? ReadonlyMap : State extends readonly (infer T)[] - ? Serialize extends true - ? ReadonlyMap | None> - : readonly (SyncPatch | None | undefined)[] + ? FixArrays extends true + ? ReadonlyMap | None> + : readonly (SyncPatch | None | undefined)[] : State extends DataType ? State : State extends object - ? { readonly [P in keyof State]?: SyncPatch } + ? { readonly [P in keyof State]?: SyncPatch } : State; /** * A payload that can be sent from the server to the client to synchronize * state between the two. */ -export type SyncPayload = +export type SyncPayload = | { type: "init"; data: StateOfMap } - | { type: "patch"; data: SyncPatch, Serialize> }; + | { type: "patch"; data: SyncPatch, FixArrays> }; /** * Global configuration options that affect the behavior of Charm Sync. @@ -102,38 +102,37 @@ export const config: { */ export namespace client { /** - * Registers atoms to be kept in sync with the server. Atoms with matching - * keys on the server will be synced to these atoms. + * Registers a map of atoms to the state with the corresponding keys on the + * server. When the server sends updates, these atoms will be hydrated with + * the new values. * - * @param atoms An object mapping string keys to atoms to be synced. + * If syncing signals instead of atoms, you can use `signalToAtom` to sync + * signal getters and setters as atoms. + * + * @param atoms A map of atoms to sync with the server. */ - export function syncAtoms(atoms: { [key: string]: Atom }): void; + export function register(atoms: { [key: string]: Atom }): void; /** - * Unregisters one or more atoms from being kept in sync with the server. - * The atoms will retain their current values, but will no longer receive - * updates from the server. - * - * @param keys The keys of the atoms to stop syncing. + * Unregisters from server state updates for the given keys. The atoms + * will retain their current values, but will no longer receive updates + * from the server. */ - export function unsyncAtoms(...keys: string[]): void; + export function unregister(...keys: string[]): void; /** - * Unregisters all atoms from being kept in sync with the server. The - * atoms will retain their current values, but will no longer receive - * updates from the server. + * Unsubscribes from all server state updates. */ export function reset(): void; /** - * Applies one or more payloads received from the server to the synced - * atoms. Payloads are state patches or initial state updates sent by the - * server, and are usually received via a remote event. + * Merges the server's state patches into the client's state, either + * initializing it with the full state or merging a partial patch. * - * @param payloads The payloads to apply. + * @param payloads The state updates received from the server. */ - export function applyPayloads }, Serialize extends boolean = true>( - payloads: SyncPayload[], + export function hydrate }, FixArrays extends boolean = true>( + payloads: SyncPayload[], ): void; } @@ -142,61 +141,62 @@ export namespace client { */ export namespace server { /** - * Registers atoms to be kept in sync with a specific client. Should be - * called when a player joins the game to start syncing atoms to them. Call - * `unsyncAtomsFromClient` or `unsyncClient` to stop syncing. + * Subscribes a client to the given atoms. When an update occurs, the client + * will receive a partial state patch to merge into their local state. May be + * called multiple times to subscribe to additional keys. * - * Each atom should be assigned a unique key, allowing the server to re-use - * patches between multiple clients. + * Atoms, signals converted to atoms, and signal getter functions are allowed. + * Note that the client still requires some way to set the state, so + * registering signals on the client should be done through `signalToAtom()`. * - * @param client The player to sync atoms to. - * @param atoms An object mapping string keys to atoms to be synced. + * @param client The client receiving state updates. + * @param atoms A map of atoms to sync with the client. */ - export function syncAtomsToClient(client: Player, atoms: { [key: string]: Atom }): void; + export function subscribeClient(client: Player, atoms: { [key: string]: Atom }): void; /** - * Unregisters one or more atoms from being kept in sync with a specific - * client. + * Unsubscribes a client from receiving all state updates. To only unsubscribe + * from specific keys, use `unsubscribeClientFrom` instead. * - * @param client The player to stop syncing atoms to. - * @param keys The keys of the atoms to stop syncing. + * @param client The client receiving state updates. */ - export function unsyncAtomsFromClient(client: Player, ...keys: string[]): void; + export function unsubscribeClient(client: Player): void; /** - * Unregisters all atoms from being kept in sync with a specific client. - * Should be called when a player leaves the game. + * Unsubscribes a client from receiving updates for the given keys. * - * @param client The player to stop syncing atoms to. + * @param client The client receiving state updates. + * @param keys The keys of the state to unsubscribe from. */ - export function unsyncClient(client: Player): void; + export function unsubscribeClientFrom(client: Player, ...keys: string[]): void; /** - * Starts automatically syncing registered atoms to clients. Patches will - * be sent to clients whenever the atoms are updated. + * Uses the callback to send state updates to clients that are subscribed + * to state changes. Starts automatically sending updates at the interval + * specified in `config.interval`. * - * @param onSync The function to call when patches can be sent to a client. + * @param onSync Called when sending patches to a client. */ - export function startSync }, Serialize extends boolean = true>( - onSync: (client: Player, payloads: SyncPayload[]) => void, + export function connect }, FixArrays extends boolean = true>( + onSync: (client: Player, payloads: SyncPayload[]) => void, ): void; /** - * Stops automatically syncing registered atoms to clients. Patches will - * no longer be sent to clients until `startSync` is called again. + * Stops syncing state updates to clients at the automatic interval. + * Flushing can still be performed manually via `flush`. */ - export function stopSync(): void; + export function disconnect(): void; /** - * Immediately sends pending updates and initial states to all clients. - * Normally, updates are sent at the interval specified in `config.interval`. - * Calling this function forces all pending updates to be sent immediately. + * Immediately sends pending updates to all clients. Normally, updates are + * sent at the interval specified in `config.interval`, but this function + * can be used to force an immediate sync. */ export function flush(): void; /** - * Unregisters all atoms from being kept in sync with all clients, and - * stops automatic syncing. + * Unsubscribes all clients, stops tracking all state, and disconnects + * from the automatic sync interval. */ export function reset(): void; } @@ -204,8 +204,8 @@ export namespace server { export namespace patch { /** * Converts `nil` values to the special `None` symbol. This is useful when - * preparing data for serialization, since `nil` can represent both the absence - * of a value and the removal of a value in patches. + * preparing data for serialization, since `nil` can represent both the + * absence of a value and the removal of a value in patches. */ export function nilToNone(value: T | undefined): T | None; @@ -217,10 +217,10 @@ export namespace patch { * @param nextState The next state. * @returns A patch that can be applied to the current state. */ - export function diff( + export function diff( currentState: State, nextState: State, - ): SyncPatch; + ): SyncPatch; /** * Applies a patch to a state and returns the resulting value. @@ -229,9 +229,9 @@ export namespace patch { * @param statePatch The patches to apply. * @returns The new state with the patch applied. */ - export function apply( + export function apply( currentState: State, - statePatch: SyncPatch, + statePatch: SyncPatch, ): State; } @@ -247,8 +247,6 @@ type AppendPath = undefined exte : never : never; -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; - type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; export type FlattenNestedAtoms< @@ -269,7 +267,7 @@ export type FlattenNestedAtoms< * @param atoms The nested atom map to flatten. * @returns A flattened atom map. */ -export function flatten(atoms: Atoms): FlattenNestedAtoms; +export function flattenAtoms(atoms: Atoms): FlattenNestedAtoms; /** * Checks whether a value is `None`. If `true`, the value is scheduled to be diff --git a/packages/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau index f9c1a8e..aca6061 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -1,9 +1,9 @@ local client = require("@self/client") local config = require("@self/config") -local flatten = require("@self/flatten") local patch = require("@self/patch") local server = require("@self/server") local types = require("@self/types") +local utils = require("@self/utils") export type None = types.None export type SyncPayload = types.SyncPayload @@ -14,5 +14,6 @@ return { server = server, patch = patch, isNone = patch.isNone, - flatten = flatten, + flattenState = utils.flattenState, + signalToAtom = utils.signalToAtom, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index ba021f6..4058bcf 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -7,85 +7,95 @@ type SyncPayload = types.SyncPayload local globals = Charm.globals -type RegisteredAtom = { - callback: () -> T, +type SyncStatus = number + +type ClientAtoms = { + [string]: SyncStatus, +} + +type TrackedState = { + getter: () -> T, currentValue: T, previousValue: T, dispose: () -> (), } -type SyncStatus = number +type StateUpdate = { + key: string, + currentValue: any, + previousValue: any, +} local PENDING_INITIAL_STATE: SyncStatus = 0 local LISTENING_FOR_CHANGES: SyncStatus = 1 -local atomRegister: { [string]: RegisteredAtom } = {} -local clientRegister: { [any]: { [string]: SyncStatus } } = {} -local globalUpdates: { { key: string, currentValue: any, previousValue: any } } = {} +local trackedAtoms: { [string]: TrackedState } = {} +local globalUpdates: { StateUpdate } = {} +local clients: { [any]: ClientAtoms } = {} local connection: RBXScriptConnection? -local onSyncCallback: (client: any, payloads: { SyncPayload }) -> ()? +local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? -local function syncGlobalAtom(key: string, callback: () -> T) - if atomRegister[key] then - if atomRegister[key].callback ~= callback then - warn("[Charm] Attempted to sync different atoms with the same key:", key) +local function startTracking(key: string, getter: () -> T) + if trackedAtoms[key] then + -- Ignore duplicates, but warn against name collisions + if trackedAtoms[key].getter ~= getter then + warn("[Charm] Attempted to sync different states with the same key:", key) end return end - local registeredAtom: RegisteredAtom = { - callback = callback, - currentValue = callback(), - previousValue = callback(), - dispose = function() end, + local value = getter() + local trackedState = { + getter = getter, + currentValue = value, + previousValue = value, } - registeredAtom.dispose = Charm.effect(function() - local newValue = callback() + trackedState.dispose = Charm.effect(function() + local newValue = getter() -- When preserving history, we need to keep track of previous values -- so we can create granular patches for each change. - if config.preserveHistory and registeredAtom.currentValue ~= newValue then + if config.preserveHistory and trackedState.currentValue ~= newValue then table.insert(globalUpdates, { key = key, currentValue = newValue, - previousValue = registeredAtom.currentValue, + previousValue = trackedState.previousValue, }) end - registeredAtom.currentValue = newValue + trackedState.currentValue = newValue end) - atomRegister[key] = registeredAtom + trackedAtoms[key] = trackedState end -local function unsyncGlobalAtom(key: string) - local registeredAtom = atomRegister[key] - - if registeredAtom then - registeredAtom.dispose() - atomRegister[key] = nil +local function stopTracking(key: string) + local tracker = trackedAtoms[key] + if tracker then + tracker.dispose() + trackedAtoms[key] = nil end end -local function unsyncStaleGlobalAtoms() +local function purgeUntracked() local active: { [string]: boolean } = {} - for _, clientAtoms in clientRegister do + for _, clientAtoms in clients do for key in clientAtoms do active[key] = true end end - for key in atomRegister do + for key in trackedAtoms do if not active[key] then - unsyncGlobalAtom(key) + stopTracking(key) end end end -local function createGlobalPatchHistory(): { any } +local function diffGlobalUpdateBuffer(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) @@ -96,10 +106,10 @@ local function createGlobalPatchHistory(): { any } return patches end -local function createPayloadHistoryForClient(client: any, patches: { any }): { SyncPayload } - local syncedAtoms = clientRegister[client] +local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload } + local clientAtoms = clients[client] - if not syncedAtoms then + if not clientAtoms then return {} end @@ -108,14 +118,14 @@ local function createPayloadHistoryForClient(client: any, patches: { any }): { S -- Iterate over all global updates and see if the client is interested in -- any of them. If so, we create a patch payload for the update. for index, update in globalUpdates do - local patchData = patches[index] + local patchData = globalPatches[index] if not patchData then continue end local key = update.key - local status = syncedAtoms[key] + local status = clientAtoms[key] if status == LISTENING_FOR_CHANGES then table.insert(payloads, { @@ -127,179 +137,180 @@ local function createPayloadHistoryForClient(client: any, patches: { any }): { S type = "init", data = { [key] = update.currentValue }, }) - syncedAtoms[key] = LISTENING_FOR_CHANGES + clientAtoms[key] = LISTENING_FOR_CHANGES end end -- If a pending atom was never updated, we still need to send its initial -- state. This can happen if the atom was registered after its last change. - local initPayload: { [string]: any } = {} + local initData: { [string]: any } = {} - for key, status in syncedAtoms do - local registeredAtom = atomRegister[key] + for key, status in clientAtoms do + local trackedState = trackedAtoms[key] - if status == PENDING_INITIAL_STATE and registeredAtom then - initPayload[key] = registeredAtom.currentValue - syncedAtoms[key] = LISTENING_FOR_CHANGES + if status == PENDING_INITIAL_STATE and trackedState then + initData[key] = trackedState.currentValue + clientAtoms[key] = LISTENING_FOR_CHANGES end end - if next(initPayload) ~= nil then - table.insert(payloads, 1, { type = "init" :: "init", data = initPayload }) + if next(initData) ~= nil then + table.insert(payloads, 1, { type = "init" :: "init", data = initData }) end return payloads end -local function createGlobalPatch(): any - local current: { [string]: any } = {} - local previous: { [string]: any } = {} +local function diffGlobalState(): any + local currentGlobalState: { [string]: any } = {} + local previousGlobalState: { [string]: any } = {} - for key, registeredAtom in atomRegister do - current[key] = registeredAtom.currentValue - previous[key] = registeredAtom.previousValue - registeredAtom.previousValue = registeredAtom.currentValue + for key, tracker in trackedAtoms do + currentGlobalState[key] = tracker.currentValue + previousGlobalState[key] = tracker.previousValue + tracker.previousValue = tracker.currentValue end - return patch.diff(previous, current) + return patch.diff(previousGlobalState, currentGlobalState) end -local function createPayloadForClient(client: any, patchData: any): { SyncPayload } - local syncedAtoms = clientRegister[client] - local payloads: { SyncPayload } = {} +local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload } + local clientAtoms = clients[client] - if not syncedAtoms then - return payloads + if not clientAtoms then + return {} end - local initPayload: { [string]: any } = {} - local patchPayload: { [string]: any } = {} + local payloads: { SyncPayload } = {} + local initData: { [string]: any } = {} + local patchData: { [string]: any } = {} -- Create patches for initialized atoms that changed, and add atoms that -- are still pending initial state to the init payload. - for key, status in syncedAtoms do - local registeredAtom = atomRegister[key] + for key, status in clientAtoms do + local trackedState = trackedAtoms[key] if status == LISTENING_FOR_CHANGES then - patchPayload[key] = patchData[key] + patchData[key] = globalPatch[key] elseif status == PENDING_INITIAL_STATE then - initPayload[key] = registeredAtom.currentValue - syncedAtoms[key] = LISTENING_FOR_CHANGES + initData[key] = trackedState.currentValue + clientAtoms[key] = LISTENING_FOR_CHANGES end end - if next(initPayload) ~= nil then - table.insert(payloads, { type = "init", data = initPayload }) + if next(initData) ~= nil then + table.insert(payloads, { type = "init", data = initData }) end - if next(patchPayload) ~= nil then - table.insert(payloads, { type = "patch", data = patchPayload }) + if next(patchData) ~= nil then + table.insert(payloads, { type = "patch", data = patchData }) end return payloads end ---- Registers atoms to be kept in sync with a specific client. Should be ---- called when a player joins the game to start syncing atoms to them. Call ---- `unsyncAtomsFromClient` or `unsyncClient` to stop syncing. +--- Subscribes a client to the given atoms. When an update occurs, the client +--- will receive a partial state patch to merge into their local state. May be +--- called multiple times to subscribe to additional keys. --- ---- Each atom should be assigned a unique key, allowing the server to re-use ---- patches between multiple clients. +--- Atoms, signals converted to atoms, and signal getter functions are allowed. +--- Note that the client still requires some way to set the state, so +--- registering signals on the client should be done through `signalToAtom()`. --- ---- @param client The player to sync atoms to. ---- @param atoms An object mapping string keys to atoms to be synced. -local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) - if not clientRegister[client] then - clientRegister[client] = {} +--- @param client The client receiving state updates. +--- @param atoms A map of atoms to sync with the client. +local function subscribeClient(client: any, atoms: { [string]: () -> any }) + if not clients[client] then + clients[client] = {} end - local syncedAtoms = clientRegister[client] + local clientAtoms = clients[client] - for key, atom in atoms do - syncGlobalAtom(key, atom) + for key, state in atoms do + startTracking(key, if type(state) == "function" then state else state.get) - if not syncedAtoms[key] then + if not clientAtoms[key] then -- Mark the atom as pending initial state so we send its full value -- the next time we sync. - syncedAtoms[key] = PENDING_INITIAL_STATE + clientAtoms[key] = PENDING_INITIAL_STATE end end end ---- Unregisters all atoms from being kept in sync with a specific client. ---- Should be called when a player leaves the game. +--- Unsubscribes a client from receiving all state updates. To only unsubscribe +--- from specific keys, use `unsubscribeClientFrom` instead. --- ---- @param client The player to stop syncing atoms to. -local function unsyncClient(client: any) - clientRegister[client] = nil +--- @param client The client receiving state updates. +local function unsubscribeClient(client: any) + clients[client] = nil end ---- Unregisters one or more atoms from being kept in sync with a specific ---- client. +--- Unsubscribes a client from receiving updates for the given keys. --- ---- @param client The player to stop syncing atoms to. ---- @param keys The keys of the atoms to stop syncing. -local function unsyncAtomsFromClient(client: any, ...: string) - local syncedAtoms = clientRegister[client] +--- @param client The client receiving state updates. +--- @param keys The keys of the state to unsubscribe from. +local function unsubscribeClientFrom(client: any, ...: string) + local clientAtoms = clients[client] - if not syncedAtoms then + if not clientAtoms then return end for index = 1, select("#", ...) do local key = select(index, ...) - syncedAtoms[key] = nil + clientAtoms[key] = nil end - if next(syncedAtoms) == nil then - clientRegister[client] = nil + if next(clientAtoms) == nil then + clients[client] = nil end end ---- Immediately sends pending updates and initial states to all clients. ---- Normally, updates are sent at the interval specified in `config.interval`. ---- Calling this function forces all pending updates to be sent immediately. +--- Immediately sends pending updates to all clients. Normally, updates are +--- sent at the interval specified in `config.interval`, but this function +--- can be used to force an immediate sync. local function flush() - if not onSyncCallback then + if not syncCallback then return end - local patches = if config.preserveHistory then createGlobalPatchHistory() else createGlobalPatch() + local globalPatch = if config.preserveHistory then diffGlobalUpdateBuffer() else diffGlobalState() -- While payload objects are technically mutable as they are unique to -- each client, the data inside them is not safe to mutate. if globals.frozen then - Charm.deepFreeze(patches) + Charm.deepFreeze(globalPatch) end - for client in clientRegister do + for client in clients do local payloads: { SyncPayload } = if config.preserveHistory - then createPayloadHistoryForClient(client, patches) - else createPayloadForClient(client, patches) + then selectFromGlobalPatchList(client, globalPatch) + else selectFromGlobalPatch(client, globalPatch) if payloads[1] then if task then - task.spawn(onSyncCallback, client, payloads) + task.spawn(syncCallback, client, payloads) else - onSyncCallback(client, payloads) + syncCallback(client, payloads) end end end - unsyncStaleGlobalAtoms() + purgeUntracked() table.clear(globalUpdates) end ---- Starts automatically syncing registered atoms to clients. Patches will ---- be sent to clients whenever the atoms are updated. +--- Uses the callback to send state updates to clients that are subscribed +--- to state changes. Starts automatically sending updates at the interval +--- specified in `config.interval`. --- ---- @param onSync The function to call when patches can be sent to a client. -local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) +--- @param onSync Called when sending patches to a client. +local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ()) if connection then connection:Disconnect() end - onSyncCallback = onSync + syncCallback = onSync if not game then return @@ -323,30 +334,30 @@ local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) end) end ---- Stops automatically syncing registered atoms to clients. Patches will ---- no longer be sent to clients until `startSync` is called again. -local function stopSync() +--- Stops syncing state updates to clients at the automatic interval. +--- Flushing can still be performed manually via `flush`. +local function disconnect() if connection then connection:Disconnect() connection = nil end end ---- Unregisters all atoms from being kept in sync with all clients, and ---- stops automatic syncing. +--- Unsubscribes all clients, stops tracking all state, and disconnects +--- from the automatic sync interval. local function reset() - table.clear(atomRegister) - table.clear(clientRegister) + table.clear(trackedAtoms) + table.clear(clients) table.clear(globalUpdates) - stopSync() + disconnect() end return { - syncAtomsToClient = syncAtomsToClient, - unsyncClient = unsyncClient, - unsyncAtomsFromClient = unsyncAtomsFromClient, - startSync = startSync, - stopSync = stopSync, + subscribeClient = subscribeClient, + unsubscribeClient = unsubscribeClient, + unsubscribeClientFrom = unsubscribeClientFrom, + connect = connect, + disconnect = disconnect, flush = flush, reset = reset, } diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau new file mode 100644 index 0000000..84d1d74 --- /dev/null +++ b/packages/charm-sync/src/utils.luau @@ -0,0 +1,52 @@ +local Charm = require("../Charm") + +type Atom = Charm.Atom + +type NestedState = { + [string]: NestedState | () -> any, +} + +type FlattenedState = { + [string]: () -> any, +} + +local function isAtomOrSignal(value: any): boolean + return type(value) ~= "table" or (type(value.get) == "function" and type(value.set) == "function") +end + +--- Flattens a nested map of atoms or signals into a single object with +--- slash-separated keys. Useful for recursively collecting atoms returned by +--- modules. +--- +--- @param states A nested table of atoms or signals. +--- @return A flattened map of atoms or signals. +local function flattenState(states: NestedState): FlattenedState + local result: FlattenedState = {} + + local function visit(node: NestedState, path: string) + for key, value in node do + local location = if path == "" then key else path .. "/" .. key + + if isAtomOrSignal(value) then + result[location] = value :: () -> any + else + visit(value :: NestedState, location) + end + end + end + + visit(states, "") + + return result +end + +local function signalToAtom(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom + return function(...) + return if select("#", ...) == 0 then getter() else setter((...)) + end +end + +return { + flattenState = flattenState, + signalToAtom = signalToAtom, +} diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 410f4ff..4c5ee5e 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -312,7 +312,7 @@ end --- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T local getter, setter = signal(initialValue, equals) - return function(...: ((T) -> T) | T): T + return function(...) return if select("#", ...) == 0 then getter() else setter((...)) end end @@ -321,9 +321,6 @@ end --- The returned atom is read-only, and its value is automatically updated when --- any atoms accessed from within the getter function change. --- ---- If an initial value is provided, the getter will receive this value as its ---- first argument on the initial run, instead of `undefined`. ---- --- @example --- ```ts --- const count = atom(1); @@ -332,8 +329,7 @@ end --- ``` --- --- @param getter A function that produces the next value. ---- @param value An optional initial value for the selector. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options +--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter local function computed(getter: (previousValue: T?) -> T): () -> T return computedOper({ flags = bit32.bor(MUTABLE, DIRTY), @@ -432,6 +428,7 @@ end --- @param callback The callback function. --- @param args Arguments to pass to the callback. --- @return The return value of the callback. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... local prevSub = setActiveSub(nil) local results: { any } = { pcall(callback, ...) } @@ -450,6 +447,7 @@ end --- @param callback The callback function. --- @param args Arguments to pass to the callback. --- @return The return value of the callback. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback local function peek(callback: (Args...) -> Results..., ...: Args...): Results... if activeSub @@ -474,6 +472,7 @@ end --- @param callback A function that performs multiple atom updates. --- @param args Arguments to pass to the callback. --- @return The return value of the callback. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback local function batched(callback: (Args...) -> Results..., ...: Args...): Results... startBatch() local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 03f37c3..ace5fac 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -83,11 +83,9 @@ export function atom(): Atom; * ``` * * @param getter A function that produces the next value. - * @param value An optional initial value for the selector. * @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options */ export function computed(getter: (previousValue?: T) => T): () => T; -export function computed(getter: (previousValue: T) => T, value: T): () => T; /** * Creates an effect that runs a callback in response to atom state changes. From 7a88c784faeb98e7a371c2d4f9c45db14e668396 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 31 Oct 2025 23:06:43 -0700 Subject: [PATCH 054/160] feat: update alien-signals --- .../src/__tests__/core/computed.spec.luau | 34 ++-- .../charm/src/__tests__/core/effect.spec.luau | 152 +++++++++--------- .../src/__tests__/core/effectScope.spec.luau | 16 +- .../src/__tests__/core/topology.spec.luau | 70 ++++---- packages/charm/src/__tests__/listen.spec.luau | 44 ++--- packages/charm/src/__tests__/mapped.spec.luau | 28 ++-- .../charm/src/__tests__/observe.spec.luau | 30 ++-- packages/charm/src/__tests__/peek.spec.luau | 58 +++---- .../charm/src/__tests__/subscribe.spec.luau | 48 +++--- .../charm/src/__tests__/untracked.spec.luau | 56 +++---- packages/charm/src/alien.luau | 43 ++++- packages/charm/src/core.luau | 141 +++++++++------- packages/charm/src/utils.luau | 12 +- rokit.toml | 8 +- 14 files changed, 397 insertions(+), 343 deletions(-) diff --git a/packages/charm/src/__tests__/core/computed.spec.luau b/packages/charm/src/__tests__/core/computed.spec.luau index 43c5fad..939d72a 100644 --- a/packages/charm/src/__tests__/core/computed.spec.luau +++ b/packages/charm/src/__tests__/core/computed.spec.luau @@ -1,13 +1,13 @@ local core = require("../../core") local suite = require("../../../../../test/suite") -local atom = core.atom local computed = core.computed local effect = core.effect +local signal = core.signal local test = suite.test test("should correctly propagate changes through computed core", function() - local src = atom(0) + local src, setSrc = signal(0) local c1 = computed(function() return src() % 2 end) @@ -19,15 +19,15 @@ test("should correctly propagate changes through computed core", function() end) c3() - src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty + setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty c2() -- c1 -> none, c2 -> none - src(3) -- c1 -> dirty, c2 -> toCheckDirty + setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty assert(c3() == 1, "bad final value") end) test("should propagate updated source value through chained computations", function() - local src = atom(0) + local src, setSrc = signal(0) local a = computed(function() return src() end) @@ -42,12 +42,12 @@ test("should propagate updated source value through chained computations", funct end) assert(d() == 0, "bad initial value") - src(2) + setSrc(2) assert(d() == 2, "bad updated value") end) test("should handle flags are indirectly updated during checkDirty", function() - local a = atom(false) + local a, setA = signal(false) local b = computed(function() return a() end) @@ -61,29 +61,29 @@ test("should handle flags are indirectly updated during checkDirty", function() end) assert(d() == false, "bad initial value") - a(true) + setA(true) assert(d() == true, "bad updated value") end) -test("should not update if the atom value is reverted", function() +test("should not update if the signal value is reverted", function() local times = 0 - local src = atom(0) + local src, setSrc = signal(0) local c1 = computed(function() times += 1 return src() end) c1() assert(times == 1, "computed did not run") - src(1) - src(0) + setSrc(1) + setSrc(0) c1() assert(times == 1, "computed ran when it should not") end) test("should receive previous value", function() local calls = {} - local src = atom(0) + local src, setSrc = signal(0) local c1 = computed(function(current): number local incoming = src() return if current == incoming or incoming % 2 == 1 then current or incoming else incoming @@ -93,12 +93,12 @@ test("should receive previous value", function() table.insert(calls, c1()) end) - src(2) - src(3) + setSrc(2) + setSrc(3) assert(c1() == 2, "should skip odd value") - src(4) - src(5) + setSrc(4) + setSrc(5) assert(c1() == 4, "should skip odd value") assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) end) diff --git a/packages/charm/src/__tests__/core/effect.spec.luau b/packages/charm/src/__tests__/core/effect.spec.luau index 89e4240..0d729ff 100644 --- a/packages/charm/src/__tests__/core/effect.spec.luau +++ b/packages/charm/src/__tests__/core/effect.spec.luau @@ -5,16 +5,16 @@ local computed = core.computed local effect = core.effect local effectScope = core.effectScope local endBatch = core.endBatch -local setActiveSub = core.setActiveSub local onCleanup = core.onCleanup -local atom = core.atom +local setActiveSub = core.setActiveSub +local signal = core.signal local startBatch = core.startBatch local test = suite.test test("should clear subscriptions when untracked by all subscribers", function() local bRunTimes = 0 - local a = atom(1) + local a, setA = signal(1) local b = computed(function() bRunTimes += 1 return a() * 2 @@ -24,15 +24,15 @@ test("should clear subscriptions when untracked by all subscribers", function() end) assert(bRunTimes == 1, "should run initially") - a(2) + setA(2) assert(bRunTimes == 2, "should run after change") stopEffect() - a(3) + setA(3) assert(bRunTimes == 2, "should not run after stop") end) test("should not run untracked inner effect", function() - local a = atom(3) + local a, setA = signal(3) local b = computed(function() return a() > 0 end) @@ -47,14 +47,14 @@ test("should not run untracked inner effect", function() end end) - a(2) - a(1) - a(0) + setA(2) + setA(1) + setA(0) end) test("should run outer effect first", function() - local a = atom(1) - local b = atom(1) + local a, setA = signal(1) + local b, setB = signal(1) effect(function() if a() > 0 then @@ -68,13 +68,13 @@ test("should run outer effect first", function() end) startBatch() - b(0) - a(0) + setB(0) + setA(0) endBatch() end) test("should not trigger inner effect when resolve maybe dirty", function() - local a = atom(0) + local a, setA = signal(0) local b = computed(function() return a() % 2 end) @@ -91,68 +91,66 @@ test("should not trigger inner effect when resolve maybe dirty", function() end) end) - a(2) + setA(2) end) -test("should trigger inner effects in sequence", function() - local a = atom(0) - local b = atom(0) +test("should notify inner effects in the same order as non-inner effects", function() + local getA, setA = signal(0) + local getB, setB = signal(0) local c = computed(function() - return a() - b() + return getA() - getB() + end) + local order1 = {} + local order2 = {} + local order3 = {} + + effect(function() + table.insert(order1, "effect1") + getA() + end) + effect(function() + table.insert(order1, "effect2") + getA() + getB() end) - local order: { string } = {} effect(function() c() - effect(function() - table.insert(order, "first inner") - a() + table.insert(order2, "effect1") + getA() end) - effect(function() - table.insert(order, "last inner") - a() - b() + table.insert(order2, "effect2") + getA() + getB() 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 = atom(0) - local b = atom(0) - local order: { string } = {} - effectScope(function() effect(function() - table.insert(order, "first inner") - a() + table.insert(order3, "effect1") + getA() end) - effect(function() - table.insert(order, "last inner") - a() - b() + table.insert(order3, "effect2") + getA() + getB() end) end) - table.clear(order) + table.clear(order1) + table.clear(order2) + table.clear(order3) startBatch() - b(1) - a(1) + setB(1) + setA(1) endBatch() - assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`) + assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1") + assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2") + assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3") end) test("should custom effect support batch", function() @@ -168,13 +166,13 @@ test("should custom effect support batch", function() end local logs: { string } = {} - local a = atom(0) - local b = atom(0) + local a = signal(0) + local b, setB = signal(0) local aa = computed(function() table.insert(logs, "aa-0") if a() == 0 then - b(1) + setB(1) end table.insert(logs, "aa-1") return nil @@ -196,8 +194,8 @@ test("should custom effect support batch", function() end) test("should duplicate subscribers do not affect the notify order", function() - local src1 = atom(0) - local src2 = atom(0) + local src1, setSrc1 = signal(0) + local src2, setSrc2 = signal(0) local order: { string } = {} effect(function() @@ -215,17 +213,17 @@ test("should duplicate subscribers do not affect the notify order", function() table.insert(order, "b") src1() end) - src2(1) -- src1.subs: a -> b -> a + setSrc2(1) -- src1.subs: a -> b -> a table.clear(order) - src1(src1() + 1) + setSrc1(src1() + 1) assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) end) test("should handle side effect with inner effects", function() - local a = atom(0) - local b = atom(0) + local a, setA = signal(0) + local b, setB = signal(0) local order: { string } = {} effect(function() @@ -240,14 +238,14 @@ test("should handle side effect with inner effects", function() assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) table.clear(order) - b(1) - a(1) + setB(1) + setA(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 = atom(false) + local a, setA = signal(false) local b = computed(function() return a() end) @@ -267,14 +265,14 @@ test("should handle flags are indirectly updated during checkDirty", function() triggers += 1 end) assert(triggers == 1, "did not run initially") - a(true) + setA(true) assert(triggers == 2, "did not update indirectly") end) test("should run cleanups in sequence", function() local order = {} - local a = atom(false) + local a, setA = signal(false) local stopEffect = effect(function() a() onCleanup(function() @@ -287,7 +285,7 @@ test("should run cleanups in sequence", function() assert(table.concat(order) == "", "should not cleanup immediately") - a(true) + setA(true) assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`) table.clear(order) @@ -295,14 +293,14 @@ test("should run cleanups in sequence", function() assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`) table.clear(order) - a(false) + setA(false) assert(table.concat(order) == "", "should not cleanup after update") end) test("should run inner effect cleanup", function() local cleanups = 0 - local src = atom(false) + local src, setSrc = signal(false) local stopEffect = effect(function() src() effect(function() @@ -318,18 +316,18 @@ test("should run inner effect cleanup", function() end) assert(cleanups == 0, "should not cleanup immediately") - src(true) + setSrc(true) assert(cleanups == 2, "should cleanup after outer update") stopEffect() assert(cleanups == 4, "should cleanup after outer dispose") - src(false) + setSrc(false) assert(cleanups == 4, "should not cleanup after update") end) test("should run cleanups outside any evaluation context", function() local calls = 0 - local a = atom(0) - local b = atom(0) + local a, setA = signal(0) + local b, setB = signal(0) local c = computed(function() if a() == 0 then effect(function() @@ -348,18 +346,18 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 1, "should run once") calls = 0 - a(1) + setA(1) assert(calls == 1, "should run once after a update") calls = 0 - b(1) + setB(1) assert(calls == 0, "should not run after b update") end) test("should allow self cleanup", function() local calls = 0 local cleanups = 0 - local a = atom(0) + local a, setA = signal(0) effect(function(stopEffect) onCleanup(function() @@ -372,7 +370,7 @@ test("should allow self cleanup", function() assert(calls == 1, "should call effect once") assert(cleanups == 1, "should call cleanup once") - a(1) + setA(1) assert(calls == 1, "should not call effect after a update") assert(cleanups == 1, "should not call cleanup after a update") end) diff --git a/packages/charm/src/__tests__/core/effectScope.spec.luau b/packages/charm/src/__tests__/core/effectScope.spec.luau index c5dcbe8..b2dcdc7 100644 --- a/packages/charm/src/__tests__/core/effectScope.spec.luau +++ b/packages/charm/src/__tests__/core/effectScope.spec.luau @@ -3,12 +3,12 @@ local suite = require("../../../../../test/suite") local effect = core.effect local effectScope = core.effectScope -local atom = core.atom +local signal = core.signal local onCleanup = core.onCleanup local test = suite.test test("should not trigger after stop", function() - local count = atom(1) + local count, setCount = signal(1) local triggers = 0 @@ -19,14 +19,14 @@ test("should not trigger after stop", function() end) assert(triggers == 1, "did not run initially") - count(2) + setCount(2) assert(triggers == 2, "did not run after update") end) - count(3) + setCount(3) assert(triggers == 3, "did not run after update outside scope") stopScope() - count(4) + setCount(4) assert(triggers == 3, "ran after stop") end) @@ -52,7 +52,7 @@ end) test("should run indirect effect cleanup", function() local cleanups = 0 - local a = atom(false) + local a, setA = signal(false) local stopScope = effectScope(function() effect(function() a() @@ -63,11 +63,11 @@ test("should run indirect effect cleanup", function() end) assert(cleanups == 0, "should not cleanup immediately") - a(true) + setA(true) assert(cleanups == 1, "should cleanup once") stopScope() assert(cleanups == 2, "should cleanup after outer dispose") - a(false) + setA(false) assert(cleanups == 2, "should not cleanup after update") end) diff --git a/packages/charm/src/__tests__/core/topology.spec.luau b/packages/charm/src/__tests__/core/topology.spec.luau index d3cdbf2..f40837d 100644 --- a/packages/charm/src/__tests__/core/topology.spec.luau +++ b/packages/charm/src/__tests__/core/topology.spec.luau @@ -3,13 +3,13 @@ local suite = require("../../../../../test/suite") local computed = core.computed local effect = core.effect -local atom = core.atom +local signal = core.signal local describe = suite.describe local test = suite.test --[[ Tests adopted with thanks from preact-core implementation at - https://github.com/preactjs/core/blob/main/packages/core/test/atom.test.tsx + https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx The MIT License (MIT) @@ -43,7 +43,7 @@ describe("graph updates", function() -- C -- | -- D - local a = atom(2) + local a, setA = signal(2) local b = computed(function() return a() - 1 @@ -64,12 +64,12 @@ describe("graph updates", function() assert(computeCalls == 1, "compute should be called once") computeCalls = 0 - a(4) + setA(4) d() assert(computeCalls == 1, "compute should drop update") end) - test("should only update every atom once (diamond graph)", function() + 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 @@ -77,8 +77,7 @@ describe("graph updates", function() -- B C -- \ / -- D - - local a = atom("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -96,12 +95,12 @@ describe("graph updates", function() assert(d() == "a a", "d should be 'a a'") assert(spyCalls == 1, "compute should be called once") - a("aa") + setA("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 atom once (diamond graph + tail)", function() + 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 -- / \ @@ -110,8 +109,7 @@ describe("graph updates", function() -- D -- | -- E - - local a = atom("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -133,7 +131,7 @@ describe("graph updates", function() assert(e() == "a a", "e should be 'a a'") assert(spyCalls == 1, "compute should be called once") - a("aa") + setA("aa") assert(e() == "aa aa", "e should be 'aa aa'") assert(spyCalls == 2, "compute should be called only twice") end) @@ -141,7 +139,7 @@ describe("graph updates", function() test("should bail out if result is the same", function() -- Bail out if value of "B" never changes -- A->B->C - local a = atom("a") + local a, setA = signal("a") local b = computed(function() a() return "foo" @@ -157,12 +155,12 @@ describe("graph updates", function() assert(c() == "foo", "c should be 'foo'") assert(spyCalls == 1, "compute should be called once") - a("aa") + setA("aa") assert(c() == "foo", "c should be 'foo'") assert(spyCalls == 1, "compute should bail out") end) - test("should only update every atom once (jagged diamond graph + tails)", function() + 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 -- / \ @@ -173,7 +171,7 @@ describe("graph updates", function() -- E -- / \ -- F G - local a = atom("a") + local a, setA = signal("a") local b = computed(function() return a() @@ -222,7 +220,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - a("b") + setA("b") assert(e() == "b b", "e should be 'b b'") assert(eSpyCalls == 1, "e should run once after update") @@ -237,7 +235,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - a("c") + setA("c") assert(e() == "c c", "e should be 'c c'") assert(eSpyCalls == 1, "e should run once after update") @@ -258,7 +256,7 @@ describe("graph updates", function() -- *A -- / \ -- *B C <- we don't listen to C - local a = atom("a") + local a, setA = signal("a") local b = computed(function() return a() @@ -273,7 +271,7 @@ describe("graph updates", function() assert(b() == "a", "b should be 'a'") assert(spyCalls == 0, "compute should not run") - a("aa") + setA("aa") assert(b() == "aa", "b should be 'aa'") assert(spyCalls == 0, "compute should not run") end) @@ -287,7 +285,7 @@ describe("graph updates", function() -- *B D <- we don't listen to C -- | -- *C - local a = atom("a") + local a, setA = signal("a") local spyBCalls = 0 local spyB = function() spyBCalls += 1 @@ -318,7 +316,7 @@ describe("graph updates", function() spyCCalls = 0 unsub() - a("aa") + setA("aa") assert(spyBCalls == 0, "b should not run") assert(spyCCalls == 0, "c should not run") @@ -335,7 +333,7 @@ describe("graph updates", function() -- B *C <- returns same value every time -- \ / -- D - local a = atom("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -353,7 +351,7 @@ describe("graph updates", function() assert(d() == "a c", "d should be 'a c'") spyResult = nil - a("aa") + setA("aa") d() assert(spyResult == "aa c", "d should have returned 'aa c'") end) @@ -367,7 +365,7 @@ describe("graph updates", function() -- B *C *D -- \ | / -- E - local a = atom("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -389,13 +387,13 @@ describe("graph updates", function() assert(e() == "a c d", "e should be 'a c d'") spyResult = nil - a("aa") + setA("aa") e() assert(spyResult == "aa c d", "e should have returned 'aa c d'") end) test("should support lazy branches", function() - local a = atom(0) + local a, setA = signal(0) local b = computed(function() return a() end) @@ -404,10 +402,10 @@ describe("graph updates", function() end) assert(c() == 0, "c should be 0") - a(1) + setA(1) assert(c() == 1, "c should be 1") - a(0) + setA(0) assert(c() == 0, "c should be 0 again") end) @@ -419,7 +417,7 @@ describe("graph updates", function() -- *B *C -- \ / -- D - local a = atom("a") + local a, setA = signal("a") local b = computed(function() a() return "b" @@ -438,14 +436,14 @@ describe("graph updates", function() assert(d() == "b c", "d should be 'b c'") spyCalls = 0 - a("aa") + setA("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 = atom(0) + local a, setA = signal(0) local b = computed(function() error("fail") end) @@ -459,12 +457,12 @@ describe("error handling", function() assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - a(1) + setA(1) assert(c() == 1, "c should recover and return 1") end) test("should keep graph consistent on errors in computeds", function() - local a = atom(0) + local a, setA = signal(0) local b = computed(function() if a() == 1 then error("fail") @@ -477,14 +475,14 @@ describe("error handling", function() assert(c() == 0, "c should be 0") - a(1) + setA(1) local success, result = pcall(function() b() end) assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - a(2) + setA(2) assert(c() == 2, "c should recover and return 2") end) end) diff --git a/packages/charm/src/__tests__/listen.spec.luau b/packages/charm/src/__tests__/listen.spec.luau index b529cde..d8c14c9 100644 --- a/packages/charm/src/__tests__/listen.spec.luau +++ b/packages/charm/src/__tests__/listen.spec.luau @@ -1,14 +1,14 @@ local Charm = require("../") local suite = require("../../../../test/suite") -local atom = Charm.atom +local signal = Charm.signal local listen = Charm.listen local effect = Charm.effect local onCleanup = Charm.onCleanup local test = suite.test test("should listen to selector", function() - local source = atom(0) + local source, setSource = signal(0) local current, previous local unsub = listen(function() @@ -19,20 +19,20 @@ test("should listen to selector", function() end) assert(current == 1 and previous == nil, "should run initially") - source(0) + setSource(0) assert(current == 1 and previous == nil, "should skip redundant update") - source(1) + setSource(1) assert(current == 2 and previous == 1, "should run with new value") unsub() - source(2) + setSource(2) assert(current == 2 and previous == 1, "should not run after unsub") end) test("should run in peek", function() local runs = 0 local cleanups = 0 - local a = atom(0) - local b = atom(0) + local a, setA = signal(0) + local b, setB = signal(0) listen(a, function() runs += 1 @@ -47,31 +47,31 @@ test("should run in peek", function() assert(runs == 1, "should run once on init") assert(cleanups == 0, "should not cleanup yet") - a(1) + setA(1) assert(runs == 2, "should run on a change") assert(cleanups == 1, "should cleanup first call") - b(1) + setB(1) assert(runs == 2, "should not run on b change") assert(cleanups == 1, "should not cleanup second call") end) test("should run if first value is nil", function() local runs = 0 - local source = atom(nil :: number?) + local source, setSource = signal(nil :: number?) listen(source, function() runs += 1 end) assert(runs == 1, "should run once on init") - source(1) + setSource(1) assert(runs == 2, "should run once on change") end) test("should track inner subs", function() local order = {} - local a = atom(1) + local a, setA = signal(1) local unsub = listen(a, function() table.insert(order, 1) @@ -86,22 +86,22 @@ test("should track inner subs", function() assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`) table.clear(order) - a(a() + 1) + setA(a() + 1) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) table.clear(order) - a(a() + 1) + setA(a() + 1) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) table.clear(order) unsub() - a(a() + 1) + setA(a() + 1) assert(table.concat(order) == "", `bad order: {table.concat(order)}`) end) test("should skip redundant updates", function() local order = {} - local a = atom(0) + local a, setA = signal(0) listen(function(): "even" | "odd" return if a() % 2 == 0 then "even" else "odd" @@ -109,17 +109,17 @@ test("should skip redundant updates", function() table.insert(order, value) end) - a(1) - a(3) - a(4) - a(6) + setA(1) + setA(3) + setA(4) + setA(6) assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`) end) test("should allow self cleanup", function() local calls = 0 local cleanups = 0 - local a = atom(0) + local a, setA = signal(0) listen(a, function(_, _, stopEffect) onCleanup(function() @@ -131,7 +131,7 @@ test("should allow self cleanup", function() assert(calls == 1, "should call effect once") assert(cleanups == 1, "should call cleanup once") - a(1) + setA(1) assert(calls == 1, "should not call effect after a update") assert(cleanups == 1, "should not call cleanup after a update") end) diff --git a/packages/charm/src/__tests__/mapped.spec.luau b/packages/charm/src/__tests__/mapped.spec.luau index 2d1e857..b812aea 100644 --- a/packages/charm/src/__tests__/mapped.spec.luau +++ b/packages/charm/src/__tests__/mapped.spec.luau @@ -1,25 +1,25 @@ local Charm = require("../") local suite = require("../../../../test/suite") -local atom = Charm.atom +local signal = Charm.signal local mapped = Charm.mapped local test = suite.test -test("should map atom", function() - local src = atom({ 1, 2, 3 }) +test("should map state", function() + local src, setSrc = signal({ 1, 2, 3 }) local map = mapped(src, function(value: number) return value + 1 end) assert(table.concat(map()) == "234", table.concat(map())) - src({ 4, 5, 6 }) + setSrc({ 4, 5, 6 }) assert(table.concat(map()) == "567", table.concat(map())) end) test("should not map unchanged values", function() local calls = 0 - local src = atom({ 1, 2, 3 }) + local src, setSrc = signal({ 1, 2, 3 }) local map = mapped(src, function(value: number) calls += 1 return value + 1 @@ -28,11 +28,11 @@ test("should not map unchanged values", function() assert(table.concat(map()) == "234", table.concat(map())) assert(calls == 3, "should map 3 values") - src({ 1, 3, 3 }) + setSrc({ 1, 3, 3 }) assert(table.concat(map()) == "244", table.concat(map())) assert(calls == 4, "should map 1 change") - src({}) + setSrc({}) assert(#map() == 0, "should be empty") assert(calls == 4, "should not map for removed values") end) @@ -40,7 +40,7 @@ end) test("should not update if no values change", function() local calls = 0 - local src = atom({ 1, 2, 3 }) + local src, setSrc = signal({ 1, 2, 3 }) local map = mapped(src, function(value: number) calls += 1 return value + 1 @@ -50,8 +50,8 @@ test("should not update if no values change", function() assert(calls == 3, "should map 3 values") assert(table.concat(map()) == "234", table.concat(map())) - src({ 4, 5, 6, 7 }) - src({ 1, 2, 3 }) + setSrc({ 4, 5, 6, 7 }) + setSrc({ 1, 2, 3 }) map() assert(calls == 3, "should not map unchanged values") assert(table.concat(map()) == "234", table.concat(map())) @@ -61,7 +61,7 @@ end) test("should filter values", function() local calls = 0 - local src = atom({ a = 1, b = 2, c = 3 }) + local src, setSrc = signal({ a = 1, b = 2, c = 3 }) local map = mapped(src, function(value: number) calls += 1 return if value < 0 then nil else value @@ -72,7 +72,7 @@ test("should filter values", function() assert(state.a and state.b and state.c, "should map all values") calls = 0 - src({ a = -1, b = 2, c = 3, d = 4 }) + setSrc({ a = -1, b = 2, c = 3, d = 4 }) state = map() assert(calls == 2, "should map 2 values") assert(state.a == nil, "should remove a") @@ -83,7 +83,7 @@ end) test("should map keys", function() local calls = 0 - local src = atom({ a = 1, b = 2, c = 3 }) + local src, setSrc = signal({ a = 1, b = 2, c = 3 }) local map = mapped(src, function(value: number, key: string) calls += 1 return if value < 0 then nil else value, string.upper(key) @@ -97,7 +97,7 @@ test("should map keys", function() assert(state.C == 3, "should map c") calls = 0 - src({ a = -1, b = 2, c = 3, d = 4 }) + setSrc({ a = -1, b = 2, c = 3, d = 4 }) state = map() assert(calls == 2, "should map 2 values") assert(state.A == nil and state.a == nil, "should remove a") diff --git a/packages/charm/src/__tests__/observe.spec.luau b/packages/charm/src/__tests__/observe.spec.luau index ed2bdda..1119b4e 100644 --- a/packages/charm/src/__tests__/observe.spec.luau +++ b/packages/charm/src/__tests__/observe.spec.luau @@ -1,14 +1,14 @@ local Charm = require("../") local suite = require("../../../../test/suite") -local atom = Charm.atom +local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local observe = Charm.observe local test = suite.test test("should observe items", function() - local items = atom({ 0, 0, 0 }) + local items, setItems = signal({ 0, 0, 0 }) local updates = {} local cleanups = {} @@ -29,18 +29,18 @@ test("should observe items", function() assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`) table.clear(updates) - items({ 1, 2, 3 }) + setItems({ 1, 2, 3 }) assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`) table.clear(updates) - items({ 1, 2, nil :: any, 4 }) + setItems({ 1, 2, nil :: any, 4 }) assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`) assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`) end) test("should dispose scopes", function() - local items = atom({ 1, 2, 3 }) - local a = atom(0) + local items, setItems = signal({ 1, 2, 3 }) + local a, setA = signal(0) local runs = {} local dispose = observe(items, function(item) @@ -51,23 +51,23 @@ test("should dispose scopes", function() end) table.clear(runs) - items({ 1, 2 }) - a(1) + setItems({ 1, 2 }) + setA(1) assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`) table.clear(runs) dispose() - a(2) + setA(2) assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`) end) test("should queue addition", function() - local items = atom({ 1 }) + local items, setItems = signal({ 1 }) local edits = {} observe(items, function(item) - items({ 1, 2 }) - items({ 1, 2, 3, 4 }) + setItems({ 1, 2 }) + setItems({ 1, 2, 3, 4 }) table.insert(edits, `+{item}`) return function() table.insert(edits, `-{item}`) @@ -78,12 +78,12 @@ test("should queue addition", function() end) test("should queue removal", function() - local items = atom({ 1, 2, 3, 4 }) + local items, setItems = signal({ 1, 2, 3, 4 }) local edits = {} observe(items, function(item) - items({ 1, 2, 3 }) - items({ 1 }) + setItems({ 1, 2, 3 }) + setItems({ 1 }) table.insert(edits, `+{item}`) return function() table.insert(edits, `-{item}`) diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.spec.luau index 0c9cc5b..b786135 100644 --- a/packages/charm/src/__tests__/peek.spec.luau +++ b/packages/charm/src/__tests__/peek.spec.luau @@ -1,14 +1,14 @@ local Charm = require("../") local suite = require("../../../../test/suite") -local atom = Charm.atom +local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local peek = Charm.peek local test = suite.test test("should not run detached", function() - local a = atom(1) + local a, setA = signal(1) local spyCalls = 0 effect(function() @@ -23,15 +23,15 @@ test("should not run detached", function() end) assert(spyCalls == 0, "should not be called yet") - a(2) + setA(2) assert(spyCalls == 1, "should be called once") - a(3) + setA(3) assert(spyCalls == 2, "should be called twice") end) test("should block tracking inside effects", function() - local a = atom(1) - local b = atom(2) + local a, setA = signal(1) + local b, setB = signal(2) local spyCalls = 0 effect(function() @@ -42,13 +42,13 @@ test("should block tracking inside effects", function() end) assert(spyCalls == 1, "should be called once") - a(10) - b(20) + setA(10) + setB(20) assert(spyCalls == 1, "should be called once") end) test("should block tracking even when run inside effect run inside peek", function() - local s = atom(1) + local s, setS = signal(1) local spyCalls = 0 peek(function() @@ -61,32 +61,32 @@ test("should block tracking even when run inside effect run inside peek", functi end) assert(spyCalls == 1, "should be called once") - s(2) + setS(2) assert(spyCalls == 1, "should be called once") end) -test("should not cause atom assignments throw", function() - local a = atom(1) - local aChangedTime = atom(0) +test("should not cause signal assignments to throw", function() + local a, setA = signal(1) + local aChangedTime, setAChangedTime = signal(0) local dispose = effect(function() a() peek(function() - aChangedTime(aChangedTime() + 1) + setAChangedTime(aChangedTime() + 1) end) end) - a(2) + setA(2) assert(aChangedTime() == 2, "aChangedTime should be 2") - a(3) + setA(3) assert(aChangedTime() == 3, "aChangedTime should be 3") dispose() end) -test("should block tracking inside computed atoms", function() - local a = atom(1) - local b = atom(2) +test("should block tracking inside computed signals", function() + local a, setA = signal(1) + local b, setB = signal(2) local spyCalls = 0 local c = computed(function() return peek(function() @@ -97,18 +97,18 @@ test("should block tracking inside computed atoms", function() assert(spyCalls == 0, "should not be called yet") assert(c() == 3, "c should be 3") - a(10) + setA(10) c() - b(20) + setB(20) c() assert(spyCalls == 1, "should be called once") assert(c() == 3, "c should be 3") end) test("should track outside peek", function() - local a = atom(1) - local b = atom(2) - local c = atom(3) + local a, setA = signal(1) + local b, setB = signal(2) + local c, setC = signal(3) local spyCalls = 0 effect(function() @@ -119,16 +119,16 @@ test("should track outside peek", function() end) assert(spyCalls == 1, "should be called once") - a(10) + setA(10) assert(spyCalls == 2, "should be called twice") - b(20) + setB(20) assert(spyCalls == 3, "should be called three times") - c(30) + setC(30) assert(spyCalls == 3, "should not run on c change") end) test("should stack peek", function() - local a = atom(1) + local a, setA = signal(1) local spyCalls = 0 effect(function() @@ -140,7 +140,7 @@ test("should stack peek", function() end) assert(spyCalls == 1, "should be called once") - a(2) + setA(2) assert(spyCalls == 1, "should not be called twice") end) diff --git a/packages/charm/src/__tests__/subscribe.spec.luau b/packages/charm/src/__tests__/subscribe.spec.luau index 3ebb003..938931b 100644 --- a/packages/charm/src/__tests__/subscribe.spec.luau +++ b/packages/charm/src/__tests__/subscribe.spec.luau @@ -1,9 +1,7 @@ local Charm = require("../") local suite = require("../../../../test/suite") -type Atom = Charm.Atom - -local atom = Charm.atom +local signal = Charm.signal local computed = Charm.computed local subscribe = Charm.subscribe local listen = Charm.listen @@ -11,7 +9,7 @@ local effect = Charm.effect local test = suite.test test("should subscribe to selector", function() - local source = atom(0) + local source, setSource = signal(0) local current, previous local unsub = subscribe(function() @@ -22,20 +20,20 @@ test("should subscribe to selector", function() end) assert(not current and not previous, "should not run initially") - source(0) + setSource(0) assert(not current and not previous, "should skip redundant update") - source(1) + setSource(1) assert(current == 2 and previous == 1, "should run with new value") unsub() - source(2) + setSource(2) assert(current == 2 and previous == 1, "should not run after unsub") end) test("should run in peek", function() local runs = 0 local cleanups = 0 - local a = atom(0) - local b = atom(0) + local a, setA = signal(0) + local b, setB = signal(0) subscribe(a, function() runs += 1 @@ -50,30 +48,30 @@ test("should run in peek", function() assert(runs == 0, "should not run once on init") assert(cleanups == 0, "should not cleanup yet") - a(1) + setA(1) assert(runs == 1, "should run once on change") assert(cleanups == 0, "should not cleanup yet") - b(1) + setB(1) assert(runs == 1, "should not run on b change") assert(cleanups == 0, "should not cleanup first call") - a(2) + setA(2) assert(runs == 2, "should run on a change") assert(cleanups == 1, "should cleanup first call") end) test("should ensure correct execution order", function() local order = {} - local n1 = atom(0) - local n2 = atom(0) + local n1, setN1 = signal(0) + local n2, setN2 = signal(0) local sum = computed(function() return n1() + n2() end) subscribe(n1, function() table.insert(order, 1) - n2(n2() + 1) + setN2(n2() + 1) end) subscribe(sum, function() @@ -84,14 +82,14 @@ test("should ensure correct execution order", function() table.insert(order, 3) end) - n1(n1() + 1) + setN1(n1() + 1) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) end) test("should track inner subs", function() local order = {} - local a = atom(1) + local a, setA = signal(1) local unsub = subscribe(a, function() table.insert(order, 1) @@ -103,22 +101,22 @@ test("should track inner subs", function() end) end) - a(a() + 1) + setA(a() + 1) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) table.clear(order) - a(a() + 1) + setA(a() + 1) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) table.clear(order) unsub() - a(a() + 1) + setA(a() + 1) assert(table.concat(order) == "", `bad order: {table.concat(order)}`) end) test("should skip redundant updates", function() local order = {} - local a = atom(0) + local a, setA = signal(0) subscribe(function(): "even" | "odd" return if a() % 2 == 0 then "even" else "odd" @@ -126,10 +124,10 @@ test("should skip redundant updates", function() table.insert(order, value) end) - a(1) - a(3) - a(4) - a(6) + setA(1) + setA(3) + setA(4) + setA(6) assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`) end) diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau index f5c5f07..d4bfc4c 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -1,7 +1,7 @@ local Charm = require("../") local suite = require("../../../../test/suite") -local atom = Charm.atom +local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope @@ -9,7 +9,7 @@ local untracked = Charm.untracked local test = suite.test test("should run detached from effect", function() - local a = atom(1) + local a, setA = signal(1) local effects = 0 local innerEffects = {} @@ -27,7 +27,7 @@ test("should run detached from effect", function() end) assert(effects == 1, "should have one effect") - a(2) + setA(2) assert(effects == 2, "should have two effects") stopEffect() assert(effects == 2, "should still have two effects") @@ -60,8 +60,8 @@ test("should run detached from effect scope", function() end) test("should block tracking inside effects", function() - local a = atom(1) - local b = atom(2) + local a, setA = signal(1) + local b, setB = signal(2) local spyCalls = 0 effect(function() @@ -72,13 +72,13 @@ test("should block tracking inside effects", function() end) assert(spyCalls == 1, "should be called once") - a(10) - b(20) + setA(10) + setB(20) assert(spyCalls == 1, "should be called once") end) test("should block tracking even when run inside effect run inside untracked", function() - local s = atom(1) + local s, setS = signal(1) local spyCalls = 0 untracked(function() @@ -91,32 +91,32 @@ test("should block tracking even when run inside effect run inside untracked", f end) assert(spyCalls == 1, "should be called once") - s(2) + setS(2) assert(spyCalls == 1, "should be called once") end) -test("should not cause atom assignments throw", function() - local a = atom(1) - local aChangedTime = atom(0) +test("should not cause signal assignments to throw", function() + local a, setA = signal(1) + local aChangedTime, setAChangedTime = signal(0) local dispose = effect(function() a() untracked(function() - aChangedTime(aChangedTime() + 1) + setAChangedTime(aChangedTime() + 1) end) end) - a(2) + setA(2) assert(aChangedTime() == 2, "aChangedTime should be 2") - a(3) + setA(3) assert(aChangedTime() == 3, "aChangedTime should be 3") dispose() end) -test("should block tracking inside computed atoms", function() - local a = atom(1) - local b = atom(2) +test("should block tracking inside computed signals", function() + local a, setA = signal(1) + local b, setB = signal(2) local spyCalls = 0 local c = computed(function() return untracked(function() @@ -127,18 +127,18 @@ test("should block tracking inside computed atoms", function() assert(spyCalls == 0, "should not be called yet") assert(c() == 3, "c should be 3") - a(10) + setA(10) c() - b(20) + setB(20) c() assert(spyCalls == 1, "should be called once") assert(c() == 3, "c should be 3") end) test("should track outside untracked", function() - local a = atom(1) - local b = atom(2) - local c = atom(3) + local a, setA = signal(1) + local b, setB = signal(2) + local c, setC = signal(3) local spyCalls = 0 effect(function() @@ -149,16 +149,16 @@ test("should track outside untracked", function() end) assert(spyCalls == 1, "should be called once") - a(10) + setA(10) assert(spyCalls == 2, "should be called twice") - b(20) + setB(20) assert(spyCalls == 3, "should be called three times") - c(30) + setC(30) assert(spyCalls == 3, "should not run on c change") end) test("should stack untracked", function() - local a = atom(1) + local a, setA = signal(1) local spyCalls = 0 effect(function() @@ -170,7 +170,7 @@ test("should stack untracked", function() end) assert(spyCalls == 1, "should be called once") - a(2) + setA(2) assert(spyCalls == 1, "should not be called twice") end) diff --git a/packages/charm/src/alien.luau b/packages/charm/src/alien.luau index b7604f3..5812be0 100644 --- a/packages/charm/src/alien.luau +++ b/packages/charm/src/alien.luau @@ -1,3 +1,30 @@ +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/system.ts + + MIT License + + Copyright (c) 2024-present Johnson Chu + + 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?, @@ -23,14 +50,14 @@ type Stack = { export type ReactiveFlags = number -local NONE = 0b00000000 -local MUTABLE = 0b00000001 -local WATCHING = 0b00000010 -local RECURSED_CHECK = 0b00000100 -local RECURSED = 0b00001000 -local DIRTY = 0b00010000 -local PENDING = 0b00100000 -local PEEKING = 0b10000000 +local NONE = 0b0000000 +local MUTABLE = 0b0000001 +local WATCHING = 0b0000010 +local RECURSED_CHECK = 0b0000100 +local RECURSED = 0b0001000 +local DIRTY = 0b0010000 +local PENDING = 0b0100000 +local PEEKING = 0b1000000 local ReactiveFlags = { None = NONE, diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 4c5ee5e..f60d2e0 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -11,14 +11,40 @@ local createReactiveSystem = alien.createReactiveSystem local deepFreeze = utils.deepFreeze local noYield = utils.noYield -local NONE = 0b00000000 -local MUTABLE = 0b00000001 -local WATCHING = 0b00000010 -local RECURSED_CHECK = 0b00000100 -local DIRTY = 0b00010000 -local PENDING = 0b00100000 -local QUEUED = 0b01000000 -local PEEKING = 0b10000000 +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/index.ts + + MIT License + + Copyright (c) 2024-present Johnson Chu + + 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 NONE = 0b0000000 +local MUTABLE = 0b0000001 +local WATCHING = 0b0000010 +local RECURSED_CHECK = 0b0000100 +local DIRTY = 0b0010000 +local PENDING = 0b0100000 +local PEEKING = 0b1000000 type EffectScope = ReactiveNode & { cleanups: { () -> () }, @@ -49,8 +75,8 @@ local shallowPropagate: (link: Link) -> () local cycle = 0 local batchDepth = 0 local notifyIndex = 0 -local queuedEffects: { Effect | EffectScope? } = {} -local queuedEffectsLength = 0 +local queued: { Effect? } = {} +local queuedLength = 0 local activeSub: ReactiveNode? local function getActiveSub(): ReactiveNode? @@ -83,15 +109,9 @@ local function runCleanups(effect: EffectScope) table.clear(effect.cleanups) end -local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) - sub.flags = bit32.band(flags, bit32.bnot(PENDING)) -end - -local function run(e: Effect, flags: ReactiveFlags): () - if - bit32.btest(flags, DIRTY) - or (bit32.btest(flags, PENDING) and (checkDirty(e.deps :: Link, e) or setNotPending(e, flags))) - then +local function run(e: Effect): () + local flags = e.flags + if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then runCleanups(e) cycle += 1 e.depsTail = nil @@ -107,30 +127,19 @@ local function run(e: Effect, flags: ReactiveFlags): () table.insert(e.cleanups, result) end else - local link = e.deps - while link do - local dep = link.dep - local depFlags = dep.flags - if bit32.btest(depFlags, QUEUED) then - depFlags = bit32.band(depFlags, bit32.bnot(QUEUED)) - dep.flags = depFlags - run(dep :: Effect, depFlags) - end - link = link.nextDep - end + e.flags = WATCHING end end local function flush(): () - while notifyIndex < queuedEffectsLength do + while notifyIndex < queuedLength do + local effect = queued[notifyIndex] :: Effect + queued[notifyIndex] = nil notifyIndex += 1 - local effect = queuedEffects[notifyIndex] :: Effect - queuedEffects[notifyIndex] = nil - effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED)) - run(effect, effect.flags) + run(effect) end notifyIndex = 0 - queuedEffectsLength = 0 + queuedLength = 0 end local function getBatchDepth(): number @@ -183,6 +192,10 @@ local function updateSignal(s: Signal): boolean end end +local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) + sub.flags = bit32.band(flags, bit32.bnot(PENDING)) +end + local function computedOper(this: Computed): () -> T return function(): T local flags = this.flags @@ -196,6 +209,16 @@ local function computedOper(this: Computed): () -> T shallowPropagate(subs) end end + elseif flags == NONE then + this.flags = MUTABLE + local prevSub = setActiveSub(this) + local success, result = pcall(this.getter) + activeSub = prevSub + if success then + this.value = result + else + error(result) + end end if activeSub then link(this, activeSub, cycle) @@ -332,7 +355,7 @@ end --- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter local function computed(getter: (previousValue: T?) -> T): () -> T return computedOper({ - flags = bit32.bor(MUTABLE, DIRTY), + flags = NONE, getter = getter, }) end @@ -536,30 +559,40 @@ local function listen(getter: () -> T, callback: (state: T, previous: T?, dis return dispose end -local function notify(e: ReactiveNode) - local flags = e.flags - if not bit32.btest(flags, QUEUED) then - e.flags = bit32.bor(flags, QUEUED) - local subs = e.subs - if subs then - notify(subs.sub) - else - queuedEffectsLength += 1 - queuedEffects[queuedEffectsLength] = e :: Effect - end - end -end - local reactiveSystem = createReactiveSystem({ - update = function(node: ReactiveNode): boolean + update = function(node) if node.depsTail then return updateComputed(node :: Computed) else return updateSignal(node :: Signal) end end, - notify = notify, - unwatched = function(node: ReactiveNode) + notify = function(effect) + local insertIndex = queuedLength + local firstInsertedIndex = insertIndex + + repeat + effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) + queued[insertIndex] = effect :: Effect + insertIndex += 1 + effect = (effect.subs and effect.subs.sub) :: Effect + if not effect or bit32.band(effect.flags, WATCHING) == 0 then + break + end + until false + + queuedLength = insertIndex + + insertIndex -= 1 + while firstInsertedIndex < insertIndex do + local left = queued[firstInsertedIndex] + queued[firstInsertedIndex] = queued[insertIndex] + queued[insertIndex] = left + firstInsertedIndex += 1 + insertIndex -= 1 + end + end, + unwatched = function(node) if bit32.band(node.flags, MUTABLE) == 0 then effectScopeOper(node :: EffectScope)() elseif node.depsTail then diff --git a/packages/charm/src/utils.luau b/packages/charm/src/utils.luau index 1f19410..d5b19cf 100644 --- a/packages/charm/src/utils.luau +++ b/packages/charm/src/utils.luau @@ -1,10 +1,10 @@ -local function noYieldHandler(thread: thread, ok: boolean, ...) - if not ok then - return false, debug.traceback(thread, tostring((...))) - elseif coroutine.status(thread) ~= "dead" then - return false, debug.traceback(thread, "Attempted to yield!") - else +local function noYieldHandler(thread: thread, ok: boolean, ...): (boolean, ...any) + if ok and coroutine.status(thread) == "dead" then return true, ... + elseif not ok then + return false, debug.traceback(thread, tostring((...))) + else + return false, debug.traceback(thread, "Attempted to yield!") end end diff --git a/rokit.toml b/rokit.toml index 1b1c5d7..30840b6 100644 --- a/rokit.toml +++ b/rokit.toml @@ -5,9 +5,9 @@ [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.5.1" -stylua = "johnnymorganz/stylua@2.2.0" +rojo = "rojo-rbx/rojo@7.6.0" +stylua = "johnnymorganz/stylua@2.3.0" selene = "Kampfkarren/selene@0.29.0" -luau-lsp = "johnnymorganz/luau-lsp@1.53.5" -lune = "lune-org/lune@0.10.2" +luau-lsp = "johnnymorganz/luau-lsp@1.55.0" +lune = "lune-org/lune@0.10.4" lute = "luau-lang/lute@0.1.0-nightly.20250923" From 37f79ecc3f476f50eb060445a3737f5571b10105 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 2 Nov 2025 18:55:41 -0800 Subject: [PATCH 055/160] refactor: remove unnecessary features, update docs --- README.md | 952 ++++++++---------- packages/charm-sync/src/index.d.ts | 17 +- packages/charm-sync/src/server.luau | 84 +- packages/charm-sync/src/utils.luau | 7 + .../charm/src/__tests__/core/effect.spec.luau | 21 - packages/charm/src/__tests__/listen.spec.luau | 21 - packages/charm/src/core.luau | 159 ++- packages/charm/src/globals.luau | 4 +- packages/charm/src/index.d.ts | 223 ++-- packages/charm/src/mapped.luau | 36 +- packages/charm/src/observe.luau | 19 +- packages/react-charm/src/__tests__/spec.luau | 8 +- ...g.spec.luau => useSignalBinding.spec.luau} | 28 +- ...tom.spec.luau => useSignalState.spec.luau} | 28 +- packages/react-charm/src/index.d.ts | 30 +- packages/react-charm/src/init.luau | 28 +- packages/vide-charm/src/__tests__/spec.luau | 4 +- ...tom.spec.luau => useSignalState.spec.luau} | 36 +- packages/vide-charm/src/index.d.ts | 13 +- packages/vide-charm/src/init.luau | 11 +- 20 files changed, 774 insertions(+), 955 deletions(-) rename packages/react-charm/src/__tests__/{useAtomBinding.spec.luau => useSignalBinding.spec.luau} (67%) rename packages/react-charm/src/__tests__/{useAtom.spec.luau => useSignalState.spec.luau} (65%) rename packages/vide-charm/src/__tests__/{useAtom.spec.luau => useSignalState.spec.luau} (59%) diff --git a/README.md b/README.md index e3a421f..01d0b60 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Charm

- Atomic state management for Roblox. + Manage state with reactive signals
npm package →

@@ -18,25 +18,96 @@ -**Charm** is an atomic and immutable state management library, inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Store your state in atoms, and write your own functions to read, write, and observe state. +**Charm** is a state management library that aims to bring [reactive signals](https://preactjs.com/blog/signal-boosting/) from libraries like Preact and Solid to Roblox. It's built on the [alien-signals](https://github.com/stackblitz/alien-signals) signal algorithm, expanding upon it further to handle a wide range of use cases, from rendering user interfaces to handling server game logic. -See an example of Charm's features in [this example repository](https://github.com/littensy/charm-example). +**Charm has a few core principles:** -## 🍀 Features +- Manage state with reactive signals: state containers that hold a value +- React to state updates: reading a signal automatically subscribes to it +- Combine multiple signals: derive a new value from existing state that stays up-to-date +- Fine-grained reactivity: make targeted updates in response to specific changes in the state +- Use immutable data: values are compared directly (`==`) to optimize change detection -- ⚛️ **Manage state with _atoms_.** Decompose state into independent containers called _atoms_, as opposed to combining them into a single store. +**Want to learn more about signals?** -- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. +- https://preactjs.com/blog/signal-boosting +- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity +- https://angular.dev/guide/signals -- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. +## At a Glance -- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. +```luau +local getTodos, setTodos = signal({} :: { [number]: string }) +local getQuery, setQuery = signal("") ---- +observe(getTodos, function(todo, index) + local instance = Instance.new("TextLabel") -## 📦 Setup + local getText = computed(function(prevText) + return getTodos()[index] or prevText + end) -Install Charm for roblox-ts using your package manager of choice. + effect(function() + instance.Text = getText() + instance.Visible = getText():match(getQuery()) ~= nil + end) + + instance.LayoutOrder = index + instance.Size = UDim2.new(1, 0, 0, 40) + instance.Parent = screenGui + + return function() + instance:Destroy() + end +end) + +setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) +setQuery("^Buy") +``` + +
+Explain code + +```luau +local getTodos, setTodos = signal({} :: { [number]: string }) +local getQuery, setQuery = signal("") + +-- Create a text label when an item is added to the list +observe(getTodos, function(todo, index) + local instance = Instance.new("TextLabel") + + -- Create a computed signal that stores the current text, or the previous + -- value if the item was removed + local getText = computed(function(prevText) + return getTodos()[index] or prevText + end) + + -- Set properties when the text or query updates + effect(function() + instance.Text = getText() + instance.Visible = getText():match(getQuery()) ~= nil + end) + + instance.LayoutOrder = index + instance.Size = UDim2.new(1, 0, 0, 40) + instance.Parent = screenGui + + -- Destroy the instance when this item is removed + return function() + instance:Destroy() + end +end) + +-- Add items to the todo list +setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) +-- Filter for items starting with "Buy" +setQuery("^Buy") +``` + + +
+ +## Installation ```sh npm install @rbxts/charm @@ -44,8 +115,6 @@ yarn add @rbxts/charm pnpm add @rbxts/charm ``` -Alternatively, add `littensy/charm` to your `wally.toml` file. - ```toml [dependencies] Charm = "littensy/charm@VERSION" @@ -53,653 +122,432 @@ Charm = "littensy/charm@VERSION" --- -## 🐛 Debugging +## Guide -Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` before importing Charm. +### `signal(initialValue, equals?)` -Enabling `__DEV__` adds a few helpful features: +Signals are the core of reactivity in Charm. The `signal` function creates a reactive signal that acts as a container for a value. It returns a function to access the value, and another to update the value. -- Better error handling for selectors, subscriptions, and batched functions: +```luau +local getCounter, setCounter = signal(0) - - Errors provide the function's name and line number. - - Yielding in certain functions will throw an error. +print(getCounter()) --> 0 +setCounter(1) +setCounter(function(count) + return count + 1 +end) +``` -- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. +Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately update every effect and computed signal that depends signal, ensuring every part of your state is correct and up-to-date. -Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead. +You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: + +```luau +local getMax, setMax = signal(0, function(incoming, current) + return incoming <= current +end) + +setMax(1) --> 1 +setMax(-1) --> 1 +``` + +> [!NOTE] +> Looking for atoms? You can still use `atom()` to create a signal with a combined getter and setter. To use signals for APIs that require atoms, use `signalToAtom()`. --- -## 📚 Reference +### `computed(getter)` -### `atom(state, options?)` +The `computed` function creates a new signal with a value derived from multiple signals. The computed signal can be reacted to like a normal signal, and it returns the getter function's last return value. -Atoms are the building blocks of Charm. They are functions that hold a single value, and calling them can read or write to that value. Atoms, or any function that reads from atoms, can also be [subscribed](#subscribecallback-listener) to. - -Call `atom` to create a state container initialized with the value `state`. +The computed signal will subscribe to signals accessed by the getter and track them as dependencies. Changes to these dependencies will re-execute the getter, and if it returns a new value, then the computed signal is updated to that value. ```luau -local nameAtom = atom("John") -local todosAtom: Atom<{ string }> = atom({}) -``` - -#### Parameters - -- `state`: The value to assign to the atom initially. - -- **optional** `options`: An object that configures the behavior of this atom. - - - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. - -#### Returns - -The `atom` constructor returns an atom function with two possible operations: - -1. **Read the state.** Call the atom without arguments to get the current state. -2. **Set the state.** Pass a new value or an updater function to change the state. - -```luau -local function newTodo() - nameAtom("Jane") - - todosAtom(function(todos) - todos = table.clone(todos) - table.insert(todos, "Buy milk") - return todos - end) - - print(nameAtom()) --> "Jane" -end -``` - ---- - -### `subscribe(callback, listener)` - -Call `subscribe` to listen for changes in an atom or selector function. When the function's result changes, subscriptions are immediately called with the new state and the previous state. - -```luau -local nameAtom = atom("John") - -local cleanup = subscribe(nameAtom, function(name, prevName) - print(name) +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local getFullName = computed(function() + return `{getName()} {getSurname()}` end) -nameAtom("Jane") --> "Jane" +print(getFullName()) --> "John Doe" +setName("Jane") +print(getFullName()) --> "Jane Doe" ``` -You may also pass a selector function that calls other atoms. The function will be memoized and only runs when its atom dependencies update. +The getter function also receives the previous state (or `nil` if running for the first time). You can use this for more complex state: ```luau -local function getUppercase() - return string.upper(nameAtom()) -end - -local cleanup = subscribe(getUppercase, function(name) - print(name) +local getCounter, setCounter = signal(10) +local getMax = computed(function(prevMax) + return math.max(getCounter(), prevMax or 0) end) -nameAtom("Jane") --> "JANE" +print(getMax()) --> 10 +setCounter(5) +print(getMax()) --> 10 ``` -#### Parameters - -- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. - -- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. - -#### Returns - -`subscribe` returns a cleanup function. +Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called _and_ if a dependency updated since the last call. --- ### `effect(callback)` -Call `effect` to track state changes in all atoms read within the callback. The callback will run once to retrieve its dependencies, and then again whenever they change. Your callback may return a cleanup function to run when the effect is removed or about to re-run. +Effects are fundamental to reactivity, allowing you to react to signal updates. The `effect` function subscribes to signals accessed by the effect callback, and when a dependency updates, the callback will re-execute. ```luau -local nameAtom = atom("John") +local getCounter, setCounter = signal(0) -local cleanup = effect(function() - print(nameAtom()) +effect(function() + print(`Count is {getCounter()}`) +end) --> Count is 0 + +setCounter(1) --> Count is 1 +``` + +You can also return a cleanup function that will run once, either before the effect re-runs or when the effect is disposed: + +```luau +local getCounter, setCounter = signal(0) + +local dispose = effect(function() + local count = getCounter() return function() - print("Cleanup function called!") + print(`Cleanup {count}`) end end) + +setCounter(1) --> Cleanup 0 +dispose() --> Cleanup 1 ``` -Because `effect` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. - -#### Parameters - -- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. - -#### Returns - -`effect` returns a cleanup function. - -#### Caveats - -- **If your effect should disconnect itself, use the `cleanup` argument.** Because effects run immediately, your effect may run before a `cleanup` function is returned. To disconnect an effect from the inside, use the argument passed to your effect instead: - ```lua - effect(function(cleanup) - if condition() then - cleanup() - end - end) - ``` - ---- - -### `computed(callback, options?)` - -Call `computed` when you want to derive a new atom from one or more atoms. The callback will be memoized, meaning that subsequent calls to the atom return a cached value that is only re-calculated when the dependencies change. - -```luau -local todosAtom: Atom<{ string }> = atom({}) -local mapToUppercase = computed(function() - local result = table.clone(todosAtom()) - for key, todo in result do - result[key] = string.upper(todo) - end - return result -end) -``` - -Because `computed` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. - -This function is also useful for optimizing `effect` calls that depend on multiple atoms. For instance, if an effect derives some value from two atoms, it will run twice if both atoms change at the same time. Using `computed` can group these dependencies together and avoid re-running side effects. - -#### Parameters - -- `callback`: A function that returns a new value depending on one or more atoms. - -- **optional** [`options`](#parameters): An object that configures the behavior of this atom. - -#### Returns - -`computed` returns a read-only atom. - ---- - -### `observe(callback, factory)` - -Call `observe` to create an instance of `factory` for each key present in a dictionary or array. Your factory can return a cleanup function to run when the key is removed or the observer is cleaned up. +Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`. > [!NOTE] -> Because `observe` tracks the lifetime of each key in your data, your keys must be unique and unchanging. If your data is not keyed by unique and stable identifiers, consider using [`mapped`](#mappedcallback-mapper) to transform it into a keyed object before passing it to `observe`. +> To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `globals.trackInnerEffects` flag to assist with debugging. ```luau -local todosAtom: Atom<{ [string]: Todo }> = atom({}) +local getCounter, setCounter = signal(0) -local cleanup = observe(todosAtom, function(todo, key) - print(`Added {key}: {todo.name}`) +effect(function() + print(`Outer: {getCounter()}`) + effect(function() + print(`Inner: {getcounter()}`) + end) +end) + +setCounter(1) --> Outer: 1, Inner: 1 +setCounter(2) --> Outer: 2, Inner: 2 +``` + +--- + +### `untracked(callback)` + +In case you want to read signals that you don't want to subscribe to, you can use `untracked()` to essentially call a function _outside_ the current effect, preventing signals and effects in the callback from being tracked. + +```luau +local getCounter, setCounter = signal(0) +local getRuns, setRuns = signal(0) + +effect(function() + local runs = untracked(getRuns) + 1 + setRuns(runs) + print(`Count is {getCounter()}, ran {runs} times`) +end) --> Count is 0, ran 1 times + +setCounter(10) --> Count is 10, ran 2 times +setRuns(10) -- No output +setCounter(20) --> Count is 20, ran 11 times +``` + +Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect: + +```luau +local disposeInner +local disposeOuter = effect(function() + untracked(function() + disposeInner = effect(function() + return function() + print("Cleaned up inner effect") + end + end) + end) +end) + +disposeOuter() -- No output; outer effect did not track inner effect +disposeInner() --> Cleaned up inner effect +``` + +--- + +### `peek(callback)` + +Similar to `untracked()`, but does not prevent effects created by the callback from automatically disposing. If your callback reads a signal and creates effects, only the signals get untracked. + +```luau +local getCounter, setCounter = signal(0) +local disposeOuter = effect(function() + peek(function() + print(`Count is {getCounter()}`) + effect(function() + return function() + print("Cleaned up inner effect") + end + end) + end) +end) --> Count is 0 + +setCounter(1) -- No output; count was accessed in peek() +disposeOuter() --> Cleaned up inner effect +``` + +--- + +### `batched(callback)` + +Combines multiple signal updates made by the callback into a single commit that gets triggered when the callback completes. + +```luau +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") + +effect(function() + print(`Full name: {getName()} {getSurname()}`) +end) + +-- Combines both writes into a single update. +-- Once the callback completes, outputs "Full name: Foo Bar" +batched(function() + setName("Foo") + setSurname("Bar") +end) +``` + +--- + +### `effectScope(callback)` + +Scopes allow you to dispose multiple effects at once. The `effectScope` function creates an effect with no dependencies, and effects created inside the callback clean up when the effect scope disposes. + +```luau +local getCounter, setCounter = signal(0) + +local dispose = effectScope(function() + effect(function() + print(`Count 1 is {getCounter()}`) + end) + effect(function() + print(`Count 2 is {getCounter()}`) + end) +end) + +setCounter(1) --> Count 1 is 1, Count 2 is 1 +dispose() +setCounter(2) -- No output; effects got disposed +``` + +Similar to `effect()`, the callback can return a cleanup function that runs when the effect scope is disposed. + +--- + +### `listen(getter, callback)` + +The `listen` function creates an effect that only subscribes to the signals accessed by `getter`. Signals accessed by the callback will not be subscribed to, avoiding accidental subscriptions when you want to run side effects. + +The callback also receives the previous value, or `nil` when running for the first time. + +```luau +local getCounter, setCounter = signal(0) + +listen(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) +end) --> Count is 0 (was nil) + +setCounter(1) --> Count is 1 (was 0) +``` + +Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed. + +--- + +### `subscribe(getter, callback)` + +The `subscribe` function is identical to `listen()`, but the callback does not run initially. The callback only runs when the value returned by the getter function changes. + +```luau +local getCounter, setCounter = signal(0) + +-- Does not output anything initially +subscribe(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) +end) + +setCounter(1) --> Count is 1 (was 0) +``` + +Note that nested effects can be created inside `subscribe`, and they will clean up automatically when the subscription re-runs or gets disposed. + +--- + +### `observe(getter, callback)` + +[Observers](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) allow you to track the lifetime of a given state. The `observe` function executes the callback for every unique key added to a table, and disposes the callback when that key is removed. + +```luau +local getItems, setItems = signal({ a = 0, b = 0 }) + +observe(getItems, function(value, key) + print(`Added {key}`) return function() print(`Removed {key}`) end -end) +end) --> Added a, Added b + +setItems({ a = 0, c = 0 }) --> Removed b, Added c ``` -#### Parameters - -- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. - -- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. - -#### Returns - -`observe` returns a cleanup function. - ---- - -### `mapped(callback, mapper)` - -Call `mapped` to transform the keys and values of your state. The `mapper` function will be called for each key-value pair in the atom's state, and the new keys and atoms will be stored in a new atom. +The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: ```luau -local todosAtom: Atom<{ Todo }> = atom({}) -local todosById = mapped(todosAtom, function(todo, index) - return todo, todo.id -end) -``` - -#### Parameters - -- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. - -- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: - - 1. Return a single value to map the table's original key to a new value. - 2. Return two values, the first being the value and the second being the key, to update both keys and values. - 3. Return `nil` for the value to remove the key from the resulting table. - -#### Returns - -`mapped` returns a read-only atom. - ---- - -### `peek(value, ...)` - -Call `peek` to call a function without tracking it as the dependency of an effect or a selector function. - -```luau -local nameAtom = atom("John") -local ageAtom = atom(25) - -effect(function() - local name = nameAtom() - local age = peek(ageAtom) -end) -``` - -#### Parameters - -- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. - -- **optional** `...args`: Additional arguments to pass to the value if it is a function. - -#### Returns - -`peek` returns the result of the given function. If the value is not a function, it will return the value as-is. - ---- - -### `batch(callback)` - -Call `batch` to defer state changes until after the callback has run. This is useful when you need to make multiple changes to the state and only want listeners to be notified once. - -```luau -local nameAtom = atom("John") -local ageAtom = atom(25) - -batch(function() - nameAtom("Jane") - ageAtom(26) -end) -``` - -#### Parameters - -- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. - -#### Returns - -`batch` does not return anything. - ---- - -## 📦 React - -### Setup - -Install the React bindings for Charm using your package manager of choice. - -```sh -npm install @rbxts/react-charm -yarn add @rbxts/react-charm -pnpm add @rbxts/react-charm -``` - -```toml -[dependencies] -ReactCharm = "littensy/react-charm@VERSION" -``` - ---- - -### `useAtom(callback, dependencies?)` - -Call `useAtom` at the top-level of a React component to read from an atom or selector. The component will re-render when the value changes. - -```luau -local todosAtom = require(script.Parent.todosAtom) - -local function Todos() - local todos = useAtom(todosAtom) - -- ... -end -``` - -If your selector depends on the component's state or props, remember to pass them in a dependency array. This prevents skipped updates when an untracked parameter of the selector changes. - -```luau -local todos = useAtom(function() - return searchTodos(props.filter) -end, { props.filter }) -``` - -#### Parameters - -- `callback`: An atom or selector function that depends on an atom. - -- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. - -#### Returns - -`useAtom` returns the current state of the atom. - ---- - -## 📦 Vide - -### Setup - -Install the Vide bindings for Charm using your package manager of choice. - -```sh -npm install @rbxts/vide-charm -yarn add @rbxts/vide-charm -pnpm add @rbxts/vide-charm -``` - -```toml -[dependencies] -VideCharm = "littensy/vide-charm@VERSION" -``` - ---- - -### `useAtom(callback)` - -Call `useAtom` in any scope to create a Vide source that returns the current state of an atom or selector. - -```luau -local todosAtom = require(script.Parent.todosAtom) - -local function Todos() - local todos = useAtom(todosAtom) - -- ... -end -``` - -#### Parameters - -- `callback`: An atom or selector function that depends on an atom. - -#### Returns - -`useAtom` returns a Vide source. - ---- - -## 📗 Charm Sync - -### Setup - -The Charm Sync package provides server-client synchronization for your Charm atoms. Install it using your package manager of choice. - -```sh -npm install @rbxts/charm-sync -yarn add @rbxts/charm-sync -pnpm add @rbxts/charm-sync -``` - -```toml -[dependencies] -CharmSync = "littensy/charm-sync@VERSION" -``` - ---- - -### `server(options)` - -Call `server` to create a server sync object. This synchronizes every client's atoms with the server's state by sending partial patches that the client merges into its state. - -```luau -local syncer = CharmSync.server({ - -- A dictionary of the atoms to sync, matching the client's - atoms = atomsToSync, - -- The minimum interval between state updates - interval = 0, - -- Whether to send a full history of changes made to the atoms (slower) - preserveHistory = false, - -- Whether to apply fixes for remote event limitations. Disable this option - -- when using a network library with custom ser/des, like ByteNet or Zap. - autoSerialize = true, -}) - --- Sends state updates to clients when a synced atom changes. --- Omitting sensitive information and data serialization can be done here. -syncer:connect(function(player, ...) - remotes.syncState:fire(player, ...) +local getItems, setItems = signal({ a = 0, b = 0 }) + +local dispose = observe(getItems, function(value, key) + local getValue = computed(function(prevValue) + return getItems()[key] or prevValue + end) + + effect(function() + local value = getValue() + print(`{key} = {value}`) + return function() + print(`Cleanup {key} = {value}`) + end + end) end) --- Sends the initial state to a player upon request. This should fire when a --- player joins the game. -remotes.requestState:connect(function(player) - syncer:hydrate(player) -end) +setItems({ a = 1, b = 0 }) --> Cleanup a = 0, a = 1 +setItems({ a = 1 }) --> Cleanup b = 0 +dispose() --> Cleanup a = 1 ``` -#### Parameters - -- `options`: An object to configure sync behavior. - - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. - - - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. - - - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. - - - **optional** `autoSerialize`: Whether to apply validation and workarounds to certain [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#table-indexing). Defaults to `true`, but you should set it to `false` if you serialize remote arguments (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). - -> [!NOTE] -> Charm sends table updates in the form of partial tables, so arrays will contain `nil` values, which has undefined behavior in remotes without serialization. -> -> Charm's default `autoSerialize` behavior fixes this, but it can interfere with custom serialization. Disable this option if you use a network library that serializes remote event arguments. - -#### Returns - -`server` returns an object with the following methods: - -- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. - -- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. - -#### Caveats - -- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. [Read more about argument limitations in remotes.](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) - -- **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. - -- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `autoSerialize` accordingly! - --- -### `client(options)` +### `mapped(getter, mapper)` -Call `client` to create a client sync object. This synchronizes the client's atoms with the server's state by merging partial patches sent by the server into each atom. +The `mapped` function iterates over every key in a table and uses the mapper to generate a new key and value. The resulting table is returned in a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value. + +The first value returned by the mapper is used as the new value: ```luau -local syncer = CharmSync.client({ - atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's - ignoreUnhydrated = true, -- Whether to ignore state updates before the initial update -}) +local getList, setList = signal({ "a", "b", "c" }) --- Applies state updates from the server to the client's atoms. --- Data deserialization can be done here. -remotes.syncState:connect(function(...) - syncer:sync(...) +local getUppercase = mapped(getList, function(value) + return string.upper(value) end) --- Requests the initial state from the server when the client joins the game. --- Before this runs, the client uses the atoms' default values. -remotes.requestState:fire() +print(getUppercase()) --> { "A", "B", "C" } ``` -#### Parameters +If the mapper returns two values, the second value is used as the new key: -- `options`: An object to configure sync behavior. +```luau +local getList, setList = signal({ "a", "b", "c" }) - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. +local getSwapped = mapped(getList, function(value, key) + return key, value +end) - - **optional** `ignoreUnhydrated`: Whether to ignore state updates before setting the initial state. Defaults to `true`. - -#### Returns - -`client` returns an object with the following methods: - -- `syncer:sync(...payloads)` applies a state update from the server. - -#### Caveats - -- **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above. +print(getSwapped()) --> { a = 1, b = 2, c = 3 } +``` --- -### `isNone(value)` +### `onCleanup(callback, failSilently?)` -Call `isNone` to check if a value is `None`. Charm's partial state patches omit values that did not change between sync events, so to mark keys for deletion, Charm uses the `None` marker. +The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect. -This function can be used to check whether a value is about to be removed from an atom. +If there is no active effect, a warning is logged, unless `failSilently` is set to `true`. ```luau -local syncer = CharmSync.server({ atoms = atomsToSync }) - -syncer:connect(function(player, payload) - if - payload.type === "patch" - and payload.data.todosAtom - and CharmSync.isNone(payload.data.todosAtom.eggs) - then - -- 'eggs' will be removed from the client's todo list - end - remotes.syncState.fire(player, payload) +local dispose = effectScope(function() + onCleanup(function() + print("Cleaned up") + end) end) + +dispose() --> Cleaned up ``` -#### Parameters - -- `value`: Any value. If the value is `None`, `isNone` will return `true`. - -#### Returns - -`isNone` returns a boolean. - --- -## 🚀 Examples +### `atom(initialValue, equals?)` -### Counter atom +The `atom` function creates a new reactive signal and returns a single function that acts as both a getter and setter. + +If the atom is called with 0 arguments, the atom returns the current value and subscribes to the signal. Otherwise, when called with 1 or more arguments, the atom will update the signal's value. ```luau -local counterAtom = atom(0) +local counter = atom(0) --- Create a selector that returns double the counter value -local function doubleCounter() - return counterAtom() * 2 -end - --- Runs after counterAtom is updated and prints double the new value -subscribe(doubleCounter, function(value) - print(value) -end) - -counterAtom(1) --> 2 -counterAtom(function(count) +print(counter()) --> 0 +counter(1) +counter(function(count) return count + 1 -end) --> 4 +end) ``` -### React component +You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: ```luau -local counter = require(script.Parent.counter) -local counterAtom = counter.counterAtom -local incrementCounter = counter.incrementCounter +local max = atom(0, function(incoming, current) + return incoming <= current +end) + +max(1) --> 1 +max(-1) --> 1 +``` + +--- + +## Examples + +Check out this simple [example project →](https://github.com/littensy/charm-example) + +### React Counter + +```luau +local counterStore = require("./stores/counterStore") local function Counter() - local count = useAtom(counterAtom) + local count = useSignalState(counterStore.getCounter) return React.createElement("TextButton", { - [React.Event.Activated] = incrementCounter, + [React.Event.Activated] = counterStore.incrementCounter, Text = `Count: {count}`, - Size = UDim2.new(0, 100, 0, 50), + Size = UDim2.fromOffset(100, 50), }) end ``` -### Vide component +### Vide Counter ```luau -local counter = require(script.Parent.counter) -local counterAtom = counter.counterAtom -local incrementCounter = counter.incrementCounter +local counterStore = require("./stores/counterStore") local function Counter() - local count = useAtom(counterAtom) + local count = useSignalState(counterStore.getCounter) return create "TextButton" { - Activated = incrementCounter, + Activated = counterStore.incrementCounter, Text = function() return `Count: {count()}` end, - Size = UDim2.new(0, 100, 0, 50), + Size = UDim2.fromOffset(100, 50), } end ``` -### Server-client sync - -Charm is designed for both client and server use, but there are often cases where the client needs to reference state that lives on the server. The CharmSync package provides a way to synchronize atoms between the server and its clients using remote events. - -Start by creating a set of atoms to sync between the server and clients. Export these atoms from a module to be shared between the server and client: - -```luau --- atoms.luau -local counter = require(script.Parent.counter) -local todos = require(script.Parent.todos) - -return { - counterAtom = counter.counterAtom, - todosAtom = todos.todosAtom, -} -``` - -Then, on the server, create a server sync object and pass in the atoms to sync. Use remote events to broadcast state updates and send initial state to clients upon request. - -> [!NOTE] -> If `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a variadic `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter. - -```luau --- sync.server.luau -local atoms = require(script.Parent.atoms) - -local syncer = CharmSync.server({ atoms = atoms }) - --- Sends state updates to clients when a synced atom changes. --- Omitting sensitive information and data serialization can be done here. -syncer:connect(function(player, payload) - remotes.syncState.fire(player, payload) -end) - --- Sends the initial state to a player upon request. This should fire when a --- player joins the game. -remotes.requestState:connect(function(player) - syncer:hydrate(player) -end) -``` - -Finally, on the client, create a client sync object and use it to apply incoming state changes. - -```luau --- sync.client.luau -local atoms = require(script.Parent.atoms) - -local syncer = CharmSync.client({ atoms = atoms }) - --- Applies state updates from the server to the client's atoms. -remotes.syncState:connect(function(payload) - syncer:sync(payload) -end) - --- Requests the initial state from the server when the client joins the game. --- Before this runs, the client uses the atoms' default values. -remotes.requestState:fire() -``` - ---

diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 70bdda9..85a70b0 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -141,7 +141,7 @@ export namespace client { */ export namespace server { /** - * Subscribes a client to the given atoms. When an update occurs, the client + * Subscribes a client to the given signals. When an update occurs, the client * will receive a partial state patch to merge into their local state. May be * called multiple times to subscribe to additional keys. * @@ -150,9 +150,9 @@ export namespace server { * registering signals on the client should be done through `signalToAtom()`. * * @param client The client receiving state updates. - * @param atoms A map of atoms to sync with the client. + * @param signals A map of signals to sync with the client. */ - export function subscribeClient(client: Player, atoms: { [key: string]: Atom }): void; + export function subscribeClient(client: Player, signals: { [key: string]: () => any }): void; /** * Unsubscribes a client from receiving all state updates. To only unsubscribe @@ -277,3 +277,14 @@ export function flattenAtoms(atoms: Atoms): Flatten * @returns `true` if the value is `None`, otherwise `false`. */ export function isNone(value: unknown): value is None; + +/** + * Converts a signal getter and setter into an atom. This is useful for + * syncing client signals with the server, as the sync system requires both + * the getter and setter to update state. + * + * @param getter A function that returns the current value of the signal. + * @param setter A function that sets the value of the signal. + * @returns An atom that can either get or set the signal's value. + */ +export function signalToAtom(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom; diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 4058bcf..528a4e2 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -9,7 +9,7 @@ local globals = Charm.globals type SyncStatus = number -type ClientAtoms = { +type ClientKeys = { [string]: SyncStatus, } @@ -29,17 +29,17 @@ type StateUpdate = { local PENDING_INITIAL_STATE: SyncStatus = 0 local LISTENING_FOR_CHANGES: SyncStatus = 1 -local trackedAtoms: { [string]: TrackedState } = {} +local trackedSignals: { [string]: TrackedState } = {} local globalUpdates: { StateUpdate } = {} -local clients: { [any]: ClientAtoms } = {} +local clients: { [any]: ClientKeys } = {} local connection: RBXScriptConnection? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? local function startTracking(key: string, getter: () -> T) - if trackedAtoms[key] then + if trackedSignals[key] then -- Ignore duplicates, but warn against name collisions - if trackedAtoms[key].getter ~= getter then + if trackedSignals[key].getter ~= getter then warn("[Charm] Attempted to sync different states with the same key:", key) end return @@ -68,27 +68,27 @@ local function startTracking(key: string, getter: () -> T) trackedState.currentValue = newValue end) - trackedAtoms[key] = trackedState + trackedSignals[key] = trackedState end local function stopTracking(key: string) - local tracker = trackedAtoms[key] + local tracker = trackedSignals[key] if tracker then tracker.dispose() - trackedAtoms[key] = nil + trackedSignals[key] = nil end end local function purgeUntracked() local active: { [string]: boolean } = {} - for _, clientAtoms in clients do - for key in clientAtoms do + for _, clientKeys in clients do + for key in clientKeys do active[key] = true end end - for key in trackedAtoms do + for key in trackedSignals do if not active[key] then stopTracking(key) end @@ -107,9 +107,9 @@ local function diffGlobalUpdateBuffer(): { any } end local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload } - local clientAtoms = clients[client] + local clientKeys = clients[client] - if not clientAtoms then + if not clientKeys then return {} end @@ -125,7 +125,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { end local key = update.key - local status = clientAtoms[key] + local status = clientKeys[key] if status == LISTENING_FOR_CHANGES then table.insert(payloads, { @@ -137,20 +137,20 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { type = "init", data = { [key] = update.currentValue }, }) - clientAtoms[key] = LISTENING_FOR_CHANGES + clientKeys[key] = LISTENING_FOR_CHANGES end end - -- If a pending atom was never updated, we still need to send its initial - -- state. This can happen if the atom was registered after its last change. + -- If a pending signal was never updated, we still need to send its initial + -- state. This can happen if a signal was registered after its last change. local initData: { [string]: any } = {} - for key, status in clientAtoms do - local trackedState = trackedAtoms[key] + for key, status in clientKeys do + local trackedState = trackedSignals[key] if status == PENDING_INITIAL_STATE and trackedState then initData[key] = trackedState.currentValue - clientAtoms[key] = LISTENING_FOR_CHANGES + clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -165,7 +165,7 @@ local function diffGlobalState(): any local currentGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {} - for key, tracker in trackedAtoms do + for key, tracker in trackedSignals do currentGlobalState[key] = tracker.currentValue previousGlobalState[key] = tracker.previousValue tracker.previousValue = tracker.currentValue @@ -175,9 +175,9 @@ local function diffGlobalState(): any end local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload } - local clientAtoms = clients[client] + local clientKeys = clients[client] - if not clientAtoms then + if not clientKeys then return {} end @@ -185,16 +185,16 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo local initData: { [string]: any } = {} local patchData: { [string]: any } = {} - -- Create patches for initialized atoms that changed, and add atoms that - -- are still pending initial state to the init payload. - for key, status in clientAtoms do - local trackedState = trackedAtoms[key] + -- Create patches for initialized signals that changed, and add signals + -- that are still pending initial state to the init payload. + for key, status in clientKeys do + local trackedState = trackedSignals[key] if status == LISTENING_FOR_CHANGES then patchData[key] = globalPatch[key] elseif status == PENDING_INITIAL_STATE then initData[key] = trackedState.currentValue - clientAtoms[key] = LISTENING_FOR_CHANGES + clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -209,7 +209,7 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo return payloads end ---- Subscribes a client to the given atoms. When an update occurs, the client +--- Subscribes a client to the given signals. When an update occurs, the client --- will receive a partial state patch to merge into their local state. May be --- called multiple times to subscribe to additional keys. --- @@ -218,21 +218,21 @@ end --- registering signals on the client should be done through `signalToAtom()`. --- --- @param client The client receiving state updates. ---- @param atoms A map of atoms to sync with the client. -local function subscribeClient(client: any, atoms: { [string]: () -> any }) +--- @param signals A map of signals to sync with the client. +local function subscribeClient(client: any, signals: { [string]: () -> any }) if not clients[client] then clients[client] = {} end - local clientAtoms = clients[client] + local clientKeys = clients[client] - for key, state in atoms do + for key, state in signals do startTracking(key, if type(state) == "function" then state else state.get) - if not clientAtoms[key] then - -- Mark the atom as pending initial state so we send its full value - -- the next time we sync. - clientAtoms[key] = PENDING_INITIAL_STATE + if not clientKeys[key] then + -- Mark the signal as pending initial state so we send its full + -- value the next time we sync. + clientKeys[key] = PENDING_INITIAL_STATE end end end @@ -250,18 +250,18 @@ end --- @param client The client receiving state updates. --- @param keys The keys of the state to unsubscribe from. local function unsubscribeClientFrom(client: any, ...: string) - local clientAtoms = clients[client] + local clientKeys = clients[client] - if not clientAtoms then + if not clientKeys then return end for index = 1, select("#", ...) do local key = select(index, ...) - clientAtoms[key] = nil + clientKeys[key] = nil end - if next(clientAtoms) == nil then + if next(clientKeys) == nil then clients[client] = nil end end @@ -346,7 +346,7 @@ end --- Unsubscribes all clients, stops tracking all state, and disconnects --- from the automatic sync interval. local function reset() - table.clear(trackedAtoms) + table.clear(trackedSignals) table.clear(clients) table.clear(globalUpdates) disconnect() diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index 84d1d74..962d1dc 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -40,6 +40,13 @@ local function flattenState(states: NestedState): FlattenedState return result end +--- Converts a signal getter and setter into an atom. This is useful for +--- syncing client signals with the server, as the sync system requires both +--- the getter and setter to update state. +--- +--- @param getter A function that returns the current value of the signal. +--- @param setter A function that sets the value of the signal. +--- @return An atom that can either get or set the signal's value. local function signalToAtom(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom return function(...) return if select("#", ...) == 0 then getter() else setter((...)) diff --git a/packages/charm/src/__tests__/core/effect.spec.luau b/packages/charm/src/__tests__/core/effect.spec.luau index 0d729ff..b9db5ac 100644 --- a/packages/charm/src/__tests__/core/effect.spec.luau +++ b/packages/charm/src/__tests__/core/effect.spec.luau @@ -354,25 +354,4 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) -test("should allow self cleanup", function() - local calls = 0 - local cleanups = 0 - local a, setA = signal(0) - - effect(function(stopEffect) - onCleanup(function() - cleanups += 1 - end) - stopEffect() - a() - calls += 1 - end) - - assert(calls == 1, "should call effect once") - assert(cleanups == 1, "should call cleanup once") - setA(1) - assert(calls == 1, "should not call effect after a update") - assert(cleanups == 1, "should not call cleanup after a update") -end) - return {} diff --git a/packages/charm/src/__tests__/listen.spec.luau b/packages/charm/src/__tests__/listen.spec.luau index d8c14c9..f3098d1 100644 --- a/packages/charm/src/__tests__/listen.spec.luau +++ b/packages/charm/src/__tests__/listen.spec.luau @@ -4,7 +4,6 @@ local suite = require("../../../../test/suite") local signal = Charm.signal local listen = Charm.listen local effect = Charm.effect -local onCleanup = Charm.onCleanup local test = suite.test test("should listen to selector", function() @@ -116,24 +115,4 @@ test("should skip redundant updates", function() assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`) end) -test("should allow self cleanup", function() - local calls = 0 - local cleanups = 0 - local a, setA = signal(0) - - listen(a, function(_, _, stopEffect) - onCleanup(function() - cleanups += 1 - end) - stopEffect() - calls += 1 - end) - - assert(calls == 1, "should call effect once") - assert(cleanups == 1, "should call cleanup once") - setA(1) - assert(calls == 1, "should not call effect after a update") - assert(cleanups == 1, "should not call cleanup after a update") -end) - return {} diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index f60d2e0..3e6ca7a 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -51,9 +51,8 @@ type EffectScope = ReactiveNode & { } type Effect = ReactiveNode & { - fn: (stop: () -> ()) -> (() -> ())?, + fn: () -> (() -> ())?, cleanups: { () -> () }, - stop: () -> (), } type Computed = ReactiveNode & { @@ -117,7 +116,7 @@ local function run(e: Effect): () e.depsTail = nil e.flags = bit32.bor(WATCHING, RECURSED_CHECK) local prevSub = setActiveSub(e) - local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn, e.stop) + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn) activeSub = prevSub e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) purgeDeps(e) @@ -197,7 +196,7 @@ local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) end local function computedOper(this: Computed): () -> T - return function(): T + local function computedGetter(): T local flags = this.flags if bit32.btest(flags, DIRTY) @@ -225,13 +224,14 @@ local function computedOper(this: Computed): () -> T end return this.value :: T end + return computedGetter end local function signalOper( this: Signal, equals: ((current: T, incoming: T) -> boolean)? ): (() -> T, (((T) -> T) | T) -> T) - local function setter(update: ((T) -> T) | T): T + local function signalSetter(update: ((T) -> T) | T): T local value: T if type(update) == "function" then local prevSub = setActiveSub(nil) @@ -262,7 +262,7 @@ local function signalOper( return this.currentValue end - local function getter(): T + local function signalGetter(): T if bit32.btest(this.flags, DIRTY) then if updateSignal(this) then local subs = this.subs @@ -285,7 +285,7 @@ local function signalOper( return this.currentValue end - return getter, setter + return signalGetter, signalSetter end local function effectScopeOper(this: EffectScope): () -> () @@ -311,7 +311,7 @@ end --- @param initialValue The initial value of the signal. --- @param equals A comparator function to determine if the signal's value has changed. --- @return A tuple containing the getter and setter functions for the signal. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals +--- @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals local function signal( initialValue: T, equals: ((current: T, incoming: T) -> boolean)? @@ -326,30 +326,24 @@ local function signal( }, equals) end ---- Creates a reactive atom that acts as both a getter and setter. Calling the ---- atom with an argument sets its value, while calling it with no arguments ---- returns its current value. +--- Creates a reactive atom that acts as both a signal getter and setter. +--- Calling the atom with an argument sets its value, while calling it with no +--- arguments returns its current value. --- ---- @param value The initial value of the atom. If empty, the atom's value will be `undefined`. +--- @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. --- @param equals A comparator function to determine if the atom's value has changed. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals +--- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T local getter, setter = signal(initialValue, equals) - return function(...) + local function atomGetterSetter(...) return if select("#", ...) == 0 then getter() else setter((...)) end + return atomGetterSetter end ---- Creates a new atom that is computed based on the values of other atoms. ---- The returned atom is read-only, and its value is automatically updated when ---- any atoms accessed from within the getter function change. ---- ---- @example ---- ```ts ---- const count = atom(1); ---- const plusOne = computed(() => count() + 1); ---- print(plusOne()); // 2 ---- ``` +--- Creates a new read-only signal that is computed based on the values of +--- other signals. The computed signal's value is automatically updated when a +--- change is detected in any of its dependencies. --- --- @param getter A function that produces the next value. --- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter @@ -360,50 +354,41 @@ local function computed(getter: (previousValue: T?) -> T): () -> T }) end ---- Creates an effect that runs a callback in response to atom state changes. ---- An effect tracks which atoms are accessed during its execution, and re-runs ---- the callback when those atoms change. +--- Creates an effect that runs a callback in response to signal state changes. +--- An effect tracks which signals are accessed during its execution, and runs +--- the callback when those signals change. --- --- The effect callback may return a cleanup function, which gets called once, --- either before the effect re-runs or when it is disposed. --- --- If the effect is called within another effect, it will be disposed when the ---- outer effect re-runs. +--- parent effect re-runs. --- --- @param fn The function to run when dependencies change. --- @return A function for disposing the effect. --- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback -local function effect(fn: (stop: () -> ()) -> ()): () -> () +local function effect(fn: () -> ()): () -> () local e: Effect = { fn = fn, flags = WATCHING, cleanups = {}, - stop = nil :: any, } - e.stop = effectOper(e) local prevSub = setActiveSub(e) if prevSub and (globals.trackInnerEffects or prevSub.flags == NONE) then link(e, prevSub, 0) end - local stopped = false - local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn, function() - stopped = true - end) + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn) activeSub = prevSub if not success then error(result) elseif result then table.insert(e.cleanups, result) end - if stopped then - e.stop() - end - return e.stop + return effectOper(e) end ---- Creates an effect scope that can capture multiple reactive effects (i.e. ---- computed, effect, subscribe) created within it so that they can be disposed ---- together. +--- Creates an effect scope that can capture reactive effects and computed +--- signals created within it so that they can be disposed together. --- --- @param fn A function that may create reactive effects. --- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. @@ -428,12 +413,12 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> () return effectScopeOper(e) end ---- Registers a cleanup function to the current active effect or effect scope. ---- If there is no active effect or scope, a warning is logged and the callback ---- is not registered, unless `failSilently` is set to true. +--- Binds the cleanup function to the current active effect or effect scope. If +--- there is no active effect, a warning is logged, unless `failSilently` is +--- set to `true`. --- --- @param callback The cleanup function to register. ---- @param failSilently If true, suppresses the warning when there is no active effect or scope. Defaults to false. +--- @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. --- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently local function onCleanup(cleanup: () -> (), failSilently: boolean?) if activeSub and (activeSub :: Effect).cleanups then @@ -443,7 +428,7 @@ local function onCleanup(cleanup: () -> (), failSilently: boolean?) end end ---- Runs the callback without subscribing to atom updates or capturing inner +--- Runs the callback without subscribing to signal updates or capturing inner --- effects in the parent effect or scope. --- --- For an alternative that still cleans up inner effects, use `peek` instead. @@ -462,7 +447,7 @@ local function untracked(callback: (Args...) -> Results..., return unpack(results, 2) end ---- Runs the callback without subscribing to atom updates, but still captures +--- Runs the callback without subscribing to signal updates, but still captures --- inner effects so that they dispose with the parent effect. --- --- To avoid capturing inner effects, use `untracked` instead. @@ -489,10 +474,10 @@ local function peek(callback: (Args...) -> Results..., ...: end end ---- Combines multiple atom updates into a single "commit" that runs effects at ---- the end of the provided callback. +--- Combines multiple updates into a single "commit" that runs effects and +--- computed signals after the provided callback finishes running. --- ---- @param callback A function that performs multiple atom updates. +--- @param callback A function that performs multiple updates. --- @param args Arguments to pass to the callback. --- @return The return value of the callback. --- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback @@ -506,18 +491,56 @@ local function batched(callback: (Args...) -> Results..., . return unpack(results, 2) end +--- Converts a non-signal into a computed signal. If the callback is already a +--- signal, it is returned as-is. Useful for memoizing a getter function +--- without creating unnecessary nested signals. +--- +--- @param getter A function that returns the value to track. +--- @return A computed signal that tracks the value returned by the getter. +local function toComputed(getter: () -> T): () -> T + local name = debug.info(getter, "n") + if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then + return getter + end + return computed(function() + return getter() + end) +end + +--- Creates an effect that runs the callback one immediately, and then again +--- when the value returned by the getter changes. The callback receives the new +--- value and the previous value as arguments. +--- +--- Effects created within the callback are cleaned up when the listener runs +--- or the listener is disposed. +--- +--- @param getter A function that returns the value to watch. +--- @param callback The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +local function listen(getter: () -> T, callback: (state: T, previous: T?) -> ()): () -> () + local tracked = toComputed(getter) + local current: T + return effect(function() + local previous = current + current = tracked() + peek(callback, current, previous) + end) +end + --- Creates an effect that only runs the callback when the value returned by the --- getter changes. The callback receives the new value and the previous value --- as arguments. --- +--- Effects created within the callback are cleaned up when the listener runs +--- or the listener is disposed. +--- --- @param getter A function that returns the value to watch. --- @param callback The function to run when the value changes. --- @return A function for disposing the effect. --- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () - local tracked = computed(function() - return getter() - end) + local tracked = toComputed(getter) local current: T local mounted = true return effect(function() @@ -531,34 +554,6 @@ local function subscribe(getter: () -> T, callback: (state: T, lastState: T) end) end ---- Creates an effect that runs the callback one immediately, and then again ---- when the value returned by the getter changes. The callback receives the new ---- value, the previous value, and a cleanup function as arguments. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback -local function listen(getter: () -> T, callback: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () - local tracked = computed(function() - return getter() - end) - local current: T - local disposed = false - local dispose = function() - disposed = true - end - dispose = effect(function() - local previous = current - current = tracked() - peek(callback, current, previous, dispose) - end) - if disposed then - dispose() - end - return dispose -end - local reactiveSystem = createReactiveSystem({ update = function(node) if node.depsTail then diff --git a/packages/charm/src/globals.luau b/packages/charm/src/globals.luau index d1c1b17..af6a381 100644 --- a/packages/charm/src/globals.luau +++ b/packages/charm/src/globals.luau @@ -6,11 +6,11 @@ local isO2 = inlineTest() ~= "inlineTest" local isDev = not isO2 or _G.__DEV__ == true return { - --- Enforces synchronous, non-yielding behavior in atoms and effects. + --- Enforces synchronous, non-yielding behavior in signals and effects. --- Also enables state validation in Charm Sync to catch sync errors early. --- Enabled if the optimization level is below 2, which is true in studio. strict = isDev, - --- Enforces immutability of atoms by deep-freezing table values. Enabled + --- Enforces immutability of tables by deep-freezing table values. Enabled --- if the optimization level is below 2, which is true in studio. frozen = isDev, --- Whether parent effects should track inner effects and clean them up diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index ace5fac..43d56d8 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -20,13 +20,13 @@ export type Equals = (current: T, incoming: T) => boolean; */ export const globals: { /** - * Enforces synchronous, non-yielding behavior in atoms and effects. + * Enforces synchronous, non-yielding behavior in signals and effects. * Also enables state validation in Charm Sync to catch sync errors early. * Enabled if the optimization level is below 2, which is true in studio. */ strict: boolean; /** - * Enforces immutability of atoms by deep-freezing table values. Enabled + * Enforces immutability of tables by deep-freezing table values. Enabled * if the optimization level is below 2, which is true in studio. */ frozen: boolean; @@ -44,84 +44,138 @@ export const globals: { * * @param initialValue The initial value of the signal. * @param equals A comparator function to determine if the signal's value has changed. - * @return A tuple containing the getter and setter functions for the signal. - * @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals + * @returns A tuple containing the getter and setter functions for the signal. + * @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals */ export function signal( - value: T, + initialValue: T, equals?: Equals, -): LuaTuple<[() => T, (newValue: T | ((currentValue: T) => T)) => T]>; +): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>; export function signal(): LuaTuple< - [() => T | undefined, (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined] + [getter: () => T | undefined, setter: (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined] >; /** - * Creates a reactive atom that acts as both a getter and setter. Calling the - * atom with an argument sets its value, while calling it with no arguments - * returns its current value. + * Creates a reactive atom that acts as both a signal getter and setter. + * Calling the atom with an argument sets its value, while calling it with no + * arguments returns its current value. * - * @param value The initial value of the atom. If empty, the atom's value will be `undefined`. + * @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. * @param equals A comparator function to determine if the atom's value has changed. - * @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options + * @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals */ -export function atom(value: T, equals?: Equals): Atom; +export function atom(initialValue: T, equals?: Equals): Atom; export function atom(): Atom; /** - * Creates a new atom that is computed based on the values of other atoms. - * The returned atom is read-only, and its value is automatically updated when - * any atoms accessed from within the getter function change. - * - * If an initial value is provided, the getter will receive this value as its - * first argument on the initial run, instead of `undefined`. - * - * @example - * ```ts - * const count = atom(1); - * const plusOne = computed(() => count() + 1); - * print(plusOne()); // 2 - * ``` + * Creates a new read-only signal that is computed based on the values of + * other signals. The computed signal's value is automatically updated when a + * change is detected in any of its dependencies. * * @param getter A function that produces the next value. - * @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options + * @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter */ export function computed(getter: (previousValue?: T) => T): () => T; /** - * Creates an effect that runs a callback in response to atom state changes. - * An effect tracks which atoms are accessed during its execution, and re-runs - * the callback when those atoms change. + * Creates an effect that runs a callback in response to signal state changes. + * An effect tracks which signals are accessed during its execution, and runs + * the callback when those signals change. * * The effect callback may return a cleanup function, which gets called once, * either before the effect re-runs or when it is disposed. * * If the effect is called within another effect, it will be disposed when the - * outer effect re-runs. + * parent effect re-runs. * - * @param callback The function to run when dependencies change. + * @param fn The function to run when dependencies change. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback */ -export function effect(callback: (dispose: Cleanup) => Cleanup | void): Cleanup; +export function effect(callback: () => Cleanup | void): Cleanup; /** - * Creates an effect scope that can capture multiple reactive effects (i.e. - * computed, effect, subscribe) created within it so that they can be disposed - * together. + * Creates an effect scope that can capture reactive effects and computed + * signals created within it so that they can be disposed together. * - * @param callback A function that may create reactive effects. - * @param detached If true, the scope will not be automatically disposed of - * when the parent scope is disposed. Defaults to false. + * @param fn A function that may create reactive effects. + * @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. * @returns A function for disposing all effects created within the scope. * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached */ export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; +/** + * Binds the cleanup function to the current active effect or effect scope. If + * there is no active effect, a warning is logged, unless `failSilently` is + * set to `true`. + * + * @param callback The cleanup function to register. + * @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. + * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently + */ +export function onCleanup(callback: Cleanup, failSilently?: boolean): void; + +/** + * Runs the callback without subscribing to signal updates or capturing inner + * effects in the parent effect or scope. + * + * For an alternative that still cleans up inner effects, use `peek` instead. + * + * @param callback The callback function. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + * @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback + */ +export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; + +/** + * Runs the callback without subscribing to signal updates, but still captures + * inner effects so that they dispose with the parent effect. + * + * To avoid capturing inner effects, use `untracked` instead. + * + * @param callback The callback function. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + * @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback + */ +export function peek(callback: (...args: Args) => Result, ...args: Args): Result; + +/** + * Combines multiple updates into a single "commit" that runs effects and + * computed signals after the provided callback finishes running. + * + * @param callback A function that performs multiple updates. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + * @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback + */ +export function batched(callback: (...args: Args) => Result, ...args: Args): Result; + +/** + * Creates an effect that runs the callback one immediately, and then again + * when the value returned by the getter changes. The callback receives the new + * value and the previous value as arguments. + * + * Effects created within the callback are cleaned up when the listener runs + * or the listener is disposed. + * + * @param getter A function that returns the value to watch. + * @param callback The function to run when the value changes. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback + */ +export function listen(getter: () => T, callback: (value: T, previousValue: T | undefined) => void): Cleanup; + /** * Creates an effect that only runs the callback when the value returned by the * getter changes. The callback receives the new value and the previous value * as arguments. * + * Effects created within the callback are cleaned up when the listener runs + * or the listener is disposed. + * * @param getter A function that returns the value to watch. * @param callback The function to run when the value changes. * @returns A function for disposing the effect. @@ -130,28 +184,12 @@ export function effectScope(callback: () => Cleanup | void, detached?: boolean): export function subscribe(getter: () => T, callback: (value: T, previousValue: T) => void): Cleanup; /** - * Creates an effect that runs the callback one immediately, and then again - * when the value returned by the getter changes. The callback receives the new - * value, the previous value, and a cleanup function as arguments. + * Creates a new read-only signal that is computed by mapping over the entries + * of an existing atom. If the key is omitted from the mapper's return value, + * the original key is preserved. * - * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. - * @returns A function for disposing the effect. - * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback - */ -export function listen( - getter: () => T, - callback: (value: T, previousValue: T | undefined, dispose: Cleanup) => void, -): Cleanup; - -/** - * Creates a new atom that is computed by mapping over the entries of an - * existing atom. The returned atom is read-only, and its value is - * automatically updated when the source atom changes. - * - * @param getter A function that returns the table to map over. - * @param mapper A function that transforms each value-key pair from the source - * into a new value or value-key pair for the resulting table. + * @param getter Returns the table to map over. + * @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper */ export function mapped( @@ -170,70 +208,27 @@ export function mapped( ): () => { readonly [K in KO]: VO }; /** - * Calls the observer function for each unique key in the table returned by the - * getter. The observer is called once for each key, and again when a new key + * Calls the callback function for each unique key in the table returned by the + * getter. The callback is called once for each key, and again when a new key * is added. * - * The observer may return a cleanup function, which will be called when the - * key is removed or when the entire observer is disposed. + * The callback may return a cleanup function, which will be called when the + * key is removed or when the entire callback is disposed. + * + * Effects created within the callback are cleaned up when the item is removed + * from the table or when the callback is disposed. * * @param getter A function that returns the table to observe. - * @param observer A function that is called for each key in the table. + * @param callback A function that is called for each key in the table. * @returns A function for disposing the observer. - * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer + * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback */ export function observe( getter: () => Map | ReadonlyMap | readonly V[], - observer: (value: V, key: K) => Cleanup | void, + callback: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( getter: () => { readonly [K in Key]: V }, - observer: (value: V, key: K) => Cleanup | void, + callback: (value: V, key: K) => Cleanup | void, ): Cleanup; - -/** - * Combines multiple atom updates into a single "commit" that runs effects at - * the end of the provided callback. - * - * @param callback A function that performs multiple atom updates. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. - */ -export function batched(callback: (...args: Args) => Result, ...args: Args): Result; - -/** - * Runs the callback without subscribing to atom updates or capturing inner - * effects in the parent effect or scope. - * - * For an alternative that still cleans up inner effects, use `peek` instead. - * - * @param callback The callback function. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. - */ -export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; - -/** - * Runs the callback without subscribing to atom updates, but still captures - * inner effects so that they dispose with the parent effect. - * - * To avoid capturing inner effects, use `untracked` instead. - * - * @param callback The callback function. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. - */ -export function peek(callback: (...args: Args) => Result, ...args: Args): Result; - -/** - * Registers a cleanup function to the current active effect or effect scope. - * If there is no active effect or scope, a warning is logged and the callback - * is not registered, unless `failSilently` is set to true. - * - * @param callback The cleanup function to register. - * @param failSilently If true, suppresses the warning when there is no active - * effect or scope. Defaults to false. - * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently - */ -export function onCleanup(callback: Cleanup, failSilently?: boolean): void; diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index 8f9b528..940a6ae 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -8,7 +8,7 @@ type Mapped = & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } - local currentData: { [KI]: VI } = {} + local currentInput: { [KI]: VI } = {} local keyCache: { [KI]: KO } = {} local function map(value: VI, key: KI): (VO, KO) @@ -25,38 +25,38 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: return computed(function(previousOutput) local currentOutput: { [KO]: VO } = previousOutput or {} - local previousData = currentData - currentData = getter() + local previousInput = currentInput + currentInput = getter() - for key in previousData do - if currentData[key] ~= nil then + for keyIn in previousInput do + if currentInput[keyIn] ~= nil then continue end - local cachedKey = keyCache[key] + local prevKeyOut = keyCache[keyIn] - if cachedKey then + if prevKeyOut then if currentOutput == previousOutput then currentOutput = table.clone(currentOutput) end - currentOutput[cachedKey] = nil - keyCache[key] = nil + currentOutput[prevKeyOut] = nil + keyCache[keyIn] = nil end end - for key, value in currentData do - if value == previousData[key] then + for keyIn, valueIn in currentInput do + if valueIn == previousInput[keyIn] then continue end - local valueOut, keyOut = map(value, key) + local valueOut, keyOut = map(valueIn, keyIn) if currentOutput[keyOut] ~= valueOut then if currentOutput == previousOutput then currentOutput = table.clone(currentOutput) end currentOutput[keyOut] = valueOut - keyCache[key] = keyOut + keyCache[keyIn] = keyOut end end @@ -64,12 +64,12 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: end) end ---- Creates a new atom that is computed by mapping over the entries of an ---- existing atom. The returned atom is read-only, and its value is ---- automatically updated when the source atom changes. +--- Creates a new read-only signal that is computed by mapping over the entries +--- of an existing atom. If the key is omitted from the mapper's return value, +--- the original key is preserved. --- ---- @param getter A function that returns the table to map over. ---- @param mapper A function that transforms each value-key pair from the source into a new value or value-key pair for the resulting table. +--- @param getter Returns the table to map over. +--- @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. --- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper local mapped = mapped :: Mapped diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index 5f5f9ba..bc8230e 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -6,18 +6,21 @@ local effect = core.effect local effectScope = core.effectScope local onCleanup = core.onCleanup ---- Calls the observer function for each unique key in the table returned by the ---- getter. The observer is called once for each key, and again when a new key +--- Calls the callback function for each unique key in the table returned by the +--- getter. The callback is called once for each key, and again when a new key --- is added. --- ---- The observer may return a cleanup function, which will be called when the ---- key is removed or when the entire observer is disposed. +--- The callback may return a cleanup function, which will be called when the +--- key is removed or when the entire callback is disposed. +--- +--- Effects created within the callback are cleaned up when the item is removed +--- from the table or when the callback is disposed. --- --- @param getter A function that returns the table to observe. ---- @param observer A function that is called for each key in the table. +--- @param callback A function that is called for each key in the table. --- @return A function for disposing the observer. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer -local function observe(getter: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () +--- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback +local function observe(getter: () -> { [K]: V }, callback: (item: V, key: K) -> ...(() -> ())?): () -> () local scopes: { [K]: () -> () } = {} local currentItems: { [K]: V } = {} @@ -33,7 +36,7 @@ local function observe(getter: () -> { [K]: V }, observer: (item: V, key: for key, value in currentItems do if previousItems[key] == nil and scopes[key] == nil then scopes[key] = effectScope(function() - return observer(value, key) + return callback(value, key) end, true) end end diff --git a/packages/react-charm/src/__tests__/spec.luau b/packages/react-charm/src/__tests__/spec.luau index 2dcdfe7..69f078a 100644 --- a/packages/react-charm/src/__tests__/spec.luau +++ b/packages/react-charm/src/__tests__/spec.luau @@ -1,11 +1,11 @@ local suite = require("../../../../test/suite") -suite.describe("useAtom", function() - require("./useAtom.spec") +suite.describe("useSignalState", function() + require("./useSignalState.spec") end) -suite.describe("useAtomBinding", function() - require("./useAtomBinding.spec") +suite.describe("useSignalBinding", function() + require("./useSignalBinding.spec") end) return {} diff --git a/packages/react-charm/src/__tests__/useAtomBinding.spec.luau b/packages/react-charm/src/__tests__/useSignalBinding.spec.luau similarity index 67% rename from packages/react-charm/src/__tests__/useAtomBinding.spec.luau rename to packages/react-charm/src/__tests__/useSignalBinding.spec.luau index d1fa23d..1ad6df3 100644 --- a/packages/react-charm/src/__tests__/useAtomBinding.spec.luau +++ b/packages/react-charm/src/__tests__/useSignalBinding.spec.luau @@ -3,45 +3,43 @@ local ReactCharm = require("../") local renderHook = require("./helpers/renderHook") local suite = require("../../../../test/suite") -local atom = Charm.atom -local useAtomBinding = ReactCharm.useAtomBinding local test = suite.test -test("tracks atom state", function() - local state = atom(0) +test("tracks signal state", function() + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtomBinding(state) + return ReactCharm.useSignalBinding(getState) end) assert(hook.result:getValue() == 0, "should return 0") - state(1) + setState(1) assert(hook.result:getValue() == 1, "should return 1") end) test("accepts a selector", function() - local state = atom(0) + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtomBinding(function() - return 2 * state() + return ReactCharm.useSignalBinding(function() + return 2 * getState() end) end) assert(hook.result:getValue() == 0, "should return 0") - state(1) + setState(1) assert(hook.result:getValue() == 2, "should return 2") end) test("tracks dependencies", function() - local state = atom(0) + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtomBinding(state, { state }) + return ReactCharm.useSignalBinding(getState, { getState }) end) assert(hook.result:getValue() == 0, "should return 0") - state(1) + setState(1) assert(hook.result:getValue() == 1, "should return 1") - state = atom(0) + getState, setState = Charm.signal(0) hook.rerender() assert(hook.result:getValue() == 0, "should return 0") - state(1) + setState(1) assert(hook.result:getValue() == 1, "should return 1") end) diff --git a/packages/react-charm/src/__tests__/useAtom.spec.luau b/packages/react-charm/src/__tests__/useSignalState.spec.luau similarity index 65% rename from packages/react-charm/src/__tests__/useAtom.spec.luau rename to packages/react-charm/src/__tests__/useSignalState.spec.luau index c695f9f..ca22e49 100644 --- a/packages/react-charm/src/__tests__/useAtom.spec.luau +++ b/packages/react-charm/src/__tests__/useSignalState.spec.luau @@ -3,45 +3,43 @@ local ReactCharm = require("../") local renderHook = require("./helpers/renderHook") local suite = require("../../../../test/suite") -local atom = Charm.atom -local useAtom = ReactCharm.useAtom local test = suite.test -test("tracks atom state", function() - local state = atom(0) +test("tracks signal state", function() + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtom(state) + return ReactCharm.useSignalState(getState) end) assert(hook.result == 0, "should return 0") - state(1) + setState(1) assert(hook.result == 1, "should return 1") end) test("accepts a selector", function() - local state = atom(0) + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtom(function() - return 2 * state() + return ReactCharm.useSignalState(function() + return 2 * getState() end) end) assert(hook.result == 0, "should return 0") - state(1) + setState(1) assert(hook.result == 2, "should return 2") end) test("tracks dependencies", function() - local state = atom(0) + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtom(state, { state }) + return ReactCharm.useSignalState(getState, { getState }) end) assert(hook.result == 0, "should return 0") - state(1) + setState(1) assert(hook.result == 1, "should return 1") - state = atom(0) + getState, setState = Charm.signal(0) hook.rerender() assert(hook.result == 0, "should return 0") - state(1) + setState(1) assert(hook.result == 1, "should return 1") end) diff --git a/packages/react-charm/src/index.d.ts b/packages/react-charm/src/index.d.ts index f24d4af..db8ac24 100644 --- a/packages/react-charm/src/index.d.ts +++ b/packages/react-charm/src/index.d.ts @@ -1,29 +1,31 @@ import { Binding } from "@rbxts/react"; /** - * A hook that subscribes to changes in the given atom or selector. The - * component is re-rendered with the new state after an update. + * Returns a React state that updates when the getter function updates. + * Re-renders the component when the value changes. * * If the `dependencies` array is provided, a new effect is created for the - * current `callback` when the dependencies change. Otherwise, the effect is + * current `getter` when the dependencies change. Otherwise, the effect is * created only once, when the component is mounted. * - * @param callback The atom or selector to watch. - * @param dependencies An array of values used to memoize the callback. - * @returns The current state. + * @param getter The signal or selector to watch. + * @param dependencies An array of values used to memoize the getter. + * @return The current state. */ -export function useAtom(callback: () => T, dependencies?: any[]): T; +export function useSignalState(getter: () => T, dependencies?: any[]): T; /** - * A hook that subscribes to changes in the given atom or selector. Returns - * a binding that updates when the state changes. + * Returns a React binding that updates when the getter function updates. * * If the `dependencies` array is provided, a new effect is created for the - * current `callback` when the dependencies change. Otherwise, the effect is + * current `getter` when the dependencies change. Otherwise, the effect is * created only once, when the component is mounted. * - * @param callback The atom or selector to watch. - * @param dependencies An array of values used to memoize the callback. - * @returns A binding that stores the current state. + * @param getter The atom or selector to watch. + * @param dependencies An array of values used to memoize the getter. + * @return A binding that stores the current state. */ -export function useAtomBinding(callback: () => T, dependencies?: any[]): Binding; +export function useSignalBinding(getter: () => T, dependencies?: any[]): Binding; + +// Aliases +export { useSignalState as useAtom }; diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index a923ff5..481275a 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -1,31 +1,30 @@ local Charm = require("./Charm") local React = require("./React") ---- A hook that subscribes to changes in the given atom or selector. The ---- component is re-rendered with the new state after an update. +--- Returns a React state that updates when the getter function updates. +--- Re-renders the component when the value changes. --- --- If the `dependencies` array is provided, a new effect is created for the --- current `getter` when the dependencies change. Otherwise, the effect is --- created only once, when the component is mounted. --- ---- @param getter The atom or selector to watch. +--- @param getter The signal or selector to watch. --- @param dependencies An array of values used to memoize the getter. --- @return The current state. -local function useAtom(getter: () -> T, dependencies: { any }?): T - local state, setState = React.useState(getter) +local function useSignalState(getter: () -> T, dependencies: { any }?): T + local value, setValue = React.useState(getter) React.useLayoutEffect(function() return Charm.effect(function() -- Wrap in untracked to avoid tracking inner effect and atom calls - Charm.untracked(setState, getter()) + Charm.untracked(setValue, getter()) end) end, dependencies or {}) - return state + return value end ---- A hook that subscribes to changes in the given atom or selector. Returns ---- a binding that updates when the state changes. +--- Returns a React binding that updates when the getter function updates. --- --- If the `dependencies` array is provided, a new effect is created for the --- current `getter` when the dependencies change. Otherwise, the effect is @@ -34,9 +33,9 @@ end --- @param getter The atom or selector to watch. --- @param dependencies An array of values used to memoize the getter. --- @return A binding that stores the current state. -local function useAtomBinding(getter: () -> T, dependencies: { any }?) - local initialState = React.useMemo(getter, {}) - local binding, setBinding = React.useBinding(initialState) +local function useSignalBinding(getter: () -> T, dependencies: { any }?) + local initialValue = React.useMemo(getter, {}) + local binding, setBinding = React.useBinding(initialValue) React.useLayoutEffect(function() return Charm.effect(function() @@ -49,6 +48,7 @@ local function useAtomBinding(getter: () -> T, dependencies: { any }?) end return { - useAtom = useAtom, - useAtomBinding = useAtomBinding, + useSignalState = useSignalState, + useSignalBinding = useSignalBinding, + useAtom = useSignalState, } diff --git a/packages/vide-charm/src/__tests__/spec.luau b/packages/vide-charm/src/__tests__/spec.luau index fddcda9..79a4998 100644 --- a/packages/vide-charm/src/__tests__/spec.luau +++ b/packages/vide-charm/src/__tests__/spec.luau @@ -1,7 +1,7 @@ local suite = require("../../../../test/suite") -suite.describe("useAtom", function() - require("./useAtom.spec") +suite.describe("useSignalState", function() + require("./useSignalState.spec") end) return {} diff --git a/packages/vide-charm/src/__tests__/useAtom.spec.luau b/packages/vide-charm/src/__tests__/useSignalState.spec.luau similarity index 59% rename from packages/vide-charm/src/__tests__/useAtom.spec.luau rename to packages/vide-charm/src/__tests__/useSignalState.spec.luau index 88c6b5b..6d13460 100644 --- a/packages/vide-charm/src/__tests__/useAtom.spec.luau +++ b/packages/vide-charm/src/__tests__/useSignalState.spec.luau @@ -5,55 +5,55 @@ local suite = require("../../../../test/suite") local test = suite.test -test("tracks atom state", function() - local state = Charm.atom(0) +test("tracks signal state", function() + local getState, setState = Charm.signal(0) local source local cleanup = Vide.root(function() - source = VideCharm.useAtom(state) + source = VideCharm.useSignalState(getState) end) assert(source() == 0, "should return 0") - state(1) + setState(1) assert(source() == 1, "should return 1") cleanup() end) test("accepts a selector", function() - local state = Charm.atom(0) + local getState, setState = Charm.signal(0) local source local cleanup = Vide.root(function() - source = VideCharm.useAtom(function() - return 2 * state() + source = VideCharm.useSignalState(function() + return 2 * getState() end) end) assert(source() == 0, "should return 0") - state(1) + setState(1) assert(source() == 2, "should return 2") cleanup() end) test("updates source in untracked", function() - local outerAtom = Charm.atom(nil :: any) - local innerAtom = Charm.atom(0) + local getOuter, setOuter = Charm.signal(nil :: any) + local getInner, setInner = Charm.signal(0) local spyCalls = 0 local cleanup = Vide.root(function() - return Vide.show(VideCharm.useAtom(outerAtom), function() - -- Charm effects created in Vide scopes triggered by useAtom might - -- incorrectly get treated as an inner effect in useAtom. This test + return Vide.show(VideCharm.useSignalState(getOuter), function() + -- Charm effects created in Vide scopes triggered by useSignalState might + -- incorrectly get treated as an inner effect in useSignalState. This test -- ensures that doesn't happen. Vide.cleanup(Charm.effect(function() - innerAtom() + getInner() spyCalls += 1 end)) return nil end) end) assert(spyCalls == 0, "should not call spy initially") - outerAtom(true) + setOuter(true) assert(spyCalls == 1, "should call spy when visible") - innerAtom(1) + setInner(1) assert(spyCalls == 2, "spy should run when counter updates") - outerAtom({}) -- set to a truthy value to trigger an outer effect re-run - innerAtom(2) + setOuter({}) -- set to a truthy value to trigger an outer effect re-run + setInner(2) assert(spyCalls == 3, "spy should still run when counter updates") cleanup() end) diff --git a/packages/vide-charm/src/index.d.ts b/packages/vide-charm/src/index.d.ts index f9541ab..eb12134 100644 --- a/packages/vide-charm/src/index.d.ts +++ b/packages/vide-charm/src/index.d.ts @@ -1,8 +1,11 @@ /** - * Subscribes to the state of an atom or selector. Returns a source that - * updates when the state changes. + * Returns a reactive source that subscribes to the current value of the given + * signal or getter function. * - * @param callback The atom or selector to subscribe to. - * @returns The reactive source. + * @param getter The getter function to subscribe to. + * @return The reactive source. */ -export function useAtom(callback: () => T, dependencies?: any[]): () => T; +export function useSignalState(getter: () => T, dependencies?: any[]): () => T; + +// Aliases +export { useSignalState as useAtom }; diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index d6d27ae..34b1c7b 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -1,12 +1,12 @@ local Charm = require("./Charm") local Vide = require("./Vide") ---- Returns a reactive source that updates when the given getter function's ---- value changes. +--- Returns a reactive source that subscribes to the current value of the given +--- signal or getter function. --- ---- @param getter The atom or selector to subscribe to. +--- @param getter The getter function to subscribe to. --- @return The reactive source. -local function useAtom(getter: () -> State): () -> State +local function useSignalState(getter: () -> State): () -> State local state = Vide.source(getter()) Vide.cleanup(Charm.effect(function() @@ -18,5 +18,6 @@ local function useAtom(getter: () -> State): () -> State end return { - useAtom = useAtom, + useSignalState = useSignalState, + useAtom = useSignalState, } From ea40833d3bf241e3c1121dfdcbf9a5b18c17cdb4 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:12:06 -0800 Subject: [PATCH 056/160] feat: finalize sync docs --- README.md | 248 +++++++++++++++++- packages/charm-sync/Charm.luau | 1 - .../charm-sync/src/__tests__/client.spec.luau | 12 +- .../charm-sync/src/__tests__/e2e.spec.luau | 10 +- .../charm-sync/src/__tests__/server.spec.luau | 30 +-- .../charm-sync/src/__tests__/utils.spec.luau | 12 +- packages/charm-sync/src/client.luau | 63 +++-- packages/charm-sync/src/index.d.ts | 96 +++---- packages/charm-sync/src/init.luau | 2 +- packages/charm-sync/src/server.luau | 75 +++--- packages/charm-sync/src/utils.luau | 37 ++- packages/charm/src/index.d.ts | 10 +- packages/charm/src/init.luau | 8 +- packages/react-charm/Charm.luau | 1 - packages/vide-charm/Charm.luau | 1 - 15 files changed, 400 insertions(+), 206 deletions(-) diff --git a/README.md b/README.md index 01d0b60..e007e07 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ **Charm** is a state management library that aims to bring [reactive signals](https://preactjs.com/blog/signal-boosting/) from libraries like Preact and Solid to Roblox. It's built on the [alien-signals](https://github.com/stackblitz/alien-signals) signal algorithm, expanding upon it further to handle a wide range of use cases, from rendering user interfaces to handling server game logic. -**Charm has a few core principles:** +**Charm works under a few core principles.** - Manage state with reactive signals: state containers that hold a value - React to state updates: reading a signal automatically subscribes to it -- Combine multiple signals: derive a new value from existing state that stays up-to-date +- Combine multiple signals: derive new values from existing state that stay up-to-date - Fine-grained reactivity: make targeted updates in response to specific changes in the state - Use immutable data: values are compared directly (`==`) to optimize change detection @@ -34,6 +34,28 @@ - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity - https://angular.dev/guide/signals +

+Table of Contents + +- [Installation](#installation) +- [Reference](#reference) + - [`signal(initialValue, equals?)`](#signalinitialvalue-equals) + - [`computed(getter)`](#computedgetter) + - [`effect(callback)`](#effectcallback) + - [`untracked(callback)`](#untrackedcallback) + - [`peek(callback)`](#peekcallback) + - [`batched(callback)`](#batchedcallback) + - [`effectScope(callback)`](#effectscopecallback) + - [`listen(getter, callback)`](#listengetter-callback) + - [`subscribe(getter, callback)`](#subscribegetter-callback) + - [`observe(getter, callback)`](#observegetter-callback) + - [`mapped(getter, mapper)`](#mappedgetter-mapper) + - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) + - [`atom(initialValue, equals?)`](#atominitialvalue-equals) +- [Examples](#examples) + +
+ ## At a Glance ```luau @@ -104,7 +126,6 @@ setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) setQuery("^Buy") ``` - ## Installation @@ -122,7 +143,7 @@ Charm = "littensy/charm@VERSION" --- -## Guide +## Reference ### `signal(initialValue, equals?)` @@ -143,12 +164,13 @@ Accessing the signal's value in an effect or computed signal will subscribe to i You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: ```luau -local getMax, setMax = signal(0, function(incoming, current) +local getMax, setMax = signal(0, function(current, incoming) return incoming <= current end) setMax(1) --> 1 -setMax(-1) --> 1 +setMax(2) --> 2 +setMax(-1) --> 2 ``` > [!NOTE] @@ -223,9 +245,6 @@ dispose() --> Cleanup 1 Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`. -> [!NOTE] -> To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `globals.trackInnerEffects` flag to assist with debugging. - ```luau local getCounter, setCounter = signal(0) @@ -240,11 +259,14 @@ setCounter(1) --> Outer: 1, Inner: 1 setCounter(2) --> Outer: 2, Inner: 2 ``` +> [!NOTE] +> To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `globals.trackInnerEffects` flag to assist with debugging. + --- ### `untracked(callback)` -In case you want to read signals that you don't want to subscribe to, you can use `untracked()` to essentially call a function _outside_ the current effect, preventing signals and effects in the callback from being tracked. +In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked. ```luau local getCounter, setCounter = signal(0) @@ -283,10 +305,11 @@ disposeInner() --> Cleaned up inner effect ### `peek(callback)` -Similar to `untracked()`, but does not prevent effects created by the callback from automatically disposing. If your callback reads a signal and creates effects, only the signals get untracked. +Similar to `untracked()`, but does not prevent the parent effect from tracking nested effects created in the callback. If your callback reads a signal and creates effects, only the signals get untracked. ```luau local getCounter, setCounter = signal(0) + local disposeOuter = effect(function() peek(function() print(`Count is {getCounter()}`) @@ -306,7 +329,7 @@ disposeOuter() --> Cleaned up inner effect ### `batched(callback)` -Combines multiple signal updates made by the callback into a single commit that gets triggered when the callback completes. +Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. ```luau local getName, setName = signal("John") @@ -435,7 +458,7 @@ dispose() --> Cleanup a = 1 ### `mapped(getter, mapper)` -The `mapped` function iterates over every key in a table and uses the mapper to generate a new key and value. The resulting table is returned in a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value. +The `mapped` function iterates over every key in a table and uses the mapper to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value. The first value returned by the mapper is used as the new value: @@ -500,7 +523,7 @@ end) You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: ```luau -local max = atom(0, function(incoming, current) +local max = atom(0, function(current, incoming) return incoming <= current end) @@ -510,6 +533,203 @@ max(-1) --> 1 --- +### `globals` + +Global flags that customize the behavior of Charm. The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio. + +| Flag | Default | Description | +| ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| strict | `true` in Studio | Enforces synchronous, non-yielding behavior in signals, effects, and critical code. | +| frozen | `true` in Studio | Enforces immutability by deep-freezing tables passed to signals. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. Should only be disabled to debug issues during migration. | + +--- + +## Client-server sync + +### Installation + +```sh +npm install @rbxts/charm-sync +yarn add @rbxts/charm-sync +pnpm add @rbxts/charm-sync +``` + +```toml +[dependencies] +CharmSync = "littensy/charm-sync@VERSION" +``` + +### Quick start + +Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: + +```luau +-- nameStore +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") + +return { + getName = getName, + setName = setName, + getSurname = getSurname, + setSurname = setSurname, +} +``` + +When a player joins on the server, call `server.addSignalsToClient` with the keyed signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. + +Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to. + +> [!NOTE] +> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. + +```luau +local function onPlayerAdded(player: Player) + -- Add signal getters, computed signals, or atoms + server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, + }) +end + +for _, player in Players:GetPlayers() do + onPlayerAdded(player) +end + +Players.PlayerAdded:Connect(onPlayerAdded) + +Players.PlayerRemoving:Connect(function(player) + server.removeClient(player) +end) + +server.connect(function(player, updates) + -- Customize how you send state updates to clients + syncEvent:FireClient(player, updates) +end) +``` + +To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. + +After the client receives updates from the server, call `client.hydrate` to patch the client's signals with the incoming state updates. + +```luau +-- Add signal setters or atoms +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, +}) + +-- Update client signals when receiving updates from the server +syncEvent.OnClientEvent:Connect(function(updates) + client.hydrate(updates) +end) +``` + +--- + +### `config` + +A configuration table that customizes the behavior of Charm Sync on the server. + +| Option | Default | Description | +| --------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. | +| preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. | +| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | +| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, patches will be validated to enforce remote event limitations. | + +--- + +### `server.addSignalsToClient(client, getters)` + +The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. + +You can pass signal getter functions, computed signals, and atoms in the `getters` table. This function can also be called multiple on the same client to subscribe to new signals. + +```luau +Players.PlayerAdded:Connect(function(player) + server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, + }) +end) +``` + +You're also allowed to create new signals to sync to specific players, as long as the key is also unique to that player: + +```luau +server.addSignalsToClient(player, { + [`data-{player.UserId}`] = computed(function() + return getPlayerData(player.UserId) + end), +}) +``` + +--- + +### `server.removeSignalsFromClient(client, ...keys)` + +The `removeSignalsFromClient` function unsubscribes the client from a list of keys that were previously subscribed to via `addSignalsToClient`. + +```luau +server.addSignalsToClient(player, { + surname = nameStore.getSurname, +}) + +server.removeSignalsFromClient(player, "surname") +``` + +--- + +### `server.removeClient(client)` + +The `removeClient` function unsubscribes a client from receiving all state updates from the server. You should call this function when a player leaves the game. + +```luau +Players.PlayerAdded:Connect(function(player) + server.addSignalsToClient(player, signals) +end) + +Players.PlayerRemoving:Connect(function(player) + server.removeClient(player) +end) +``` + +--- + +### `server.connect(onSync)` + +Binds a callback to run when sending state updates a client. Scheduled updates will be sent periodically at the interval specified in [`config.interval`](#config). + +When a synced signal updates, the `onSync` function is scheduled to run for each client subscribed to that signal on the next sync event. + +```luau +server.connect(function(player, updates) + syncEvent:FireServer(player, updates) +end) +``` + +--- + +### `server.disconnect()` + +Stops syncing state updates to clients at the automatic interval. This doesn't unbind the last callback, so you can still manually trigger sync events after calling this function by calling `server.flush()`. + +```luau +server.connect(function(player, updates) + syncEvent:FireServer(player, updates) +end) + +-- Stops calling the function at the automatic interval +server.disconnect() + +-- Flushing still sends pending updates +server.flush() +``` + +--- + ## Examples Check out this simple [example project →](https://github.com/littensy/charm-example) diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index 0070134..9a55fe0 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -5,6 +5,5 @@ end local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom -export type Selector = Charm.Selector return Charm diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index d3a9fb9..4b13e1d 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -6,13 +6,13 @@ local test = suite.test local afterEach = suite.afterEach afterEach(function() - client.reset() + client.removeAllSignals() end) test("should hydrate atom", function() local a = Charm.atom(1) - client.register({ a = a }) + client.addSignals({ a = a }) client.hydrate({ { type = "init", data = { a = 2 } }, }) @@ -24,7 +24,7 @@ test("should merge patch", function() local a = Charm.atom({ b = 1, c = 2 }) local previous = a() - client.register({ a = a }) + client.addSignals({ a = a }) client.hydrate({ { type = "init", data = { a = a() } }, }) @@ -43,7 +43,7 @@ end) test("should fix sparse array patches", function() local a = Charm.atom({ 1 }) - client.register({ a = a }) + client.addSignals({ a = a }) client.hydrate({ { type = "init", data = { a = a() } }, }) @@ -63,7 +63,7 @@ test("should merge multiple patches", function() local a = Charm.atom(1) local calls = {} - client.register({ a = a }) + client.addSignals({ a = a }) Charm.subscribe(a, function(value) table.insert(calls, value) @@ -87,7 +87,7 @@ test("should batch patches", function() local b = Charm.atom(0) local states = {} - client.register({ a = a, b = b }) + client.addSignals({ a = a, b = b }) Charm.subscribe(a, function() states.b = b() diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau index 2f2c647..f0cae67 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -9,7 +9,7 @@ local test = suite.test afterEach(function() server.reset() - client.reset() + client.removeAllSignals() config.preserveHistory = false end) @@ -20,8 +20,8 @@ test("should replicate server state to client", function() local clientA = Charm.atom({ count = -1 }) local clientB = Charm.atom({ count = -1 }) - client.register({ a = clientA, b = clientB }) - server.subscribeClient(target, { a = serverA, b = serverB }) + client.addSignals({ a = clientA, b = clientB }) + server.addSignalsToClient(target, { a = serverA, b = serverB }) server.connect(function(syncTarget, payloads) if syncTarget == target then client.hydrate(payloads) @@ -54,8 +54,8 @@ test("should replicate historical server state to client", function() config.preserveHistory = true - client.register({ a = clientA, b = clientB }) - server.subscribeClient(target, { a = serverA, b = serverB }) + client.addSignals({ a = clientA, b = clientB }) + server.addSignalsToClient(target, { a = serverA, b = serverB }) server.connect(function(syncTarget, payloads) if syncTarget == target then client.hydrate(payloads) diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau index 19e60cd..f26b753 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -20,7 +20,7 @@ test("should replicate state changes", function() local c = Charm.atom(0) local target, payloads - server.subscribeClient(client, { a = a, b = b, c = c }) + server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) target, payloads = ... end) @@ -47,7 +47,7 @@ test("should replicate partial state patches", function() local b = Charm.atom(0 :: number?) local target, payloads - server.subscribeClient(client, { a = a, b = b }) + server.addSignalsToClient(client, { a = a, b = b }) server.connect(function(...) target, payloads = ... end) @@ -75,7 +75,7 @@ test("should init new clients", function() local a = Charm.atom(0) local payloadsA, payloadsB - server.subscribeClient(clientA, { a = a }) + server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) if target == clientA then payloadsA = payloads @@ -89,7 +89,7 @@ test("should init new clients", function() assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") - server.subscribeClient(clientB, { a = a }) + server.addSignalsToClient(clientB, { a = a }) a(2) server.flush() assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") @@ -104,13 +104,13 @@ test("should init new atoms", function() local b = Charm.atom(0) local target, payloads - server.subscribeClient(client, { a = a }) + server.addSignalsToClient(client, { a = a }) server.connect(function(...) target, payloads = ... end) server.flush() - server.subscribeClient(client, { b = b }) + server.addSignalsToClient(client, { b = b }) a(1) b(1) server.flush() @@ -129,8 +129,8 @@ test("should only replicate to synced clients", function() local b = Charm.atom(0) local payloadsA, payloadsB - server.subscribeClient(clientA, { a = a }) - server.subscribeClient(clientB, { b = b }) + server.addSignalsToClient(clientA, { a = a }) + server.addSignalsToClient(clientB, { b = b }) server.connect(function(target, payloads) if target == clientA then payloadsA = payloads @@ -170,7 +170,7 @@ test("should validate state", function() local client = {} local a = Charm.atom({}) - server.subscribeClient(client, { a = a }) + server.addSignalsToClient(client, { a = a }) server.connect(function() end) a({ [0.5] = true }) @@ -185,7 +185,7 @@ test("should serialize state", function() local a = Charm.atom({ 1, 2, 3, 4, 5 }) local payloads - server.subscribeClient(client, { a = a }) + server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) payloads = p end) @@ -213,7 +213,7 @@ describe("historical", function() local c = Charm.atom(0) local target, payloads - server.subscribeClient(client, { a = a, b = b, c = c }) + server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) target, payloads = ... end) @@ -242,7 +242,7 @@ describe("historical", function() local a = Charm.atom(0) local payloadsA, payloadsB - server.subscribeClient(clientA, { a = a }) + server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) if target == clientA then payloadsA = payloads @@ -259,7 +259,7 @@ describe("historical", function() assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A") assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A") - server.subscribeClient(clientB, { a = a }) + server.addSignalsToClient(clientB, { a = a }) a(3) a(4) server.flush() @@ -278,7 +278,7 @@ describe("historical", function() local b = Charm.atom(0) local payloads - server.subscribeClient(client, { a = a }) + server.addSignalsToClient(client, { a = a }) server.connect(function(_, ...) payloads = ... end) @@ -287,7 +287,7 @@ describe("historical", function() assert(payloads[1].type == "init", "Expected first payload to be init") assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") - server.subscribeClient(client, { b = b }) + server.addSignalsToClient(client, { b = b }) a(1) a(2) b(1) diff --git a/packages/charm-sync/src/__tests__/utils.spec.luau b/packages/charm-sync/src/__tests__/utils.spec.luau index e4215e7..47d2e39 100644 --- a/packages/charm-sync/src/__tests__/utils.spec.luau +++ b/packages/charm-sync/src/__tests__/utils.spec.luau @@ -2,17 +2,15 @@ local Charm = require("../../Charm") local suite = require("../../../../test/suite") local utils = require("../utils") -local atom = Charm.atom local test = suite.test -test("should flatten atoms", function() - local getF, setF = Charm.signal(0) +test("should flatten functions", function() local input = { - a = atom(0), - b = { c = atom(0) }, - d = { e = { f = utils.signalToAtom(getF, setF) } }, + a = Charm.atom(0), + b = { c = Charm.atom(0) }, + d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, } - local result = utils.flattenState(input) + local result = utils.flatten(input) assert(result.a == input.a, "should flatten the first level") assert(result["b/c"] == input.b.c, "should flatten the second level") diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 6b4ad08..e067be5 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,60 +2,59 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") -type Atom = Charm.Atom type SyncPayload = types.SyncPayload -local trackedAtoms: { [string]: Atom } = {} +type Setter = (update: ((T) -> T) | T) -> () ---- Registers a map of atoms to the state with the corresponding keys on the ---- server. When the server sends updates, these atoms will be hydrated with +local trackedSetters: { [string]: Setter } = {} + +--- Registers a map of setters to the state with the corresponding keys on the +--- server. When the server sends updates, these setters will be called with --- the new values. --- ---- If syncing signals instead of atoms, you can use `signalToAtom` to sync ---- signal getters and setters. +--- Atoms, signals converted to atoms, and signal setter functions are allowed. +--- Note that `server.addSignalsToClient` still requires some way to get the +--- state. --- ---- @param atoms A map of atoms to sync with the server. -local function register(atoms: { [string]: Atom }) - for key, atom in atoms do - trackedAtoms[key] = atom +--- @param setters A map of setter functions to sync with the server. +local function addSignals(setters: { [string]: Setter }) + for key, setter in setters do + trackedSetters[key] = setter end end ---- Unregisters from server state updates for the given keys. The atoms +--- Unregisters from server state updates for the given keys. The signals --- will retain their current values, but will no longer receive updates --- from the server. -local function unregister(...: string) +local function removeSignals(...: string) for index = 1, select("#", ...) do local key = select(index, ...) - trackedAtoms[key] = nil + trackedSetters[key] = nil end end --- Unsubscribes from all server state updates. -local function reset() - table.clear(trackedAtoms) +local function removeAllSignals() + table.clear(trackedSetters) end local function applyInitPayload(data: { [string]: any }) for key, value in data do - local atom = trackedAtoms[key] - if atom then - atom(value) + local setter = trackedSetters[key] + if setter then + setter(value) end end end -local function applyPatchPayload(data: { [string | number]: any }) - local target = {} - - for key, atom in trackedAtoms do - target[key] = atom() - end - - target = patch.apply(target, data) - - for key, atom in trackedAtoms do - atom(target[key]) +local function applyPatchPayload(data: { [string]: any }) + for key, value in data do + local setter = trackedSetters[key] + if setter then + setter(function(current) + return patch.apply(current, value) + end) + end end end @@ -74,8 +73,8 @@ local function hydrate(payloads: { SyncPayload }) end return { - register = register, - unregister = unregister, - reset = reset, + addSignals = addSignals, + removeSignals = removeSignals, + removeAllSignals = removeAllSignals, hydrate = hydrate, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 85a70b0..70a0f22 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -1,4 +1,4 @@ -import { Atom } from "@rbxts/charm"; +import { Atom, Getter, Setter } from "@rbxts/charm"; type Key = string | number | symbol; @@ -11,7 +11,8 @@ export interface None { export type MaybeNone = undefined extends T ? None : never; -type SelectorMap = Record any>; +type GetterMap = Record>; +type SetterMap = Record>; /** * Infers the type of the return values produced by a map of functions. @@ -33,6 +34,7 @@ type DataType = DataTypes[keyof DataTypes]; * If a value was removed, it is replaced with `None`. This can be checked * using the `isNone` function. */ +// TODO: Optimize type, this looks overly complex export type SyncPatch = MaybeNone | State extends | ReadonlyMap | Map @@ -53,9 +55,9 @@ export type SyncPatch = MaybeNone = - | { type: "init"; data: StateOfMap } - | { type: "patch"; data: SyncPatch, FixArrays> }; +export type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch, FixArrays> }; /** * Global configuration options that affect the behavior of Charm Sync. @@ -102,28 +104,29 @@ export const config: { */ export namespace client { /** - * Registers a map of atoms to the state with the corresponding keys on the - * server. When the server sends updates, these atoms will be hydrated with + * Registers a map of setters to the state with the corresponding keys on the + * server. When the server sends updates, these setters will be called with * the new values. * - * If syncing signals instead of atoms, you can use `signalToAtom` to sync - * signal getters and setters as atoms. + * Atoms, signals converted to atoms, and signal setter functions are allowed. + * Note that `server.addSignalsToClient` still requires some way to get the + * state. * - * @param atoms A map of atoms to sync with the server. + * @param setters A map of setter functions to sync with the server. */ - export function register(atoms: { [key: string]: Atom }): void; + export function addSignals(setters: Setters): void; /** - * Unregisters from server state updates for the given keys. The atoms + * Unregisters from server state updates for the given keys. The signals * will retain their current values, but will no longer receive updates * from the server. */ - export function unregister(...keys: string[]): void; + export function removeSignals(...keys: (keyof Setters)[]): void; /** * Unsubscribes from all server state updates. */ - export function reset(): void; + export function removeAllSignals(): void; /** * Merges the server's state patches into the client's state, either @@ -131,8 +134,8 @@ export namespace client { * * @param payloads The state updates received from the server. */ - export function hydrate }, FixArrays extends boolean = true>( - payloads: SyncPayload[], + export function hydrate( + payloads: SyncPayload[], ): void; } @@ -145,22 +148,21 @@ export namespace server { * will receive a partial state patch to merge into their local state. May be * called multiple times to subscribe to additional keys. * - * Atoms, signals converted to atoms, and signal getter functions are allowed. - * Note that the client still requires some way to set the state, so - * registering signals on the client should be done through `signalToAtom()`. + * Atoms, computed signals, and signal getter functions are allowed. Note + * that `client.addSignals` still requires some way to set the state. * * @param client The client receiving state updates. - * @param signals A map of signals to sync with the client. + * @param getters A map of getter functions to sync with the client. */ - export function subscribeClient(client: Player, signals: { [key: string]: () => any }): void; + export function addSignalsToClient(client: Player, getters: Getters): void; /** * Unsubscribes a client from receiving all state updates. To only unsubscribe - * from specific keys, use `unsubscribeClientFrom` instead. + * from specific keys, use `removeSignalsFromClient` instead. * * @param client The client receiving state updates. */ - export function unsubscribeClient(client: Player): void; + export function removeClient(client: Player): void; /** * Unsubscribes a client from receiving updates for the given keys. @@ -168,7 +170,10 @@ export namespace server { * @param client The client receiving state updates. * @param keys The keys of the state to unsubscribe from. */ - export function unsubscribeClientFrom(client: Player, ...keys: string[]): void; + export function removeSignalsFromClient( + client: Player, + ...keys: (keyof Getters)[] + ): void; /** * Uses the callback to send state updates to clients that are subscribed @@ -177,8 +182,8 @@ export namespace server { * * @param onSync Called when sending patches to a client. */ - export function connect }, FixArrays extends boolean = true>( - onSync: (client: Player, payloads: SyncPayload[]) => void, + export function connect( + onSync: (client: Player, payloads: SyncPayload[]) => void, ): void; /** @@ -235,39 +240,24 @@ export namespace patch { ): State; } -interface NestedAtomMap { - readonly [key: string]: NestedAtomMap | Atom; -} +type DeepFlattenImpl = { + [K in keyof T]: T[K] extends DataType | Callback + ? { [P in `${Prefix}${Exclude}`]: T[K] } + : T[K] extends object + ? DeepFlattenImpl}/`> + : { [P in `${Prefix}${Exclude}`]: T[K] }; +}[keyof T]; -type AppendPath = undefined extends Path - ? Name - : Name extends string - ? Path extends string - ? `${Path}/${Name}` - : never - : never; - -type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; - -export type FlattenNestedAtoms< - Atoms extends NestedAtomMap, - Path extends Key | undefined = undefined, -> = IntersectValues<{ - readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom - ? { readonly [K in AppendPath]: Atoms[Name] } - : Atoms[Name] extends NestedAtomMap - ? FlattenNestedAtoms> - : never; -}>; +type DeepFlatten = Reconstruct>>; /** - * Flattens a nested atom map into a single object with slash-separated keys. + * Flattens a nested table into a single table with slash-separated keys. * Useful for recursively collecting atoms returned by modules. * - * @param atoms The nested atom map to flatten. - * @returns A flattened atom map. + * @param input A nested table. + * @return A flattened table with slash-separated keys. */ -export function flattenAtoms(atoms: Atoms): FlattenNestedAtoms; +export function flatten(input: T): DeepFlatten; /** * Checks whether a value is `None`. If `true`, the value is scheduled to be diff --git a/packages/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau index aca6061..53a6353 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -14,6 +14,6 @@ return { server = server, patch = patch, isNone = patch.isNone, - flattenState = utils.flattenState, + flatten = utils.flatten, signalToAtom = utils.signalToAtom, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 528a4e2..6692d0c 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -8,12 +8,9 @@ type SyncPayload = types.SyncPayload local globals = Charm.globals type SyncStatus = number +type ClientKeys = { [string]: SyncStatus } -type ClientKeys = { - [string]: SyncStatus, -} - -type TrackedState = { +type GetterState = { getter: () -> T, currentValue: T, previousValue: T, @@ -29,7 +26,7 @@ type StateUpdate = { local PENDING_INITIAL_STATE: SyncStatus = 0 local LISTENING_FOR_CHANGES: SyncStatus = 1 -local trackedSignals: { [string]: TrackedState } = {} +local trackedGetters: { [string]: GetterState } = {} local globalUpdates: { StateUpdate } = {} local clients: { [any]: ClientKeys } = {} @@ -37,9 +34,9 @@ local connection: RBXScriptConnection? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? local function startTracking(key: string, getter: () -> T) - if trackedSignals[key] then + if trackedGetters[key] then -- Ignore duplicates, but warn against name collisions - if trackedSignals[key].getter ~= getter then + if trackedGetters[key].getter ~= getter then warn("[Charm] Attempted to sync different states with the same key:", key) end return @@ -68,14 +65,14 @@ local function startTracking(key: string, getter: () -> T) trackedState.currentValue = newValue end) - trackedSignals[key] = trackedState + trackedGetters[key] = trackedState end local function stopTracking(key: string) - local tracker = trackedSignals[key] + local tracker = trackedGetters[key] if tracker then tracker.dispose() - trackedSignals[key] = nil + trackedGetters[key] = nil end end @@ -88,7 +85,7 @@ local function purgeUntracked() end end - for key in trackedSignals do + for key in trackedGetters do if not active[key] then stopTracking(key) end @@ -106,11 +103,11 @@ local function diffGlobalUpdateBuffer(): { any } return patches end -local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload } +local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }? local clientKeys = clients[client] if not clientKeys then - return {} + return nil end local payloads: { SyncPayload } = {} @@ -146,10 +143,10 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { local initData: { [string]: any } = {} for key, status in clientKeys do - local trackedState = trackedSignals[key] + local getterState = trackedGetters[key] - if status == PENDING_INITIAL_STATE and trackedState then - initData[key] = trackedState.currentValue + if status == PENDING_INITIAL_STATE and getterState then + initData[key] = getterState.currentValue clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -165,7 +162,7 @@ local function diffGlobalState(): any local currentGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {} - for key, tracker in trackedSignals do + for key, tracker in trackedGetters do currentGlobalState[key] = tracker.currentValue previousGlobalState[key] = tracker.previousValue tracker.previousValue = tracker.currentValue @@ -174,11 +171,11 @@ local function diffGlobalState(): any return patch.diff(previousGlobalState, currentGlobalState) end -local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload } +local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }? local clientKeys = clients[client] if not clientKeys then - return {} + return nil end local payloads: { SyncPayload } = {} @@ -188,12 +185,12 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo -- Create patches for initialized signals that changed, and add signals -- that are still pending initial state to the init payload. for key, status in clientKeys do - local trackedState = trackedSignals[key] + local getterState = trackedGetters[key] if status == LISTENING_FOR_CHANGES then patchData[key] = globalPatch[key] elseif status == PENDING_INITIAL_STATE then - initData[key] = trackedState.currentValue + initData[key] = getterState.currentValue clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -214,23 +211,22 @@ end --- called multiple times to subscribe to additional keys. --- --- Atoms, signals converted to atoms, and signal getter functions are allowed. ---- Note that the client still requires some way to set the state, so ---- registering signals on the client should be done through `signalToAtom()`. +--- Note that `client.addSignals` still requires some way to set the state. --- --- @param client The client receiving state updates. ---- @param signals A map of signals to sync with the client. -local function subscribeClient(client: any, signals: { [string]: () -> any }) +--- @param getters A map of getter functions to sync with the client. +local function addSignalsToClient(client: any, getters: { [string]: () -> any }) if not clients[client] then clients[client] = {} end local clientKeys = clients[client] - for key, state in signals do - startTracking(key, if type(state) == "function" then state else state.get) + for key, getter in getters do + startTracking(key, if type(getter) == "function" then getter else getter.get) if not clientKeys[key] then - -- Mark the signal as pending initial state so we send its full + -- Mark the getter as pending initial state so we send its full -- value the next time we sync. clientKeys[key] = PENDING_INITIAL_STATE end @@ -241,7 +237,7 @@ end --- from specific keys, use `unsubscribeClientFrom` instead. --- --- @param client The client receiving state updates. -local function unsubscribeClient(client: any) +local function removeClient(client: any) clients[client] = nil end @@ -249,7 +245,7 @@ end --- --- @param client The client receiving state updates. --- @param keys The keys of the state to unsubscribe from. -local function unsubscribeClientFrom(client: any, ...: string) +local function removeSignalsFromClient(client: any, ...: string) local clientKeys = clients[client] if not clientKeys then @@ -262,7 +258,7 @@ local function unsubscribeClientFrom(client: any, ...: string) end if next(clientKeys) == nil then - clients[client] = nil + removeClient(client) end end @@ -274,7 +270,8 @@ local function flush() return end - local globalPatch = if config.preserveHistory then diffGlobalUpdateBuffer() else diffGlobalState() + local granular = config.preserveHistory + local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState() -- While payload objects are technically mutable as they are unique to -- each client, the data inside them is not safe to mutate. @@ -283,11 +280,11 @@ local function flush() end for client in clients do - local payloads: { SyncPayload } = if config.preserveHistory + local payloads = if granular then selectFromGlobalPatchList(client, globalPatch) else selectFromGlobalPatch(client, globalPatch) - if payloads[1] then + if payloads and payloads[1] then if task then task.spawn(syncCallback, client, payloads) else @@ -346,16 +343,16 @@ end --- Unsubscribes all clients, stops tracking all state, and disconnects --- from the automatic sync interval. local function reset() - table.clear(trackedSignals) + table.clear(trackedGetters) table.clear(clients) table.clear(globalUpdates) disconnect() end return { - subscribeClient = subscribeClient, - unsubscribeClient = unsubscribeClient, - unsubscribeClientFrom = unsubscribeClientFrom, + addSignalsToClient = addSignalsToClient, + removeClient = removeClient, + removeSignalsFromClient = removeSignalsFromClient, connect = connect, disconnect = disconnect, flush = flush, diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index 962d1dc..6a7f7b0 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -2,40 +2,35 @@ local Charm = require("../Charm") type Atom = Charm.Atom -type NestedState = { - [string]: NestedState | () -> any, +type NestedTable = { + [any]: NestedTable | any, } -type FlattenedState = { - [string]: () -> any, +type FlattenedTable = { + [any]: any, } -local function isAtomOrSignal(value: any): boolean - return type(value) ~= "table" or (type(value.get) == "function" and type(value.set) == "function") -end - ---- Flattens a nested map of atoms or signals into a single object with ---- slash-separated keys. Useful for recursively collecting atoms returned by ---- modules. +--- Flattens a nested table into a single table with slash-separated keys. +--- Useful for recursively collecting atoms returned by modules. --- ---- @param states A nested table of atoms or signals. ---- @return A flattened map of atoms or signals. -local function flattenState(states: NestedState): FlattenedState - local result: FlattenedState = {} +--- @param input A nested table. +--- @return A flattened table with slash-separated keys. +local function flatten(input: NestedTable): FlattenedTable + local result: FlattenedTable = {} - local function visit(node: NestedState, path: string) + local function visit(node: NestedTable, path: string) for key, value in node do local location = if path == "" then key else path .. "/" .. key - if isAtomOrSignal(value) then - result[location] = value :: () -> any + if type(value) == "table" then + visit(value :: NestedTable, location) else - visit(value :: NestedState, location) + result[location] = value end end end - visit(states, "") + visit(input, "") return result end @@ -54,6 +49,6 @@ local function signalToAtom(getter: () -> T, setter: (value: ((T) -> T) | T) end return { - flattenState = flattenState, + flatten = flatten, signalToAtom = signalToAtom, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 43d56d8..926a5ea 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -2,17 +2,17 @@ type Key = string | number | symbol; type Cleanup = () => void; /** - * A reactive atom that acts as both a getter and setter. Calling the atom with - * an argument sets its value, while calling it with no arguments returns its - * current value. + * A reactive signal that acts as both a getter and setter. Calling the signal + * with an argument sets its value, while calling it with no arguments returns + * its current value. */ export interface Atom { (newValue: T | ((currentValue: T) => T)): T; (): T; } -export type Selector = () => T; - +export type Getter = () => T; +export type Setter = (newValue: T | ((currentValue: T) => T)) => T; export type Equals = (current: T, incoming: T) => boolean; /** diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 1275509..62a0daf 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -6,13 +6,11 @@ local utils = require("@self/utils") type Update = ((previous: T) -> T) | T ---- A reactive atom that acts as both a getter and setter. Calling the atom with ---- an argument sets its value, while calling it with no arguments returns its ---- current value. +--- A reactive signal that acts as both a getter and setter. Calling the signal +--- with an argument sets its value, while calling it with no arguments returns +--- its current value. export type Atom = (...Update) -> T -export type Selector = () -> T - return { core = core, globals = globals, diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau index 6b6fb92..9588118 100644 --- a/packages/react-charm/Charm.luau +++ b/packages/react-charm/Charm.luau @@ -1,6 +1,5 @@ local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom -export type Selector = Charm.Selector return Charm diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau index 6b6fb92..9588118 100644 --- a/packages/vide-charm/Charm.luau +++ b/packages/vide-charm/Charm.luau @@ -1,6 +1,5 @@ local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom -export type Selector = Charm.Selector return Charm From 0f2f2d642cd66109730c5a5710288d36fe098156 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:41:24 -0800 Subject: [PATCH 057/160] docs: update sync section --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e007e07..8ba7908 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,12 @@ - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) +- [Client-server sync](#client-server-sync) + - [Installation](#installation-1) + - [Quick start](#quick-start) + - [Server API](#config) + - [Client API](#clientaddsignalssetters) + - [Sync caveats](#sync-caveats) - [Examples](#examples) @@ -581,9 +587,6 @@ When a player joins on the server, call `server.addSignalsToClient` with the key Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to. -> [!NOTE] -> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. - ```luau local function onPlayerAdded(player: Player) -- Add signal getters, computed signals, or atoms @@ -609,6 +612,9 @@ server.connect(function(player, updates) end) ``` +> [!NOTE] +> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. + To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. After the client receives updates from the server, call `client.hydrate` to patch the client's signals with the incoming state updates. @@ -730,6 +736,64 @@ server.flush() --- +### `client.addSignals(setters)` + +Subscribes the given writable signals to the states with the corresponding keys on the server. When the server sends updates, the functions associated with each key in the state will be called with the patched values. + +You should pass either pass signal setter functions or atoms to this function: + +```luau +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, +}) +``` + +--- + +### `client.removeSignals(...keys)` + +Unsubscribes from each signal with the corresponding keys. The signals will retain their current values, but will no longer receive updates from the server. + +```luau +client.addSignals({ + name = nameStore.setName, +}) + +client.removeSignals("name") +``` + +--- + +### `client.hydrate(updates)` + +The `hydrate` function patches the client's state with the updates sent from the server. The initial update sent by the server will be the full state, and later updates will only include the values that changed. + +You should call `hydrate` when receiving updates from the server from a remote event: + +```luau +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, +}) + +syncEvent.OnClientEvent:Connect(function(updates) + client.hydrate(updates) +end) +``` + +--- + +### Sync caveats + +Charm Sync will only send clients the differences between the current state and the previously-synced state, which is a practice called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. + +But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`. + +This means nilable values can be represented as `None`, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. + +--- + ## Examples Check out this simple [example project →](https://github.com/littensy/charm-example) From 682b3dfcffdbb4c8965e38a2208b87f354ae9829 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:47:35 -0800 Subject: [PATCH 058/160] docs: simplify examples --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8ba7908..d73f64a 100644 --- a/README.md +++ b/README.md @@ -801,13 +801,15 @@ Check out this simple [example project →](https://github.com/littensy/charm-ex ### React Counter ```luau -local counterStore = require("./stores/counterStore") +local getCounter, setCounter = signal(0) local function Counter() - local count = useSignalState(counterStore.getCounter) + local count = useSignalState(getCounter) return React.createElement("TextButton", { - [React.Event.Activated] = counterStore.incrementCounter, + [React.Event.Activated] = function() + setCounter(count + 1) + end, Text = `Count: {count}`, Size = UDim2.fromOffset(100, 50), }) @@ -817,13 +819,17 @@ end ### Vide Counter ```luau -local counterStore = require("./stores/counterStore") +local getCounter, setCounter = signal(0) local function Counter() - local count = useSignalState(counterStore.getCounter) + local count = useSignalState(getCounter) return create "TextButton" { - Activated = counterStore.incrementCounter, + Activated = function() + setCounter(function(count) + return count + 1 + end) + end, Text = function() return `Count: {count()}` end, From 90399a816fab1a8112fa4812cc9f0da628c7f1d4 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:51:34 -0800 Subject: [PATCH 059/160] docs: capitalize headers --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d73f64a..da600f3 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,12 @@ - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) -- [Client-server sync](#client-server-sync) +- [Client-Server Sync](#client-server-sync) - [Installation](#installation-1) - - [Quick start](#quick-start) + - [Quick Start](#quick-start) - [Server API](#config) - [Client API](#clientaddsignalssetters) - - [Sync caveats](#sync-caveats) + - [Sync Caveats](#sync-caveats) - [Examples](#examples) @@ -551,7 +551,7 @@ Global flags that customize the behavior of Charm. The `strict` and `frozen` fla --- -## Client-server sync +## Client-Server Sync ### Installation @@ -566,7 +566,7 @@ pnpm add @rbxts/charm-sync CharmSync = "littensy/charm-sync@VERSION" ``` -### Quick start +### Quick Start Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: @@ -784,7 +784,7 @@ end) --- -### Sync caveats +### Sync Caveats Charm Sync will only send clients the differences between the current state and the previously-synced state, which is a practice called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. From 3d4ce41d201226172fb6ec574a9b2ad289310b08 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 3 Nov 2025 02:15:15 -0800 Subject: [PATCH 060/160] docs: add migration tips --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index da600f3..b0f5259 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ - [Server API](#config) - [Client API](#clientaddsignalssetters) - [Sync Caveats](#sync-caveats) +- [Migration](#migration) - [Examples](#examples) @@ -165,7 +166,7 @@ setCounter(function(count) end) ``` -Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately update every effect and computed signal that depends signal, ensuring every part of your state is correct and up-to-date. +Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately notify every effect and computed signal that depends on said signal, ensuring all of your state is correct and up-to-date. You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: @@ -180,7 +181,7 @@ setMax(-1) --> 2 ``` > [!NOTE] -> Looking for atoms? You can still use `atom()` to create a signal with a combined getter and setter. To use signals for APIs that require atoms, use `signalToAtom()`. +> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a combined getter and setter. --- @@ -794,6 +795,59 @@ This means nilable values can be represented as `None`, and code working with up --- +## Migration + +Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that might help you migrate from an older version. + +
+Terminology changes + +- Signal: A state container with one function to get the state, and another to update it +- Atom: A signal with the getter and setter combined into one function +- Effect: Main way to react to state changes +- `subscribe(getter, callback)`: Creates an effect that only subscribes to signals accessed in the getter + +
+ +
+ +**What to look out for:** + +1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: + - `batch()` was renamed to `batched()` + - The second arguments of `atom()` and `computed()` changed from an `options` table to an equality function + - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`) + +2. If you use Charm Sync, you'll have to rewrite a lot of your sync code. Fortunately, most of the changes should make your code _less_ complicated: + - You can now sync signals per-client, including computed signals. You shouldn't have to modify sync payloads to filter data anymore. + - Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`. + - [Read the updated docs for syncing state →](#client-server-sync) + +3. The [`strict` and `frozen` flags](#globals) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors if you didn't use the old `__DEV__` flag. The flags have the following behavior: + - `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error + - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations + +4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent. + - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`. + - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). + - This feature can cause runtime bugs that are hard to track! If you need to narrow down a cause, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`. + - > [!NOTE] + > An example of this feature causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. + > + > This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. + +5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible with many thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! + - Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit + - Added [`listen()`](#listengetter-callback) for running a subscription once immediately + - Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once + - Added [`onCleanup()`](#oncleanupcallback-failsilently) for running code when the currently-running effect, subscription, or observer cleans up + - The [`computed()`](#computedgetter) callback now gets called with the previous computed value + - Optimized `computed()` to use lazy evaluation instead of eager updates + - Optimized `mapped()` to only map values that changed in the original table + - Effect execution and ordering is more reliable + +--- + ## Examples Check out this simple [example project →](https://github.com/littensy/charm-example) From 86ade3c36d91693563239a3cb334aea3fe96b381 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 3 Nov 2025 02:16:45 -0800 Subject: [PATCH 061/160] docs: fix whitespace --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b0f5259..2d1ed7f 100644 --- a/README.md +++ b/README.md @@ -809,8 +809,6 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that -
- **What to look out for:** 1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: @@ -831,10 +829,11 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`. - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). - This feature can cause runtime bugs that are hard to track! If you need to narrow down a cause, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`. - - > [!NOTE] - > An example of this feature causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. - > - > This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. + +> [!NOTE] +> An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. +> +> This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. 5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible with many thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! - Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit From 2aa7bd3e15fea0b48698acb0c06f041ca87233f0 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 3 Nov 2025 02:22:51 -0800 Subject: [PATCH 062/160] docs: rephrase computed arg change --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d1ed7f..ece7086 100644 --- a/README.md +++ b/README.md @@ -813,7 +813,8 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that 1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: - `batch()` was renamed to `batched()` - - The second arguments of `atom()` and `computed()` changed from an `options` table to an equality function + - The second arguments of `atom()` changed from an `options` table to an equality function + - Removed the second argument of `computed()` (you can do your own equality checks now) - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`) 2. If you use Charm Sync, you'll have to rewrite a lot of your sync code. Fortunately, most of the changes should make your code _less_ complicated: From 7bb6edb4036a42f3b9f073b07aa7b6dedf8ad39e Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 4 Nov 2025 21:39:28 -0800 Subject: [PATCH 063/160] refactor: rename batch, misc fixes & perf updates --- README.md | 21 +-- packages/charm-sync/src/client.luau | 4 +- .../__tests__/{core => }/computed.spec.luau | 4 +- .../src/__tests__/{core => }/effect.spec.luau | 4 +- .../{core => }/effectScope.spec.luau | 4 +- .../src/__tests__/{core => }/gc.spec.luau | 4 +- .../charm/src/__tests__/observe.spec.luau | 52 +++++--- .../src/__tests__/{core => }/signal.spec.luau | 14 +- packages/charm/src/__tests__/spec.luau | 43 +++---- .../__tests__/{core => }/topology.spec.luau | 64 +++++----- packages/charm/src/alien.luau | 120 ++++-------------- packages/charm/src/core.luau | 49 +++---- packages/charm/src/index.d.ts | 4 +- packages/charm/src/init.luau | 13 +- packages/charm/src/observe.luau | 70 +++++----- packages/charm/src/utils.luau | 2 +- 16 files changed, 214 insertions(+), 258 deletions(-) rename packages/charm/src/__tests__/{core => }/computed.spec.luau (96%) rename packages/charm/src/__tests__/{core => }/effect.spec.luau (98%) rename packages/charm/src/__tests__/{core => }/effectScope.spec.luau (94%) rename packages/charm/src/__tests__/{core => }/gc.spec.luau (95%) rename packages/charm/src/__tests__/{core => }/signal.spec.luau (74%) rename packages/charm/src/__tests__/{core => }/topology.spec.luau (94%) diff --git a/README.md b/README.md index ece7086..01a90a8 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ - [`effect(callback)`](#effectcallback) - [`untracked(callback)`](#untrackedcallback) - [`peek(callback)`](#peekcallback) - - [`batched(callback)`](#batchedcallback) + - [`batch(callback)`](#batchcallback) - [`effectScope(callback)`](#effectscopecallback) - [`listen(getter, callback)`](#listengetter-callback) - [`subscribe(getter, callback)`](#subscribegetter-callback) @@ -334,7 +334,7 @@ disposeOuter() --> Cleaned up inner effect --- -### `batched(callback)` +### `batch(callback)` Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. @@ -348,7 +348,7 @@ end) -- Combines both writes into a single update. -- Once the callback completes, outputs "Full name: Foo Bar" -batched(function() +batch(function() setName("Foo") setSurname("Bar") end) @@ -542,13 +542,15 @@ max(-1) --> 1 ### `globals` -Global flags that customize the behavior of Charm. The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio. +Global flags that customize the behavior of Charm. -| Flag | Default | Description | -| ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| strict | `true` in Studio | Enforces synchronous, non-yielding behavior in signals, effects, and critical code. | -| frozen | `true` in Studio | Enforces immutability by deep-freezing tables passed to signals. | -| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. Should only be disabled to debug issues during migration. | +The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio. + +| Flag | Default | Description | +| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | +| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. Should only be disabled to debug issues during migration. | --- @@ -812,7 +814,6 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that **What to look out for:** 1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: - - `batch()` was renamed to `batched()` - The second arguments of `atom()` changed from an `options` table to an equality function - Removed the second argument of `computed()` (you can do your own equality checks now) - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index e067be5..8392792 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -65,9 +65,9 @@ end local function hydrate(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then - Charm.batched(applyInitPayload, payload.data) + Charm.batch(applyInitPayload, payload.data) elseif payload.type == "patch" then - Charm.batched(applyPatchPayload, payload.data) + Charm.batch(applyPatchPayload, payload.data) end end end diff --git a/packages/charm/src/__tests__/core/computed.spec.luau b/packages/charm/src/__tests__/computed.spec.luau similarity index 96% rename from packages/charm/src/__tests__/core/computed.spec.luau rename to packages/charm/src/__tests__/computed.spec.luau index 939d72a..522f403 100644 --- a/packages/charm/src/__tests__/core/computed.spec.luau +++ b/packages/charm/src/__tests__/computed.spec.luau @@ -1,5 +1,5 @@ -local core = require("../../core") -local suite = require("../../../../../test/suite") +local core = require("../core") +local suite = require("../../../../test/suite") local computed = core.computed local effect = core.effect diff --git a/packages/charm/src/__tests__/core/effect.spec.luau b/packages/charm/src/__tests__/effect.spec.luau similarity index 98% rename from packages/charm/src/__tests__/core/effect.spec.luau rename to packages/charm/src/__tests__/effect.spec.luau index b9db5ac..b1ba2bf 100644 --- a/packages/charm/src/__tests__/core/effect.spec.luau +++ b/packages/charm/src/__tests__/effect.spec.luau @@ -1,5 +1,5 @@ -local core = require("../../core") -local suite = require("../../../../../test/suite") +local core = require("../core") +local suite = require("../../../../test/suite") local computed = core.computed local effect = core.effect diff --git a/packages/charm/src/__tests__/core/effectScope.spec.luau b/packages/charm/src/__tests__/effectScope.spec.luau similarity index 94% rename from packages/charm/src/__tests__/core/effectScope.spec.luau rename to packages/charm/src/__tests__/effectScope.spec.luau index b2dcdc7..77798ff 100644 --- a/packages/charm/src/__tests__/core/effectScope.spec.luau +++ b/packages/charm/src/__tests__/effectScope.spec.luau @@ -1,5 +1,5 @@ -local core = require("../../core") -local suite = require("../../../../../test/suite") +local core = require("../core") +local suite = require("../../../../test/suite") local effect = core.effect local effectScope = core.effectScope diff --git a/packages/charm/src/__tests__/core/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau similarity index 95% rename from packages/charm/src/__tests__/core/gc.spec.luau rename to packages/charm/src/__tests__/gc.spec.luau index d5b9bfc..cf4b213 100644 --- a/packages/charm/src/__tests__/core/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -1,5 +1,5 @@ -local core = require("../../core") -local suite = require("../../../../../test/suite") +local core = require("../core") +local suite = require("../../../../test/suite") local atom = core.atom local computed = core.computed diff --git a/packages/charm/src/__tests__/observe.spec.luau b/packages/charm/src/__tests__/observe.spec.luau index 1119b4e..01add3d 100644 --- a/packages/charm/src/__tests__/observe.spec.luau +++ b/packages/charm/src/__tests__/observe.spec.luau @@ -4,6 +4,7 @@ local suite = require("../../../../test/suite") local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect +local effectScope = Charm.effectScope local observe = Charm.observe local test = suite.test @@ -42,48 +43,44 @@ test("should dispose scopes", function() local items, setItems = signal({ 1, 2, 3 }) local a, setA = signal(0) local runs = {} + local disposes = {} local dispose = observe(items, function(item) effect(function() table.insert(runs, item) a() end) + effectScope(function() + return function() + table.insert(disposes, item) + end + end) end) table.clear(runs) setItems({ 1, 2 }) setA(1) assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`) + assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`) table.clear(runs) + table.clear(disposes) dispose() setA(2) assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`) + assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`) end) -test("should queue addition", function() +test("should queue addition and removal", function() local items, setItems = signal({ 1 }) local edits = {} observe(items, function(item) - setItems({ 1, 2 }) - setItems({ 1, 2, 3, 4 }) - table.insert(edits, `+{item}`) - return function() - table.insert(edits, `-{item}`) + if item == 1 then + setItems({ 1, 2, 3, 4 }) + elseif item == 4 then + setItems({ 1 }) end - end) - - assert(table.concat(edits) == "+1+2+3+4", `bad edits: {table.concat(edits)}`) -end) - -test("should queue removal", function() - local items, setItems = signal({ 1, 2, 3, 4 }) - local edits = {} - - observe(items, function(item) - setItems({ 1, 2, 3 }) - setItems({ 1 }) table.insert(edits, `+{item}`) return function() table.insert(edits, `-{item}`) @@ -93,4 +90,23 @@ test("should queue removal", function() assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`) end) +test("should handle self removal", function() + local items, setItems = signal({ 1, 2, 3, 4 }) + local edits = {} + + observe(items, function(item, index) + setItems(function(state) + state = table.clone(state) + state[index] = nil + return state + end) + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) +end) + return {} diff --git a/packages/charm/src/__tests__/core/signal.spec.luau b/packages/charm/src/__tests__/signal.spec.luau similarity index 74% rename from packages/charm/src/__tests__/core/signal.spec.luau rename to packages/charm/src/__tests__/signal.spec.luau index e63b4f6..6197693 100644 --- a/packages/charm/src/__tests__/core/signal.spec.luau +++ b/packages/charm/src/__tests__/signal.spec.luau @@ -1,5 +1,5 @@ -local core = require("../../core") -local suite = require("../../../../../test/suite") +local core = require("../core") +local suite = require("../../../../test/suite") local signal = core.signal local effect = core.effect @@ -37,4 +37,14 @@ test("should enforce immutability", function() end), "should not allow mutation of new value") end) +test("should not freeze values with metatables", function() + local src = signal({ object = setmetatable({}, {}) }) + assert( + pcall(function() + src().object.a = 2 + end), + "should allow mutation of values with metatables" + ) +end) + return {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index 4560796..d603583 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -1,26 +1,17 @@ local suite = require("../../../../test/suite") -suite.describe("core", function() - suite.describe("computed", function() - require("./core/computed.spec") - end) - suite.describe("effect", function() - require("./core/effect.spec") - end) - suite.describe("effectScope", function() - require("./core/effectScope.spec") - end) - suite.describe("gc", function() - require("./core/gc.spec") - end) - suite.describe("signal", function() - require("./core/signal.spec") - end) - suite.describe("topology", function() - require("./core/topology.spec") - end) +suite.describe("computed", function() + require("./computed.spec") +end) +suite.describe("effect", function() + require("./effect.spec") +end) +suite.describe("effectScope", function() + require("./effectScope.spec") +end) +suite.describe("gc", function() + require("./gc.spec") end) - suite.describe("listen", function() require("./listen.spec") end) @@ -30,14 +21,20 @@ end) suite.describe("observe", function() require("./observe.spec") end) +suite.describe("peek", function() + require("./peek.spec") +end) +suite.describe("signal", function() + require("./signal.spec") +end) suite.describe("subscribe", function() require("./subscribe.spec") end) +suite.describe("topology", function() + require("./topology.spec") +end) suite.describe("untracked", function() require("./untracked.spec") end) -suite.describe("peek", function() - require("./peek.spec") -end) return {} diff --git a/packages/charm/src/__tests__/core/topology.spec.luau b/packages/charm/src/__tests__/topology.spec.luau similarity index 94% rename from packages/charm/src/__tests__/core/topology.spec.luau rename to packages/charm/src/__tests__/topology.spec.luau index f40837d..0cffebd 100644 --- a/packages/charm/src/__tests__/core/topology.spec.luau +++ b/packages/charm/src/__tests__/topology.spec.luau @@ -1,9 +1,9 @@ -local core = require("../../core") -local suite = require("../../../../../test/suite") +local core = require("../core") +local suite = require("../../../../test/suite") +local atom = core.atom local computed = core.computed local effect = core.effect -local signal = core.signal local describe = suite.describe local test = suite.test @@ -43,7 +43,7 @@ describe("graph updates", function() -- C -- | -- D - local a, setA = signal(2) + local a = atom(2) local b = computed(function() return a() - 1 @@ -64,7 +64,7 @@ describe("graph updates", function() assert(computeCalls == 1, "compute should be called once") computeCalls = 0 - setA(4) + a(4) d() assert(computeCalls == 1, "compute should drop update") end) @@ -77,7 +77,7 @@ describe("graph updates", function() -- B C -- \ / -- D - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() end) @@ -95,7 +95,7 @@ describe("graph updates", function() assert(d() == "a a", "d should be 'a a'") assert(spyCalls == 1, "compute should be called once") - setA("aa") + a("aa") assert(d() == "aa aa", "d should be 'aa aa'") assert(spyCalls == 2, "compute should be called only twice") end) @@ -109,7 +109,7 @@ describe("graph updates", function() -- D -- | -- E - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() end) @@ -131,7 +131,7 @@ describe("graph updates", function() assert(e() == "a a", "e should be 'a a'") assert(spyCalls == 1, "compute should be called once") - setA("aa") + a("aa") assert(e() == "aa aa", "e should be 'aa aa'") assert(spyCalls == 2, "compute should be called only twice") end) @@ -139,7 +139,7 @@ describe("graph updates", function() test("should bail out if result is the same", function() -- Bail out if value of "B" never changes -- A->B->C - local a, setA = signal("a") + local a = atom("a") local b = computed(function() a() return "foo" @@ -155,7 +155,7 @@ describe("graph updates", function() assert(c() == "foo", "c should be 'foo'") assert(spyCalls == 1, "compute should be called once") - setA("aa") + a("aa") assert(c() == "foo", "c should be 'foo'") assert(spyCalls == 1, "compute should bail out") end) @@ -171,7 +171,7 @@ describe("graph updates", function() -- E -- / \ -- F G - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() @@ -220,7 +220,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - setA("b") + a("b") assert(e() == "b b", "e should be 'b b'") assert(eSpyCalls == 1, "e should run once after update") @@ -235,7 +235,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - setA("c") + a("c") assert(e() == "c c", "e should be 'c c'") assert(eSpyCalls == 1, "e should run once after update") @@ -256,7 +256,7 @@ describe("graph updates", function() -- *A -- / \ -- *B C <- we don't listen to C - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() @@ -271,7 +271,7 @@ describe("graph updates", function() assert(b() == "a", "b should be 'a'") assert(spyCalls == 0, "compute should not run") - setA("aa") + a("aa") assert(b() == "aa", "b should be 'aa'") assert(spyCalls == 0, "compute should not run") end) @@ -285,7 +285,7 @@ describe("graph updates", function() -- *B D <- we don't listen to C -- | -- *C - local a, setA = signal("a") + local a = atom("a") local spyBCalls = 0 local spyB = function() spyBCalls += 1 @@ -316,7 +316,7 @@ describe("graph updates", function() spyCCalls = 0 unsub() - setA("aa") + a("aa") assert(spyBCalls == 0, "b should not run") assert(spyCCalls == 0, "c should not run") @@ -333,7 +333,7 @@ describe("graph updates", function() -- B *C <- returns same value every time -- \ / -- D - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() end) @@ -351,7 +351,7 @@ describe("graph updates", function() assert(d() == "a c", "d should be 'a c'") spyResult = nil - setA("aa") + a("aa") d() assert(spyResult == "aa c", "d should have returned 'aa c'") end) @@ -365,7 +365,7 @@ describe("graph updates", function() -- B *C *D -- \ | / -- E - local a, setA = signal("a") + local a = atom("a") local b = computed(function() return a() end) @@ -387,13 +387,13 @@ describe("graph updates", function() assert(e() == "a c d", "e should be 'a c d'") spyResult = nil - setA("aa") + a("aa") e() assert(spyResult == "aa c d", "e should have returned 'aa c d'") end) test("should support lazy branches", function() - local a, setA = signal(0) + local a = atom(0) local b = computed(function() return a() end) @@ -402,10 +402,10 @@ describe("graph updates", function() end) assert(c() == 0, "c should be 0") - setA(1) + a(1) assert(c() == 1, "c should be 1") - setA(0) + a(0) assert(c() == 0, "c should be 0 again") end) @@ -417,7 +417,7 @@ describe("graph updates", function() -- *B *C -- \ / -- D - local a, setA = signal("a") + local a = atom("a") local b = computed(function() a() return "b" @@ -436,14 +436,14 @@ describe("graph updates", function() assert(d() == "b c", "d should be 'b c'") spyCalls = 0 - setA("aa") + 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, setA = signal(0) + local a = atom(0) local b = computed(function() error("fail") end) @@ -457,12 +457,12 @@ describe("error handling", function() assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - setA(1) + a(1) assert(c() == 1, "c should recover and return 1") end) test("should keep graph consistent on errors in computeds", function() - local a, setA = signal(0) + local a = atom(0) local b = computed(function() if a() == 1 then error("fail") @@ -475,14 +475,14 @@ describe("error handling", function() assert(c() == 0, "c should be 0") - setA(1) + a(1) local success, result = pcall(function() b() end) assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - setA(2) + a(2) assert(c() == 2, "c should recover and return 2") end) end) diff --git a/packages/charm/src/alien.luau b/packages/charm/src/alien.luau index 5812be0..498b8b9 100644 --- a/packages/charm/src/alien.luau +++ b/packages/charm/src/alien.luau @@ -167,21 +167,17 @@ local function createReactiveSystem(props: { end local function propagate(link: Link): () - local next = link.nextSub - local stack: Stack? - repeat local sub = link.sub - local flags = sub.flags - if not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) then + if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then sub.flags = bit32.bor(flags, PENDING) - elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then + elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then flags = NONE - elseif not bit32.btest(flags, RECURSED_CHECK) then + elseif bit32.band(flags, RECURSED_CHECK) == 0 then sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) - elseif not bit32.btest(flags, bit32.bor(DIRTY, PENDING)) and isValidLink(link, sub) then + 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 @@ -196,41 +192,12 @@ local function createReactiveSystem(props: { if bit32.btest(flags, MUTABLE) then local subSubs = sub.subs if subSubs then - link = subSubs - local nextSub = link.nextSub - if nextSub then - stack = { value = next, prev = stack } - next = nextSub - end - continue + propagate(subSubs) 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 + link = link.nextSub :: Link + until not link end local function shallowPropagate(link: Link): () @@ -248,81 +215,38 @@ local function createReactiveSystem(props: { end local function checkDirty(link: Link, sub: ReactiveNode): boolean - local stack: Stack? - local checkDepth = 0 - local dirty = false - repeat local dep = link.dep - local flags = dep.flags + local depFlags = dep.flags if bit32.btest(sub.flags, DIRTY) then - dirty = true - elseif bit32.band(flags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then + return true + elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then if update(dep) then local subs = dep.subs :: Link if subs.nextSub then shallowPropagate(subs) end - dirty = true + return true end - elseif bit32.band(flags, 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 then - local nextDep = link.nextDep - if nextDep then - link = nextDep - continue - end - 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) + elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then + if checkDirty(dep.deps :: Link, dep) then + if update(dep) then + local subs = dep.subs :: Link + if subs.nextSub then + shallowPropagate(subs) end - sub = link.sub - continue + return true end - dirty = false else - sub.flags = bit32.band(sub.flags, bit32.bnot(PENDING)) - end - sub = link.sub - local nextDep = link.nextDep - if nextDep then - link = nextDep - continueTop = true - break + dep.flags = bit32.band(depFlags, bit32.bnot(PENDING)) end end - if continueTop then - continue - end + link = link.nextDep :: Link + until not link - return dirty - until false + return false end return { diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 3e6ca7a..48d545d 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -235,7 +235,7 @@ local function signalOper( local value: T if type(update) == "function" then local prevSub = setActiveSub(nil) - local success, result: T = (if globals.strict then noYield else pcall)(update, this.currentValue) + local success, result: T = (if globals.strict then noYield else pcall)(update, this.pendingValue) activeSub = prevSub if not success then error(result) @@ -278,9 +278,7 @@ local function signalOper( break end local subs = sub.subs - if subs then - sub = subs.sub - end + sub = if subs then subs.sub else nil end return this.currentValue end @@ -434,17 +432,21 @@ end --- For an alternative that still cleans up inner effects, use `peek` instead. --- --- @param callback The callback function. ---- @param args Arguments to pass to the callback. +--- @param ...args Arguments to pass to the callback. --- @return The return value of the callback. --- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - local prevSub = setActiveSub(nil) - local results: { any } = { pcall(callback, ...) } - activeSub = prevSub - if not results[1] then - error(results[2]) + if activeSub then + local prevSub = setActiveSub(nil) + local results: { any } = { pcall(callback, ...) } + activeSub = prevSub + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + else + return callback(...) end - return unpack(results, 2) end --- Runs the callback without subscribing to signal updates, but still captures @@ -453,7 +455,7 @@ end --- To avoid capturing inner effects, use `untracked` instead. --- --- @param callback The callback function. ---- @param args Arguments to pass to the callback. +--- @param ...args Arguments to pass to the callback. --- @return The return value of the callback. --- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback local function peek(callback: (Args...) -> Results..., ...: Args...): Results... @@ -478,10 +480,10 @@ end --- computed signals after the provided callback finishes running. --- --- @param callback A function that performs multiple updates. ---- @param args Arguments to pass to the callback. +--- @param ...args Arguments to pass to the callback. --- @return The return value of the callback. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback -local function batched(callback: (Args...) -> Results..., ...: Args...): Results... +--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback +local function batch(callback: (Args...) -> Results..., ...: Args...): Results... startBatch() local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) } endBatch() @@ -497,7 +499,7 @@ end --- --- @param getter A function that returns the value to track. --- @return A computed signal that tracks the value returned by the getter. -local function toComputed(getter: () -> T): () -> T +local function toSignal(getter: () -> T): () -> T local name = debug.info(getter, "n") if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then return getter @@ -519,7 +521,7 @@ end --- @return A function for disposing the effect. --- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback local function listen(getter: () -> T, callback: (state: T, previous: T?) -> ()): () -> () - local tracked = toComputed(getter) + local tracked = toSignal(getter) local current: T return effect(function() local previous = current @@ -540,15 +542,12 @@ end --- @return A function for disposing the effect. --- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () - local tracked = toComputed(getter) - local current: T - local mounted = true + local tracked = toSignal(getter) + local current = tracked() return effect(function() local previous = current current = tracked() - if mounted then - mounted = false - else + if current ~= previous then peek(callback, current, previous) end end) @@ -578,12 +577,14 @@ local reactiveSystem = createReactiveSystem({ queuedLength = insertIndex + -- while (firstInsertedIndex < --insertIndex) insertIndex -= 1 while firstInsertedIndex < insertIndex do local left = queued[firstInsertedIndex] queued[firstInsertedIndex] = queued[insertIndex] queued[insertIndex] = left firstInsertedIndex += 1 + -- Decrement before next iteration to simulate --insertIndex insertIndex -= 1 end end, @@ -620,7 +621,7 @@ return { onCleanup = onCleanup, untracked = untracked, peek = peek, - batched = batched, + batch = batch, subscribe = subscribe, listen = listen, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 926a5ea..de9c2c4 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -149,9 +149,9 @@ export function peek(callback: (...args: Args) => Re * @param callback A function that performs multiple updates. * @param args Arguments to pass to the callback. * @returns The return value of the callback. - * @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback + * @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback */ -export function batched(callback: (...args: Args) => Result, ...args: Args): Result; +export function batch(callback: (...args: Args) => Result, ...args: Args): Result; /** * Creates an effect that runs the callback one immediately, and then again diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 62a0daf..88f60ef 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -11,11 +11,22 @@ type Update = ((previous: T) -> T) | T --- its current value. export type Atom = (...Update) -> T +--- A getter-only reactive signal. Calling the signal with no arguments returns +--- its current value. It cannot be set directly. +export type Getter = () -> T + +--- A setter-only reactive signal. Calling the signal with an argument sets its +--- value. It cannot be read directly. +export type Setter = (...Update) -> () + +export type ReactiveNode = core.ReactiveNode +export type Link = core.Link + return { core = core, globals = globals, atom = core.atom, - batched = core.batched, + batch = core.batch, computed = core.computed, effect = core.effect, effectScope = core.effectScope, diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index bc8230e..244b08b 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -1,70 +1,66 @@ local core = require("./core") -local globals = require("./globals") -local batched = core.batched +type ReactiveNode = core.ReactiveNode + +local batch = core.batch local effect = core.effect local effectScope = core.effectScope -local onCleanup = core.onCleanup --- Calls the callback function for each unique key in the table returned by the --- getter. The callback is called once for each key, and again when a new key --- is added. --- --- The callback may return a cleanup function, which will be called when the ---- key is removed or when the entire callback is disposed. ---- ---- Effects created within the callback are cleaned up when the item is removed ---- from the table or when the callback is disposed. +--- key is removed or the `observe()` effect is disposed. Effects created +--- during the callback will also be bound to the lifetime of the key. --- --- @param getter A function that returns the table to observe. --- @param callback A function that is called for each key in the table. --- @return A function for disposing the observer. --- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback local function observe(getter: () -> { [K]: V }, callback: (item: V, key: K) -> ...(() -> ())?): () -> () - local scopes: { [K]: () -> () } = {} - local currentItems: { [K]: V } = {} + local innerScopes: { [K]: () -> () } = {} - local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V }) - for key in previousItems do - local dispose = scopes[key] - if currentItems[key] == nil and dispose then + local function updateScopes(data: { [K]: V }) + for key, dispose in innerScopes do + if data[key] == nil then + innerScopes[key] = nil dispose() - scopes[key] = nil end end - for key, value in currentItems do - if previousItems[key] == nil and scopes[key] == nil then - scopes[key] = effectScope(function() + for key, value in data do + if innerScopes[key] == nil then + -- Detach the inner scope from the parent effect to prevent + -- disposing when the effect re-runs. + innerScopes[key] = effectScope(function() return callback(value, key) end, true) end end end - local function disposeScopes() - for _, dispose in scopes do - dispose() - end - table.clear(scopes) - end + return effectScope(function() + effect(function() + -- Batch so that side effects run after all scopes are created. This + -- ensures that a scope creating or removing items doesn't interfere + -- with the current update cycle. + batch(updateScopes, getter()) - local disposeEffect = effect(function() - local previousItems = currentItems - currentItems = getter() - if currentItems ~= previousItems then - batched(updateScopes, currentItems, previousItems) + -- Effects can't trigger themselves more than once, so if a scope + -- creates or removes items, we need to check for updates again. The + -- way we do this abuses a weird property of signal accesses that + -- causes the effect to re-run. + getter() + end) + + return function() + for _, dispose in innerScopes do + dispose() + end + table.clear(innerScopes) end end) - - if globals.trackInnerEffects then - onCleanup(disposeScopes, true) - end - - return function() - disposeEffect() - disposeScopes() - end end return observe diff --git a/packages/charm/src/utils.luau b/packages/charm/src/utils.luau index d5b19cf..367e83c 100644 --- a/packages/charm/src/utils.luau +++ b/packages/charm/src/utils.luau @@ -14,7 +14,7 @@ local function noYield(callback: (Args...) -> Results..., . end local function deepFreeze(object: any) - if type(object) == "table" and not table.isfrozen(object) then + if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then table.freeze(object) for _, value in object do deepFreeze(value) From dffeb359336e9e6284c8ef0c0081706838f4afa4 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:30:10 -0800 Subject: [PATCH 064/160] refactor: simplify charm sync validation --- README.md | 12 ++-- .../charm-sync/src/__tests__/patch.spec.luau | 16 ++--- .../src/__tests__/validate.spec.luau | 20 ++----- packages/charm-sync/src/config.luau | 4 +- packages/charm-sync/src/index.d.ts | 4 +- packages/charm-sync/src/patch.luau | 6 +- packages/charm-sync/src/validate.luau | 60 +++++++------------ 7 files changed, 48 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 01a90a8..a580e50 100644 --- a/README.md +++ b/README.md @@ -641,12 +641,12 @@ end) A configuration table that customizes the behavior of Charm Sync on the server. -| Option | Default | Description | -| --------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. | -| preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. | -| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | -| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, patches will be validated to enforce remote event limitations. | +| Option | Default | Description | +| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. | +| preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. | +| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | +| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will throw an error. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | --- diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index fbf5607..a62b456 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -81,16 +81,18 @@ test("preserves array density after serdes", function() local patches = mockArraySerdes(patch.diff(prevState, nextState)) local applied = patch.apply(prevState, patches) - for index in next, prevState do + for index in prevState do assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) end - for index in next, applied do + for index in applied do assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) end end) test("cannot preserve sparse arrays", function() + config.validatePatches = false + local prevState = {} local nextState = { [2] = "foo" } @@ -106,13 +108,13 @@ end) test("optionally fixes sparse arrays", function() config.fixArrays = true - for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do + for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "string", `expected {type(index)} {index} to be string`) end config.fixArrays = false - for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do + for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "number", `expected {type(index)} {index} to be number`) end end) @@ -120,14 +122,14 @@ end) test("optionally validates state", function() local success = pcall(function() config.validatePatches = true - patch.diff({}, { [0.1] = true }) + patch.diff({}, { [1] = 1, a = true }) end) - assert(not success, "should not allow non-integer keys") + assert(not success, "should not allow mixed tables") success = pcall(function() config.validatePatches = false - patch.diff({}, { [0.1] = true }) + patch.diff({}, { [1] = 1, a = true }) end) assert(success, "should skip validation if not requested") diff --git a/packages/charm-sync/src/__tests__/validate.spec.luau b/packages/charm-sync/src/__tests__/validate.spec.luau index 5d8b99a..1f107fd 100644 --- a/packages/charm-sync/src/__tests__/validate.spec.luau +++ b/packages/charm-sync/src/__tests__/validate.spec.luau @@ -1,28 +1,18 @@ -local Charm = require("../../Charm") local suite = require("../../../../test/suite") local validate = require("../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 }, + arraySparse = { value = { 1, nil :: any, 3 }, expected = true }, + arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false }, + mixedTable = { value = { [1] = "foo", a = true }, expected = false }, } - for key, case in next, cases do - local success, result = pcall(function() - validate(case.value, key) - end) - + for key, case in cases do + local success, result: any = pcall(validate, case.value) if case.expected and not success then error(`Expected {key} to be valid, threw {result}`) elseif not case.expected and success then diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau index 6a71e12..9b528fc 100644 --- a/packages/charm-sync/src/config.luau +++ b/packages/charm-sync/src/config.luau @@ -13,8 +13,8 @@ return { --- This option should be disabled if your network library uses a custom --- serialization method (i.e. Zap, ByteNet) to prevent interference. fixArrays = true, - --- When `true`, patches will be validated before being sent to clients to - --- enforce remote event limitations. Only checked in strict mode and if + --- When `true`, synced state containing unsafe sparse arrays or mixed + --- tables will throw an error. Only checked in strict mode and if --- `fixArrays` is enabled. Defaults to `true`. validatePatches = true, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 70a0f22..3fed7a8 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -90,8 +90,8 @@ export const config: { */ fixArrays: boolean; /** - * When `true`, patches will be validated before being sent to clients to - * enforce remote event limitations. Only checked in strict mode and if + * When `true`, synced state containing unsafe sparse arrays or mixed + * tables will throw an error. Only checked in strict mode and if * `fixArrays` is enabled. Defaults to `true`. * * @server diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 88f414b..39723d5 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -74,15 +74,13 @@ local function diff(currentState: any, nextState: any): any end if config.fixArrays then - -- Coerce sparse array patches into dictionaries + -- Convert sparse array patches that could be misserialized if currentState[1] ~= nil or nextState[1] ~= nil then patches = stringifySparseArray(patches) end if globals.strict and config.validatePatches then - for key, value in patches do - validate(value, key) - end + validate(patches) end end diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index 64d7caf..f350bb7 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,66 +1,50 @@ -local SAFE_KEYS = { string = true, number = true } -local UNSAFE_VALUES = { ["function"] = true, thread = true } - local function isTableUnsafe(object: { [any]: any }) local key = next(object) if key == nil then - -- An empty table is always safe return false end local keyType = type(key) - local keyCount = 0 + local maxKey = 0 - -- Keys must either be all strings or all numbers + -- Keys must all be the same type for key in object do if type(key) ~= keyType then return true + elseif keyType == "number" and key > maxKey then + maxKey = key end - keyCount += 1 end -- Array keys must be sequential - if keyType == "number" and keyCount > #object then + if keyType == "number" and maxKey > #object then return true end return false end ---- Validates a value to ensure it can be synced over a remote event. +--- Throws an error if the value is a table that cannot be synced via +--- RemoteEvents or RemoteFunctions. This only aims to catch common mistakes +--- with remote argument limitations, such as sparse arrays or mixed tables. --- ---- @param value The value to validate. ---- @param key The key of the value in the table. +--- @param value The table value. +--- @param key The key of the value. --- @error Throws an error if the value cannot be synced. -local function validate(value: any, key: any) - local typeKey = type(key) - local typeValue = type(value) - - if not SAFE_KEYS[typeKey] then - error(`Invalid key type '{typeKey}' at key '{key}'`) - elseif UNSAFE_VALUES[typeValue] then - error(`Invalid value type '{typeValue}' at key '{key}'`) - elseif typeValue == "table" then - if getmetatable(value) ~= nil then - error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) - elseif isTableUnsafe(value) then - error( - `Cannot sync tables unsupported by remote events! The table was found at the key '{key}'.\n\n` - .. "This can be for the following reasons:\n" - .. "1. The object is an array with non-sequential keys\n" - .. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n" - .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations" - ) - end - end - - if typeKey == "number" then - if key == math.huge or key == -math.huge then - error("Cannot sync infinity as key") - elseif key ~= math.floor(key) then - error("Cannot sync non-integer number as key") +local function validate(value: { [any]: any }) + if isTableUnsafe(value) then + -- Silence warnings during testing + if game then + warn("Cannot sync table:", value) end + error( + `Cannot sync tables unsupported by remote events! See the warning above for more info.\n\n` + .. "This can be for the following reasons:\n" + .. "1. The table is a sparse array with non-sequential indices\n" + .. "2. The table is a mixed table (not all keys have the same type)\n\n" + .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations" + ) end end From 96ab2317f2bc554f8bb1b0605ed5ef046a4a68c9 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 5 Nov 2025 00:16:18 -0800 Subject: [PATCH 065/160] feat: update alien-signals, add recursion flag --- README.md | 23 +++++++++++-------- packages/charm/src/__tests__/effect.spec.luau | 21 +++++++++++++++++ .../charm/src/__tests__/observe.spec.luau | 23 +++++++++++++++++++ packages/charm/src/alien.luau | 10 +++++++- packages/charm/src/core.luau | 22 ++++++++++-------- packages/charm/src/globals.luau | 4 ++++ packages/charm/src/index.d.ts | 6 +++++ packages/charm/src/observe.luau | 21 +++++++++-------- 8 files changed, 100 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a580e50..7747b2d 100644 --- a/README.md +++ b/README.md @@ -542,16 +542,17 @@ max(-1) --> 1 ### `globals` -Global flags that customize the behavior of Charm. +You can customize the behavior of Charm through the `globals` table: + +| Flag | Default | Description | +| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | +| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | +| allowRecursion | `false` | Allows recursion in effects and computed signals, which is intentionally disallowed in the [alien-signals](https://github.com/stackblitz/alien-signals) reactive system. | The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio. -| Flag | Default | Description | -| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | -| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. | -| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. Should only be disabled to debug issues during migration. | - --- ## Client-Server Sync @@ -830,14 +831,17 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that 4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent. - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`. - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). - - This feature can cause runtime bugs that are hard to track! If you need to narrow down a cause, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`. + - This feature can introduce runtime bugs in legacy code. If you need to narrow down a cause, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`. > [!NOTE] > An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. > > This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. -5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible with many thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! +5. Recursion is disallowed in effects and computed signals now that Charm uses the [alien-signals algorithm](https://github.com/stackblitz/alien-signals). This change may introduce bugs in code relying on the old behavior. + - You can disable recursion checks by setting [`globals.allowRecursion`](#globals) to `true`, but try to consider other options first, as recursion isn't fully supported and is not well optimized. + +6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! - Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit - Added [`listen()`](#listengetter-callback) for running a subscription once immediately - Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once @@ -845,7 +849,6 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that - The [`computed()`](#computedgetter) callback now gets called with the previous computed value - Optimized `computed()` to use lazy evaluation instead of eager updates - Optimized `mapped()` to only map values that changed in the original table - - Effect execution and ordering is more reliable --- diff --git a/packages/charm/src/__tests__/effect.spec.luau b/packages/charm/src/__tests__/effect.spec.luau index b1ba2bf..071d2a0 100644 --- a/packages/charm/src/__tests__/effect.spec.luau +++ b/packages/charm/src/__tests__/effect.spec.luau @@ -269,6 +269,27 @@ test("should handle flags are indirectly updated during checkDirty", function() assert(triggers == 2, "did not update indirectly") end) +test("should handle effect recursion for the first execution", function() + local src1, setSrc1 = signal(0) + local src2, setSrc2 = signal(0) + + local triggers1 = 0 + local triggers2 = 0 + + effect(function() + triggers1 += 1 + setSrc1(math.min(src1() + 1, 5)) + end) + effect(function() + triggers2 += 1 + setSrc2(math.min(src2() + 1, 5)) + src2() + end) + + assert(triggers1 == 1, `bad triggers1: {triggers1}`) + assert(triggers2 == 1, `bad triggers2: {triggers2}`) +end) + test("should run cleanups in sequence", function() local order = {} diff --git a/packages/charm/src/__tests__/observe.spec.luau b/packages/charm/src/__tests__/observe.spec.luau index 01add3d..3f9a835 100644 --- a/packages/charm/src/__tests__/observe.spec.luau +++ b/packages/charm/src/__tests__/observe.spec.luau @@ -90,6 +90,29 @@ test("should queue addition and removal", function() assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`) end) +test("should handle recursive additions and removals", function() + local items, setItems = signal({ 1 }) + local edits = {} + + observe(items, function(item) + if item < 5 then + setItems(function(state) + state = table.clone(state) + table.insert(state, item + 1) + return state + end) + else + setItems({}) + end + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`) +end) + test("should handle self removal", function() local items, setItems = signal({ 1, 2, 3, 4 }) local edits = {} diff --git a/packages/charm/src/alien.luau b/packages/charm/src/alien.luau index 498b8b9..5a610e2 100644 --- a/packages/charm/src/alien.luau +++ b/packages/charm/src/alien.luau @@ -1,3 +1,4 @@ +local globals = require("./globals") --[[ Reactive system adapted from alien-signals at https://github.com/stackblitz/alien-signals/blob/master/src/system.ts @@ -167,10 +168,17 @@ local function createReactiveSystem(props: { end local function propagate(link: Link): () + local allowRecursion = globals.allowRecursion + repeat local sub = link.sub local flags = sub.flags + if allowRecursion then + flags = bit32.band(flags, bit32.bnot(bit32.bor(RECURSED, RECURSED_CHECK))) + sub.flags = flags + end + 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 @@ -185,7 +193,7 @@ local function createReactiveSystem(props: { flags = NONE end - if bit32.btest(flags, WATCHING) then + if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then notify(sub) end diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 48d545d..cdcb6ac 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -51,7 +51,7 @@ type EffectScope = ReactiveNode & { } type Effect = ReactiveNode & { - fn: () -> (() -> ())?, + fn: () -> ...(() -> ())?, cleanups: { () -> () }, } @@ -116,7 +116,7 @@ local function run(e: Effect): () e.depsTail = nil e.flags = bit32.bor(WATCHING, RECURSED_CHECK) local prevSub = setActiveSub(e) - local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn) + local success, result = (if globals.strict then noYield else pcall)(e.fn) activeSub = prevSub e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) purgeDeps(e) @@ -162,7 +162,7 @@ local function updateComputed(c: Computed): boolean c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) local prevSub = setActiveSub(c) local oldValue = c.value - local success, result: unknown = (if globals.strict then noYield else pcall)(c.getter, oldValue) + local success, result = (if globals.strict then noYield else pcall)(c.getter, oldValue) activeSub = prevSub c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) purgeDeps(c) @@ -209,10 +209,11 @@ local function computedOper(this: Computed): () -> T end end elseif flags == NONE then - this.flags = MUTABLE + this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) local prevSub = setActiveSub(this) local success, result = pcall(this.getter) activeSub = prevSub + this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) if success then this.value = result else @@ -235,7 +236,7 @@ local function signalOper( local value: T if type(update) == "function" then local prevSub = setActiveSub(nil) - local success, result: T = (if globals.strict then noYield else pcall)(update, this.pendingValue) + local success, result = (if globals.strict then noYield else pcall)(update, this.pendingValue) activeSub = prevSub if not success then error(result) @@ -365,18 +366,19 @@ end --- @param fn The function to run when dependencies change. --- @return A function for disposing the effect. --- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback -local function effect(fn: () -> ()): () -> () +local function effect(fn: () -> ...(() -> ())?): () -> () local e: Effect = { fn = fn, - flags = WATCHING, + flags = bit32.bor(WATCHING, RECURSED_CHECK), cleanups = {}, } local prevSub = setActiveSub(e) if prevSub and (globals.trackInnerEffects or prevSub.flags == NONE) then link(e, prevSub, 0) end - local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn) + local success, result = (if globals.strict then noYield else pcall)(fn) activeSub = prevSub + e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) if not success then error(result) elseif result then @@ -392,7 +394,7 @@ end --- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. --- @return A function for disposing all effects created within the scope. --- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached -local function effectScope(fn: () -> (), detached: boolean?): () -> () +local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () local e: EffectScope = { flags = NONE, cleanups = {}, @@ -401,7 +403,7 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> () if prevSub and not detached then link(e, prevSub, 0) end - local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn) + local success, result = (if globals.strict then noYield else pcall)(fn) activeSub = prevSub if not success then error(result) diff --git a/packages/charm/src/globals.luau b/packages/charm/src/globals.luau index af6a381..26273d9 100644 --- a/packages/charm/src/globals.luau +++ b/packages/charm/src/globals.luau @@ -17,4 +17,8 @@ return { --- when the parent effect re-runs. This is usually desirable, but can be --- disabled to revert to the old behavior. Defaults to `true`. trackInnerEffects = true, + --- Whether to allow recursive updates in effects and computed signals. + --- Disabling this can help catch certain classes of bugs, but may also + --- cause valid code to error. Defaults to `false`. + allowRecursion = false, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index de9c2c4..8400040 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -36,6 +36,12 @@ export const globals: { * disabled to revert to the old behavior. Defaults to `true`. */ trackInnerEffects: boolean; + /** + * Whether to allow recursive updates in effects and computed signals. + * Disabling this can help catch certain classes of bugs, but may also + * cause valid code to error. Defaults to `false`. + */ + allowRecursion: boolean; }; /** diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index 244b08b..d169ac3 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -42,16 +42,19 @@ local function observe(getter: () -> { [K]: V }, callback: (item: V, key: return effectScope(function() effect(function() - -- Batch so that side effects run after all scopes are created. This - -- ensures that a scope creating or removing items doesn't interfere - -- with the current update cycle. - batch(updateScopes, getter()) + local data = getter() - -- Effects can't trigger themselves more than once, so if a scope - -- creates or removes items, we need to check for updates again. The - -- way we do this abuses a weird property of signal accesses that - -- causes the effect to re-run. - getter() + -- Batch so that side effects run after all scopes are created. + -- This ensures that a scope creating or removing items doesn't + -- interfere with the current update cycle. + batch(updateScopes, data) + + -- Effects can't trigger themselves, so if a scope creates or + -- removes items, we need to check for updates again. + local newData = getter() + if newData ~= data then + batch(updateScopes, newData) + end end) return function() From d890f4fc2cb5bf4eacf65ebfea268ebb0ec016e7 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 5 Nov 2025 02:30:41 -0800 Subject: [PATCH 066/160] feat: add `recursive()` --- README.md | 33 +++++++--- packages/charm/src/__tests__/effect.spec.luau | 13 ++++ packages/charm/src/alien.luau | 8 --- packages/charm/src/core.luau | 19 ++++++ packages/charm/src/globals.luau | 4 -- packages/charm/src/index.d.ts | 63 +++++++++++++++++-- packages/charm/src/init.luau | 1 + packages/charm/src/observe.luau | 17 ++--- 8 files changed, 120 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 7747b2d..b210cbd 100644 --- a/README.md +++ b/README.md @@ -540,16 +540,33 @@ max(-1) --> 1 --- +### `recursive()` + +By design, Charm's reactive system [does not allow recursion](https://github.com/stackblitz/alien-signals/issues/90#issuecomment-3489711800) in effects or computed signals. In case you need to opt out of recursion checks for an effect, you can call `recursive()` at the top of the effect callback: + +```luau +local getCounter, setCounter = signal(0) + +effect(function() + recursive() + print(getCounter()) + setCounter(function(count) + return math.min(count + 1, 3) + end) +end) --> 0, 1, 2, 3 +``` + +--- + ### `globals` You can customize the behavior of Charm through the `globals` table: -| Flag | Default | Description | -| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | -| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. | -| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | -| allowRecursion | `false` | Allows recursion in effects and computed signals, which is intentionally disallowed in the [alien-signals](https://github.com/stackblitz/alien-signals) reactive system. | +| Flag | Default | Description | +| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | +| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio. @@ -831,7 +848,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that 4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent. - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`. - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). - - This feature can introduce runtime bugs in legacy code. If you need to narrow down a cause, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`. + - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`. > [!NOTE] > An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. @@ -839,7 +856,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that > This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. 5. Recursion is disallowed in effects and computed signals now that Charm uses the [alien-signals algorithm](https://github.com/stackblitz/alien-signals). This change may introduce bugs in code relying on the old behavior. - - You can disable recursion checks by setting [`globals.allowRecursion`](#globals) to `true`, but try to consider other options first, as recursion isn't fully supported and is not well optimized. + - Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback. 6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! - Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit diff --git a/packages/charm/src/__tests__/effect.spec.luau b/packages/charm/src/__tests__/effect.spec.luau index 071d2a0..96b0256 100644 --- a/packages/charm/src/__tests__/effect.spec.luau +++ b/packages/charm/src/__tests__/effect.spec.luau @@ -6,6 +6,7 @@ local effect = core.effect local effectScope = core.effectScope local endBatch = core.endBatch local onCleanup = core.onCleanup +local recursive = core.recursive local setActiveSub = core.setActiveSub local signal = core.signal local startBatch = core.startBatch @@ -290,6 +291,18 @@ test("should handle effect recursion for the first execution", function() assert(triggers2 == 1, `bad triggers2: {triggers2}`) end) +test("should opt in to recursion", function() + local src, setSrc = signal(0) + local order = {} + + effect(function() + recursive() + table.insert(order, src()) + setSrc(math.min(src() + 1, 5)) + end) + assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`) +end) + test("should run cleanups in sequence", function() local order = {} diff --git a/packages/charm/src/alien.luau b/packages/charm/src/alien.luau index 5a610e2..495a5c3 100644 --- a/packages/charm/src/alien.luau +++ b/packages/charm/src/alien.luau @@ -1,4 +1,3 @@ -local globals = require("./globals") --[[ Reactive system adapted from alien-signals at https://github.com/stackblitz/alien-signals/blob/master/src/system.ts @@ -168,17 +167,10 @@ local function createReactiveSystem(props: { end local function propagate(link: Link): () - local allowRecursion = globals.allowRecursion - repeat local sub = link.sub local flags = sub.flags - if allowRecursion then - flags = bit32.band(flags, bit32.bnot(bit32.bor(RECURSED, RECURSED_CHECK))) - sub.flags = flags - end - 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 diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index cdcb6ac..9cf1467 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -555,6 +555,24 @@ local function subscribe(getter: () -> T, callback: (state: T, lastState: T) end) end +--- Disables recursive checks for the current effect or computed signal. Useful +--- for effects that intentionally update signals they depend on. +--- +--- @example +--- ```luau +--- effect(function() +--- recursive() +--- setCount(getCount() + 1) +--- end) +--- ``` +local function recursive() + if activeSub then + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK)) + else + warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2)) + end +end + local reactiveSystem = createReactiveSystem({ update = function(node) if node.depsTail then @@ -626,4 +644,5 @@ return { batch = batch, subscribe = subscribe, listen = listen, + recursive = recursive, } diff --git a/packages/charm/src/globals.luau b/packages/charm/src/globals.luau index 26273d9..af6a381 100644 --- a/packages/charm/src/globals.luau +++ b/packages/charm/src/globals.luau @@ -17,8 +17,4 @@ return { --- when the parent effect re-runs. This is usually desirable, but can be --- disabled to revert to the old behavior. Defaults to `true`. trackInnerEffects = true, - --- Whether to allow recursive updates in effects and computed signals. - --- Disabling this can help catch certain classes of bugs, but may also - --- cause valid code to error. Defaults to `false`. - allowRecursion = false, } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 8400040..82ca1d8 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -36,12 +36,6 @@ export const globals: { * disabled to revert to the old behavior. Defaults to `true`. */ trackInnerEffects: boolean; - /** - * Whether to allow recursive updates in effects and computed signals. - * Disabling this can help catch certain classes of bugs, but may also - * cause valid code to error. Defaults to `false`. - */ - allowRecursion: boolean; }; /** @@ -238,3 +232,60 @@ export function observe( getter: () => { readonly [K in Key]: V }, callback: (value: V, key: K) => Cleanup | void, ): Cleanup; + +export namespace core { + export { signal, effect, effectScope, atom, computed, onCleanup, untracked, peek, batch, listen }; + + export enum ReactiveFlags { + None = 0b0000000, + Mutable = 0b0000001, + Watching = 0b0000010, + RecursedCheck = 0b0000100, + Recursed = 0b0001000, + Dirty = 0b0010000, + Pending = 0b0100000, + Peeking = 0b1000000, + } + + export interface ReactiveNode { + deps?: Link; + depsTail?: Link; + subs?: Link; + subsTail?: Link; + flags: ReactiveFlags; + } + + export interface Link { + version: number; + dep: ReactiveNode; + sub: ReactiveNode; + prevSub?: Link; + nextSub?: Link; + prevDep?: Link; + nextDep?: Link; + } + + /** + * Disables recursive checks for the current effect or computed signal. + * Useful for effects that intentionally update signals they depend on. + * + * @example + * ```ts + * effect(() => { + * recursive(); + * setCount(getCount() + 1); + * }) + * ``` + */ + export function recursive(): void; + + export function getActiveSub(): ReactiveNode | undefined; + + export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; + + export function getBatchDepth(): number; + + export function startBatch(): void; + + export function endBatch(): void; +} diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 88f60ef..4aee63e 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -33,6 +33,7 @@ return { listen = core.listen, onCleanup = core.onCleanup, peek = core.peek, + recursive = core.recursive, signal = core.signal, subscribe = core.subscribe, untracked = core.untracked, diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index d169ac3..8511f43 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -1,10 +1,9 @@ local core = require("./core") -type ReactiveNode = core.ReactiveNode - local batch = core.batch local effect = core.effect local effectScope = core.effectScope +local recursive = core.recursive --- Calls the callback function for each unique key in the table returned by the --- getter. The callback is called once for each key, and again when a new key @@ -42,19 +41,13 @@ local function observe(getter: () -> { [K]: V }, callback: (item: V, key: return effectScope(function() effect(function() - local data = getter() - + -- Allow recursive updates in case the callback creates or removes + -- items in the observed table. + recursive() -- Batch so that side effects run after all scopes are created. -- This ensures that a scope creating or removing items doesn't -- interfere with the current update cycle. - batch(updateScopes, data) - - -- Effects can't trigger themselves, so if a scope creates or - -- removes items, we need to check for updates again. - local newData = getter() - if newData ~= data then - batch(updateScopes, newData) - end + batch(updateScopes, getter()) end) return function() From fdc598f6e6d799f1649c004f25c488d1cf7b9ffa Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:35:25 -0800 Subject: [PATCH 067/160] fix: allow dispose in observers --- .../charm/src/__tests__/observe.spec.luau | 36 +++++++++++++++++++ packages/charm/src/observe.luau | 21 ++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/charm/src/__tests__/observe.spec.luau b/packages/charm/src/__tests__/observe.spec.luau index 3f9a835..f308477 100644 --- a/packages/charm/src/__tests__/observe.spec.luau +++ b/packages/charm/src/__tests__/observe.spec.luau @@ -132,4 +132,40 @@ test("should handle self removal", function() assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) end) +test("should allow disposal during addition", function() + local items, setItems = signal({}) + local edits = {} + local dispose + + dispose = observe(items, function(item) + if item == 1 then + dispose() + end + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + setItems({ 1, 2, 3, 4 }) + assert(table.concat(edits) == "+1-1", `bad edits: {table.concat(edits)}`) +end) + +test("should allow disposal during removal", function() + local items, setItems = signal({ 1, 2, 3, 4 }) + local edits = {} + local dispose + + dispose = observe(items, function(item) + table.insert(edits, `+{item}`) + return function() + dispose() + table.insert(edits, `-{item}`) + end + end) + + setItems({ 1, 2, 3, nil :: any, 5 }) + assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) +end) + return {} diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index 8511f43..2a6b9b9 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -19,12 +19,16 @@ local recursive = core.recursive --- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback local function observe(getter: () -> { [K]: V }, callback: (item: V, key: K) -> ...(() -> ())?): () -> () local innerScopes: { [K]: () -> () } = {} + local disposed = false local function updateScopes(data: { [K]: V }) for key, dispose in innerScopes do if data[key] == nil then innerScopes[key] = nil dispose() + if disposed then + return + end end end @@ -32,9 +36,15 @@ local function observe(getter: () -> { [K]: V }, callback: (item: V, key: if innerScopes[key] == nil then -- Detach the inner scope from the parent effect to prevent -- disposing when the effect re-runs. - innerScopes[key] = effectScope(function() + local stopScope = effectScope(function() return callback(value, key) end, true) + + if disposed then + return stopScope() + else + innerScopes[key] = stopScope + end end end end @@ -51,10 +61,13 @@ local function observe(getter: () -> { [K]: V }, callback: (item: V, key: end) return function() - for _, dispose in innerScopes do - dispose() + if not disposed then + disposed = true + for key, dispose in innerScopes do + innerScopes[key] = nil + dispose() + end end - table.clear(innerScopes) end end) end From 898fbdba29edb130f1334a06f664eacf0d71178d Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:28:41 -0800 Subject: [PATCH 068/160] feat: add `trigger()`, refactor source --- README.md | 50 +- benches/mapped.luau | 2 +- packages/charm-sync/src/patch.luau | 4 +- packages/charm-sync/src/server.luau | 56 +- .../charm/src/__tests__/computed.spec.luau | 8 +- packages/charm/src/__tests__/effect.spec.luau | 20 +- .../charm/src/__tests__/effectScope.spec.luau | 19 +- packages/charm/src/__tests__/gc.spec.luau | 10 +- packages/charm/src/__tests__/signal.spec.luau | 6 +- packages/charm/src/__tests__/spec.luau | 3 + .../charm/src/__tests__/topology.spec.luau | 8 +- .../charm/src/__tests__/trigger.spec.luau | 85 ++ packages/charm/src/core.luau | 648 ------------- packages/charm/src/globals.luau | 20 - packages/charm/src/index.d.ts | 168 ++-- packages/charm/src/init.luau | 866 +++++++++++++++++- packages/charm/src/mapped.luau | 76 -- packages/charm/src/observe.luau | 75 -- .../charm/src/{alien.luau => system.luau} | 2 +- packages/charm/src/utils.luau | 28 - test/spec.server.luau | 4 +- 21 files changed, 1119 insertions(+), 1039 deletions(-) create mode 100644 packages/charm/src/__tests__/trigger.spec.luau delete mode 100644 packages/charm/src/core.luau delete mode 100644 packages/charm/src/globals.luau delete mode 100644 packages/charm/src/mapped.luau delete mode 100644 packages/charm/src/observe.luau rename packages/charm/src/{alien.luau => system.luau} (99%) delete mode 100644 packages/charm/src/utils.luau diff --git a/README.md b/README.md index b210cbd..5016d83 100644 --- a/README.md +++ b/README.md @@ -558,9 +558,52 @@ end) --> 0, 1, 2, 3 --- -### `globals` +### `trigger(callback)` -You can customize the behavior of Charm through the `globals` table: +The `trigger()` function allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter: + +```luau +local array = signal({} :: { number }) +local length = computed(function() + return #array() +end) + +print(length()) --> 0 + +-- Direct mutation doesn't automatically trigger updates +table.insert(array(), 1) +print(length()) --> Still 0 + +-- Manually trigger updates +trigger(array) +print(length()) --> 1 +``` + +You can also trigger multiple signals at once: + +```luau +local src1 = signal({} :: { number }) +local src2 = signal({} :: { number }) +local total = computed(function() + return #src1() + #src2() +end) + +table.insert(src1(), 1) +table.insert(src2(), 2) + +trigger(function() + src1() + src2() +end) + +print(total()) --> 2 +``` + +--- + +### `flags` + +Charm exposes the following global flags to customize behavior: | Flag | Default | Description | | ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -568,7 +611,7 @@ You can customize the behavior of Charm through the `globals` table: | frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. | | trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | -The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio. +The `strict` and `frozen` flags are automatically enabled in Roblox Studio. More accurately, they are enabled for the Luau optimization levels `O1` and lower. --- @@ -863,6 +906,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that - Added [`listen()`](#listengetter-callback) for running a subscription once immediately - Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once - Added [`onCleanup()`](#oncleanupcallback-failsilently) for running code when the currently-running effect, subscription, or observer cleans up + - Added [`trigger()`](#triggercallback) for triggering updates for table mutations - The [`computed()`](#computedgetter) callback now gets called with the previous computed value - Optimized `computed()` to use lazy evaluation instead of eager updates - Optimized `mapped()` to only map values that changed in the original table diff --git a/benches/mapped.luau b/benches/mapped.luau index d9758fc..6b6d5dc 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -3,7 +3,7 @@ local bench = require("./bench") local computed = Charm.computed local mapped = Charm.mapped -local setActiveSub = Charm.core.setActiveSub +local setActiveSub = Charm.setActiveSub local signal = Charm.signal type Entry = { number } diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 39723d5..f56a118 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -3,7 +3,7 @@ local config = require("./config") local types = require("./types") local validate = require("./validate") -local globals = Charm.globals +local flags = Charm.flags export type None = types.None @@ -79,7 +79,7 @@ local function diff(currentState: any, nextState: any): any patches = stringifySparseArray(patches) end - if globals.strict and config.validatePatches then + if flags.strict and config.validatePatches then validate(patches) end end diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 6692d0c..8979e0c 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -5,22 +5,21 @@ local types = require("./types") type SyncPayload = types.SyncPayload -local globals = Charm.globals - type SyncStatus = number + type ClientKeys = { [string]: SyncStatus } type GetterState = { getter: () -> T, currentValue: T, - previousValue: T, + lastSyncedValue: T, dispose: () -> (), } type StateUpdate = { key: string, - currentValue: any, - previousValue: any, + newValue: any, + oldValue: any, } local PENDING_INITIAL_STATE: SyncStatus = 0 @@ -46,22 +45,19 @@ local function startTracking(key: string, getter: () -> T) local trackedState = { getter = getter, currentValue = value, - previousValue = value, + lastSyncedValue = value, } - trackedState.dispose = Charm.effect(function() - local newValue = getter() - + trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue) -- When preserving history, we need to keep track of previous values -- so we can create granular patches for each change. - if config.preserveHistory and trackedState.currentValue ~= newValue then + if config.preserveHistory then table.insert(globalUpdates, { key = key, - currentValue = newValue, - previousValue = trackedState.previousValue, + newValue = newValue, + oldValue = oldValue, }) end - trackedState.currentValue = newValue end) @@ -69,9 +65,9 @@ local function startTracking(key: string, getter: () -> T) end local function stopTracking(key: string) - local tracker = trackedGetters[key] - if tracker then - tracker.dispose() + local trackedState = trackedGetters[key] + if trackedState then + trackedState.dispose() trackedGetters[key] = nil end end @@ -97,7 +93,7 @@ local function diffGlobalUpdateBuffer(): { any } local patches = table.create(patchCount) for index, update in globalUpdates do - patches[index] = patch.diff(update.previousValue, update.currentValue) + patches[index] = patch.diff(update.oldValue, update.newValue) end return patches @@ -132,7 +128,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { elseif status == PENDING_INITIAL_STATE then table.insert(payloads, { type = "init", - data = { [key] = update.currentValue }, + data = { [key] = update.newValue }, }) clientKeys[key] = LISTENING_FOR_CHANGES end @@ -143,10 +139,10 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { local initData: { [string]: any } = {} for key, status in clientKeys do - local getterState = trackedGetters[key] + local trackedState = trackedGetters[key] - if status == PENDING_INITIAL_STATE and getterState then - initData[key] = getterState.currentValue + if status == PENDING_INITIAL_STATE and trackedState then + initData[key] = trackedState.currentValue clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -162,10 +158,10 @@ local function diffGlobalState(): any local currentGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {} - for key, tracker in trackedGetters do - currentGlobalState[key] = tracker.currentValue - previousGlobalState[key] = tracker.previousValue - tracker.previousValue = tracker.currentValue + for key, trackedState in trackedGetters do + currentGlobalState[key] = trackedState.currentValue + previousGlobalState[key] = trackedState.lastSyncedValue + trackedState.lastSyncedValue = trackedState.currentValue end return patch.diff(previousGlobalState, currentGlobalState) @@ -185,12 +181,12 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo -- Create patches for initialized signals that changed, and add signals -- that are still pending initial state to the init payload. for key, status in clientKeys do - local getterState = trackedGetters[key] + local trackedState = trackedGetters[key] if status == LISTENING_FOR_CHANGES then patchData[key] = globalPatch[key] elseif status == PENDING_INITIAL_STATE then - initData[key] = getterState.currentValue + initData[key] = trackedState.currentValue clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -273,12 +269,6 @@ local function flush() local granular = config.preserveHistory local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState() - -- While payload objects are technically mutable as they are unique to - -- each client, the data inside them is not safe to mutate. - if globals.frozen then - Charm.deepFreeze(globalPatch) - end - for client in clients do local payloads = if granular then selectFromGlobalPatchList(client, globalPatch) diff --git a/packages/charm/src/__tests__/computed.spec.luau b/packages/charm/src/__tests__/computed.spec.luau index 522f403..3dd7284 100644 --- a/packages/charm/src/__tests__/computed.spec.luau +++ b/packages/charm/src/__tests__/computed.spec.luau @@ -1,9 +1,9 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local computed = core.computed -local effect = core.effect -local signal = core.signal +local computed = Charm.computed +local effect = Charm.effect +local signal = Charm.signal local test = suite.test test("should correctly propagate changes through computed core", function() diff --git a/packages/charm/src/__tests__/effect.spec.luau b/packages/charm/src/__tests__/effect.spec.luau index 96b0256..56fe9c1 100644 --- a/packages/charm/src/__tests__/effect.spec.luau +++ b/packages/charm/src/__tests__/effect.spec.luau @@ -1,15 +1,15 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local computed = core.computed -local effect = core.effect -local effectScope = core.effectScope -local endBatch = core.endBatch -local onCleanup = core.onCleanup -local recursive = core.recursive -local setActiveSub = core.setActiveSub -local signal = core.signal -local startBatch = core.startBatch +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local endBatch = Charm.endBatch +local onCleanup = Charm.onCleanup +local recursive = Charm.recursive +local setActiveSub = Charm.setActiveSub +local signal = Charm.signal +local startBatch = Charm.startBatch local test = suite.test test("should clear subscriptions when untracked by all subscribers", function() diff --git a/packages/charm/src/__tests__/effectScope.spec.luau b/packages/charm/src/__tests__/effectScope.spec.luau index 77798ff..c484151 100644 --- a/packages/charm/src/__tests__/effectScope.spec.luau +++ b/packages/charm/src/__tests__/effectScope.spec.luau @@ -1,10 +1,10 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local effect = core.effect -local effectScope = core.effectScope -local signal = core.signal -local onCleanup = core.onCleanup +local effect = Charm.effect +local effectScope = Charm.effectScope +local signal = Charm.signal +local onCleanup = Charm.onCleanup local test = suite.test test("should not trigger after stop", function() @@ -71,4 +71,13 @@ test("should run indirect effect cleanup", function() assert(cleanups == 2, "should not cleanup after update") end) +test("should disallow yielding", function() + local success = pcall(function() + effectScope(function() + (if task then task.wait else coroutine.yield :: never)() + end) + end) + assert(not success, "should error on yield") +end) + return {} diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau index cf4b213..4a73233 100644 --- a/packages/charm/src/__tests__/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -1,10 +1,10 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local atom = core.atom -local computed = core.computed -local effect = core.effect -local effectScope = core.effectScope +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope local test = suite.test --- Creates a weak reference to a value. diff --git a/packages/charm/src/__tests__/signal.spec.luau b/packages/charm/src/__tests__/signal.spec.luau index 6197693..a9693f6 100644 --- a/packages/charm/src/__tests__/signal.spec.luau +++ b/packages/charm/src/__tests__/signal.spec.luau @@ -1,8 +1,8 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local signal = core.signal -local effect = core.effect +local signal = Charm.signal +local effect = Charm.effect local test = suite.test test("should accept equality function", function() diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index d603583..b95c070 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -33,6 +33,9 @@ end) suite.describe("topology", function() require("./topology.spec") end) +suite.describe("trigger", function() + require("./trigger.spec") +end) suite.describe("untracked", function() require("./untracked.spec") end) diff --git a/packages/charm/src/__tests__/topology.spec.luau b/packages/charm/src/__tests__/topology.spec.luau index 0cffebd..6f1e1e0 100644 --- a/packages/charm/src/__tests__/topology.spec.luau +++ b/packages/charm/src/__tests__/topology.spec.luau @@ -1,9 +1,9 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local atom = core.atom -local computed = core.computed -local effect = core.effect +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect local describe = suite.describe local test = suite.test diff --git a/packages/charm/src/__tests__/trigger.spec.luau b/packages/charm/src/__tests__/trigger.spec.luau new file mode 100644 index 0000000..4055cbe --- /dev/null +++ b/packages/charm/src/__tests__/trigger.spec.luau @@ -0,0 +1,85 @@ +local Charm = require("../") +local suite = require("../../../../test/suite") + +local computed = Charm.computed +local effect = Charm.effect +local signal = Charm.signal +local subscribe = Charm.subscribe +local trigger = Charm.trigger +local test = suite.test +local beforeEach = suite.beforeEach +local afterEach = suite.afterEach + +beforeEach(function() + Charm.flags.frozen = false +end) + +afterEach(function() + Charm.flags.frozen = true +end) + +test("should not throw when triggering with no dependencies", function() + trigger(function() end) +end) + +test("should trigger updates for dependent computed signals", function() + local arr = signal({} :: { number }) + local length = computed(function() + return #arr() + end) + + assert(length() == 0, "initial length should be 0") + table.insert(arr(), 1) + trigger(arr) + assert(length() == 1, "length should be 1") +end) + +test("should trigger updates for the second source signal", function() + local src1 = signal({} :: { number }) + local src2 = signal({} :: { number }) + local length = computed(function() + return #src2() + end) + + assert(length() == 0, "initial length should be 0") + table.insert(src2(), 1) + trigger(function() + src1() + src2() + end) + assert(length() == 1, "length should be 1") +end) + +test("should trigger effect once", function() + local src1 = signal({} :: { number }) + local src2 = signal({} :: { number }) + local triggers = 0 + + effect(function() + triggers += 1 + src1() + src2() + end) + assert(triggers == 1, "effect should run once initially") + + trigger(function() + src1() + src2() + end) + assert(triggers == 2, "effect should run twice") +end) + +test("should trigger updates for subscribe", function() + local src = signal({} :: { number }) + local triggers = 0 + + subscribe(src, function() + triggers += 1 + end) + + table.insert(src(), 1) + trigger(src) + assert(triggers == 1, "subscribe callback should run after trigger") +end) + +return {} diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau deleted file mode 100644 index 9cf1467..0000000 --- a/packages/charm/src/core.luau +++ /dev/null @@ -1,648 +0,0 @@ -local alien = require("./alien") -local globals = require("./globals") -local utils = require("./utils") - -export type ReactiveFlags = alien.ReactiveFlags -export type ReactiveNode = alien.ReactiveNode -export type Link = alien.Link - -local ReactiveFlags = alien.ReactiveFlags -local createReactiveSystem = alien.createReactiveSystem -local deepFreeze = utils.deepFreeze -local noYield = utils.noYield - ---[[ - Reactive system adapted from alien-signals at - https://github.com/stackblitz/alien-signals/blob/master/src/index.ts - - MIT License - - Copyright (c) 2024-present Johnson Chu - - 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 NONE = 0b0000000 -local MUTABLE = 0b0000001 -local WATCHING = 0b0000010 -local RECURSED_CHECK = 0b0000100 -local DIRTY = 0b0010000 -local PENDING = 0b0100000 -local PEEKING = 0b1000000 - -type EffectScope = ReactiveNode & { - cleanups: { () -> () }, -} - -type Effect = ReactiveNode & { - fn: () -> ...(() -> ())?, - cleanups: { () -> () }, -} - -type Computed = ReactiveNode & { - value: T?, - getter: (previousValue: T?) -> T, -} - -type Signal = ReactiveNode & { - currentValue: T, - pendingValue: T, -} - -local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () -local unlink: (link: Link, sub: ReactiveNode?) -> Link? -local propagate: (link: Link) -> () -local checkDirty: (link: Link, sub: ReactiveNode) -> boolean -local shallowPropagate: (link: Link) -> () - -local cycle = 0 -local batchDepth = 0 -local notifyIndex = 0 -local queued: { Effect? } = {} -local queuedLength = 0 -local activeSub: ReactiveNode? - -local function getActiveSub(): ReactiveNode? - return activeSub -end - -local function setActiveSub(sub: ReactiveNode?) - local prevSub = activeSub - activeSub = sub - return prevSub -end - -local function purgeDeps(sub: ReactiveNode) - local depsTail = sub.depsTail - local dep = if depsTail then depsTail.nextDep else sub.deps - while dep do - dep = unlink(dep, sub) - end -end - -local function runCleanups(effect: EffectScope) - for _, cleanup in effect.cleanups do - local prevSub = setActiveSub(nil) - local success, result: any = (if globals.strict then noYield else pcall)(cleanup) - activeSub = prevSub - if not success then - error(result) - end - end - table.clear(effect.cleanups) -end - -local function run(e: Effect): () - local flags = e.flags - if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then - runCleanups(e) - cycle += 1 - e.depsTail = nil - e.flags = bit32.bor(WATCHING, RECURSED_CHECK) - local prevSub = setActiveSub(e) - local success, result = (if globals.strict then noYield else pcall)(e.fn) - activeSub = prevSub - e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) - purgeDeps(e) - if not success then - error(result) - elseif result then - table.insert(e.cleanups, result) - end - else - e.flags = WATCHING - end -end - -local function flush(): () - while notifyIndex < queuedLength do - local effect = queued[notifyIndex] :: Effect - queued[notifyIndex] = nil - notifyIndex += 1 - run(effect) - end - notifyIndex = 0 - queuedLength = 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 - cycle += 1 - c.depsTail = nil - c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - local prevSub = setActiveSub(c) - local oldValue = c.value - local success, result = (if globals.strict then noYield else pcall)(c.getter, oldValue) - activeSub = prevSub - c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) - purgeDeps(c) - if not success then - error(result) - end - if oldValue ~= result then - if globals.frozen then - deepFreeze(result) - end - c.value = result - return true - else - return false - end -end - -local function updateSignal(s: Signal): boolean - s.flags = MUTABLE - local pendingValue = s.pendingValue - if s.currentValue ~= pendingValue then - s.currentValue = pendingValue - return true - else - return false - end -end - -local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) - sub.flags = bit32.band(flags, bit32.bnot(PENDING)) -end - -local function computedOper(this: Computed): () -> T - local function computedGetter(): T - local flags = this.flags - if - bit32.btest(flags, DIRTY) - or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags))) - then - if updateComputed(this) then - local subs = this.subs - if subs then - shallowPropagate(subs) - end - end - elseif flags == NONE then - this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - local prevSub = setActiveSub(this) - local success, result = pcall(this.getter) - activeSub = prevSub - this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) - if success then - this.value = result - else - error(result) - end - end - if activeSub then - link(this, activeSub, cycle) - end - return this.value :: T - end - return computedGetter -end - -local function signalOper( - this: Signal, - equals: ((current: T, incoming: T) -> boolean)? -): (() -> T, (((T) -> T) | T) -> T) - local function signalSetter(update: ((T) -> T) | T): T - local value: T - if type(update) == "function" then - local prevSub = setActiveSub(nil) - local success, result = (if globals.strict then noYield else pcall)(update, this.pendingValue) - activeSub = prevSub - if not success then - error(result) - end - value = result - else - value = update - end - if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then - if globals.frozen then - deepFreeze(value) - end - this.pendingValue = value - this.flags = bit32.bor(MUTABLE, DIRTY) - local subs = this.subs - if subs then - propagate(subs) - if batchDepth == 0 then - flush() - end - end - return value - end - return this.currentValue - end - - local function signalGetter(): T - if bit32.btest(this.flags, DIRTY) then - if updateSignal(this) then - local subs = this.subs - if subs then - shallowPropagate(subs) - end - end - end - local sub = activeSub - while sub do - if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then - link(this, sub, cycle) - break - end - local subs = sub.subs - sub = if subs then subs.sub else nil - end - return this.currentValue - end - - return signalGetter, signalSetter -end - -local function effectScopeOper(this: EffectScope): () -> () - return function() - this.depsTail = nil - this.flags = NONE - purgeDeps(this) - local sub = this.subs - if sub then - unlink(sub, this) - end - runCleanups(this) - end -end - -local function effectOper(this: Effect): () -> () - return effectScopeOper(this) -end - ---- Creates a reactive signal that stores a value and notifies subscribers when ---- the value changes. The signal provides both a getter and a setter. ---- ---- @param initialValue The initial value of the signal. ---- @param equals A comparator function to determine if the signal's value has changed. ---- @return A tuple containing the getter and setter functions for the signal. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals -local function signal( - initialValue: T, - equals: ((current: T, incoming: T) -> boolean)? -): (() -> T, (((T) -> T) | T) -> T) - if globals.frozen then - deepFreeze(initialValue) - end - return signalOper({ - currentValue = initialValue, - pendingValue = initialValue, - flags = MUTABLE, - }, equals) -end - ---- Creates a reactive atom that acts as both a signal getter and setter. ---- Calling the atom with an argument sets its value, while calling it with no ---- arguments returns its current value. ---- ---- @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. ---- @param equals A comparator function to determine if the atom's value has changed. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals -local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T - local getter, setter = signal(initialValue, equals) - local function atomGetterSetter(...) - return if select("#", ...) == 0 then getter() else setter((...)) - end - return atomGetterSetter -end - ---- Creates a new read-only signal that is computed based on the values of ---- other signals. The computed signal's value is automatically updated when a ---- change is detected in any of its dependencies. ---- ---- @param getter A function that produces the next value. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter -local function computed(getter: (previousValue: T?) -> T): () -> T - return computedOper({ - flags = NONE, - getter = getter, - }) -end - ---- Creates an effect that runs a callback in response to signal state changes. ---- An effect tracks which signals are accessed during its execution, and runs ---- the callback when those signals change. ---- ---- The effect callback may return a cleanup function, which gets called once, ---- either before the effect re-runs or when it is disposed. ---- ---- If the effect is called within another effect, it will be disposed when the ---- parent effect re-runs. ---- ---- @param fn The function to run when dependencies change. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback -local function effect(fn: () -> ...(() -> ())?): () -> () - local e: Effect = { - fn = fn, - flags = bit32.bor(WATCHING, RECURSED_CHECK), - cleanups = {}, - } - local prevSub = setActiveSub(e) - if prevSub and (globals.trackInnerEffects or prevSub.flags == NONE) then - link(e, prevSub, 0) - end - local success, result = (if globals.strict then noYield else pcall)(fn) - activeSub = prevSub - e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) - if not success then - error(result) - elseif result then - table.insert(e.cleanups, result) - end - return effectOper(e) -end - ---- Creates an effect scope that can capture reactive effects and computed ---- signals created within it so that they can be disposed together. ---- ---- @param fn A function that may create reactive effects. ---- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. ---- @return A function for disposing all effects created within the scope. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached -local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () - local e: EffectScope = { - flags = NONE, - cleanups = {}, - } - local prevSub = setActiveSub(e) - if prevSub and not detached then - link(e, prevSub, 0) - end - local success, result = (if globals.strict then noYield else pcall)(fn) - activeSub = prevSub - if not success then - error(result) - elseif result then - table.insert(e.cleanups, result) - end - return effectScopeOper(e) -end - ---- Binds the cleanup function to the current active effect or effect scope. If ---- there is no active effect, a warning is logged, unless `failSilently` is ---- set to `true`. ---- ---- @param callback The cleanup function to register. ---- @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently -local function onCleanup(cleanup: () -> (), failSilently: boolean?) - if activeSub and (activeSub :: Effect).cleanups then - table.insert((activeSub :: Effect).cleanups, cleanup) - elseif not failSilently then - warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) - end -end - ---- Runs the callback without subscribing to signal updates or capturing inner ---- effects in the parent effect or scope. ---- ---- For an alternative that still cleans up inner effects, use `peek` instead. ---- ---- @param callback The callback function. ---- @param ...args Arguments to pass to the callback. ---- @return The return value of the callback. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback -local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - if activeSub then - local prevSub = setActiveSub(nil) - local results: { any } = { pcall(callback, ...) } - activeSub = prevSub - if not results[1] then - error(results[2]) - end - return unpack(results, 2) - else - return callback(...) - end -end - ---- Runs the callback without subscribing to signal updates, but still captures ---- inner effects so that they dispose with the parent effect. ---- ---- To avoid capturing inner effects, use `untracked` instead. ---- ---- @param callback The callback function. ---- @param ...args Arguments to pass to the callback. ---- @return The return value of the callback. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback -local function peek(callback: (Args...) -> Results..., ...: Args...): Results... - if - activeSub - and bit32.band(activeSub.flags, PEEKING) == 0 - and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE)) - then - activeSub.flags = bit32.bor(activeSub.flags, PEEKING) - local results: { any } = { pcall(callback, ...) } - activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING)) - if not results[1] then - error(results[2]) - end - return unpack(results, 2) - else - return callback(...) - end -end - ---- Combines multiple updates into a single "commit" that runs effects and ---- computed signals after the provided callback finishes running. ---- ---- @param callback A function that performs multiple updates. ---- @param ...args Arguments to pass to the callback. ---- @return The return value of the callback. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback -local function batch(callback: (Args...) -> Results..., ...: Args...): Results... - startBatch() - local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) } - endBatch() - if not results[1] then - error(results[2]) - end - return unpack(results, 2) -end - ---- Converts a non-signal into a computed signal. If the callback is already a ---- signal, it is returned as-is. Useful for memoizing a getter function ---- without creating unnecessary nested signals. ---- ---- @param getter A function that returns the value to track. ---- @return A computed signal that tracks the value returned by the getter. -local function toSignal(getter: () -> T): () -> T - local name = debug.info(getter, "n") - if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then - return getter - end - return computed(function() - return getter() - end) -end - ---- Creates an effect that runs the callback one immediately, and then again ---- when the value returned by the getter changes. The callback receives the new ---- value and the previous value as arguments. ---- ---- Effects created within the callback are cleaned up when the listener runs ---- or the listener is disposed. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback -local function listen(getter: () -> T, callback: (state: T, previous: T?) -> ()): () -> () - local tracked = toSignal(getter) - local current: T - return effect(function() - local previous = current - current = tracked() - peek(callback, current, previous) - end) -end - ---- Creates an effect that only runs the callback when the value returned by the ---- getter changes. The callback receives the new value and the previous value ---- as arguments. ---- ---- Effects created within the callback are cleaned up when the listener runs ---- or the listener is disposed. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback -local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () - local tracked = toSignal(getter) - local current = tracked() - return effect(function() - local previous = current - current = tracked() - if current ~= previous then - peek(callback, current, previous) - end - end) -end - ---- Disables recursive checks for the current effect or computed signal. Useful ---- for effects that intentionally update signals they depend on. ---- ---- @example ---- ```luau ---- effect(function() ---- recursive() ---- setCount(getCount() + 1) ---- end) ---- ``` -local function recursive() - if activeSub then - activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK)) - else - warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2)) - end -end - -local reactiveSystem = createReactiveSystem({ - update = function(node) - if node.depsTail then - return updateComputed(node :: Computed) - else - return updateSignal(node :: Signal) - end - end, - notify = function(effect) - local insertIndex = queuedLength - local firstInsertedIndex = insertIndex - - repeat - effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) - queued[insertIndex] = effect :: Effect - insertIndex += 1 - effect = (effect.subs and effect.subs.sub) :: Effect - if not effect or bit32.band(effect.flags, WATCHING) == 0 then - break - end - until false - - queuedLength = insertIndex - - -- while (firstInsertedIndex < --insertIndex) - insertIndex -= 1 - while firstInsertedIndex < insertIndex do - local left = queued[firstInsertedIndex] - queued[firstInsertedIndex] = queued[insertIndex] - queued[insertIndex] = left - firstInsertedIndex += 1 - -- Decrement before next iteration to simulate --insertIndex - insertIndex -= 1 - end - end, - unwatched = function(node) - if bit32.band(node.flags, MUTABLE) == 0 then - effectScopeOper(node :: EffectScope)() - elseif node.depsTail then - node.depsTail = nil - node.flags = bit32.bor(MUTABLE, DIRTY) - purgeDeps(node) - end - end, -}) - -link = reactiveSystem.link -unlink = reactiveSystem.unlink -propagate = reactiveSystem.propagate -checkDirty = reactiveSystem.checkDirty -shallowPropagate = reactiveSystem.shallowPropagate - -return { - ReactiveFlags = ReactiveFlags, - createReactiveSystem = createReactiveSystem, - getActiveSub = getActiveSub, - setActiveSub = setActiveSub, - getBatchDepth = getBatchDepth, - startBatch = startBatch, - endBatch = endBatch, - atom = atom, - signal = signal, - computed = computed, - effect = effect, - effectScope = effectScope, - onCleanup = onCleanup, - untracked = untracked, - peek = peek, - batch = batch, - subscribe = subscribe, - listen = listen, - recursive = recursive, -} diff --git a/packages/charm/src/globals.luau b/packages/charm/src/globals.luau deleted file mode 100644 index af6a381..0000000 --- a/packages/charm/src/globals.luau +++ /dev/null @@ -1,20 +0,0 @@ -local function inlineTest(): string - return debug.info(1, "n") -end - -local isO2 = inlineTest() ~= "inlineTest" -local isDev = not isO2 or _G.__DEV__ == true - -return { - --- Enforces synchronous, non-yielding behavior in signals and effects. - --- Also enables state validation in Charm Sync to catch sync errors early. - --- Enabled if the optimization level is below 2, which is true in studio. - strict = isDev, - --- Enforces immutability of tables by deep-freezing table values. Enabled - --- if the optimization level is below 2, which is true in studio. - frozen = isDev, - --- Whether parent effects should track inner effects and clean them up - --- when the parent effect re-runs. This is usually desirable, but can be - --- disabled to revert to the old behavior. Defaults to `true`. - trackInnerEffects = true, -} diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 82ca1d8..cc27c44 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1,24 +1,48 @@ type Key = string | number | symbol; type Cleanup = () => void; -/** - * A reactive signal that acts as both a getter and setter. Calling the signal - * with an argument sets its value, while calling it with no arguments returns - * its current value. - */ +export enum ReactiveFlags { + None = 0b0000000, + Mutable = 0b0000001, + Watching = 0b0000010, + RecursedCheck = 0b0000100, + Recursed = 0b0001000, + Dirty = 0b0010000, + Pending = 0b0100000, + Peeking = 0b1000000, +} + +export interface ReactiveNode { + deps?: Link; + depsTail?: Link; + subs?: Link; + subsTail?: Link; + flags: ReactiveFlags; +} + +export interface Link { + version: number; + dep: ReactiveNode; + sub: ReactiveNode; + prevSub?: Link; + nextSub?: Link; + prevDep?: Link; + nextDep?: Link; +} + export interface Atom { (newValue: T | ((currentValue: T) => T)): T; (): T; } -export type Getter = () => T; export type Setter = (newValue: T | ((currentValue: T) => T)) => T; + export type Equals = (current: T, incoming: T) => boolean; /** * Global flags that modify the behavior of Charm's reactive system. */ -export const globals: { +export const flags: { /** * Enforces synchronous, non-yielding behavior in signals and effects. * Also enables state validation in Charm Sync to catch sync errors early. @@ -92,7 +116,7 @@ export function computed(getter: (previousValue?: T) => T): () => T; * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback */ -export function effect(callback: () => Cleanup | void): Cleanup; +export function effect(fn: () => Cleanup | void): Cleanup; /** * Creates an effect scope that can capture reactive effects and computed @@ -103,55 +127,64 @@ export function effect(callback: () => Cleanup | void): Cleanup; * @returns A function for disposing all effects created within the scope. * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached */ -export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; +export function effectScope(fn: () => Cleanup | void, detached?: boolean): Cleanup; + +/** + * Allows you to manually trigger updates for downstream dependencies when + * you've directly mutated a signal's value without using the signal setter. + * + * @param fn The dependency or a function that calls multiple dependencies. + * @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback + */ +export function trigger(fn: () => void): void; /** * Binds the cleanup function to the current active effect or effect scope. If * there is no active effect, a warning is logged, unless `failSilently` is * set to `true`. * - * @param callback The cleanup function to register. + * @param fn The cleanup function to register. * @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently */ -export function onCleanup(callback: Cleanup, failSilently?: boolean): void; +export function onCleanup(fn: Cleanup, failSilently?: boolean): void; /** - * Runs the callback without subscribing to signal updates or capturing inner + * Runs the function without subscribing to signal updates or capturing inner * effects in the parent effect or scope. * * For an alternative that still cleans up inner effects, use `peek` instead. * - * @param callback The callback function. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. + * @param fn A function that may read signals or create effects. + * @param args Arguments to pass to the function. + * @returns The return value of the function. * @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback */ -export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; +export function untracked(fn: (...args: Args) => Result, ...args: Args): Result; /** - * Runs the callback without subscribing to signal updates, but still captures + * Runs the function without subscribing to signal updates, but still captures * inner effects so that they dispose with the parent effect. * * To avoid capturing inner effects, use `untracked` instead. * - * @param callback The callback function. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. + * @param fn A function that may read signals. + * @param args Arguments to pass to the function. + * @returns The return value of the function. * @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback */ -export function peek(callback: (...args: Args) => Result, ...args: Args): Result; +export function peek(fn: (...args: Args) => Result, ...args: Args): Result; /** * Combines multiple updates into a single "commit" that runs effects and - * computed signals after the provided callback finishes running. + * computed signals after the provided function finishes running. * - * @param callback A function that performs multiple updates. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. + * @param fn A function that performs multiple updates. + * @param args Arguments to pass to the function. + * @returns The return value of the function. * @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback */ -export function batch(callback: (...args: Args) => Result, ...args: Args): Result; +export function batch(fn: (...args: Args) => Result, ...args: Args): Result; /** * Creates an effect that runs the callback one immediately, and then again @@ -162,11 +195,11 @@ export function batch(callback: (...args: Args) => R * or the listener is disposed. * * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. + * @param fn The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback */ -export function listen(getter: () => T, callback: (value: T, previousValue: T | undefined) => void): Cleanup; +export function listen(getter: () => T, fn: (value: T, previousValue: T | undefined) => void): Cleanup; /** * Creates an effect that only runs the callback when the value returned by the @@ -177,11 +210,11 @@ export function listen(getter: () => T, callback: (value: T, previousValue: T * or the listener is disposed. * * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. + * @param fn The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback */ -export function subscribe(getter: () => T, callback: (value: T, previousValue: T) => void): Cleanup; +export function subscribe(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup; /** * Creates a new read-only signal that is computed by mapping over the entries @@ -219,73 +252,38 @@ export function mapped( * from the table or when the callback is disposed. * * @param getter A function that returns the table to observe. - * @param callback A function that is called for each key in the table. + * @param fn A function that is called for each key in the table. * @returns A function for disposing the observer. * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback */ export function observe( getter: () => Map | ReadonlyMap | readonly V[], - callback: (value: V, key: K) => Cleanup | void, + fn: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( getter: () => { readonly [K in Key]: V }, - callback: (value: V, key: K) => Cleanup | void, + fn: (value: V, key: K) => Cleanup | void, ): Cleanup; -export namespace core { - export { signal, effect, effectScope, atom, computed, onCleanup, untracked, peek, batch, listen }; +/** + * Disables recursive checks for the current effect or computed signal. + * Useful for effects that intentionally update signals they depend on. + * + * @example + * ```ts + * effect(() => { + * recursive(); + * setCount(getCount() + 1); + * }) + * ``` + */ +export function recursive(): void; - export enum ReactiveFlags { - None = 0b0000000, - Mutable = 0b0000001, - Watching = 0b0000010, - RecursedCheck = 0b0000100, - Recursed = 0b0001000, - Dirty = 0b0010000, - Pending = 0b0100000, - Peeking = 0b1000000, - } +export function getActiveSub(): ReactiveNode | undefined; - export interface ReactiveNode { - deps?: Link; - depsTail?: Link; - subs?: Link; - subsTail?: Link; - flags: ReactiveFlags; - } +export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; - export interface Link { - version: number; - dep: ReactiveNode; - sub: ReactiveNode; - prevSub?: Link; - nextSub?: Link; - prevDep?: Link; - nextDep?: Link; - } +export function startBatch(): void; - /** - * Disables recursive checks for the current effect or computed signal. - * Useful for effects that intentionally update signals they depend on. - * - * @example - * ```ts - * effect(() => { - * recursive(); - * setCount(getCount() + 1); - * }) - * ``` - */ - export function recursive(): void; - - export function getActiveSub(): ReactiveNode | undefined; - - export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; - - export function getBatchDepth(): number; - - export function startBatch(): void; - - export function endBatch(): void; -} +export function endBatch(): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 4aee63e..6ba6de2 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,43 +1,841 @@ -local core = require("@self/core") -local globals = require("@self/globals") -local mapped = require("@self/mapped") -local observe = require("@self/observe") -local utils = require("@self/utils") +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/index.ts -type Update = ((previous: T) -> T) | T + MIT License ---- A reactive signal that acts as both a getter and setter. Calling the signal ---- with an argument sets its value, while calling it with no arguments returns ---- its current value. -export type Atom = (...Update) -> T + Copyright (c) 2024-present Johnson Chu ---- A getter-only reactive signal. Calling the signal with no arguments returns ---- its current value. It cannot be set directly. -export type Getter = () -> T + 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: ---- A setter-only reactive signal. Calling the signal with an argument sets its ---- value. It cannot be read directly. -export type Setter = (...Update) -> () + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. -export type ReactiveNode = core.ReactiveNode -export type Link = core.Link + 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("@self/system") + +export type ReactiveFlags = system.ReactiveFlags +export type ReactiveNode = system.ReactiveNode +export type Link = system.Link + +export type Atom = (...((T) -> T) | T) -> T + +type EffectScopeNode = ReactiveNode & { + cleanups: { () -> () }, +} + +type EffectNode = ReactiveNode & { + fn: () -> ...(() -> ())?, + cleanups: { () -> () }, +} + +type ComputedNode = ReactiveNode & { + value: T?, + getter: (previousValue: T?) -> T, +} + +type SignalNode = ReactiveNode & { + currentValue: T, + pendingValue: T, +} + +local NONE = 0b0000000 +local MUTABLE = 0b0000001 +local WATCHING = 0b0000010 +local RECURSED_CHECK = 0b0000100 +local DIRTY = 0b0010000 +local PENDING = 0b0100000 +local PEEKING = 0b1000000 + +local function inlineTest(): string + return debug.info(1, "n") +end + +local flags = { + --- Enforces synchronous, non-yielding behavior in signals and effects. + --- Also enables state validation in Charm Sync to catch sync errors early. + --- Enabled if the optimization level is below 2, which is true in studio. + strict = inlineTest() == "inlineTest", + --- Enforces immutability of tables by deep-freezing table values. Enabled + --- if the optimization level is below 2, which is true in studio. + frozen = inlineTest() == "inlineTest", + --- Whether parent effects should track inner effects and clean them up + --- when the parent effect re-runs. This is usually desirable, but can be + --- disabled to revert to the old behavior. Defaults to `true`. + trackInnerEffects = true, +} + +local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () +local unlink: (link: Link, sub: ReactiveNode?) -> Link? +local propagate: (link: Link) -> () +local checkDirty: (link: Link, sub: ReactiveNode) -> boolean +local shallowPropagate: (link: Link) -> () + +local cycle = 0 +local batchDepth = 0 +local notifyIndex = 0 +local queued: { EffectNode? } = {} +local queuedLength = 0 +local activeSub: ReactiveNode? + +local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... + if not flags.strict then + return fn + end + local function resultHandler(thread: thread, ok: boolean, ...) + if not ok then + error(debug.traceback(thread, tostring((...)))) + elseif coroutine.status(thread) ~= "dead" then + error(debug.traceback(thread, "Yielding is not allowed in strict mode")) + end + return ... + end + return function(...) + local thread = coroutine.create(fn) + return resultHandler(thread, coroutine.resume(thread, ...)) + end +end + +local function deepFreeze(object: any) + if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then + table.freeze(object) + for _, value in object do + deepFreeze(value) + end + end +end + +local function getActiveSub(): ReactiveNode? + return activeSub +end + +local function setActiveSub(sub: ReactiveNode?) + local prevSub = activeSub + activeSub = sub + return prevSub +end + +local function purgeDeps(sub: ReactiveNode) + local depsTail = sub.depsTail + local dep = if depsTail then depsTail.nextDep else sub.deps + while dep do + dep = unlink(dep, sub) + end +end + +local function runCleanups(effect: EffectScopeNode) + local cleanups = effect.cleanups + for key, cleanup in cleanups do + local prevSub = setActiveSub(nil) + local success, result: any = pcall(cleanup) + activeSub = prevSub + if not success then + warn("An error occurred during effect cleanup:", result) + end + cleanups[key] = nil + end +end + +local function run(e: EffectNode): () + local flags = e.flags + if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then + runCleanups(e) + cycle += 1 + e.depsTail = nil + e.flags = bit32.bor(WATCHING, RECURSED_CHECK) + local prevSub = setActiveSub(e) + local success, result = pcall(e.fn) + activeSub = prevSub + e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(e) + if not success then + error(result) + elseif result then + table.insert(e.cleanups, result) + end + else + e.flags = WATCHING + end +end + +local function flush(): () + while notifyIndex < queuedLength do + local effect = queued[notifyIndex] :: EffectNode + queued[notifyIndex] = nil + notifyIndex += 1 + run(effect) + end + notifyIndex = 0 + queuedLength = 0 +end + +local function startBatch() + batchDepth += 1 +end + +local function endBatch() + batchDepth -= 1 + if batchDepth == 0 then + flush() + end +end + +local function updateComputed(c: ComputedNode): boolean + cycle += 1 + c.depsTail = nil + c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + local prevSub = setActiveSub(c) + local oldValue = c.value + local success, result = pcall(c.getter, oldValue) + activeSub = prevSub + c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(c) + if not success then + error(result) + end + if oldValue ~= result then + if flags.frozen then + deepFreeze(result) + end + c.value = result + return true + else + return false + end +end + +local function updateSignal(s: SignalNode): boolean + s.flags = MUTABLE + local pendingValue = s.pendingValue + if s.currentValue ~= pendingValue then + s.currentValue = pendingValue + return true + else + return false + end +end + +local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) + sub.flags = bit32.band(flags, bit32.bnot(PENDING)) +end + +local function computedOper(this: ComputedNode): () -> T + local function computedGetter(): T + local flags = this.flags + if + bit32.btest(flags, DIRTY) + or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags))) + then + if updateComputed(this) then + local subs = this.subs + if subs then + shallowPropagate(subs) + end + end + elseif flags == NONE then + this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + local prevSub = setActiveSub(this) + local success, result = pcall(this.getter) + activeSub = prevSub + this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) + if success then + this.value = result + else + error(result) + end + end + if activeSub then + link(this, activeSub, cycle) + end + return this.value :: T + end + return computedGetter +end + +local function signalOper( + this: SignalNode, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) + local function signalSetter(update: ((T) -> T) | T): T + local value: T + if type(update) == "function" then + local prevSub = setActiveSub(nil) + local success, result = pcall(noYield(update), this.pendingValue) + activeSub = prevSub + if not success then + error(result) + end + value = result + else + value = update + end + if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then + if flags.frozen then + deepFreeze(value) + end + this.pendingValue = value + this.flags = bit32.bor(MUTABLE, DIRTY) + local subs = this.subs + if subs then + propagate(subs) + if batchDepth == 0 then + flush() + end + end + return value + end + return this.currentValue + end + + local function signalGetter(): T + if bit32.btest(this.flags, DIRTY) then + if updateSignal(this) then + local subs = this.subs + if subs then + shallowPropagate(subs) + end + end + end + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(this, sub, cycle) + break + end + local subs = sub.subs + sub = if subs then subs.sub else nil + end + return this.currentValue + end + + return signalGetter, signalSetter +end + +local function effectOper(this: EffectScopeNode): () -> () + return function() + this.depsTail = nil + this.flags = NONE + purgeDeps(this) + local sub = this.subs + if sub then + unlink(sub, this) + end + runCleanups(this) + end +end + +--- Creates a reactive signal that stores a value and notifies subscribers when +--- the value changes. The signal provides both a getter and a setter. +--- +--- @param initialValue The initial value of the signal. +--- @param equals A comparator function to determine if the signal's value has changed. +--- @return A tuple containing the getter and setter functions for the signal. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals +local function signal( + initialValue: T, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) + if flags.frozen then + deepFreeze(initialValue) + end + return signalOper({ + currentValue = initialValue, + pendingValue = initialValue, + flags = MUTABLE, + }, equals and noYield(equals)) +end + +--- Creates a reactive atom that acts as both a signal getter and setter. +--- Calling the atom with an argument sets its value, while calling it with no +--- arguments returns its current value. +--- +--- @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. +--- @param equals A comparator function to determine if the atom's value has changed. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals +local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom + local getter, setter = signal(initialValue, equals) + local function atomGetterSetter(...) + return if select("#", ...) == 0 then getter() else setter((...)) + end + return atomGetterSetter +end + +--- Creates a new read-only signal that is computed based on the values of +--- other signals. The computed signal's value is automatically updated when a +--- change is detected in any of its dependencies. +--- +--- @param getter A function that produces the next value. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter +local function computed(getter: (previousValue: T?) -> T): () -> T + return computedOper({ + flags = NONE, + getter = noYield(getter), + }) +end + +--- Creates an effect that runs a callback in response to signal state changes. +--- An effect tracks which signals are accessed during its execution, and runs +--- the callback when those signals change. +--- +--- The effect callback may return a cleanup function, which gets called once, +--- either before the effect re-runs or when it is disposed. +--- +--- If the effect is called within another effect, it will be disposed when the +--- parent effect re-runs. +--- +--- @param fn The function to run when dependencies change. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback +local function effect(fn: () -> ...(() -> ())?): () -> () + local e: EffectNode = { + fn = noYield(fn), + flags = bit32.bor(WATCHING, RECURSED_CHECK), + cleanups = {}, + } + local prevSub = setActiveSub(e) + if prevSub and (flags.trackInnerEffects or prevSub.flags == NONE) then + link(e, prevSub, 0) + end + local success, result = pcall(e.fn) + activeSub = prevSub + e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) + if not success then + error(result) + elseif result then + table.insert(e.cleanups, noYield(result)) + end + return effectOper(e) +end + +--- Creates an effect scope that can capture reactive effects and computed +--- signals created within it so that they can be disposed together. +--- +--- @param fn A function that may create reactive effects. +--- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. +--- @return A function for disposing all effects created within the scope. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached +local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () + local e: EffectScopeNode = { + flags = NONE, + cleanups = {}, + } + local prevSub = setActiveSub(e) + if prevSub and not detached then + link(e, prevSub, 0) + end + local success, result = pcall(noYield(fn)) + activeSub = prevSub + if not success then + error(result) + elseif result then + table.insert(e.cleanups, noYield(result)) + end + return effectOper(e) +end + +--- Allows you to manually trigger updates for downstream dependencies when +--- you've directly mutated a signal's value without using the signal setter. +--- +--- @param fn The dependency or a function that calls multiple dependencies. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback +local function trigger(fn: () -> ()) + local sub: ReactiveNode = { + flags = WATCHING, + } + local prevSub = setActiveSub(sub) + local success, result: any = pcall(noYield(fn)) + activeSub = prevSub + while sub.deps do + local link = sub.deps :: Link + local dep = link.dep + unlink(link, sub) + if dep.subs then + propagate(dep.subs) + shallowPropagate(dep.subs) + end + end + if batchDepth == 0 then + flush() + end + if not success then + error(result) + end +end + +--- Binds the cleanup function to the current active effect or effect scope. If +--- there is no active effect, a warning is logged, unless `failSilently` is +--- set to `true`. +--- +--- @param fn The cleanup function to register. +--- @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently +local function onCleanup(fn: () -> (), failSilently: boolean?) + if activeSub and (activeSub :: EffectNode).cleanups then + table.insert((activeSub :: EffectNode).cleanups, noYield(fn)) + elseif not failSilently then + warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) + end +end + +--- Runs the function without subscribing to signal updates or capturing inner +--- effects in the parent effect or scope. +--- +--- For an alternative that still cleans up inner effects, use `peek` instead. +--- +--- @param fn A function that may read signals or create effects. +--- @param ...args Arguments to pass to the function. +--- @return The return value of the function. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback +local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... + if activeSub then + local prevSub = setActiveSub(nil) + local results: { any } = { pcall(noYield(fn), ...) } + activeSub = prevSub + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + else + return fn(...) + end +end + +--- Runs the function without subscribing to signal updates, but still captures +--- inner effects so that they dispose with the parent effect. +--- +--- To avoid capturing inner effects, use `untracked` instead. +--- +--- @param fn A function that may read signals. +--- @param ...args Arguments to pass to the function. +--- @return The return value of the function. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback +local function peek(fn: (Args...) -> Results..., ...: Args...): Results... + if + activeSub + and bit32.band(activeSub.flags, PEEKING) == 0 + and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE)) + then + activeSub.flags = bit32.bor(activeSub.flags, PEEKING) + local results: { any } = { pcall(noYield(fn), ...) } + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING)) + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + else + return fn(...) + end +end + +--- Combines multiple updates into a single "commit" that runs effects and +--- computed signals after the provided callback finishes running. +--- +--- @param fn A function that performs multiple updates. +--- @param ...args Arguments to pass to the function. +--- @return The return value of the function. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback +local function batch(fn: (Args...) -> Results..., ...: Args...): Results... + startBatch() + local results: { any } = { pcall(noYield(fn), ...) } + endBatch() + if not results[1] then + error(results[2]) + end + return unpack(results, 2) +end + +--- Converts a non-signal into a computed signal. If the function is already a +--- signal, it is returned as-is. Useful for memoizing a getter function +--- without creating unnecessary nested signals. +--- +--- @param fn A function that returns the value to track. +--- @return A computed signal that tracks the value returned by the getter. +local function toSignal(fn: () -> T): () -> T + local name = debug.info(fn, "n") + return if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then fn else computed(fn) +end + +--- Creates an effect that runs the callback one immediately, and then again +--- when the value returned by the getter changes. The callback receives the new +--- value and the previous value as arguments. +--- +--- Effects created within the callback are cleaned up when the listener runs +--- or the listener is disposed. +--- +--- @param getter A function that returns the value to watch. +--- @param fn The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () + local tracked = toSignal(getter) + local current: T + return effect(function() + local previous = current + current = tracked() + peek(fn, current, previous) + end) +end + +--- Creates an effect that only runs the callback when the value returned by the +--- getter changes. The callback receives the new value and the previous value +--- as arguments. +--- +--- Effects created within the callback are cleaned up when the listener runs +--- or the listener is disposed. +--- +--- @param getter A function that returns the value to watch. +--- @param fn The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback +local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () + local tracked = toSignal(getter) + local current: T + local firstRun = true + return effect(function() + local previous = current + current = tracked() + if firstRun then + firstRun = false + else + peek(fn, current, previous) + end + end) +end + +--- Disables recursive checks for the current effect or computed signal. Useful +--- for effects that intentionally update signals they depend on. +--- +--- @example +--- ```luau +--- effect(function() +--- recursive() +--- setCount(getCount() + 1) +--- end) +--- ``` +local function recursive() + if activeSub then + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK)) + else + warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2)) + end +end + +--- Calls the callback function for each unique key in the table returned by the +--- getter. The callback is called once for each key, and again when a new key +--- is added. +--- +--- The callback may return a cleanup function, which will be called when the +--- key is removed or the `observe()` effect is disposed. Effects created +--- during the callback will also be bound to the lifetime of the key. +--- +--- @param getter A function that returns the table to observe. +--- @param fn A function that is called for each key in the table. +--- @return A function for disposing the observer. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback +local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> () + local innerScopes: { [K]: () -> () } = {} + local disposed = false + + local function updateScopes(data: { [K]: V }) + for key, dispose in innerScopes do + if data[key] == nil then + innerScopes[key] = nil + dispose() + if disposed then + return + end + end + end + + for key, value in data do + if innerScopes[key] == nil then + local disposeScope = effectScope(function() + return fn(value, key) + end, true) + if disposed then + disposeScope() + return + end + innerScopes[key] = disposeScope + end + end + end + + local function disposeScopes() + if not disposed then + disposed = true + for key, dispose in innerScopes do + innerScopes[key] = nil + dispose() + end + end + end + + return effectScope(function() + effect(function() + -- Allow recursive updates in case the callback creates or removes + -- items in the observed table. + recursive() + -- Batch so that side effects run after all scopes are created. + -- This ensures that a scope creating or removing items doesn't + -- interfere with the current update cycle. + batch(updateScopes, getter()) + end) + + return disposeScopes + end) +end + +--- Creates a new read-only signal that is computed by mapping over the entries +--- of an existing atom. If the key is omitted from the mapper's return value, +--- the original key is preserved. +--- +--- @param getter Returns the table to map over. +--- @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper +local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } + local currentInput: { [KI]: VI } = {} + local keyCache: { [KI]: KO } = {} + + local function map(value: VI, key: KI): (VO, KO) + local prevSub = setActiveSub(nil) + local success, valueOut, keyOut = pcall(mapper, value, key) + activeSub = prevSub + if not success then + error(`mapped() transform function failed: {valueOut}`) + end + return valueOut, if keyOut == nil then key else keyOut :: any + end + + return computed(function(previousOutput) + local currentOutput: { [KO]: VO } = previousOutput or {} + local previousInput = currentInput + currentInput = getter() + + for keyIn in previousInput do + if currentInput[keyIn] ~= nil then + continue + end + + local prevKeyOut = keyCache[keyIn] + + if prevKeyOut then + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) + end + currentOutput[prevKeyOut] = nil + keyCache[keyIn] = nil + end + end + + for keyIn, valueIn in currentInput do + if valueIn == previousInput[keyIn] then + continue + end + + local valueOut, keyOut = map(valueIn, keyIn) + + if currentOutput[keyOut] ~= valueOut then + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) + end + currentOutput[keyOut] = valueOut + keyCache[keyIn] = keyOut + end + end + + return currentOutput + end) +end + +local reactiveSystem = system.createReactiveSystem({ + update = function(node) + if node.depsTail then + return updateComputed(node :: ComputedNode) + else + return updateSignal(node :: SignalNode) + end + end, + notify = function(effect) + local insertIndex = queuedLength + local firstInsertedIndex = insertIndex + + repeat + effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) + queued[insertIndex] = effect :: EffectNode + insertIndex += 1 + effect = (effect.subs and effect.subs.sub) :: EffectNode + if not effect or bit32.band(effect.flags, WATCHING) == 0 then + break + end + until false + + queuedLength = insertIndex + + insertIndex -= 1 + while firstInsertedIndex < insertIndex do + local left = queued[firstInsertedIndex] + queued[firstInsertedIndex] = queued[insertIndex] + queued[insertIndex] = left + firstInsertedIndex += 1 + insertIndex -= 1 + end + end, + unwatched = function(node) + if bit32.band(node.flags, MUTABLE) == 0 then + effectOper(node :: EffectScopeNode)() + elseif node.depsTail then + node.depsTail = nil + node.flags = bit32.bor(MUTABLE, DIRTY) + purgeDeps(node) + end + end, +}) + +link = reactiveSystem.link +unlink = reactiveSystem.unlink +propagate = reactiveSystem.propagate +checkDirty = reactiveSystem.checkDirty +shallowPropagate = reactiveSystem.shallowPropagate return { - core = core, - globals = globals, - atom = core.atom, - batch = core.batch, - computed = core.computed, - effect = core.effect, - effectScope = core.effectScope, - listen = core.listen, - onCleanup = core.onCleanup, - peek = core.peek, - recursive = core.recursive, - signal = core.signal, - subscribe = core.subscribe, - untracked = core.untracked, - mapped = mapped, + ReactiveFlags = system.ReactiveFlags, + flags = flags, + + getActiveSub = getActiveSub, + setActiveSub = setActiveSub, + startBatch = startBatch, + endBatch = endBatch, + + signal = signal, + computed = computed, + effect = effect, + effectScope = effectScope, + trigger = trigger, + atom = atom, + onCleanup = onCleanup, + untracked = untracked, + peek = peek, + batch = batch, + subscribe = subscribe, + listen = listen, + recursive = recursive, observe = observe, - deepFreeze = utils.deepFreeze, + mapped = mapped :: (( + getter: () -> { [KI]: VI }, + mapper: (item: VI, key: KI) -> (VO, KO) + ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), } diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau deleted file mode 100644 index 940a6ae..0000000 --- a/packages/charm/src/mapped.luau +++ /dev/null @@ -1,76 +0,0 @@ -local core = require("./core") - -local computed = core.computed -local setActiveSub = core.setActiveSub - -type Mapped = - & ((getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) - & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) - -local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } - local currentInput: { [KI]: VI } = {} - local keyCache: { [KI]: KO } = {} - - local function map(value: VI, key: KI): (VO, KO) - local prevSub = setActiveSub(nil) - local success, valueOut, keyOut = pcall(mapper, value, key) - setActiveSub(prevSub) - - if not success then - error(`mapped() transform function failed: {valueOut}`) - end - - return valueOut, if keyOut == nil then key else keyOut :: any - end - - return computed(function(previousOutput) - local currentOutput: { [KO]: VO } = previousOutput or {} - local previousInput = currentInput - currentInput = getter() - - for keyIn in previousInput do - if currentInput[keyIn] ~= nil then - continue - end - - local prevKeyOut = keyCache[keyIn] - - if prevKeyOut then - if currentOutput == previousOutput then - currentOutput = table.clone(currentOutput) - end - currentOutput[prevKeyOut] = nil - keyCache[keyIn] = nil - end - end - - for keyIn, valueIn in currentInput do - if valueIn == previousInput[keyIn] then - continue - end - - local valueOut, keyOut = map(valueIn, keyIn) - - if currentOutput[keyOut] ~= valueOut then - if currentOutput == previousOutput then - currentOutput = table.clone(currentOutput) - end - currentOutput[keyOut] = valueOut - keyCache[keyIn] = keyOut - end - end - - return currentOutput - end) -end - ---- Creates a new read-only signal that is computed by mapping over the entries ---- of an existing atom. If the key is omitted from the mapper's return value, ---- the original key is preserved. ---- ---- @param getter Returns the table to map over. ---- @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper -local mapped = mapped :: Mapped - -return mapped diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau deleted file mode 100644 index 2a6b9b9..0000000 --- a/packages/charm/src/observe.luau +++ /dev/null @@ -1,75 +0,0 @@ -local core = require("./core") - -local batch = core.batch -local effect = core.effect -local effectScope = core.effectScope -local recursive = core.recursive - ---- Calls the callback function for each unique key in the table returned by the ---- getter. The callback is called once for each key, and again when a new key ---- is added. ---- ---- The callback may return a cleanup function, which will be called when the ---- key is removed or the `observe()` effect is disposed. Effects created ---- during the callback will also be bound to the lifetime of the key. ---- ---- @param getter A function that returns the table to observe. ---- @param callback A function that is called for each key in the table. ---- @return A function for disposing the observer. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback -local function observe(getter: () -> { [K]: V }, callback: (item: V, key: K) -> ...(() -> ())?): () -> () - local innerScopes: { [K]: () -> () } = {} - local disposed = false - - local function updateScopes(data: { [K]: V }) - for key, dispose in innerScopes do - if data[key] == nil then - innerScopes[key] = nil - dispose() - if disposed then - return - end - end - end - - for key, value in data do - if innerScopes[key] == nil then - -- Detach the inner scope from the parent effect to prevent - -- disposing when the effect re-runs. - local stopScope = effectScope(function() - return callback(value, key) - end, true) - - if disposed then - return stopScope() - else - innerScopes[key] = stopScope - end - end - end - end - - return effectScope(function() - effect(function() - -- Allow recursive updates in case the callback creates or removes - -- items in the observed table. - recursive() - -- Batch so that side effects run after all scopes are created. - -- This ensures that a scope creating or removing items doesn't - -- interfere with the current update cycle. - batch(updateScopes, getter()) - end) - - return function() - if not disposed then - disposed = true - for key, dispose in innerScopes do - innerScopes[key] = nil - dispose() - end - end - end - end) -end - -return observe diff --git a/packages/charm/src/alien.luau b/packages/charm/src/system.luau similarity index 99% rename from packages/charm/src/alien.luau rename to packages/charm/src/system.luau index 495a5c3..61834fb 100644 --- a/packages/charm/src/alien.luau +++ b/packages/charm/src/system.luau @@ -90,7 +90,7 @@ local function createReactiveSystem(props: { end local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): () - -- DEVIATION: Do not link signals to sub while peeking + -- DEVIATION: Do not link signals to peeking subscribers if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then return end diff --git a/packages/charm/src/utils.luau b/packages/charm/src/utils.luau deleted file mode 100644 index 367e83c..0000000 --- a/packages/charm/src/utils.luau +++ /dev/null @@ -1,28 +0,0 @@ -local function noYieldHandler(thread: thread, ok: boolean, ...): (boolean, ...any) - if ok and coroutine.status(thread) == "dead" then - return true, ... - elseif not ok then - return false, debug.traceback(thread, tostring((...))) - else - return false, debug.traceback(thread, "Attempted to yield!") - end -end - -local function noYield(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...) - local thread = coroutine.create(callback) - return noYieldHandler(thread, coroutine.resume(thread, ...)) -end - -local function deepFreeze(object: any) - if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then - table.freeze(object) - for _, value in object do - deepFreeze(value) - end - end -end - -return { - noYield = noYield :: typeof(pcall), - deepFreeze = deepFreeze, -} diff --git a/test/spec.server.luau b/test/spec.server.luau index bb1f7aa..dfb6d0b 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -1,8 +1,8 @@ local Charm = require("../packages/charm/src") local suite = require("./suite") -Charm.globals.strict = true -Charm.globals.frozen = true +Charm.flags.strict = true +Charm.flags.frozen = true suite.describe("charm", function() require("../packages/charm/src/__tests__/spec") From 29d8add26525078720441dc5c2b6333b5fec6f7f Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:43:49 -0800 Subject: [PATCH 069/160] docs: update readme --- README.md | 114 ++++++++++++++++++++++++++---------------------------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 5016d83..5670f9b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ -**Charm** is a state management library that aims to bring [reactive signals](https://preactjs.com/blog/signal-boosting/) from libraries like Preact and Solid to Roblox. It's built on the [alien-signals](https://github.com/stackblitz/alien-signals) signal algorithm, expanding upon it further to handle a wide range of use cases, from rendering user interfaces to handling server game logic. +**Charm** is a Roblox state management library inspired by the [reactive signals](https://preactjs.com/blog/introducing-signals/) from libraries like Preact and Solid. It's built on [alien-signals](https://github.com/stackblitz/alien-signals), modified for a wide range of use cases: from rendering user interfaces to handling game logic. **Charm works under a few core principles.** @@ -26,11 +26,11 @@ - React to state updates: reading a signal automatically subscribes to it - Combine multiple signals: derive new values from existing state that stay up-to-date - Fine-grained reactivity: make targeted updates in response to specific changes in the state -- Use immutable data: values are compared directly (`==`) to optimize change detection +- Data should be immutable: values are compared directly (`==`) to optimize change detection **Want to learn more about signals?** -- https://preactjs.com/blog/signal-boosting +- https://preactjs.com/blog/introducing-signals - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity - https://angular.dev/guide/signals @@ -52,6 +52,8 @@ - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) + - [`trigger(callback)`](#triggercallback) + - [`flags`](#flags) - [Client-Server Sync](#client-server-sync) - [Installation](#installation-1) - [Quick Start](#quick-start) @@ -159,25 +161,25 @@ Signals are the core of reactivity in Charm. The `signal` function creates a rea ```luau local getCounter, setCounter = signal(0) -print(getCounter()) --> 0 +print(getCounter()) -- 0 setCounter(1) setCounter(function(count) return count + 1 end) ``` -Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately notify every effect and computed signal that depends on said signal, ensuring all of your state is correct and up-to-date. +Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately notify every effect and computed signal that depends on the signal, ensuring all of your state is correct and up-to-date. -You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: +You can also pass a custom equality function to only update the signal if the equality function returns `false`: ```luau local getMax, setMax = signal(0, function(current, incoming) return incoming <= current end) -setMax(1) --> 1 -setMax(2) --> 2 -setMax(-1) --> 2 +setMax(1) -- 1 +setMax(2) -- 2 +setMax(-1) -- 2 ``` > [!NOTE] @@ -198,9 +200,9 @@ local getFullName = computed(function() return `{getName()} {getSurname()}` end) -print(getFullName()) --> "John Doe" +print(getFullName()) -- "John Doe" setName("Jane") -print(getFullName()) --> "Jane Doe" +print(getFullName()) -- "Jane Doe" ``` The getter function also receives the previous state (or `nil` if running for the first time). You can use this for more complex state: @@ -211,9 +213,9 @@ local getMax = computed(function(prevMax) return math.max(getCounter(), prevMax or 0) end) -print(getMax()) --> 10 +print(getMax()) -- 10 setCounter(5) -print(getMax()) --> 10 +print(getMax()) -- 10 ``` Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called _and_ if a dependency updated since the last call. @@ -229,9 +231,9 @@ local getCounter, setCounter = signal(0) effect(function() print(`Count is {getCounter()}`) -end) --> Count is 0 +end) -- Count is 0 -setCounter(1) --> Count is 1 +setCounter(1) -- Count is 1 ``` You can also return a cleanup function that will run once, either before the effect re-runs or when the effect is disposed: @@ -246,8 +248,8 @@ local dispose = effect(function() end end) -setCounter(1) --> Cleanup 0 -dispose() --> Cleanup 1 +setCounter(1) -- Cleanup 0 +dispose() -- Cleanup 1 ``` Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`. @@ -262,8 +264,8 @@ effect(function() end) end) -setCounter(1) --> Outer: 1, Inner: 1 -setCounter(2) --> Outer: 2, Inner: 2 +setCounter(1) -- Outer: 1, Inner: 1 +setCounter(2) -- Outer: 2, Inner: 2 ``` > [!NOTE] @@ -283,29 +285,29 @@ effect(function() local runs = untracked(getRuns) + 1 setRuns(runs) print(`Count is {getCounter()}, ran {runs} times`) -end) --> Count is 0, ran 1 times +end) -- Count is 0, ran 1 time -setCounter(10) --> Count is 10, ran 2 times +setCounter(10) -- Count is 10, ran 2 times setRuns(10) -- No output -setCounter(20) --> Count is 20, ran 11 times +setCounter(20) -- Count is 20, ran 11 times ``` Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect: ```luau -local disposeInner -local disposeOuter = effect(function() +local stopEffect +local stopScope = effectScope(function() untracked(function() - disposeInner = effect(function() + stopEffect = effect(function() return function() - print("Cleaned up inner effect") + print("Cleaned up untracked effect") end end) end) end) -disposeOuter() -- No output; outer effect did not track inner effect -disposeInner() --> Cleaned up inner effect +stopScope() -- No output, the scope did not track the effect +stopEffect() -- Cleaned up untracked effect ``` --- @@ -318,18 +320,10 @@ Similar to `untracked()`, but does not prevent the parent effect from tracking n local getCounter, setCounter = signal(0) local disposeOuter = effect(function() - peek(function() - print(`Count is {getCounter()}`) - effect(function() - return function() - print("Cleaned up inner effect") - end - end) - end) -end) --> Count is 0 + print(`Count is {peek(getCounter)}`) +end) -- Count is 0 setCounter(1) -- No output; count was accessed in peek() -disposeOuter() --> Cleaned up inner effect ``` --- @@ -372,7 +366,7 @@ local dispose = effectScope(function() end) end) -setCounter(1) --> Count 1 is 1, Count 2 is 1 +setCounter(1) -- Count 1 is 1, Count 2 is 1 dispose() setCounter(2) -- No output; effects got disposed ``` @@ -392,9 +386,9 @@ local getCounter, setCounter = signal(0) listen(getCounter, function(count, prevCount) print(`Count is {count} (was {prevCount})`) -end) --> Count is 0 (was nil) +end) -- Count is 0 (was nil) -setCounter(1) --> Count is 1 (was 0) +setCounter(1) -- Count is 1 (was 0) ``` Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed. @@ -413,7 +407,7 @@ subscribe(getCounter, function(count, prevCount) print(`Count is {count} (was {prevCount})`) end) -setCounter(1) --> Count is 1 (was 0) +setCounter(1) -- Count is 1 (was 0) ``` Note that nested effects can be created inside `subscribe`, and they will clean up automatically when the subscription re-runs or gets disposed. @@ -432,9 +426,9 @@ observe(getItems, function(value, key) return function() print(`Removed {key}`) end -end) --> Added a, Added b +end) -- Added a, Added b -setItems({ a = 0, c = 0 }) --> Removed b, Added c +setItems({ a = 0, c = 0 }) -- Removed b, Added c ``` The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: @@ -456,9 +450,9 @@ local dispose = observe(getItems, function(value, key) end) end) -setItems({ a = 1, b = 0 }) --> Cleanup a = 0, a = 1 -setItems({ a = 1 }) --> Cleanup b = 0 -dispose() --> Cleanup a = 1 +setItems({ a = 1, b = 0 }) -- Cleanup a = 0, a = 1 +setItems({ a = 1 }) -- Cleanup b = 0 +dispose() -- Cleanup a = 1 ``` --- @@ -476,7 +470,7 @@ local getUppercase = mapped(getList, function(value) return string.upper(value) end) -print(getUppercase()) --> { "A", "B", "C" } +print(getUppercase()) -- { "A", "B", "C" } ``` If the mapper returns two values, the second value is used as the new key: @@ -488,7 +482,7 @@ local getSwapped = mapped(getList, function(value, key) return key, value end) -print(getSwapped()) --> { a = 1, b = 2, c = 3 } +print(getSwapped()) -- { a = 1, b = 2, c = 3 } ``` --- @@ -506,7 +500,7 @@ local dispose = effectScope(function() end) end) -dispose() --> Cleaned up +dispose() -- Cleaned up ``` --- @@ -520,7 +514,7 @@ If the atom is called with 0 arguments, the atom returns the current value and s ```luau local counter = atom(0) -print(counter()) --> 0 +print(counter()) -- 0 counter(1) counter(function(count) return count + 1 @@ -534,8 +528,8 @@ local max = atom(0, function(current, incoming) return incoming <= current end) -max(1) --> 1 -max(-1) --> 1 +max(1) -- 1 +max(-1) -- 1 ``` --- @@ -553,7 +547,7 @@ effect(function() setCounter(function(count) return math.min(count + 1, 3) end) -end) --> 0, 1, 2, 3 +end) -- 0, 1, 2, 3 ``` --- @@ -568,15 +562,15 @@ local length = computed(function() return #array() end) -print(length()) --> 0 +print(length()) -- 0 -- Direct mutation doesn't automatically trigger updates table.insert(array(), 1) -print(length()) --> Still 0 +print(length()) -- Still 0 -- Manually trigger updates trigger(array) -print(length()) --> 1 +print(length()) -- 1 ``` You can also trigger multiple signals at once: @@ -596,7 +590,7 @@ trigger(function() src2() end) -print(total()) --> 2 +print(total()) -- 2 ``` --- @@ -884,14 +878,14 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that - Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`. - [Read the updated docs for syncing state →](#client-server-sync) -3. The [`strict` and `frozen` flags](#globals) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors if you didn't use the old `__DEV__` flag. The flags have the following behavior: +3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors if you didn't use the old `__DEV__` flag. The flags have the following behavior: - `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations 4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent. - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`. - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). - - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`. + - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`globals.trackInnerEffects`](#flags) to `false`. > [!NOTE] > An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. From ed9566bfff547dba79613c2a85eed2ad9894e79a Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 15 Nov 2025 20:38:15 -0800 Subject: [PATCH 070/160] chore: prepare release candidate --- README.md | 64 ++++++++++--------- packages/charm-sync/package.json | 2 +- .../charm-sync/src/__tests__/client.spec.luau | 18 +++--- .../charm-sync/src/__tests__/e2e.spec.luau | 4 +- packages/charm-sync/src/client.luau | 6 +- packages/charm-sync/src/index.d.ts | 6 +- packages/charm-sync/wally.toml | 4 +- packages/charm/package.json | 2 +- packages/charm/src/init.luau | 6 +- packages/charm/wally.toml | 2 +- packages/react-charm/package.json | 2 +- packages/react-charm/wally.toml | 4 +- packages/vide-charm/package.json | 2 +- packages/vide-charm/wally.toml | 4 +- 14 files changed, 67 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 5670f9b..46e04d8 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,9 @@ - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - - [`trigger(callback)`](#triggercallback) - - [`flags`](#flags) + - [`recursive()`](#recursive) + - [`trigger(callback)`](#triggercallback) + - [`flags`](#flags) - [Client-Server Sync](#client-server-sync) - [Installation](#installation-1) - [Quick Start](#quick-start) @@ -229,9 +230,10 @@ Effects are fundamental to reactivity, allowing you to react to signal updates. ```luau local getCounter, setCounter = signal(0) +-- Count is 0 effect(function() print(`Count is {getCounter()}`) -end) -- Count is 0 +end) setCounter(1) -- Count is 1 ``` @@ -269,7 +271,7 @@ setCounter(2) -- Outer: 2, Inner: 2 ``` > [!NOTE] -> To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `globals.trackInnerEffects` flag to assist with debugging. +> To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `flags.trackInnerEffects` flag to assist with debugging. --- @@ -278,18 +280,17 @@ setCounter(2) -- Outer: 2, Inner: 2 In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked. ```luau -local getCounter, setCounter = signal(0) -local getRuns, setRuns = signal(0) +local getTracked, setTracked = signal(0) +local getUntracked, setUntracked = signal(0) +-- Tracked: 0, Untracked: 0 effect(function() - local runs = untracked(getRuns) + 1 - setRuns(runs) - print(`Count is {getCounter()}, ran {runs} times`) -end) -- Count is 0, ran 1 time + print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`) +end) -setCounter(10) -- Count is 10, ran 2 times -setRuns(10) -- No output -setCounter(20) -- Count is 20, ran 11 times +setTracked(1) -- Tracked: 1, Untracked: 0 +setUntracked(1) -- No output +setTracked(2) -- Tracked: 2, Untracked: 1 ``` Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect: @@ -319,9 +320,10 @@ Similar to `untracked()`, but does not prevent the parent effect from tracking n ```luau local getCounter, setCounter = signal(0) +-- Count is 0 local disposeOuter = effect(function() print(`Count is {peek(getCounter)}`) -end) -- Count is 0 +end) setCounter(1) -- No output; count was accessed in peek() ``` @@ -384,9 +386,10 @@ The callback also receives the previous value, or `nil` when running for the fir ```luau local getCounter, setCounter = signal(0) +-- Count is 0 (was nil) listen(getCounter, function(count, prevCount) print(`Count is {count} (was {prevCount})`) -end) -- Count is 0 (was nil) +end) setCounter(1) -- Count is 1 (was 0) ``` @@ -421,12 +424,13 @@ Note that nested effects can be created inside `subscribe`, and they will clean ```luau local getItems, setItems = signal({ a = 0, b = 0 }) +-- Added a, Added b observe(getItems, function(value, key) print(`Added {key}`) return function() print(`Removed {key}`) end -end) -- Added a, Added b +end) setItems({ a = 0, c = 0 }) -- Removed b, Added c ``` @@ -547,7 +551,9 @@ effect(function() setCounter(function(count) return math.min(count + 1, 3) end) -end) -- 0, 1, 2, 3 +end) + +-- Output: 0, 1, 2, 3 ``` --- @@ -675,7 +681,7 @@ end) To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. -After the client receives updates from the server, call `client.hydrate` to patch the client's signals with the incoming state updates. +After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau -- Add signal setters or atoms @@ -686,7 +692,7 @@ client.addSignals({ -- Update client signals when receiving updates from the server syncEvent.OnClientEvent:Connect(function(updates) - client.hydrate(updates) + client.patch(updates) end) ``` @@ -798,7 +804,7 @@ server.flush() Subscribes the given writable signals to the states with the corresponding keys on the server. When the server sends updates, the functions associated with each key in the state will be called with the patched values. -You should pass either pass signal setter functions or atoms to this function: +You can pass either writable signals or atoms to this function: ```luau client.addSignals({ @@ -823,11 +829,11 @@ client.removeSignals("name") --- -### `client.hydrate(updates)` +### `client.patch(updates)` -The `hydrate` function patches the client's state with the updates sent from the server. The initial update sent by the server will be the full state, and later updates will only include the values that changed. +The `patch` function patches the client's state with the updates sent from the server. The initial update sent by the server will be the full state, and later updates will only include the values that changed. -You should call `hydrate` when receiving updates from the server from a remote event: +You should call `patch` when receiving updates from the server from a remote event: ```luau client.addSignals({ @@ -836,7 +842,7 @@ client.addSignals({ }) syncEvent.OnClientEvent:Connect(function(updates) - client.hydrate(updates) + client.patch(updates) end) ``` @@ -885,22 +891,22 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that 4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent. - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`. - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). - - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`globals.trackInnerEffects`](#flags) to `false`. + - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`. > [!NOTE] > An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. > > This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. -5. Recursion is disallowed in effects and computed signals now that Charm uses the [alien-signals algorithm](https://github.com/stackblitz/alien-signals). This change may introduce bugs in code relying on the old behavior. +5. Recursion is now disallowed in effects and computed signals by default. This change may introduce bugs in code relying on the old behavior. - Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback. 6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! - Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit - - Added [`listen()`](#listengetter-callback) for running a subscription once immediately + - Added [`listen()`](#listengetter-callback) to create a subscription that runs once immediately - Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once - - Added [`onCleanup()`](#oncleanupcallback-failsilently) for running code when the currently-running effect, subscription, or observer cleans up - - Added [`trigger()`](#triggercallback) for triggering updates for table mutations + - Added [`onCleanup()`](#oncleanupcallback-failsilently) to bind a cleanup function to the active effect + - Added [`trigger()`](#triggercallback) to trigger updates for table mutations - The [`computed()`](#computedgetter) callback now gets called with the previous computed value - Optimized `computed()` to use lazy evaluation instead of eager updates - Optimized `mapped()` to only map values that changed in the original table diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 129cf4a..d41be6c 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.3.0", + "version": "0.4.0-rc.1", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index 4b13e1d..29a172e 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -13,7 +13,7 @@ test("should hydrate atom", function() local a = Charm.atom(1) client.addSignals({ a = a }) - client.hydrate({ + client.patch({ { type = "init", data = { a = 2 } }, }) @@ -25,13 +25,13 @@ test("should merge patch", function() local previous = a() client.addSignals({ a = a }) - client.hydrate({ + client.patch({ { type = "init", data = { a = a() } }, }) assert(a() == previous, "should set a to the initial value") - client.hydrate({ + client.patch({ { type = "patch", data = { a = { b = 3 } } }, }) @@ -44,14 +44,14 @@ test("should fix sparse array patches", function() local a = Charm.atom({ 1 }) client.addSignals({ a = a }) - client.hydrate({ + client.patch({ { type = "init", data = { a = a() } }, }) -- This patch would be the result of a diff between { 1 } and { 1, 2 } that -- was JSON-serialized and deserialized. The client should be able to catch -- this and convert the string key back to a number. - client.hydrate({ + client.patch({ { type = "patch", data = { a = { ["2"] = 2 } } }, }) @@ -69,11 +69,11 @@ test("should merge multiple patches", function() table.insert(calls, value) end) - client.hydrate({ + client.patch({ { type = "init", data = { a = a() } }, }) - client.hydrate({ + client.patch({ { type = "patch", data = { a = 2 } }, { type = "patch", data = { a = 3 } }, }) @@ -97,11 +97,11 @@ test("should batch patches", function() states.a = a() end) - client.hydrate({ + client.patch({ { type = "init", data = { a = a(), b = b() } }, }) - client.hydrate({ + client.patch({ { type = "patch", data = { a = 1, b = 2 } }, }) diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau index f0cae67..09c036c 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -24,7 +24,7 @@ test("should replicate server state to client", function() server.addSignalsToClient(target, { a = serverA, b = serverB }) server.connect(function(syncTarget, payloads) if syncTarget == target then - client.hydrate(payloads) + client.patch(payloads) end end) @@ -58,7 +58,7 @@ test("should replicate historical server state to client", function() server.addSignalsToClient(target, { a = serverA, b = serverB }) server.connect(function(syncTarget, payloads) if syncTarget == target then - client.hydrate(payloads) + client.patch(payloads) end end) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 8392792..2f98bf3 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -62,7 +62,8 @@ end --- initializing it with the full state or merging a partial patch. --- --- @param payloads The state updates received from the server. -local function hydrate(payloads: { SyncPayload }) +local function patch(payloads: { SyncPayload }) + debug.profilebegin("CharmSync") for _, payload in payloads do if payload.type == "init" then Charm.batch(applyInitPayload, payload.data) @@ -70,11 +71,12 @@ local function hydrate(payloads: { SyncPayload }) Charm.batch(applyPatchPayload, payload.data) end end + debug.profileend() end return { addSignals = addSignals, removeSignals = removeSignals, removeAllSignals = removeAllSignals, - hydrate = hydrate, + patch = patch, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 3fed7a8..69381b6 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -1,4 +1,4 @@ -import { Atom, Getter, Setter } from "@rbxts/charm"; +import { Atom, Setter } from "@rbxts/charm"; type Key = string | number | symbol; @@ -11,7 +11,7 @@ export interface None { export type MaybeNone = undefined extends T ? None : never; -type GetterMap = Record>; +type GetterMap = Record any>; type SetterMap = Record>; /** @@ -134,7 +134,7 @@ export namespace client { * * @param payloads The state updates received from the server. */ - export function hydrate( + export function patch( payloads: SyncPayload[], ): void; } diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index c5fc5a0..6d6257c 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.3.0" +version = "0.4.0-rc.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.10" +Charm = "littensy/charm@0.11.0-rc.1" diff --git a/packages/charm/package.json b/packages/charm/package.json index 67c51c8..f4167c5 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.10.0", + "version": "0.11.0-rc.1", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 6ba6de2..e1fc19f 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -367,10 +367,10 @@ end --- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom local getter, setter = signal(initialValue, equals) - local function atomGetterSetter(...) + local function atomOper(...) return if select("#", ...) == 0 then getter() else setter((...)) end - return atomGetterSetter + return atomOper end --- Creates a new read-only signal that is computed based on the values of @@ -565,7 +565,7 @@ end --- @return A computed signal that tracks the value returned by the getter. local function toSignal(fn: () -> T): () -> T local name = debug.info(fn, "n") - return if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then fn else computed(fn) + return if name == "signalGetter" or name == "computedGetter" or name == "atomOper" then fn else computed(fn) end --- Creates an effect that runs the callback one immediately, and then again diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index cd94b61..ada6018 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.10.0" +version = "0.11.0-rc.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index eeaeefb..9d73025 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.3.0", + "version": "0.4.0-rc.1", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index cc76252..8407302 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.3.0" +version = "0.4.0-rc.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.10" +Charm = "littensy/charm@0.11.0-rc.1" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 4182564..b9ee228 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.3.0", + "version": "0.4.0-rc.1", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 9e88a0f..dfc2517 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.3.0" +version = "0.4.0-rc.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.10" +Charm = "littensy/charm@0.11.0-rc.1" Vide = "centau/vide@^0.3" From e583574751092b59e236c72de621493ca30ea25e Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 15 Nov 2025 20:41:38 -0800 Subject: [PATCH 071/160] test: make some tests more reliable --- package.json | 2 +- packages/charm-sync/src/client.luau | 2 -- packages/charm/src/__tests__/gc.spec.luau | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 86aa760..b9b8739 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.17.1", + "packageManager": "pnpm@10.22.0", "devDependencies": { "@eslint/js": "^9.36.0", "@rbxts/compiler-types": "3.0.0-types.0", diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 2f98bf3..3427726 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -63,7 +63,6 @@ end --- --- @param payloads The state updates received from the server. local function patch(payloads: { SyncPayload }) - debug.profilebegin("CharmSync") for _, payload in payloads do if payload.type == "init" then Charm.batch(applyInitPayload, payload.data) @@ -71,7 +70,6 @@ local function patch(payloads: { SyncPayload }) Charm.batch(applyPatchPayload, payload.data) end end - debug.profileend() end return { diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau index 4a73233..4189f87 100644 --- a/packages/charm/src/__tests__/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -14,7 +14,7 @@ end --- Triggers garbage collection by creating a large number of tables. local function gc() - for _ = 1, 1000 do + for _ = 1, 2000 do local _ = table.create(100, 0) end end From 7bad21556091520048bf61436d3451a3dc35bd9b Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 16 Nov 2025 07:02:38 -0800 Subject: [PATCH 072/160] docs: fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 46e04d8..e6172b6 100644 --- a/README.md +++ b/README.md @@ -715,7 +715,7 @@ A configuration table that customizes the behavior of Charm Sync on the server. The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. -You can pass signal getter functions, computed signals, and atoms in the `getters` table. This function can also be called multiple on the same client to subscribe to new signals. +You can pass signal getter functions, computed signals, and atoms in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. ```luau Players.PlayerAdded:Connect(function(player) From bd23d54374bffb39542aa98f4d8af12dca2c3043 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:39:37 -0800 Subject: [PATCH 073/160] feat: add `reactive()` --- README.md | 79 ++- benches/mapped.luau | 4 + benches/patch.bench.luau | 54 -- benches/reactive.luau | 87 +++ .../charm-sync/src/__tests__/client.spec.luau | 25 + .../charm-sync/src/__tests__/e2e.spec.luau | 35 +- .../charm-sync/src/__tests__/patch.spec.luau | 48 +- .../charm-sync/src/__tests__/server.spec.luau | 73 ++- packages/charm-sync/src/__tests__/spec.luau | 4 - .../src/__tests__/validate.spec.luau | 24 - packages/charm-sync/src/client.luau | 89 ++-- packages/charm-sync/src/config.luau | 36 +- packages/charm-sync/src/index.d.ts | 33 +- packages/charm-sync/src/patch.luau | 119 +++-- packages/charm-sync/src/server.luau | 163 ++++-- packages/charm-sync/src/types.luau | 12 +- packages/charm-sync/src/utils.luau | 42 +- packages/charm-sync/src/validate.luau | 26 +- packages/charm/src/__tests__/gc.spec.luau | 26 +- .../charm/src/__tests__/reactive.spec.luau | 124 +++++ packages/charm/src/__tests__/spec.luau | 3 + packages/charm/src/index.d.ts | 30 ++ packages/charm/src/init.luau | 500 +++++++++++------- packages/react-charm/src/init.luau | 42 +- packages/vide-charm/src/init.luau | 12 +- rokit.toml | 8 +- 26 files changed, 1129 insertions(+), 569 deletions(-) delete mode 100644 benches/patch.bench.luau create mode 100644 benches/reactive.luau delete mode 100644 packages/charm-sync/src/__tests__/validate.spec.luau create mode 100644 packages/charm/src/__tests__/reactive.spec.luau diff --git a/README.md b/README.md index e6172b6..a550946 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,9 @@ - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`recursive()`](#recursive) - - [`trigger(callback)`](#triggercallback) - - [`flags`](#flags) + - [`trigger(callback)`](#triggercallback) + - [`reactive(initialValue)`](#reactiveinitialvalue) + - [`flags`](#flags) - [Client-Server Sync](#client-server-sync) - [Installation](#installation-1) - [Quick Start](#quick-start) @@ -601,6 +602,44 @@ print(total()) -- 2 --- +### `reactive(initialValue)` + +The `reactive()` function creates a deeply reactive value that tracks properties when they're accessed. It returns a setter function where you can make changes by mutating the original table. + +```luau +local users, setUsers = reactive({ + user = { name = "John", surname = "Doe" }, +}) + +-- Output: John Doe +effect(function() + print(`{users.user.name} {users.user.surname}`) +end) + +-- Output: Jane Smith +setUsers(function(state) + state.user.name = "Jane" + state.user.surname = "Smith" +end) +``` + +You can also set properties on the reactive value directly: + +```luau +local id = reactive({ name = "John", surname = "Doe" }) + +effect(function() + print(`{id.name} {id.surname}`) +end) + +id.name = "Jane" -- Jane Doe +id.surname = "Smith" -- Jane Smith +``` + +Be careful when mutating the reactive value: because it's a proxy table, functions like `table.insert` will not work. Table functions should be called on the original table provided by the setter function. + +--- + ### `flags` Charm exposes the following global flags to customize behavior: @@ -638,12 +677,14 @@ Start by specifying the signals that the server should sync to clients. For this -- nameStore local getName, setName = signal("John") local getSurname, setSurname = signal("Doe") +local ageAtom = atom(20) return { getName = getName, setName = setName, getSurname = getSurname, setSurname = setSurname, + ageAtom = ageAtom, } ``` @@ -653,10 +694,11 @@ Then, use `server.connect` to specify how state updates should be sent to each c ```luau local function onPlayerAdded(player: Player) - -- Add signal getters, computed signals, or atoms + -- Add signal getters, computed signals, atoms, or reactive objects server.addSignalsToClient(player, { name = nameStore.getName, surname = nameStore.getSurname, + age = nameStore.ageAtom, }) end @@ -684,10 +726,11 @@ To sync the client with the server's state, call `client.addSignals` with a tabl After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau --- Add signal setters or atoms +-- Add signal setters, atoms, or reactive objects client.addSignals({ name = nameStore.setName, surname = nameStore.setSurname, + age = nameStore.ageAtom, }) -- Update client signals when receiving updates from the server @@ -715,13 +758,14 @@ A configuration table that customizes the behavior of Charm Sync on the server. The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. -You can pass signal getter functions, computed signals, and atoms in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. +You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. ```luau Players.PlayerAdded:Connect(function(player) server.addSignalsToClient(player, { name = nameStore.getName, surname = nameStore.getSurname, + age = nameStore.ageAtom, }) end) ``` @@ -744,6 +788,7 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke ```luau server.addSignalsToClient(player, { + name = nameStore.getName, surname = nameStore.getSurname, }) @@ -804,12 +849,13 @@ server.flush() Subscribes the given writable signals to the states with the corresponding keys on the server. When the server sends updates, the functions associated with each key in the state will be called with the patched values. -You can pass either writable signals or atoms to this function: +You can pass either writable signals, atoms, or [reactive objects](#reactiveinitialvalue) to this function: ```luau client.addSignals({ name = nameStore.setName, surname = nameStore.setSurname, + age = nameStore.ageAtom, }) ``` @@ -822,6 +868,7 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta ```luau client.addSignals({ name = nameStore.setName, + surname = nameStore.setSurname, }) client.removeSignals("name") @@ -848,13 +895,29 @@ end) --- +### `signalToAtom(getter, setter)` + +If you have a lot of signals to sync between the server and clients, it might become difficult to keep track of many getters and setters. The `signalToAtom()` function unifies a signal's `get()` and `set()` functions, allowing you to reuse the same values for `client.addSignals` and `server.addSignalsToClient`. + +```luau +local sharedState = { + name = signalToAtom(nameStore.getName, nameStore.setName), + surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), +} + +client.addSignals(sharedState) +server.addSignalsToClient(client, sharedState) +``` + +--- + ### Sync Caveats -Charm Sync will only send clients the differences between the current state and the previously-synced state, which is a practice called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. +Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`. -This means nilable values can be represented as `None`, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. +This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. --- diff --git a/benches/mapped.luau b/benches/mapped.luau index 6b6d5dc..27ca6bd 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -58,7 +58,9 @@ for _, percent in { 0.1, 0.5, 1 } do bench.profilebegin("init") out() bench.profileend() + setSrc(updatePercentage(percent)) + bench.profilebegin("update") out() bench.profileend() @@ -71,7 +73,9 @@ for _, percent in { 0.1, 0.5, 1 } do bench.profilebegin("init") out() bench.profileend() + setSrc(updatePercentage(percent)) + bench.profilebegin("update") out() bench.profileend() diff --git a/benches/patch.bench.luau b/benches/patch.bench.luau deleted file mode 100644 index dc8f918..0000000 --- a/benches/patch.bench.luau +++ /dev/null @@ -1,54 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local CharmSync = require(ReplicatedStorage.packages["charm-sync"].src) - -local patch = CharmSync.patch - -local USERS = 1000 - -local function generate() - local users = {} - - for user = 1, USERS do - users[user] = { - coins = math.random(1, 2), - gems = math.random(1, 2), - } - end - - return { users = users } -end - -local function update(state) - state = table.clone(state) - state.users = table.clone(state.users) - - for user = 1, USERS do - local rng = math.random() - - if rng < 1 / 3 then - continue - elseif rng < 2 / 3 then - state.users[user] = nil - else - state.users[user] = { - coins = math.random(1, 2), - gems = math.random(1, 2), - } - end - end - - return state -end - -return { - ParameterGenerator = function() - local state = generate() - return state, update(state) - end, - Functions = { - diff = function(_, a, b) - patch.diff(a, b) - end, - }, -} diff --git a/benches/reactive.luau b/benches/reactive.luau new file mode 100644 index 0000000..2e499ca --- /dev/null +++ b/benches/reactive.luau @@ -0,0 +1,87 @@ +local Charm = require("../packages/charm/src") +local bench = require("./bench") + +local effect = Charm.effect +local reactive = Charm.reactive + +do + local value = reactive({ a = 1 }) + bench.run("track reactive property", function() + effect(function() + local _ = value.a + end) + end) +end + +do + local value = reactive({ a = 1 }) + bench.run("write reactive property", function() + value.a += 1 + end) +end + +for _, amount in { 10, 100, 1000 } do + print("---") + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + bench.run(`track for loop, {amount} elements`, function() + local sum = 0 + effect(function() + for i = 1, #array do + sum += array[i] + end + end) + end) + end + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + bench.run(`track iteration, {amount} elements`, function() + local sum = 0 + effect(function() + for _, x in array do + sum += x + end + end) + end) + end + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + effect(function() + local sum = 0 + for _, x in array do + sum += x + end + end) + + bench.run(`track index mutation (1st only), {amount} elements`, function() + array[1] += 1 + end) + end + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + effect(function() + local sum = 0 + for _, x in array do + sum += x + end + end) + + bench.run(`track index mutation (all), {amount} elements`, function() + for i = 1, #array do + array[i] += 1 + end + end) + end +end diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index 29a172e..10164a2 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -40,6 +40,31 @@ test("should merge patch", function() assert(a().c == 2, "should not change c") end) +test("should merge patch (reactive)", function() + local a = Charm.reactive({ b = 1, c = 2 }) + local runs = 0 + + Charm.effect(function() + local _ = a.b + a.c + runs += 1 + end) + + client.addSignals({ a = a }) + client.patch({ + { type = "init", data = { a = { b = a.b, c = a.c } } }, + }) + + assert(runs == 1, "should not trigger effect for redundant init") + + client.patch({ + { type = "patch", data = { a = { b = 3 } } }, + }) + + assert(a.b == 3, "should set b to the new value") + assert(a.c == 2, "should not change c") + assert(runs == 2, "should trigger effect for patch") +end) + test("should fix sparse array patches", function() local a = Charm.atom({ 1 }) diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau index 09c036c..d8da3aa 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -16,9 +16,9 @@ end) test("should replicate server state to client", function() local target = {} local serverA = Charm.atom({ count = 0 }) - local serverB = Charm.atom({ count = 0 }) + local serverB = Charm.reactive({ count = 0 }) local clientA = Charm.atom({ count = -1 }) - local clientB = Charm.atom({ count = -1 }) + local clientB = Charm.reactive({ count = -1 }) client.addSignals({ a = clientA, b = clientB }) server.addSignalsToClient(target, { a = serverA, b = serverB }) @@ -30,27 +30,32 @@ test("should replicate server state to client", function() server.flush() assert(clientA().count == 0, "Client atom A should be initialized to 0") - assert(clientB().count == 0, "Client atom B should be initialized to 0") + assert(clientB.count == 0, "Client reactive B should be initialized to 0") serverA({ count = 1 }) - serverB({ count = 1 }) + serverB.count = 1 server.flush() assert(clientA().count == 1, "Client atom A should be updated to 1") - assert(clientB().count == 1, "Client atom B should be updated to 1") + assert(clientB.count == 1, "Client reactive B should be updated to 1") + + local runs = -1 + Charm.effect(function() + local _ = clientB.count + runs += 1 + end) - local prevClientB = clientB() serverA({ count = 2 }) server.flush() assert(clientA().count == 2, "Client atom A should be updated to 2") - assert(clientB() == prevClientB, "Client atom B should not be updated") + assert(runs == 0, "Client reactive B should not trigger update") end) test("should replicate historical server state to client", function() local target = {} local serverA = Charm.atom({ count = 0 }) - local serverB = Charm.atom({ count = 0 }) + local serverB = Charm.reactive({ count = 0 }) local clientA = Charm.atom({ count = -1 }) - local clientB = Charm.atom({ count = -1 }) + local clientB = Charm.reactive({ count = -1 }) config.preserveHistory = true @@ -67,30 +72,28 @@ test("should replicate historical server state to client", function() Charm.effect(function() local expected = table.remove(expectedA, 1) - assert(expected == clientA().count, `Client atom A should be {expected}`) + assert(expected == clientA().count, `Client reactive A should be {expected}`) end) Charm.effect(function() local expected = table.remove(expectedB, 1) - assert(expected == clientB().count, `Client atom B should be {expected}`) + assert(expected == clientB.count, `Client reactive B should be {expected}`) end) server.flush() serverA({ count = 1 }) serverA({ count = 2 }) - serverB({ count = 1 }) - serverB({ count = 2 }) + serverB.count = 1 + serverB.count = 2 server.flush() assert(clientA().count == 2, "Client atom A should be updated to 2") - assert(clientB().count == 2, "Client atom B should be updated to 2") + assert(clientB.count == 2, "Client reactive B should be updated to 2") - local prevClientB = clientB() serverA({ count = 3 }) serverA({ count = 4 }) server.flush() assert(clientA().count == 4, "Client atom A should be updated to 4") - assert(clientB() == prevClientB, "Client atom B should not be updated") end) return {} diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index a62b456..65c0e45 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -31,17 +31,17 @@ end) test("returns a patch", function() local prevState = { foo = "bar", baz = "qux" } local nextState = { foo = "bar", baz = "quux" } - local patches = patch.diff(prevState, nextState) + local diffs = patch.diff(prevState, nextState) - assert(patches ~= nil, "should return a patch") - assert(patches.baz == "quux", "should return the changed value") - assert(patches.foo == nil, "should omit unchanged values") + assert(diffs ~= nil, "should return a patch") + assert(diffs.baz == "quux", "should return the changed value") + assert(diffs.foo == nil, "should omit unchanged values") end) test("applies a patch", function() local prevState = { foo = "bar", baz = "qux" } - local patches = { baz = "quux", qaz = "qux" } - local applied = patch.apply(prevState, patches) + local diffs = { baz = "quux", qaz = "qux" } + local applied = patch.apply(prevState, diffs) assert(applied ~= nil, "should return a new state") assert(applied.baz == "quux", "should apply the changed value") @@ -49,11 +49,11 @@ test("applies a patch", function() assert(applied.qaz == "qux", "should add the new value") end) -test("allows nested patches", function() +test("allows nested diffs", function() local prevState = { foo = { bar = "baz" } } local nextState = { foo = { bar = "qux" } } - local patches = patch.diff(prevState, nextState) - local applied = patch.apply(prevState, patches) + local diffs = patch.diff(prevState, nextState) + local applied = patch.apply(prevState, diffs) assert(applied.foo.bar == "qux", "should apply the nested patch") end) @@ -78,8 +78,8 @@ test("preserves array density after serdes", function() local prevState = { "a", "b", "c", "d", "e" } local nextState = { "A", "b", "c", "D" } - local patches = mockArraySerdes(patch.diff(prevState, nextState)) - local applied = patch.apply(prevState, patches) + local diffs = mockArraySerdes(patch.diff(prevState, nextState)) + local applied = patch.apply(prevState, diffs) for index in prevState do assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) @@ -96,10 +96,10 @@ test("cannot preserve sparse arrays", function() local prevState = {} local nextState = { [2] = "foo" } - local patches = mockArraySerdes(patch.diff(prevState, nextState)) - local applied = patch.apply(prevState, patches) + local diffs = mockArraySerdes(patch.diff(prevState, nextState)) + local applied = patch.apply(prevState, diffs) - -- Not enough information is available to determine whether 'patches' + -- Not enough information is available to determine whether 'diffs' -- should keep the string keys added during remote event serialization. assert(applied["2"] ~= nil, "will fail to convert string keys to numbers") assert(applied[2] == nil, "will fail to convert string keys to numbers") @@ -119,20 +119,12 @@ test("optionally fixes sparse arrays", function() end end) -test("optionally validates state", function() - local success = pcall(function() - config.validatePatches = true - patch.diff({}, { [1] = 1, a = true }) - end) - - assert(not success, "should not allow mixed tables") - - success = pcall(function() - config.validatePatches = false - patch.diff({}, { [1] = 1, a = true }) - end) - - assert(success, "should skip validation if not requested") +test("patches mutable state", function() + local targetState: { [string]: string } = { foo = "bar", baz = "qux" } + patch.applyMutable(targetState, { baz = "quux", qaz = "qux" }) + assert(targetState.baz == "quux", "should apply the changed value") + assert(targetState.foo == "bar", "should keep the unchanged value") + assert(targetState.qaz == "qux", "should add the new value") end) return {} diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau index f26b753..4a30449 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -69,6 +69,33 @@ test("should replicate partial state patches", function() assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None") end) +test("should replicate partial state patches (reactive)", function() + local client = {} + local a: { [string]: number? } = Charm.reactive({ x = 0, y = 0, z = 0 }) + local target, payloads + + server.addSignalsToClient(client, { a = a }) + server.connect(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a.x == a.x, "Expected init payload to contain value 'a.x'") + assert(payloads[1].data.a.y == a.y, "Expected init payload to contain value 'a.y'") + assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'") + + a.x = 1 + a.z = nil + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a.x == 1, "Expected patch payload to contain value 'a.x'") + assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain value 'a.y'") + assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None") +end) + test("should init new clients", function() local clientA = {} local clientB = {} @@ -89,8 +116,8 @@ test("should init new clients", function() assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") - server.addSignalsToClient(clientB, { a = a }) a(2) + server.addSignalsToClient(clientB, { a = a }) server.flush() assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A") @@ -166,20 +193,6 @@ test("should only replicate to synced clients", function() assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B") end) -test("should validate state", function() - local client = {} - local a = Charm.atom({}) - - server.addSignalsToClient(client, { a = a }) - server.connect(function() end) - - a({ [0.5] = true }) - assert(not pcall(server.flush), "Expected server.flush to fail") - - a({ [0.5] = true, valid = true }) - server.flush() -end) - test("should serialize state", function() local client = {} local a = Charm.atom({ 1, 2, 3, 4, 5 }) @@ -236,6 +249,36 @@ describe("historical", function() assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'") end) + test("should replicate granular state changes (reactive)", function() + local client = {} + local a = Charm.reactive({ value = 0 }) + local b = Charm.reactive({ value = 0 }) + local c = Charm.reactive({ value = 0 }) + local target, payloads + + server.addSignalsToClient(client, { a = a, b = b, c = c }) + server.connect(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'") + + a.value = 1 + a.value = 2 + b.value = 2 + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'") + assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'") + end) + test("should init new clients", function() local clientA = {} local clientB = {} diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau index 5f8513c..8d07f04 100644 --- a/packages/charm-sync/src/__tests__/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -20,8 +20,4 @@ suite.describe("server", function() require("./server.spec") end) -suite.describe("validate", function() - require("./validate.spec") -end) - return {} diff --git a/packages/charm-sync/src/__tests__/validate.spec.luau b/packages/charm-sync/src/__tests__/validate.spec.luau deleted file mode 100644 index 1f107fd..0000000 --- a/packages/charm-sync/src/__tests__/validate.spec.luau +++ /dev/null @@ -1,24 +0,0 @@ -local suite = require("../../../../test/suite") -local validate = require("../validate") - -local test = suite.test - -test("validates inputs", function() - local cases: { [string]: { value: any, expected: any } } = { - array = { value = { 1, 2, 3 }, expected = true }, - arraySparse = { value = { 1, nil :: any, 3 }, expected = true }, - arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false }, - mixedTable = { value = { [1] = "foo", a = true }, expected = false }, - } - - for key, case in cases do - local success, result: any = pcall(validate, case.value) - 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) - -return {} diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 3427726..43103a7 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -4,64 +4,89 @@ local types = require("./types") type SyncPayload = types.SyncPayload -type Setter = (update: ((T) -> T) | T) -> () +type SignalSetter = (update: ((T) -> T) | T) -> () -local trackedSetters: { [string]: Setter } = {} +type ReactiveSetter = (update: ((T) -> ()) | T) -> () ---- Registers a map of setters to the state with the corresponding keys on the ---- server. When the server sends updates, these setters will be called with ---- the new values. ---- ---- Atoms, signals converted to atoms, and signal setter functions are allowed. ---- Note that `server.addSignalsToClient` still requires some way to get the ---- state. ---- ---- @param setters A map of setter functions to sync with the server. -local function addSignals(setters: { [string]: Setter }) - for key, setter in setters do - trackedSetters[key] = setter +type Reactive = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter })) + +local trackedSignals: { [string]: SignalSetter } = {} +local trackedReactives: { [string]: ReactiveSetter } = {} + +--[[ + Registers a map of setters to the state with the corresponding keys on the + server. When the server sends updates, these setters will be called with + the new values. + + Atoms, signals converted to atoms, and signal setter functions are allowed. + Note that `server.addSignalsToClient` still requires some way to get the + state. + + @param signals A map of setter functions to sync with the server. +]] +local function addSignals(signals: { [string]: SignalSetter | { [any]: any } }) + for key, signal in signals do + if type(signal) == "function" then + trackedSignals[key] = signal + else + trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue + end end end ---- Unregisters from server state updates for the given keys. The signals ---- will retain their current values, but will no longer receive updates ---- from the server. +--[[ + Unregisters from server state updates for the given keys. The signals + will retain their current values, but will no longer receive updates + from the server. +]] local function removeSignals(...: string) for index = 1, select("#", ...) do local key = select(index, ...) - trackedSetters[key] = nil + trackedSignals[key] = nil + trackedReactives[key] = nil end end ---- Unsubscribes from all server state updates. +--[[ + Unsubscribes from all server state updates. +]] local function removeAllSignals() - table.clear(trackedSetters) + table.clear(trackedSignals) + table.clear(trackedReactives) end local function applyInitPayload(data: { [string]: any }) for key, value in data do - local setter = trackedSetters[key] - if setter then - setter(value) + local trackedSignal: SignalSetter? = trackedSignals[key] or trackedReactives[key] + if trackedSignal then + trackedSignal(value) end end end local function applyPatchPayload(data: { [string]: any }) - for key, value in data do - local setter = trackedSetters[key] - if setter then - setter(function(current) - return patch.apply(current, value) + for key, diffs in data do + local trackedSignal = trackedSignals[key] + local trackedReactive = trackedReactives[key] + + if trackedSignal then + trackedSignal(function(value) + return patch.apply(value, diffs) + end) + elseif trackedReactive then + trackedReactive(function(value) + patch.applyMutable(value, diffs) end) end end end ---- Merges the server's state patches into the client's state, either ---- initializing it with the full state or merging a partial patch. ---- ---- @param payloads The state updates received from the server. +--[[ + Merges the server's state patches into the client's state, either + initializing it with the full state or merging a partial patch. + + @param payloads The state updates received from the server. +]] local function patch(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau index 9b528fc..a101f05 100644 --- a/packages/charm-sync/src/config.luau +++ b/packages/charm-sync/src/config.luau @@ -1,20 +1,28 @@ return { - --- The interval at which to send patches to the client, in seconds. - --- Defaults to `0` (patches are sent up to once per frame). Set to a - --- negative value to disable automatic syncing. + --[[ + The interval at which to send patches to the client, in seconds. + Defaults to `0` (patches are sent up to once per frame). Set to a + negative value to disable automatic syncing. + ]] interval = 0, - --- Whether the history of state changes since the client's last update - --- should be preserved. This is useful for values that change multiple - --- times per frame, where each individual change is important. Defaults - --- to `false`. + --[[ + Whether the history of state changes since the client's last update + should be preserved. This is useful for values that change multiple + times per frame, where each individual change is important. Defaults + to `false`. + ]] preserveHistory = false, - --- When `true`, Charm will apply validation and serialize unsafe arrays - --- to address remote event argument limitations. Defaults to `true`. - --- This option should be disabled if your network library uses a custom - --- serialization method (i.e. Zap, ByteNet) to prevent interference. + --[[ + When `true`, Charm will apply validation and serialize unsafe arrays + to address remote event argument limitations. Defaults to `true`. + This option should be disabled if your network library uses a custom + serialization method (i.e. Zap, ByteNet) to prevent interference. + ]] fixArrays = true, - --- When `true`, synced state containing unsafe sparse arrays or mixed - --- tables will throw an error. Only checked in strict mode and if - --- `fixArrays` is enabled. Defaults to `true`. + --[[ + When `true`, synced state containing unsafe sparse arrays or mixed + tables will throw an error. Only checked in strict mode and if + `fixArrays` is enabled. Defaults to `true`. + ]] validatePatches = true, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 69381b6..6519951 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -11,8 +11,11 @@ export interface None { export type MaybeNone = undefined extends T ? None : never; -type GetterMap = Record any>; -type SetterMap = Record>; +type ReactiveObject = { [key: Key]: any }; + +type ServerSignalMap = Record any) | ReactiveObject>; + +type ClientSignalMap = Record | ReactiveObject>; /** * Infers the type of the return values produced by a map of functions. @@ -55,7 +58,7 @@ export type SyncPatch = MaybeNone = +export type SyncPayload = | { type: "init"; data: StateOfMap } | { type: "patch"; data: SyncPatch, FixArrays> }; @@ -112,16 +115,18 @@ export namespace client { * Note that `server.addSignalsToClient` still requires some way to get the * state. * - * @param setters A map of setter functions to sync with the server. + * @param signals A map of setter functions to sync with the server. */ - export function addSignals(setters: Setters): void; + export function addSignals(signals: Signals): void; /** * Unregisters from server state updates for the given keys. The signals * will retain their current values, but will no longer receive updates * from the server. */ - export function removeSignals(...keys: (keyof Setters)[]): void; + export function removeSignals( + ...keys: (keyof Signals)[] + ): void; /** * Unsubscribes from all server state updates. @@ -134,7 +139,7 @@ export namespace client { * * @param payloads The state updates received from the server. */ - export function patch( + export function patch( payloads: SyncPayload[], ): void; } @@ -152,9 +157,12 @@ export namespace server { * that `client.addSignals` still requires some way to set the state. * * @param client The client receiving state updates. - * @param getters A map of getter functions to sync with the client. + * @param signals A map of getter functions to sync with the client. */ - export function addSignalsToClient(client: Player, getters: Getters): void; + export function addSignalsToClient( + client: Player, + signals: Signals, + ): void; /** * Unsubscribes a client from receiving all state updates. To only unsubscribe @@ -170,9 +178,9 @@ export namespace server { * @param client The client receiving state updates. * @param keys The keys of the state to unsubscribe from. */ - export function removeSignalsFromClient( + export function removeSignalsFromClient( client: Player, - ...keys: (keyof Getters)[] + ...keys: (keyof Signals)[] ): void; /** @@ -182,7 +190,7 @@ export namespace server { * * @param onSync Called when sending patches to a client. */ - export function connect( + export function connect( onSync: (client: Player, payloads: SyncPayload[]) => void, ): void; @@ -276,5 +284,6 @@ export function isNone(value: unknown): value is None; * @param getter A function that returns the current value of the signal. * @param setter A function that sets the value of the signal. * @returns An atom that can either get or set the signal's value. + * @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter */ export function signalToAtom(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom; diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index f56a118..15bfd2c 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -7,34 +7,38 @@ local flags = Charm.flags export type None = types.None ---- A special symbol that denotes the absence of a value. Used to represent ---- deleted values in patches. local None: None = { __none = "__none" } ---- Checks whether a value is `None`. If `true`, the value is scheduled to be ---- removed from the state when the patch is applied. ---- ---- @param value The value to check. ---- @return `true` if the value is `None`, otherwise `false`. +--[[ + Checks whether a value is `None`. If `true`, the value is scheduled to be + removed from the state when the patch is applied. + + @param value The value to check. + @return `true` if the value is `None`, otherwise `false`. +]] local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" end ---- Converts `nil` values to the special `None` symbol. This is useful when ---- preparing data for serialization, since `nil` can represent both the absence ---- of a value and the removal of a value in patches. +--[[ + Converts `nil` values to the special `None` symbol. This is useful when + preparing data for serialization, since `nil` can represent both the absence + of a value and the removal of a value in patches. +]] local function nilToNone(value: T?): T | None return if value == nil then None else value end ---- JSON serialization can drop the last values of a sparse array. For example, ---- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the ---- serialization of sparse arrays can be resolved by converting them to ---- dictionaries with string keys. ---- ---- The `apply` function will convert these dictionaries back into arrays as ---- long as the target array is not sparse. This function ensures that the diff ---- function produces a patch that can be correctly applied. +--[[ + JSON serialization can drop the last values of a sparse array. For example, + `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the + serialization of sparse arrays can be resolved by converting them to + dictionaries with string keys. + + The `patch` functions will convert these dictionaries back into arrays as + long as the target array is not sparse. This function ensures that the diff + function produces a patch that can be correctly applied. +]] local function stringifySparseArray(object: { any }): { [string | number]: any } local maxn = table.maxn(object) @@ -52,68 +56,78 @@ local function stringifySparseArray(object: { any }): { [string | number]: any } return dictionary end ---- Compares two states and returns a patch that can be applied to the current ---- state to produce the next state. ---- ---- @param currentState The current state. ---- @param nextState The next state. ---- @return A patch that can be applied to the current state. -local function diff(currentState: any, nextState: any): any - if currentState == nextState then +local function diff(oldState: any, newState: any): any + if oldState == newState then return nil - elseif nextState == nil then + elseif newState == nil then return None - elseif type(currentState) ~= "table" or type(nextState) ~= "table" then - return nextState + elseif type(oldState) ~= "table" or type(newState) ~= "table" then + return newState end - local patches: { [any]: any } = table.clone(nextState) + local diffs: { [any]: any } = table.clone(newState) - for key, previous in currentState do - patches[key] = diff(previous, nextState[key]) + for key, oldValue in oldState do + diffs[key] = diff(oldValue, newState[key]) + end + + if next(diffs) == nil then + return nil end if config.fixArrays then -- Convert sparse array patches that could be misserialized - if currentState[1] ~= nil or nextState[1] ~= nil then - patches = stringifySparseArray(patches) + if oldState[1] ~= nil or newState[1] ~= nil then + diffs = stringifySparseArray(diffs) end - if flags.strict and config.validatePatches then - validate(patches) + validate(diffs) end end - return patches + return diffs end ---- Applies a patch to a state and returns the resulting value. ---- ---- @param currentState The current state. ---- @param statePatch The patches to apply. ---- @return The new state with the patch applied. -local function apply(currentState: any, statePatch: any): any - if isNone(statePatch) then +local function apply(oldState: any, diffs: any): any + if isNone(diffs) then return nil - elseif type(currentState) ~= "table" or type(statePatch) ~= "table" then - return statePatch + elseif type(oldState) ~= "table" or type(diffs) ~= "table" then + return diffs end - local nextState = table.clone(currentState) - local stateIsArray = currentState[1] ~= nil + local newState = table.clone(oldState) + local stateIsArray = oldState[1] ~= nil - for key, value in statePatch do + for key, value in diffs do -- Diff-checking an array produces a sparse array, which will not be -- preserved when converted to JSON. To prevent this, we turn string -- keys back into numeric keys. if stateIsArray and type(key) == "string" then key = tonumber(key) or key end - - nextState[key] = apply(nextState[key], value) + newState[key] = apply(newState[key], value) end - return nextState + return newState +end + +local function applyMutable(targetState: any, sourceState: any): any + if type(targetState) == "table" and type(sourceState) == "table" then + local targetIsArray = targetState[1] ~= nil + + for key, value in sourceState do + if targetIsArray and type(key) == "string" then + key = tonumber(key) or key + end + targetState[key] = applyMutable(targetState[key], value) + end + + return targetState + elseif isNone(sourceState) then + return nil + else + return sourceState + end end return { @@ -121,4 +135,5 @@ return { nilToNone = nilToNone, diff = diff, apply = apply, + applyMutable = applyMutable, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 8979e0c..a43e3b9 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -2,6 +2,7 @@ local Charm = require("../Charm") local config = require("./config") local patch = require("./patch") local types = require("./types") +local utils = require("./utils") type SyncPayload = types.SyncPayload @@ -9,11 +10,14 @@ type SyncStatus = number type ClientKeys = { [string]: SyncStatus } -type GetterState = { - getter: () -> T, +type Reactive = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () })) + +type TrackedState = { + target: any, currentValue: T, lastSyncedValue: T, dispose: () -> (), + isReactive: boolean?, } type StateUpdate = { @@ -25,17 +29,17 @@ type StateUpdate = { local PENDING_INITIAL_STATE: SyncStatus = 0 local LISTENING_FOR_CHANGES: SyncStatus = 1 -local trackedGetters: { [string]: GetterState } = {} +local trackedStates: { [string]: TrackedState } = {} local globalUpdates: { StateUpdate } = {} local clients: { [any]: ClientKeys } = {} local connection: RBXScriptConnection? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? -local function startTracking(key: string, getter: () -> T) - if trackedGetters[key] then +local function trackSignal(key: string, getter: () -> T) + if trackedStates[key] then -- Ignore duplicates, but warn against name collisions - if trackedGetters[key].getter ~= getter then + if trackedStates[key].target ~= getter then warn("[Charm] Attempted to sync different states with the same key:", key) end return @@ -43,7 +47,7 @@ local function startTracking(key: string, getter: () -> T) local value = getter() local trackedState = { - getter = getter, + target = getter, currentValue = value, lastSyncedValue = value, } @@ -61,14 +65,51 @@ local function startTracking(key: string, getter: () -> T) trackedState.currentValue = newValue end) - trackedGetters[key] = trackedState + trackedStates[key] = trackedState +end + +local function trackReactive(key: string, reactive: Reactive) + if trackedStates[key] then + if trackedStates[key].target ~= reactive then + warn("[Charm] Attempted to sync different states with the same key:", key) + end + return + end + + local meta = getmetatable(reactive) + local snapshot = utils.deepCopy(meta.initialValue) + local firstRun = true + + local trackedState = { + target = reactive, + currentValue = meta.initialValue, + lastSyncedValue = snapshot, + isReactive = true, + } + + trackedState.dispose = Charm.effect(function() + meta.track() + if not config.preserveHistory or firstRun then + firstRun = false + else + local oldSnapshot = snapshot + snapshot = utils.deepCopy(meta.initialValue) + table.insert(globalUpdates, { + key = key, + newValue = snapshot, + oldValue = oldSnapshot, + }) + end + end) + + trackedStates[key] = trackedState end local function stopTracking(key: string) - local trackedState = trackedGetters[key] + local trackedState = trackedStates[key] if trackedState then trackedState.dispose() - trackedGetters[key] = nil + trackedStates[key] = nil end end @@ -81,7 +122,7 @@ local function purgeUntracked() end end - for key in trackedGetters do + for key in trackedStates do if not active[key] then stopTracking(key) end @@ -139,7 +180,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { local initData: { [string]: any } = {} for key, status in clientKeys do - local trackedState = trackedGetters[key] + local trackedState = trackedStates[key] if status == PENDING_INITIAL_STATE and trackedState then initData[key] = trackedState.currentValue @@ -158,10 +199,14 @@ local function diffGlobalState(): any local currentGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {} - for key, trackedState in trackedGetters do - currentGlobalState[key] = trackedState.currentValue + for key, trackedState in trackedStates do + local currentValue = if trackedState.isReactive + then utils.deepCopy(trackedState.currentValue) + else trackedState.currentValue + + currentGlobalState[key] = currentValue previousGlobalState[key] = trackedState.lastSyncedValue - trackedState.lastSyncedValue = trackedState.currentValue + trackedState.lastSyncedValue = currentValue end return patch.diff(previousGlobalState, currentGlobalState) @@ -181,7 +226,7 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo -- Create patches for initialized signals that changed, and add signals -- that are still pending initial state to the init payload. for key, status in clientKeys do - local trackedState = trackedGetters[key] + local trackedState = trackedStates[key] if status == LISTENING_FOR_CHANGES then patchData[key] = globalPatch[key] @@ -202,45 +247,55 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo return payloads end ---- Subscribes a client to the given signals. When an update occurs, the client ---- will receive a partial state patch to merge into their local state. May be ---- called multiple times to subscribe to additional keys. ---- ---- Atoms, signals converted to atoms, and signal getter functions are allowed. ---- Note that `client.addSignals` still requires some way to set the state. ---- ---- @param client The client receiving state updates. ---- @param getters A map of getter functions to sync with the client. -local function addSignalsToClient(client: any, getters: { [string]: () -> any }) +--[[ + Subscribes a client to the given signals. When an update occurs, the client + will receive a partial state patch to merge into their local state. May be + called multiple times to subscribe to additional keys. + + Atoms, signals converted to atoms, and signal getter functions are allowed. + Note that `client.addSignals` still requires some way to set the state. + + @param client The client receiving state updates. + @param signals A map of getter functions to sync with the client. +]] +local function addSignalsToClient(client: any, signals: { [string]: { [any]: any } | () -> any }) if not clients[client] then clients[client] = {} end local clientKeys = clients[client] - for key, getter in getters do - startTracking(key, if type(getter) == "function" then getter else getter.get) + for key, signal in signals do + if type(signal) == "function" then + trackSignal(key, signal) + else + trackReactive(key, signal :: any) + end if not clientKeys[key] then - -- Mark the getter as pending initial state so we send its full + -- Mark the signal as pending initial state so we send its full -- value the next time we sync. clientKeys[key] = PENDING_INITIAL_STATE end end end ---- Unsubscribes a client from receiving all state updates. To only unsubscribe ---- from specific keys, use `unsubscribeClientFrom` instead. ---- ---- @param client The client receiving state updates. +--[[ + Unsubscribes a client from receiving all state updates. To only unsubscribe + from specific keys, use `unsubscribeClientFrom` instead. + + @param client The client receiving state updates. +]] local function removeClient(client: any) clients[client] = nil end ---- Unsubscribes a client from receiving updates for the given keys. ---- ---- @param client The client receiving state updates. ---- @param keys The keys of the state to unsubscribe from. +--[[ + Unsubscribes a client from receiving updates for the given keys. + + @param client The client receiving state updates. + @param keys The keys of the state to unsubscribe from. +]] local function removeSignalsFromClient(client: any, ...: string) local clientKeys = clients[client] @@ -258,9 +313,11 @@ local function removeSignalsFromClient(client: any, ...: string) end end ---- Immediately sends pending updates to all clients. Normally, updates are ---- sent at the interval specified in `config.interval`, but this function ---- can be used to force an immediate sync. +--[[ + Immediately sends pending updates to all clients. Normally, updates are + sent at the interval specified in `config.interval`, but this function + can be used to force an immediate sync. +]] local function flush() if not syncCallback then return @@ -287,11 +344,13 @@ local function flush() table.clear(globalUpdates) end ---- Uses the callback to send state updates to clients that are subscribed ---- to state changes. Starts automatically sending updates at the interval ---- specified in `config.interval`. ---- ---- @param onSync Called when sending patches to a client. +--[[ + Uses the callback to send state updates to clients that are subscribed + to state changes. Starts automatically sending updates at the interval + specified in `config.interval`. + + @param onSync Called when sending patches to a client. +]] local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ()) if connection then connection:Disconnect() @@ -321,8 +380,10 @@ local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ()) end) end ---- Stops syncing state updates to clients at the automatic interval. ---- Flushing can still be performed manually via `flush`. +--[[ + Stops syncing state updates to clients at the automatic interval. + Flushing can still be performed manually via `flush`. +]] local function disconnect() if connection then connection:Disconnect() @@ -330,10 +391,12 @@ local function disconnect() end end ---- Unsubscribes all clients, stops tracking all state, and disconnects ---- from the automatic sync interval. +--[[ + Unsubscribes all clients, stops tracking all state, and disconnects + from the automatic sync interval. +]] local function reset() - table.clear(trackedGetters) + table.clear(trackedStates) table.clear(clients) table.clear(globalUpdates) disconnect() diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index ac8ffb4..20af1f3 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -1,5 +1,7 @@ ---- A payload that can be sent from the server to the client to synchronize ---- the states of the client's atoms with the server. +--[[ + A payload that can be sent from the server to the client to synchronize + the states of the client's atoms with the server. +]] export type SyncPayload = | { type: "init", @@ -10,8 +12,10 @@ export type SyncPayload = data: { [string]: any }, } ---- A special type that represents a removed value in a patch. This is used to ---- differentiate between a value that was set to `nil` and an unchanged value. +--[[ + A special type that represents a removed value in a patch. This is used to + differentiate between a value that was set to `nil` and an unchanged value. +]] export type None = { __none: "__none" } return {} diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index 6a7f7b0..fbf9260 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -10,11 +10,13 @@ type FlattenedTable = { [any]: any, } ---- Flattens a nested table into a single table with slash-separated keys. ---- Useful for recursively collecting atoms returned by modules. ---- ---- @param input A nested table. ---- @return A flattened table with slash-separated keys. +--[[ + Flattens a nested table into a single table with slash-separated keys. + Useful for recursively collecting atoms returned by modules. + + @param input A nested table. + @return A flattened table with slash-separated keys. +]] local function flatten(input: NestedTable): FlattenedTable local result: FlattenedTable = {} @@ -35,20 +37,36 @@ local function flatten(input: NestedTable): FlattenedTable return result end ---- Converts a signal getter and setter into an atom. This is useful for ---- syncing client signals with the server, as the sync system requires both ---- the getter and setter to update state. ---- ---- @param getter A function that returns the current value of the signal. ---- @param setter A function that sets the value of the signal. ---- @return An atom that can either get or set the signal's value. +--[[ + Converts a signal getter and setter into an atom. This is useful for + syncing client signals with the server, as the sync system requires both + the getter and setter to update state. + + @param getter A function that returns the current value of the signal. + @param setter A function that sets the value of the signal. + @return An atom that can either get or set the signal's value. +]] local function signalToAtom(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom return function(...) return if select("#", ...) == 0 then getter() else setter((...)) end end +--[[ + Returns a deep copy of the given table. +]] +local function deepCopy(object: T): T + local copy: any = table.clone(object :: any) + for key, value in copy do + if type(value) == "table" then + copy[key] = deepCopy(value) + end + end + return copy +end + return { flatten = flatten, signalToAtom = signalToAtom, + deepCopy = deepCopy, } diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index f350bb7..c108c73 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -25,25 +25,23 @@ local function isTableUnsafe(object: { [any]: any }) return false end ---- Throws an error if the value is a table that cannot be synced via ---- RemoteEvents or RemoteFunctions. This only aims to catch common mistakes ---- with remote argument limitations, such as sparse arrays or mixed tables. ---- ---- @param value The table value. ---- @param key The key of the value. ---- @error Throws an error if the value cannot be synced. +--[[ + Emits a warning if the value is a table that cannot be synced via + RemoteEvents or RemoteFunctions. This only aims to catch common mistakes + with remote argument limitations, such as sparse arrays or mixed tables. + + @param value The table value. + @param key The key of the value. +]] local function validate(value: { [any]: any }) if isTableUnsafe(value) then - -- Silence warnings during testing - if game then - warn("Cannot sync table:", value) - end - error( - `Cannot sync tables unsupported by remote events! See the warning above for more info.\n\n` + warn( + `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` .. "This can be for the following reasons:\n" .. "1. The table is a sparse array with non-sequential indices\n" .. "2. The table is a mixed table (not all keys have the same type)\n\n" - .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations" + .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n", + value ) end end diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau index 4189f87..34c8d4e 100644 --- a/packages/charm/src/__tests__/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -7,26 +7,32 @@ local effect = Charm.effect local effectScope = Charm.effectScope local test = suite.test ---- Creates a weak reference to a value. +--[[ + Creates a weak reference to a value. +]] local function weakRef(value: T): { value: T? } return setmetatable({ value = value }, { __mode = "v" }) :: any end ---- Triggers garbage collection by creating a large number of tables. +--[[ + Triggers garbage collection by creating a large number of tables. +]] local function gc() for _ = 1, 2000 do local _ = table.create(100, 0) end end ---- In alien-signals, a computed value unlinks from dependencies when its last ---- parent scope is disposed. However, calling a computed value outside of a ---- scope still links it to dependencies, which act as strong references. ---- Computed values called in this way won't GC until unlinked from a scope. ---- ---- This function releases a computed value by linking it to a scope and then ---- immediately closing it, causing alien-signals to unlink the computed value ---- from its dependencies and allow it to be garbage collected. +--[[ + In alien-signals, a computed value unlinks from dependencies when its last + parent scope is disposed. However, calling a computed value outside of a + scope still links it to dependencies, which act as strong references. + Computed values called in this way won't GC until unlinked from a scope. + + This function releases a computed value by linking it to a scope and then + immediately closing it, causing alien-signals to unlink the computed value + from its dependencies and allow it to be garbage collected. +]] local function unlink(getter: () -> ()) effectScope(function() getter() diff --git a/packages/charm/src/__tests__/reactive.spec.luau b/packages/charm/src/__tests__/reactive.spec.luau new file mode 100644 index 0000000..70237b9 --- /dev/null +++ b/packages/charm/src/__tests__/reactive.spec.luau @@ -0,0 +1,124 @@ +local Charm = require("../") +local suite = require("../../../../test/suite") + +local effect = Charm.effect +local reactive = Charm.reactive +local test = suite.test + +test("should track nested properties", function() + local src, setSrc = reactive({ a = { b = { c = 0 } } }) + local calls = {} + + effect(function() + table.insert(calls, `{src.a.b.c}`) + end) + + setSrc(function(state) + state.a.b.c = 1 + end) + src.a.b.c = 2 + src.a.b.c = 2 + setSrc() + assert(table.concat(calls) == "012", table.concat(calls)) +end) + +test("should track object type changes", function() + local src = reactive({ a = { b = 0 } } :: { a: any }) + local calls = {} + + effect(function() + table.insert(calls, tostring(if type(src.a) == "table" then src.a.b else src.a)) + end) + + src.a = { b = 0 } + src.a = 1 + src.a = { b = 2 } + src.a = nil + src.a = 3 + assert(table.concat(calls) == "0012nil3", table.concat(calls)) +end) + +test("should unwrap proxy assignments", function() + local src = reactive({ a = { x = 0 }, b = { x = 0 } }) + local runs = 0 + + effect(function() + local _, _ = src.a.x, src.b.x + runs += 1 + end) + + src.a, src.b = src.a, src.b + assert(runs == 1, runs) + + src.a, src.b = src.b, src.a + assert(runs == 3, runs) +end) + +test("should track iteration", function() + local src, setSrc = reactive({} :: { [string]: number }) + local calls = {} + + effect(function() + for key, value in src do + table.insert(calls, `{key}={value};`) + end + end) + + src.a = 1 + assert(table.concat(calls) == "a=1;", table.concat(calls)) + + table.clear(calls) + src.b = 2 + assert(table.concat(calls) == "a=1;b=2;", table.concat(calls)) + + table.clear(calls) + src.a = 2 + assert(table.concat(calls) == "a=2;b=2;", table.concat(calls)) + + table.clear(calls) + setSrc() + assert(table.concat(calls) == "a=2;b=2;", table.concat(calls)) +end) + +test("should track array length", function() + local src = reactive({ array = {} :: { number } }) + local calls = {} + + effect(function() + table.insert(calls, #src.array) + end) + + src.array[#src.array + 1] = 1 + src.array[#src.array + 1] = 2 + src.array[#src.array + 1] = 3 + src.array = { 1, 2, 3, 4 } + assert(table.concat(calls) == "01234", table.concat(calls)) +end) + +test("should not track unread properties", function() + local src = reactive({ value = { a = 1, b = 2 } }) + local calls = 0 + + effect(function() + local _ = src.value.a + calls += 1 + end) + assert(calls == 1, calls) + + src.value.b = 3 + assert(calls == 1, calls) +end) + +test("should overwrite with table passed to setter", function() + local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number }) + local calls = {} + + effect(function() + table.insert(calls, `{src.a}{src.b}{src.c}`) + end) + + setSrc({ a = 2, c = 4 }) + assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ",")) +end) + +return {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index b95c070..17dd1f2 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -24,6 +24,9 @@ end) suite.describe("peek", function() require("./peek.spec") end) +suite.describe("reactive", function() + require("./reactive.spec") +end) suite.describe("signal", function() require("./signal.spec") end) diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index cc27c44..973b458 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -280,6 +280,36 @@ export function observe( */ export function recursive(): void; +/** + * Creates a deep reactive value that tracks properties when they're accessed. + * Returns a proxy object that subscribes to changes on any nested property + * you read, and a function for mutating the original table. + * + * @example + * ```luau + * local users, setUsers = reactive({ + * user = { name = "John", surname = "Doe" }, + * }) + * + * effect(function() + * print(users.user and `{users.user.name} {users.user.surname}`) + * end) + * + * setUsers(function(state) + * state.user.name = "Jane" + * state.user.surname = "Smith" + * end) + * ``` + * + * @param initialValue The value to make deeply reactive. + * @return A proxy table that tracks reads to nested properties. + * @return A function for updating the original table. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function reactive( + initialValue: T, +): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>; + export function getActiveSub(): ReactiveNode | undefined; export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index e1fc19f..4334a54 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,30 +1,3 @@ ---[[ - Reactive system adapted from alien-signals at - https://github.com/stackblitz/alien-signals/blob/master/src/index.ts - - MIT License - - Copyright (c) 2024-present Johnson Chu - - 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("@self/system") export type ReactiveFlags = system.ReactiveFlags @@ -33,6 +6,13 @@ export type Link = system.Link export type Atom = (...((T) -> T) | T) -> T +type Target = { [any]: any } + +type Proxy = { + __target: Target, + __signals: { [any]: () -> any }, +} + type EffectScopeNode = ReactiveNode & { cleanups: { () -> () }, } @@ -65,16 +45,22 @@ local function inlineTest(): string end local flags = { - --- Enforces synchronous, non-yielding behavior in signals and effects. - --- Also enables state validation in Charm Sync to catch sync errors early. - --- Enabled if the optimization level is below 2, which is true in studio. + --[[ + Enforces synchronous, non-yielding behavior in signals and effects. + Also enables state validation in Charm Sync to catch sync errors early. + Enabled if the optimization level is below 2, which is true in studio. + ]] strict = inlineTest() == "inlineTest", - --- Enforces immutability of tables by deep-freezing table values. Enabled - --- if the optimization level is below 2, which is true in studio. + --[[ + Enforces immutability of tables by deep-freezing table values. Enabled + if the optimization level is below 2, which is true in studio. + ]] frozen = inlineTest() == "inlineTest", - --- Whether parent effects should track inner effects and clean them up - --- when the parent effect re-runs. This is usually desirable, but can be - --- disabled to revert to the old behavior. Defaults to `true`. + --[[ + Whether parent effects should track inner effects and clean them up + when the parent effect re-runs. This is usually desirable, but can be + disabled to revert to the old behavior. Defaults to `true`. + ]] trackInnerEffects = true, } @@ -142,10 +128,10 @@ local function runCleanups(effect: EffectScopeNode) local prevSub = setActiveSub(nil) local success, result: any = pcall(cleanup) activeSub = prevSub + cleanups[key] = nil if not success then warn("An error occurred during effect cleanup:", result) end - cleanups[key] = nil end end @@ -164,7 +150,7 @@ local function run(e: EffectNode): () if not success then error(result) elseif result then - table.insert(e.cleanups, result) + table.insert(e.cleanups, noYield(result)) end else e.flags = WATCHING @@ -207,9 +193,6 @@ local function updateComputed(c: ComputedNode): boolean error(result) end if oldValue ~= result then - if flags.frozen then - deepFreeze(result) - end c.value = result return true else @@ -337,13 +320,16 @@ local function effectOper(this: EffectScopeNode): () -> () end end ---- Creates a reactive signal that stores a value and notifies subscribers when ---- the value changes. The signal provides both a getter and a setter. ---- ---- @param initialValue The initial value of the signal. ---- @param equals A comparator function to determine if the signal's value has changed. ---- @return A tuple containing the getter and setter functions for the signal. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals +--[[ + Creates a reactive signal that stores a value and notifies subscribers when + the value changes. The signal provides both a getter and a setter. + + @param initialValue The initial value of the signal. + @param equals A comparator function to determine if the signal's value has changed. + @return A function to get the current value of the signal. + @return A function to set a new value and notify subscribers. + @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals +]] local function signal( initialValue: T, equals: ((current: T, incoming: T) -> boolean)? @@ -355,16 +341,18 @@ local function signal( currentValue = initialValue, pendingValue = initialValue, flags = MUTABLE, - }, equals and noYield(equals)) + }, equals) end ---- Creates a reactive atom that acts as both a signal getter and setter. ---- Calling the atom with an argument sets its value, while calling it with no ---- arguments returns its current value. ---- ---- @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. ---- @param equals A comparator function to determine if the atom's value has changed. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals +--[[ + Creates a reactive atom that acts as both a signal getter and setter. + Calling the atom with an argument sets its value, while calling it with no + arguments returns its current value. + + @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. + @param equals A comparator function to determine if the atom's value has changed. + @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals +]] local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom local getter, setter = signal(initialValue, equals) local function atomOper(...) @@ -373,12 +361,14 @@ local function atom(initialValue: T, equals: ((current: T, incoming: T) -> bo return atomOper end ---- Creates a new read-only signal that is computed based on the values of ---- other signals. The computed signal's value is automatically updated when a ---- change is detected in any of its dependencies. ---- ---- @param getter A function that produces the next value. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter +--[[ + Creates a new read-only signal that is computed based on the values of + other signals. The computed signal's value is automatically updated when a + change is detected in any of its dependencies. + + @param getter A function that produces the next value. + @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter +]] local function computed(getter: (previousValue: T?) -> T): () -> T return computedOper({ flags = NONE, @@ -386,19 +376,21 @@ local function computed(getter: (previousValue: T?) -> T): () -> T }) end ---- Creates an effect that runs a callback in response to signal state changes. ---- An effect tracks which signals are accessed during its execution, and runs ---- the callback when those signals change. ---- ---- The effect callback may return a cleanup function, which gets called once, ---- either before the effect re-runs or when it is disposed. ---- ---- If the effect is called within another effect, it will be disposed when the ---- parent effect re-runs. ---- ---- @param fn The function to run when dependencies change. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback +--[[ + Creates an effect that runs a callback in response to signal state changes. + An effect tracks which signals are accessed during its execution, and runs + the callback when those signals change. + + The effect callback may return a cleanup function, which gets called once, + either before the effect re-runs or when it is disposed. + + If the effect is called within another effect, it will be disposed when the + parent effect re-runs. + + @param fn The function to run when dependencies change. + @return A function for disposing the effect. + @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback +]] local function effect(fn: () -> ...(() -> ())?): () -> () local e: EffectNode = { fn = noYield(fn), @@ -420,13 +412,15 @@ local function effect(fn: () -> ...(() -> ())?): () -> () return effectOper(e) end ---- Creates an effect scope that can capture reactive effects and computed ---- signals created within it so that they can be disposed together. ---- ---- @param fn A function that may create reactive effects. ---- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. ---- @return A function for disposing all effects created within the scope. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached +--[[ + Creates an effect scope that can capture reactive effects and computed + signals created within it so that they can be disposed together. + + @param fn A function that may create reactive effects. + @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. + @return A function for disposing all effects created within the scope. + @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached +]] local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () local e: EffectScopeNode = { flags = NONE, @@ -446,11 +440,13 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> return effectOper(e) end ---- Allows you to manually trigger updates for downstream dependencies when ---- you've directly mutated a signal's value without using the signal setter. ---- ---- @param fn The dependency or a function that calls multiple dependencies. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback +--[[ + Allows you to manually trigger updates for downstream dependencies when + you've directly mutated a signal's value without using the signal setter. + + @param fn The dependency or a function that calls multiple dependencies. + @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback +]] local function trigger(fn: () -> ()) local sub: ReactiveNode = { flags = WATCHING, @@ -475,13 +471,15 @@ local function trigger(fn: () -> ()) end end ---- Binds the cleanup function to the current active effect or effect scope. If ---- there is no active effect, a warning is logged, unless `failSilently` is ---- set to `true`. ---- ---- @param fn The cleanup function to register. ---- @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently +--[[ + Binds the cleanup function to the current active effect or effect scope. If + there is no active effect, a warning is logged, unless `failSilently` is + set to `true`. + + @param fn The cleanup function to register. + @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. + @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently +]] local function onCleanup(fn: () -> (), failSilently: boolean?) if activeSub and (activeSub :: EffectNode).cleanups then table.insert((activeSub :: EffectNode).cleanups, noYield(fn)) @@ -490,15 +488,17 @@ local function onCleanup(fn: () -> (), failSilently: boolean?) end end ---- Runs the function without subscribing to signal updates or capturing inner ---- effects in the parent effect or scope. ---- ---- For an alternative that still cleans up inner effects, use `peek` instead. ---- ---- @param fn A function that may read signals or create effects. ---- @param ...args Arguments to pass to the function. ---- @return The return value of the function. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback +--[[ + Runs the function without subscribing to signal updates or capturing inner + effects in the parent effect or scope. + + For an alternative that still cleans up inner effects, use `peek` instead. + + @param fn A function that may read signals or create effects. + @param ...args Arguments to pass to the function. + @return The return value of the function. + @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback +]] local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub then local prevSub = setActiveSub(nil) @@ -513,15 +513,17 @@ local function untracked(fn: (Args...) -> Results..., ...: end end ---- Runs the function without subscribing to signal updates, but still captures ---- inner effects so that they dispose with the parent effect. ---- ---- To avoid capturing inner effects, use `untracked` instead. ---- ---- @param fn A function that may read signals. ---- @param ...args Arguments to pass to the function. ---- @return The return value of the function. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback +--[[ + Runs the function without subscribing to signal updates, but still captures + inner effects so that they dispose with the parent effect. + + To avoid capturing inner effects, use `untracked` instead. + + @param fn A function that may read signals. + @param ...args Arguments to pass to the function. + @return The return value of the function. + @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback +]] local function peek(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub @@ -540,13 +542,15 @@ local function peek(fn: (Args...) -> Results..., ...: Args. end end ---- Combines multiple updates into a single "commit" that runs effects and ---- computed signals after the provided callback finishes running. ---- ---- @param fn A function that performs multiple updates. ---- @param ...args Arguments to pass to the function. ---- @return The return value of the function. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback +--[[ + Combines multiple updates into a single "commit" that runs effects and + computed signals after the provided callback finishes running. + + @param fn A function that performs multiple updates. + @param ...args Arguments to pass to the function. + @return The return value of the function. + @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback +]] local function batch(fn: (Args...) -> Results..., ...: Args...): Results... startBatch() local results: { any } = { pcall(noYield(fn), ...) } @@ -557,28 +561,32 @@ local function batch(fn: (Args...) -> Results..., ...: Args return unpack(results, 2) end ---- Converts a non-signal into a computed signal. If the function is already a ---- signal, it is returned as-is. Useful for memoizing a getter function ---- without creating unnecessary nested signals. ---- ---- @param fn A function that returns the value to track. ---- @return A computed signal that tracks the value returned by the getter. +--[[ + Converts a non-signal into a computed signal. If the function is already a + signal, it is returned as-is. Useful for memoizing a getter function + without creating unnecessary nested signals. + + @param fn A function that returns the value to track. + @return A computed signal that tracks the value returned by the getter. +]] local function toSignal(fn: () -> T): () -> T local name = debug.info(fn, "n") return if name == "signalGetter" or name == "computedGetter" or name == "atomOper" then fn else computed(fn) end ---- Creates an effect that runs the callback one immediately, and then again ---- when the value returned by the getter changes. The callback receives the new ---- value and the previous value as arguments. ---- ---- Effects created within the callback are cleaned up when the listener runs ---- or the listener is disposed. ---- ---- @param getter A function that returns the value to watch. ---- @param fn The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +--[[ + Creates an effect that runs the callback one immediately, and then again + when the value returned by the getter changes. The callback receives the new + value and the previous value as arguments. + + Effects created within the callback are cleaned up when the listener runs + or the listener is disposed. + + @param getter A function that returns the value to watch. + @param fn The function to run when the value changes. + @return A function for disposing the effect. + @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +]] local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () local tracked = toSignal(getter) local current: T @@ -589,17 +597,19 @@ local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): ( end) end ---- Creates an effect that only runs the callback when the value returned by the ---- getter changes. The callback receives the new value and the previous value ---- as arguments. ---- ---- Effects created within the callback are cleaned up when the listener runs ---- or the listener is disposed. ---- ---- @param getter A function that returns the value to watch. ---- @param fn The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback +--[[ + Creates an effect that only runs the callback when the value returned by the + getter changes. The callback receives the new value and the previous value + as arguments. + + Effects created within the callback are cleaned up when the listener runs + or the listener is disposed. + + @param getter A function that returns the value to watch. + @param fn The function to run when the value changes. + @return A function for disposing the effect. + @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback +]] local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () local tracked = toSignal(getter) local current: T @@ -615,16 +625,18 @@ local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()) end) end ---- Disables recursive checks for the current effect or computed signal. Useful ---- for effects that intentionally update signals they depend on. ---- ---- @example ---- ```luau ---- effect(function() ---- recursive() ---- setCount(getCount() + 1) ---- end) ---- ``` +--[[ + Disables recursive checks for the current effect or computed signal. Useful + for effects that intentionally update signals they depend on. + + @example + ```luau + effect(function() + recursive() + setCount(getCount() + 1) + end) + ``` +]] local function recursive() if activeSub then activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK)) @@ -633,18 +645,20 @@ local function recursive() end end ---- Calls the callback function for each unique key in the table returned by the ---- getter. The callback is called once for each key, and again when a new key ---- is added. ---- ---- The callback may return a cleanup function, which will be called when the ---- key is removed or the `observe()` effect is disposed. Effects created ---- during the callback will also be bound to the lifetime of the key. ---- ---- @param getter A function that returns the table to observe. ---- @param fn A function that is called for each key in the table. ---- @return A function for disposing the observer. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback +--[[ + Calls the callback function for each unique key in the table returned by the + getter. The callback is called once for each key, and again when a new key + is added. + + The callback may return a cleanup function, which will be called when the + key is removed or the `observe()` effect is disposed. Effects created + during the callback will also be bound to the lifetime of the key. + + @param getter A function that returns the table to observe. + @param fn A function that is called for each key in the table. + @return A function for disposing the observer. + @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback +]] local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> () local innerScopes: { [K]: () -> () } = {} local disposed = false @@ -659,31 +673,19 @@ local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> end end end - for key, value in data do if innerScopes[key] == nil then local disposeScope = effectScope(function() return fn(value, key) end, true) if disposed then - disposeScope() - return + return disposeScope() end innerScopes[key] = disposeScope end end end - local function disposeScopes() - if not disposed then - disposed = true - for key, dispose in innerScopes do - innerScopes[key] = nil - dispose() - end - end - end - return effectScope(function() effect(function() -- Allow recursive updates in case the callback creates or removes @@ -694,18 +696,28 @@ local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> -- interfere with the current update cycle. batch(updateScopes, getter()) end) - - return disposeScopes + return function() + if disposed then + return + end + disposed = true + for key, dispose in innerScopes do + innerScopes[key] = nil + dispose() + end + end end) end ---- Creates a new read-only signal that is computed by mapping over the entries ---- of an existing atom. If the key is omitted from the mapper's return value, ---- the original key is preserved. ---- ---- @param getter Returns the table to map over. ---- @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper +--[[ + Creates a new read-only signal that is computed by mapping over the entries + of an existing atom. If the key is omitted from the mapper's return value, + the original key is preserved. + + @param getter Returns the table to map over. + @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. + @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper +]] local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } local currentInput: { [KI]: VI } = {} local keyCache: { [KI]: KO } = {} @@ -761,6 +773,111 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: end) end +--[[ + Creates a deep reactive value that tracks properties when they're accessed. + Returns a proxy object that subscribes to changes on any nested property + you read, and a function for mutating the original table. + + @example + ```luau + local users, setUsers = reactive({ + user = { name = "John", surname = "Doe" }, + }) + + effect(function() + print(users.user and `{users.user.name} {users.user.surname}`) + end) + + setUsers(function(state) + state.user.name = "Jane" + state.user.surname = "Smith" + end) + ``` + + @param initialValue The value to make deeply reactive. + @return A proxy table that tracks reads to nested properties. + @return A function for updating the original table. + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ()) + local track = signal(nil) + + local function setValue(update: ((T) -> ()) | T?) + if type(update) == "function" then + update(initialValue) + elseif type(update) == "table" then + table.clear(initialValue) + for key, value in update do + (initialValue :: Target)[key] = value + end + end + trigger(track) + end + + local proxyMeta = { + initialValue = initialValue, + track = track, + setValue = setValue, + } + + local proxyMap = setmetatable({} :: { [Target]: any }, { __mode = "k" }) + + local function trackProperty(cache: { [any]: () -> any }, target: Target, key: any) + if not cache[key] then + cache[key] = untracked(computed, function() + track() + return target[key] + end) + end + cache[key]() + end + + local function createProxy(target: Target): Proxy + if not proxyMap[target] then + proxyMap[target] = setmetatable({ + __target = target, + __signals = setmetatable({}, { __mode = "v" }), + }, proxyMeta) + end + return proxyMap[target] + end + + local function nextProxy(target: Target, index: any) + local nextKey, nextValue = next(target, index) + return nextKey, if type(nextValue) == "table" then createProxy(nextValue) else nextValue + end + + function proxyMeta.__index(self: Proxy, key: any): any + if activeSub then + trackProperty(self.__signals, self.__target, key) + end + local value = self.__target[key] + return if type(value) == "table" then createProxy(value) else value + end + + function proxyMeta.__newindex(self: Proxy, key: any, value: any) + if type(value) == "table" and getmetatable(value) == proxyMeta then + value = value.__target + end + if self.__target[key] ~= value then + self.__target[key] = value + trigger(track) + end + end + + function proxyMeta.__iter(self: Proxy) + track() + return nextProxy, self.__target + end + + function proxyMeta.__len(self: Proxy) + track() + return #self.__target + end + + return createProxy(initialValue) :: any, setValue +end + local reactiveSystem = system.createReactiveSystem({ update = function(node) if node.depsTail then @@ -814,12 +931,10 @@ shallowPropagate = reactiveSystem.shallowPropagate return { ReactiveFlags = system.ReactiveFlags, flags = flags, - getActiveSub = getActiveSub, setActiveSub = setActiveSub, startBatch = startBatch, endBatch = endBatch, - signal = signal, computed = computed, effect = effect, @@ -838,4 +953,5 @@ return { getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO) ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), + reactive = reactive, } diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 481275a..931a584 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -1,16 +1,18 @@ local Charm = require("./Charm") local React = require("./React") ---- Returns a React state that updates when the getter function updates. ---- Re-renders the component when the value changes. ---- ---- If the `dependencies` array is provided, a new effect is created for the ---- current `getter` when the dependencies change. Otherwise, the effect is ---- created only once, when the component is mounted. ---- ---- @param getter The signal or selector to watch. ---- @param dependencies An array of values used to memoize the getter. ---- @return The current state. +--[[ + Returns a React state that updates when the getter function updates. + Re-renders the component when the value changes. + + If the `dependencies` array is provided, a new effect is created for the + current `getter` when the dependencies change. Otherwise, the effect is + created only once, when the component is mounted. + + @param getter The signal or selector to watch. + @param dependencies An array of values used to memoize the getter. + @return The current state. +]] local function useSignalState(getter: () -> T, dependencies: { any }?): T local value, setValue = React.useState(getter) @@ -24,15 +26,17 @@ local function useSignalState(getter: () -> T, dependencies: { any }?): T return value end ---- Returns a React binding that updates when the getter function updates. ---- ---- If the `dependencies` array is provided, a new effect is created for the ---- current `getter` when the dependencies change. Otherwise, the effect is ---- created only once, when the component is mounted. ---- ---- @param getter The atom or selector to watch. ---- @param dependencies An array of values used to memoize the getter. ---- @return A binding that stores the current state. +--[[ + Returns a React binding that updates when the getter function updates. + + If the `dependencies` array is provided, a new effect is created for the + current `getter` when the dependencies change. Otherwise, the effect is + created only once, when the component is mounted. + + @param getter The atom or selector to watch. + @param dependencies An array of values used to memoize the getter. + @return A binding that stores the current state. +]] local function useSignalBinding(getter: () -> T, dependencies: { any }?) local initialValue = React.useMemo(getter, {}) local binding, setBinding = React.useBinding(initialValue) diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index 34b1c7b..073e759 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -1,11 +1,13 @@ local Charm = require("./Charm") local Vide = require("./Vide") ---- Returns a reactive source that subscribes to the current value of the given ---- signal or getter function. ---- ---- @param getter The getter function to subscribe to. ---- @return The reactive source. +--[[ + Returns a reactive source that subscribes to the current value of the given + signal or getter function. + + @param getter The getter function to subscribe to. + @return The reactive source. +]] local function useSignalState(getter: () -> State): () -> State local state = Vide.source(getter()) diff --git a/rokit.toml b/rokit.toml index 30840b6..17f523d 100644 --- a/rokit.toml +++ b/rokit.toml @@ -5,9 +5,9 @@ [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.6.0" -stylua = "johnnymorganz/stylua@2.3.0" +rojo = "rojo-rbx/rojo@7.6.1" +stylua = "johnnymorganz/stylua@2.3.1" selene = "Kampfkarren/selene@0.29.0" -luau-lsp = "johnnymorganz/luau-lsp@1.55.0" +luau-lsp = "johnnymorganz/luau-lsp@1.56.2" lune = "lune-org/lune@0.10.4" -lute = "luau-lang/lute@0.1.0-nightly.20250923" +lute = "luau-lang/lute@0.1.0-nightly.20251119" From 5c90f928f7ad781da08e15f179bd8d2420b21fe1 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:40:38 -0800 Subject: [PATCH 074/160] chore: bump versions --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 4 ++-- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/react-charm/package.json | 2 +- packages/react-charm/wally.toml | 4 ++-- packages/vide-charm/package.json | 2 +- packages/vide-charm/wally.toml | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index d41be6c..2251c77 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0-rc.1", + "version": "0.4.0-rc.2", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 6d6257c..b6911a2 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0-rc.1" +version = "0.4.0-rc.2" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@0.11.0-rc.1" +Charm = "littensy/charm@0.11.0-rc.2" diff --git a/packages/charm/package.json b/packages/charm/package.json index f4167c5..4bf25bf 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0-rc.1", + "version": "0.11.0-rc.2", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index ada6018..2f4a5c9 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0-rc.1" +version = "0.11.0-rc.2" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index 9d73025..22226c7 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.0-rc.1", + "version": "0.4.0-rc.2", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 8407302..d5b83f8 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.4.0-rc.1" +version = "0.4.0-rc.2" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@0.11.0-rc.1" +Charm = "littensy/charm@0.11.0-rc.2" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index b9ee228..5bc98e8 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.0-rc.1", + "version": "0.4.0-rc.2", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index dfc2517..09d1ecb 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.4.0-rc.1" +version = "0.4.0-rc.2" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@0.11.0-rc.1" +Charm = "littensy/charm@0.11.0-rc.2" Vide = "centau/vide@^0.3" From b733369e70d522e3371c4bbaa88bdd2df7623556 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:50:28 -0800 Subject: [PATCH 075/160] docs: reword reactive docs --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a550946..00c12e1 100644 --- a/README.md +++ b/README.md @@ -604,7 +604,7 @@ print(total()) -- 2 ### `reactive(initialValue)` -The `reactive()` function creates a deeply reactive value that tracks properties when they're accessed. It returns a setter function where you can make changes by mutating the original table. +The `reactive()` function creates a mutable reactive object that subscribes to only the properties you read. It also returns a setter function you can use to mutate the original table: ```luau local users, setUsers = reactive({ @@ -623,7 +623,7 @@ setUsers(function(state) end) ``` -You can also set properties on the reactive value directly: +You can also set properties on the reactive object directly: ```luau local id = reactive({ name = "John", surname = "Doe" }) @@ -636,7 +636,8 @@ id.name = "Jane" -- Jane Doe id.surname = "Smith" -- Jane Smith ``` -Be careful when mutating the reactive value: because it's a proxy table, functions like `table.insert` will not work. Table functions should be called on the original table provided by the setter function. +> [!WARN] +> Be careful when mutating the reactive object! Because it's a proxy, functions like `table.insert` will not work. Table functions should be called on the original table, which is provided by the setter function. --- From f082956ed9312e6ecf1d9da270a83ac3b53b3dcb Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:50:48 -0800 Subject: [PATCH 076/160] docs: fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 00c12e1..24a753c 100644 --- a/README.md +++ b/README.md @@ -636,7 +636,7 @@ id.name = "Jane" -- Jane Doe id.surname = "Smith" -- Jane Smith ``` -> [!WARN] +> [!WARNING] > Be careful when mutating the reactive object! Because it's a proxy, functions like `table.insert` will not work. Table functions should be called on the original table, which is provided by the setter function. --- From 9f66803f04552d6806cd7ca2e006a66812eec2cc Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:38:56 -0800 Subject: [PATCH 077/160] feat: add `toRaw()` --- README.md | 104 ++++---- packages/charm-sync/Charm.luau | 1 + .../charm-sync/src/__tests__/patch.spec.luau | 2 +- packages/charm-sync/src/client.luau | 12 +- packages/charm-sync/src/patch.luau | 4 +- packages/charm-sync/src/server.luau | 21 +- packages/charm-sync/src/utils.luau | 16 +- packages/charm-sync/src/validate.luau | 6 +- packages/charm/src/__tests__/gc.spec.luau | 65 ++++- .../charm/src/__tests__/reactive.spec.luau | 19 ++ packages/charm/src/index.d.ts | 13 +- packages/charm/src/init.luau | 226 ++++++++++-------- packages/charm/src/system.luau | 6 +- 13 files changed, 297 insertions(+), 198 deletions(-) diff --git a/README.md b/README.md index 24a753c..d2301ef 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity - https://angular.dev/guide/signals +[Migrating from an older version?](#migration) +
Table of Contents @@ -42,6 +44,7 @@ - [`signal(initialValue, equals?)`](#signalinitialvalue-equals) - [`computed(getter)`](#computedgetter) - [`effect(callback)`](#effectcallback) + - [`reactive(initialValue)`](#reactiveinitialvalue) - [`untracked(callback)`](#untrackedcallback) - [`peek(callback)`](#peekcallback) - [`batch(callback)`](#batchcallback) @@ -54,7 +57,6 @@ - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`recursive()`](#recursive) - [`trigger(callback)`](#triggercallback) - - [`reactive(initialValue)`](#reactiveinitialvalue) - [`flags`](#flags) - [Client-Server Sync](#client-server-sync) - [Installation](#installation-1) @@ -276,9 +278,50 @@ setCounter(2) -- Outer: 2, Inner: 2 --- +### `reactive(initialValue)` + +By default, Charm's signals are _shallowly reactive_, meaning only the value itself is reactive, and table properties are not tracked. Signal values must also be **immutable** in order to track changes in tables. + +You can opt-in to deep reactivity with **reactive proxies**. The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy. + +```luau +local users, setUsers = reactive({ + { name = "John", surname = "Doe" }, +}) + +-- Output: John Doe +effect(function() + print(`{users[1].name} {users[1].surname}`) +end) + +-- Output: Jane Smith +setUsers(function(state) + state[1].name = "Jane" + state[1].surname = "Smith" + table.insert(state, { name = "Steve", surname = "Doe" }) +end) + +-- You can also mutate the reactive proxy directly: +users[1].name = "John" -- John Smith +``` + +> [!WARNING] +> Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value. + +If you need to access the raw table through the reactive proxy, use the `toRaw()` function: + +```luau +local raw = {} +local proxy = reactive(raw) + +print(toRaw(proxy) == raw) -- true +``` + +--- + ### `untracked(callback)` -In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked. +In case you want to opt-out of dependency tracking in an effect, you can use `untracked()` to call a function _outside_ the current scope, preventing signals and effects in the function from being tracked. ```luau local getTracked, setTracked = signal(0) @@ -294,7 +337,7 @@ setUntracked(1) -- No output setTracked(2) -- Tracked: 2, Untracked: 1 ``` -Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect: +Because `untracked()` executes the callback outside the current effect, nested effects created during the execution of the callback will not be tracked by the parent effect: ```luau local stopEffect @@ -602,45 +645,6 @@ print(total()) -- 2 --- -### `reactive(initialValue)` - -The `reactive()` function creates a mutable reactive object that subscribes to only the properties you read. It also returns a setter function you can use to mutate the original table: - -```luau -local users, setUsers = reactive({ - user = { name = "John", surname = "Doe" }, -}) - --- Output: John Doe -effect(function() - print(`{users.user.name} {users.user.surname}`) -end) - --- Output: Jane Smith -setUsers(function(state) - state.user.name = "Jane" - state.user.surname = "Smith" -end) -``` - -You can also set properties on the reactive object directly: - -```luau -local id = reactive({ name = "John", surname = "Doe" }) - -effect(function() - print(`{id.name} {id.surname}`) -end) - -id.name = "Jane" -- Jane Doe -id.surname = "Smith" -- Jane Smith -``` - -> [!WARNING] -> Be careful when mutating the reactive object! Because it's a proxy, functions like `table.insert` will not work. Table functions should be called on the original table, which is provided by the setter function. - ---- - ### `flags` Charm exposes the following global flags to customize behavior: @@ -948,7 +952,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that - Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`. - [Read the updated docs for syncing state →](#client-server-sync) -3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors if you didn't use the old `__DEV__` flag. The flags have the following behavior: +3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors. The flags have the following behaviors: - `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations @@ -966,14 +970,12 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that - Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback. 6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! - - Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit - - Added [`listen()`](#listengetter-callback) to create a subscription that runs once immediately - - Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once - - Added [`onCleanup()`](#oncleanupcallback-failsilently) to bind a cleanup function to the active effect - - Added [`trigger()`](#triggercallback) to trigger updates for table mutations - - The [`computed()`](#computedgetter) callback now gets called with the previous computed value - - Optimized `computed()` to use lazy evaluation instead of eager updates - - Optimized `mapped()` to only map values that changed in the original table + - [`reactive()`](#reactiveinitialvalue): make mutable tables deeply reactive + - [`signal()`](#signalinitialvalue-equals): make reads and writes more explicit + - [`listen()`](#listengetter-callback): create a subscription that runs once immediately + - [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once + - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect + - [`trigger()`](#triggercallback): trigger updates for table mutations --- diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index 9a55fe0..a589bde 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -5,5 +5,6 @@ end local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom +export type ReactiveProxy = Charm.ReactiveProxy return Charm diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index 65c0e45..cad535f 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -5,7 +5,7 @@ local suite = require("../../../../test/suite") local test = suite.test local afterEach = suite.afterEach -local function mockArraySerdes(object: { [any]: any }): { [any]: any } +local function mockArraySerdes(object: {}): {} if object[1] then local copy = {} for index = 1, #object do diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 43103a7..1435619 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,13 +2,11 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") +type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload -type SignalSetter = (update: ((T) -> T) | T) -> () - -type ReactiveSetter = (update: ((T) -> ()) | T) -> () - -type Reactive = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter })) +type SignalSetter = (T | (T) -> T) -> () +type ReactiveSetter = (T | (T) -> ()) -> () local trackedSignals: { [string]: SignalSetter } = {} local trackedReactives: { [string]: ReactiveSetter } = {} @@ -24,12 +22,12 @@ local trackedReactives: { [string]: ReactiveSetter } = {} @param signals A map of setter functions to sync with the server. ]] -local function addSignals(signals: { [string]: SignalSetter | { [any]: any } }) +local function addSignals(signals: { [string]: SignalSetter | {} }) for key, signal in signals do if type(signal) == "function" then trackedSignals[key] = signal else - trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue + trackedReactives[key] = getmetatable(signal :: ReactiveProxy).set end end end diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 15bfd2c..3892e9a 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -39,7 +39,7 @@ end long as the target array is not sparse. This function ensures that the diff function produces a patch that can be correctly applied. ]] -local function stringifySparseArray(object: { any }): { [string | number]: any } +local function stringifySparseArray(object: {}): { [string | number]: any } local maxn = table.maxn(object) if maxn == 0 or maxn == #object then @@ -65,7 +65,7 @@ local function diff(oldState: any, newState: any): any return newState end - local diffs: { [any]: any } = table.clone(newState) + local diffs = table.clone(newState) for key, oldValue in oldState do diffs[key] = diff(oldValue, newState[key]) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index a43e3b9..d9b1694 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -4,20 +4,19 @@ local patch = require("./patch") local types = require("./types") local utils = require("./utils") +type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload type SyncStatus = number type ClientKeys = { [string]: SyncStatus } -type Reactive = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () })) - type TrackedState = { target: any, currentValue: T, lastSyncedValue: T, - dispose: () -> (), isReactive: boolean?, + dispose: () -> (), } type StateUpdate = { @@ -68,21 +67,21 @@ local function trackSignal(key: string, getter: () -> T) trackedStates[key] = trackedState end -local function trackReactive(key: string, reactive: Reactive) +local function trackReactive(key: string, proxy: ReactiveProxy) if trackedStates[key] then - if trackedStates[key].target ~= reactive then + if trackedStates[key].target ~= proxy then warn("[Charm] Attempted to sync different states with the same key:", key) end return end - local meta = getmetatable(reactive) - local snapshot = utils.deepCopy(meta.initialValue) + local meta = getmetatable(proxy) + local snapshot = utils.deepCopy(meta.value) local firstRun = true local trackedState = { - target = reactive, - currentValue = meta.initialValue, + target = proxy, + currentValue = meta.value, lastSyncedValue = snapshot, isReactive = true, } @@ -93,7 +92,7 @@ local function trackReactive(key: string, reactive: Reactive) firstRun = false else local oldSnapshot = snapshot - snapshot = utils.deepCopy(meta.initialValue) + snapshot = utils.deepCopy(meta.value) table.insert(globalUpdates, { key = key, newValue = snapshot, @@ -258,7 +257,7 @@ end @param client The client receiving state updates. @param signals A map of getter functions to sync with the client. ]] -local function addSignalsToClient(client: any, signals: { [string]: { [any]: any } | () -> any }) +local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any }) if not clients[client] then clients[client] = {} end diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index fbf9260..198f482 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -6,10 +6,6 @@ type NestedTable = { [any]: NestedTable | any, } -type FlattenedTable = { - [any]: any, -} - --[[ Flattens a nested table into a single table with slash-separated keys. Useful for recursively collecting atoms returned by modules. @@ -17,15 +13,15 @@ type FlattenedTable = { @param input A nested table. @return A flattened table with slash-separated keys. ]] -local function flatten(input: NestedTable): FlattenedTable - local result: FlattenedTable = {} +local function flatten(input: {}): Flattened + local result = {} - local function visit(node: NestedTable, path: string) + local function visit(node: {}, path: string) for key, value in node do - local location = if path == "" then key else path .. "/" .. key + local location = if path == "" then tostring(key) else path .. "/" .. tostring(key) if type(value) == "table" then - visit(value :: NestedTable, location) + visit(value :: {}, location) else result[location] = value end @@ -34,7 +30,7 @@ local function flatten(input: NestedTable): FlattenedTable visit(input, "") - return result + return result :: any end --[[ diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index c108c73..ac21161 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,4 +1,4 @@ -local function isTableUnsafe(object: { [any]: any }) +local function isTableUnsafe(object: {}) local key = next(object) if key == nil then @@ -12,7 +12,7 @@ local function isTableUnsafe(object: { [any]: any }) for key in object do if type(key) ~= keyType then return true - elseif keyType == "number" and key > maxKey then + elseif type(key) == "number" and key > maxKey then maxKey = key end end @@ -33,7 +33,7 @@ end @param value The table value. @param key The key of the value. ]] -local function validate(value: { [any]: any }) +local function validate(value: {}) if isTableUnsafe(value) then warn( `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau index 34c8d4e..3ab4dfd 100644 --- a/packages/charm/src/__tests__/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -1,22 +1,19 @@ local Charm = require("../") local suite = require("../../../../test/suite") -local atom = Charm.atom +type ReactiveProxy = Charm.ReactiveProxy + +local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope +local reactive = Charm.reactive local test = suite.test ---[[ - Creates a weak reference to a value. -]] local function weakRef(value: T): { value: T? } return setmetatable({ value = value }, { __mode = "v" }) :: any end ---[[ - Triggers garbage collection by creating a large number of tables. -]] local function gc() for _ = 1, 2000 do local _ = table.create(100, 0) @@ -41,14 +38,14 @@ end test("should release computed cache", function() local srcRef = weakRef({}) - local src = atom(srcRef.value) + local src, setSrc = signal(srcRef.value) local c = computed(function() return src() end) c() -- cache src value - src(nil) -- release value - c = unlink(c) -- release computed + setSrc(nil) -- release value + unlink(c) -- release computed gc() assert(srcRef.value == nil, "should release computed cache") @@ -56,7 +53,7 @@ end) test("should not release effect for atom", function() local effectCalls = 0 - local src = atom(0) + local src, setSrc = signal(0) effect(function() effectCalls += 1 @@ -65,8 +62,52 @@ test("should not release effect for atom", function() assert(effectCalls == 1, "should call effect once") gc() - src(src() + 1) + setSrc(src() + 1) assert(effectCalls == 2, "should call effect again") end) +test("should release reactive tables", function() + local valueRef = weakRef({}) + local src = reactive({ value = valueRef.value }) + + effect(function() + local _ = src.value -- create proxy + end) + src.value = nil :: any -- release proxy + + gc() + assert(valueRef.value == nil, "should release reactive table") + assert((src :: ReactiveProxy).__signals.value == nil, "should release reactive signals") +end) + +test("should release reactive property deps", function() + local src = reactive({ foo = 1 }) + + local c = computed(function() + return src.foo + end) + + c() + assert((src :: ReactiveProxy).__signals.foo ~= nil, "should have deps for foo") + + c = nil :: any + gc() + assert((src :: ReactiveProxy).__signals.foo == nil, "should release deps for foo") +end) + +test("should not release effect for reactive", function() + local src = reactive({ foo = 1 }) + local calls = 0 + + effect(function() + calls += 1 + local _ = src.foo + end) + assert(calls == 1, "should call effect once") + + gc() + src.foo += 1 + assert(calls == 2, "should call effect again") +end) + return {} diff --git a/packages/charm/src/__tests__/reactive.spec.luau b/packages/charm/src/__tests__/reactive.spec.luau index 70237b9..48b4b58 100644 --- a/packages/charm/src/__tests__/reactive.spec.luau +++ b/packages/charm/src/__tests__/reactive.spec.luau @@ -3,6 +3,7 @@ local suite = require("../../../../test/suite") local effect = Charm.effect local reactive = Charm.reactive +local toRaw = Charm.toRaw local test = suite.test test("should track nested properties", function() @@ -121,4 +122,22 @@ test("should overwrite with table passed to setter", function() assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ",")) end) +test("should unwrap raw table", function() + local raw = {} + local src = reactive(raw) + local runs = 0 + + effect(function() + local _ = toRaw(src) + runs += 1 + end) + + assert(toRaw(src) == raw, "unwrap did not return the original table") + assert(toRaw(raw) == raw, "unwrap modified a non-proxy value") + assert(runs == 1, runs) + + src.x = 1 + assert(runs == 2, runs) +end) + return {} diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 973b458..247fe91 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -302,14 +302,23 @@ export function recursive(): void; * ``` * * @param initialValue The value to make deeply reactive. - * @return A proxy table that tracks reads to nested properties. - * @return A function for updating the original table. + * @returns A proxy table and a function to mutate the original table. * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue */ export function reactive( initialValue: T, ): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>; +/** + * Unwraps a reactive proxy to get the original table. If the provided value + * is not a proxy, it is returned as-is. + * + * @param value A reactive proxy or a non-proxy value. + * @returns The unwrapped target value. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function toRaw(value: T): T; + export function getActiveSub(): ReactiveNode | undefined; export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 4334a54..7b6f4ae 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -6,13 +6,19 @@ export type Link = system.Link export type Atom = (...((T) -> T) | T) -> T -type Target = { [any]: any } - -type Proxy = { - __target: Target, - __signals: { [any]: () -> any }, +type ReactiveProxyMeta = { + value: T, + track: () -> (), + set: (T | (T) -> ()?) -> (), } +type ReactiveProxyProps = { + __target: T, + __signals: { [unknown]: () -> unknown }, +} + +export type ReactiveProxy = T & typeof(setmetatable({} :: ReactiveProxyProps, {} :: ReactiveProxyMeta)) + type EffectScopeNode = ReactiveNode & { cleanups: { () -> () }, } @@ -135,7 +141,7 @@ local function runCleanups(effect: EffectScopeNode) end end -local function run(e: EffectNode): () +local function run(e: EffectNode) local flags = e.flags if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then runCleanups(e) @@ -157,7 +163,7 @@ local function run(e: EffectNode): () end end -local function flush(): () +local function flush() while notifyIndex < queuedLength do local effect = queued[notifyIndex] :: EffectNode queued[notifyIndex] = nil @@ -320,6 +326,50 @@ local function effectOper(this: EffectScopeNode): () -> () end end +local function update(node: ReactiveNode): boolean + if node.depsTail then + return updateComputed(node :: ComputedNode) + else + return updateSignal(node :: SignalNode) + end +end + +local function notify(effect: ReactiveNode) + local insertIndex = queuedLength + local firstInsertedIndex = insertIndex + + repeat + effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) + queued[insertIndex] = effect :: EffectNode + insertIndex += 1 + effect = (effect.subs and effect.subs.sub) :: EffectNode + if not effect or bit32.band(effect.flags, WATCHING) == 0 then + break + end + until false + + queuedLength = insertIndex + + insertIndex -= 1 + while firstInsertedIndex < insertIndex do + local left = queued[firstInsertedIndex] + queued[firstInsertedIndex] = queued[insertIndex] + queued[insertIndex] = left + firstInsertedIndex += 1 + insertIndex -= 1 + end +end + +local function unwatched(node: ReactiveNode) + if bit32.band(node.flags, MUTABLE) == 0 then + effectOper(node :: EffectScopeNode)() + elseif node.depsTail then + node.depsTail = nil + node.flags = bit32.bor(MUTABLE, DIRTY) + purgeDeps(node) + end +end + --[[ Creates a reactive signal that stores a value and notifies subscribers when the value changes. The signal provides both a getter and a setter. @@ -590,6 +640,7 @@ end local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () local tracked = toSignal(getter) local current: T + return effect(function() local previous = current current = tracked() @@ -614,6 +665,7 @@ local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()) local tracked = toSignal(getter) local current: T local firstRun = true + return effect(function() local previous = current current = tracked() @@ -673,6 +725,7 @@ local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> end end end + for key, value in data do if innerScopes[key] == nil then local disposeScope = effectScope(function() @@ -696,6 +749,7 @@ local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> -- interfere with the current update cycle. batch(updateScopes, getter()) end) + return function() if disposed then return @@ -781,16 +835,16 @@ end @example ```luau local users, setUsers = reactive({ - user = { name = "John", surname = "Doe" }, + { name = "John", surname = "Doe" }, }) effect(function() - print(users.user and `{users.user.name} {users.user.surname}`) + print(`{users[1].name} {users[1].surname}`) end) setUsers(function(state) - state.user.name = "Jane" - state.user.surname = "Smith" + state[1].name = "Jane" + state[1].surname = "Smith" end) ``` @@ -801,53 +855,32 @@ end ]] local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ()) local track = signal(nil) + local proxyMeta = { value = initialValue, track = track } + local proxyMap = setmetatable({} :: { [{}]: any }, { __mode = "v" }) - local function setValue(update: ((T) -> ()) | T?) - if type(update) == "function" then - update(initialValue) - elseif type(update) == "table" then - table.clear(initialValue) - for key, value in update do - (initialValue :: Target)[key] = value - end + local function trackProperty(cache: { [any]: () -> any }, target: {}, key: any) + if cache[key] then + return cache[key]() end - trigger(track) + cache[key] = computed(function() + track() + return target[key] + end) + return cache[key]() end - local proxyMeta = { - initialValue = initialValue, - track = track, - setValue = setValue, - } - - local proxyMap = setmetatable({} :: { [Target]: any }, { __mode = "k" }) - - local function trackProperty(cache: { [any]: () -> any }, target: Target, key: any) - if not cache[key] then - cache[key] = untracked(computed, function() - track() - return target[key] - end) - end - cache[key]() - end - - local function createProxy(target: Target): Proxy - if not proxyMap[target] then - proxyMap[target] = setmetatable({ - __target = target, - __signals = setmetatable({}, { __mode = "v" }), - }, proxyMeta) + local function createProxy(target: T & {}): ReactiveProxy + if proxyMap[target] then + return proxyMap[target] end + proxyMap[target] = setmetatable({ + __target = target, + __signals = setmetatable({}, { __mode = "v" }), + }, proxyMeta) return proxyMap[target] end - local function nextProxy(target: Target, index: any) - local nextKey, nextValue = next(target, index) - return nextKey, if type(nextValue) == "table" then createProxy(nextValue) else nextValue - end - - function proxyMeta.__index(self: Proxy, key: any): any + function proxyMeta.__index(self: ReactiveProxy, key: any): any if activeSub then trackProperty(self.__signals, self.__target, key) end @@ -855,7 +888,7 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - return if type(value) == "table" then createProxy(value) else value end - function proxyMeta.__newindex(self: Proxy, key: any, value: any) + function proxyMeta.__newindex(self: ReactiveProxy, key: any, value: any) if type(value) == "table" and getmetatable(value) == proxyMeta then value = value.__target end @@ -865,61 +898,61 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - end end - function proxyMeta.__iter(self: Proxy) + function proxyMeta.__iter(self: ReactiveProxy) track() - return nextProxy, self.__target + local function iterator(target: {}, index: any) + local key, value = next(target, index) + return key, if type(value) == "table" then createProxy(value) else value + end + return iterator, self.__target end - function proxyMeta.__len(self: Proxy) + function proxyMeta.__len(self: ReactiveProxy) track() return #self.__target end - return createProxy(initialValue) :: any, setValue + function proxyMeta.set(update: ((T) -> ()) | T?) + if type(update) == "function" then + update(initialValue) + elseif type(update) == "table" then + table.clear(initialValue) + for key, value in update :: {} do + (initialValue :: {})[key] = value + end + end + trigger(track) + end + + return createProxy(initialValue), proxyMeta.set +end + +--[[ + Unwraps a reactive proxy to get the original table. If the provided value + is not a proxy, it is returned as-is. + + @param value A reactive proxy or a non-proxy value. + @return The unwrapped target value. + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function toRaw(value: T): T + if type(value) == "table" and getmetatable(value) ~= nil then + local proxy: ReactiveProxy = value + local meta = getmetatable(proxy) + if meta.value == proxy.__target then + -- Track the root proxy since nested proxies are already tracked + meta.track() + end + return proxy.__target :: any + else + return value + end end local reactiveSystem = system.createReactiveSystem({ - update = function(node) - if node.depsTail then - return updateComputed(node :: ComputedNode) - else - return updateSignal(node :: SignalNode) - end - end, - notify = function(effect) - local insertIndex = queuedLength - local firstInsertedIndex = insertIndex - - repeat - effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) - queued[insertIndex] = effect :: EffectNode - insertIndex += 1 - effect = (effect.subs and effect.subs.sub) :: EffectNode - if not effect or bit32.band(effect.flags, WATCHING) == 0 then - break - end - until false - - queuedLength = insertIndex - - insertIndex -= 1 - while firstInsertedIndex < insertIndex do - local left = queued[firstInsertedIndex] - queued[firstInsertedIndex] = queued[insertIndex] - queued[insertIndex] = left - firstInsertedIndex += 1 - insertIndex -= 1 - end - end, - unwatched = function(node) - if bit32.band(node.flags, MUTABLE) == 0 then - effectOper(node :: EffectScopeNode)() - elseif node.depsTail then - node.depsTail = nil - node.flags = bit32.bor(MUTABLE, DIRTY) - purgeDeps(node) - end - end, + update = update, + notify = notify, + unwatched = unwatched, }) link = reactiveSystem.link @@ -954,4 +987,5 @@ return { mapper: (item: VI, key: KI) -> (VO, KO) ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), reactive = reactive, + toRaw = toRaw, } diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 61834fb..565419e 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -89,7 +89,7 @@ local function createReactiveSystem(props: { return false end - local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): () + local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) -- DEVIATION: Do not link signals to peeking subscribers if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then return @@ -166,7 +166,7 @@ local function createReactiveSystem(props: { return nextDep end - local function propagate(link: Link): () + local function propagate(link: Link) repeat local sub = link.sub local flags = sub.flags @@ -200,7 +200,7 @@ local function createReactiveSystem(props: { until not link end - local function shallowPropagate(link: Link): () + local function shallowPropagate(link: Link) repeat local sub = link.sub local flags = sub.flags From 366fc668a2ec75c002658cbd83bcfec36791f82f Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:05:59 -0800 Subject: [PATCH 078/160] fix: check for nil diffs --- .../charm-sync/src/__tests__/server.spec.luau | 40 +++++++++++++++++++ packages/charm-sync/src/server.luau | 26 ++++++------ packages/charm/src/init.luau | 28 ++++++------- 3 files changed, 67 insertions(+), 27 deletions(-) diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau index 4a30449..800fa88 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -210,6 +210,26 @@ test("should serialize state", function() assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key") end) +test("should not send unchanged state", function() + local client = {} + local a = Charm.atom(0) + local payloads + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + payloads = {} + a(0) + server.flush() + assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") +end) + describe("historical", function() beforeEach(function() config.preserveHistory = true @@ -344,6 +364,26 @@ describe("historical", function() assert(payloads[4].type == "patch", "Expected fourth payload to be patch") assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'") end) + + test("should not send unchanged state", function() + local client = {} + local a = Charm.atom(0) + local payloads + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + payloads = {} + a(0) + server.flush() + assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") + end) end) return {} diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index d9b1694..9624d1c 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -75,24 +75,24 @@ local function trackReactive(key: string, proxy: ReactiveProxy) return end - local meta = getmetatable(proxy) - local snapshot = utils.deepCopy(meta.value) + local api = getmetatable(proxy) + local snapshot = utils.deepCopy(api.value) local firstRun = true local trackedState = { target = proxy, - currentValue = meta.value, + currentValue = api.value, lastSyncedValue = snapshot, isReactive = true, } trackedState.dispose = Charm.effect(function() - meta.track() + api.track() if not config.preserveHistory or firstRun then firstRun = false else local oldSnapshot = snapshot - snapshot = utils.deepCopy(meta.value) + snapshot = utils.deepCopy(api.value) table.insert(globalUpdates, { key = key, newValue = snapshot, @@ -132,8 +132,8 @@ local function diffGlobalUpdateBuffer(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) - for index, update in globalUpdates do - patches[index] = patch.diff(update.oldValue, update.newValue) + for _, update in globalUpdates do + table.insert(patches, patch.diff(update.oldValue, update.newValue)) end return patches @@ -176,7 +176,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { -- If a pending signal was never updated, we still need to send its initial -- state. This can happen if a signal was registered after its last change. - local initData: { [string]: any } = {} + local initData = {} for key, status in clientKeys do local trackedState = trackedStates[key] @@ -194,7 +194,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { return payloads end -local function diffGlobalState(): any +local function diffGlobalState(): { [string]: any }? local currentGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {} @@ -211,7 +211,7 @@ local function diffGlobalState(): any return patch.diff(previousGlobalState, currentGlobalState) end -local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }? +local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }? local clientKeys = clients[client] if not clientKeys then @@ -227,7 +227,7 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo for key, status in clientKeys do local trackedState = trackedStates[key] - if status == LISTENING_FOR_CHANGES then + if status == LISTENING_FOR_CHANGES and globalPatch then patchData[key] = globalPatch[key] elseif status == PENDING_INITIAL_STATE then initData[key] = trackedState.currentValue @@ -323,11 +323,11 @@ local function flush() end local granular = config.preserveHistory - local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState() + local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState() for client in clients do local payloads = if granular - then selectFromGlobalPatchList(client, globalPatch) + then selectFromGlobalPatchList(client, globalPatch :: { any }) else selectFromGlobalPatch(client, globalPatch) if payloads and payloads[1] then diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 7b6f4ae..85d95f2 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -6,7 +6,7 @@ export type Link = system.Link export type Atom = (...((T) -> T) | T) -> T -type ReactiveProxyMeta = { +type ReactiveProxyApi = { value: T, track: () -> (), set: (T | (T) -> ()?) -> (), @@ -17,7 +17,7 @@ type ReactiveProxyProps = { __signals: { [unknown]: () -> unknown }, } -export type ReactiveProxy = T & typeof(setmetatable({} :: ReactiveProxyProps, {} :: ReactiveProxyMeta)) +export type ReactiveProxy = T & typeof(setmetatable({} :: ReactiveProxyProps, {} :: ReactiveProxyApi)) type EffectScopeNode = ReactiveNode & { cleanups: { () -> () }, @@ -855,7 +855,7 @@ end ]] local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ()) local track = signal(nil) - local proxyMeta = { value = initialValue, track = track } + local proxyApi = { value = initialValue, track = track } local proxyMap = setmetatable({} :: { [{}]: any }, { __mode = "v" }) local function trackProperty(cache: { [any]: () -> any }, target: {}, key: any) @@ -876,11 +876,11 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - proxyMap[target] = setmetatable({ __target = target, __signals = setmetatable({}, { __mode = "v" }), - }, proxyMeta) + }, proxyApi) return proxyMap[target] end - function proxyMeta.__index(self: ReactiveProxy, key: any): any + function proxyApi.__index(self: ReactiveProxy, key: any): any if activeSub then trackProperty(self.__signals, self.__target, key) end @@ -888,8 +888,8 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - return if type(value) == "table" then createProxy(value) else value end - function proxyMeta.__newindex(self: ReactiveProxy, key: any, value: any) - if type(value) == "table" and getmetatable(value) == proxyMeta then + function proxyApi.__newindex(self: ReactiveProxy, key: any, value: any) + if type(value) == "table" and getmetatable(value) == proxyApi then value = value.__target end if self.__target[key] ~= value then @@ -898,7 +898,7 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - end end - function proxyMeta.__iter(self: ReactiveProxy) + function proxyApi.__iter(self: ReactiveProxy) track() local function iterator(target: {}, index: any) local key, value = next(target, index) @@ -907,12 +907,12 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - return iterator, self.__target end - function proxyMeta.__len(self: ReactiveProxy) + function proxyApi.__len(self: ReactiveProxy) track() return #self.__target end - function proxyMeta.set(update: ((T) -> ()) | T?) + function proxyApi.set(update: ((T) -> ()) | T?) if type(update) == "function" then update(initialValue) elseif type(update) == "table" then @@ -924,7 +924,7 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - trigger(track) end - return createProxy(initialValue), proxyMeta.set + return createProxy(initialValue), proxyApi.set end --[[ @@ -938,10 +938,10 @@ end local function toRaw(value: T): T if type(value) == "table" and getmetatable(value) ~= nil then local proxy: ReactiveProxy = value - local meta = getmetatable(proxy) - if meta.value == proxy.__target then + local api = getmetatable(proxy) + if api.value == proxy.__target then -- Track the root proxy since nested proxies are already tracked - meta.track() + api.track() end return proxy.__target :: any else From 1206e008a7297fd5ae4daab100d90e74342ccc36 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:06:30 -0800 Subject: [PATCH 079/160] chore: bump versions --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 2251c77..c2a0c98 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0-rc.2", + "version": "0.4.0-rc.3", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index b6911a2..43ab546 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0-rc.2" +version = "0.4.0-rc.3" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ From 9b892845298327d0d24f2349942a2670a8ae22c1 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:50:37 -0800 Subject: [PATCH 080/160] perf: do not create proxies for iteration --- packages/charm/src/init.luau | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 85d95f2..7d42368 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -900,11 +900,7 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - function proxyApi.__iter(self: ReactiveProxy) track() - local function iterator(target: {}, index: any) - local key, value = next(target, index) - return key, if type(value) == "table" then createProxy(value) else value - end - return iterator, self.__target + return next, self.__target end function proxyApi.__len(self: ReactiveProxy) From 189ae0f1687c2c41a65df13f17c35b2ac4afd2f7 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 21 Nov 2025 22:15:08 -0800 Subject: [PATCH 081/160] fix: make `toRaw` track all calls --- .../charm-sync/src/__tests__/server.spec.luau | 18 ++++++++++++------ packages/charm-sync/src/server.luau | 5 ++++- packages/charm/src/init.luau | 12 +++++------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau index 800fa88..a74dd33 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -71,7 +71,7 @@ end) test("should replicate partial state patches (reactive)", function() local client = {} - local a: { [string]: number? } = Charm.reactive({ x = 0, y = 0, z = 0 }) + local a = Charm.reactive({ x = { y = 0 }, z = 0 :: number? }) local target, payloads server.addSignalsToClient(client, { a = a }) @@ -82,18 +82,24 @@ test("should replicate partial state patches (reactive)", function() server.flush() assert(target == client, "Expected target to be the client") assert(payloads[1].type == "init", "Expected first payload to be init") - assert(payloads[1].data.a.x == a.x, "Expected init payload to contain value 'a.x'") - assert(payloads[1].data.a.y == a.y, "Expected init payload to contain value 'a.y'") + assert(payloads[1].data.a.x ~= nil, "Expected init payload to contain value 'a.x'") + assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'") assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'") - a.x = 1 + a.x.y = 1 a.z = nil server.flush() assert(target == client, "Expected target to be the client") assert(payloads[1].type == "patch", "Expected first payload to be patch") - assert(payloads[1].data.a.x == 1, "Expected patch payload to contain value 'a.x'") - assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain value 'a.y'") + assert(payloads[1].data.a.x.y == 1, "Expected patch payload to contain value 'a.x'") assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None") + + a.z = 1 + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a.z == 1, "Expected patch payload to contain value 'a.z'") + assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'") end) test("should init new clients", function() diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 9624d1c..3605521 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -88,7 +88,10 @@ local function trackReactive(key: string, proxy: ReactiveProxy) trackedState.dispose = Charm.effect(function() api.track() - if not config.preserveHistory or firstRun then + if not config.preserveHistory then + return + end + if firstRun then firstRun = false else local oldSnapshot = snapshot diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 7d42368..e31702f 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -933,16 +933,14 @@ end ]] local function toRaw(value: T): T if type(value) == "table" and getmetatable(value) ~= nil then - local proxy: ReactiveProxy = value - local api = getmetatable(proxy) - if api.value == proxy.__target then - -- Track the root proxy since nested proxies are already tracked + local value: ReactiveProxy = value + local api = getmetatable(value) + if api then api.track() + return value.__target :: any end - return proxy.__target :: any - else - return value end + return value end local reactiveSystem = system.createReactiveSystem({ From 422ee43a945ec4c179dcd7fa140320a0833ed64d Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 22 Nov 2025 22:03:42 -0800 Subject: [PATCH 082/160] fix: check none before patching mutable tables --- .../charm-sync/src/__tests__/patch.spec.luau | 6 +++++ packages/charm-sync/src/patch.luau | 26 +++++++++---------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index cad535f..7ce89c2 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -127,4 +127,10 @@ test("patches mutable state", function() assert(targetState.qaz == "qux", "should add the new value") end) +test("removes table from mutable state", function() + local targetState = { foo = { bar = "baz" } } + patch.applyMutable(targetState, { foo = { __none = "__none" } }) + assert(targetState.foo == nil, "should remove the table") +end) + return {} diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 3892e9a..0d1684d 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -112,22 +112,22 @@ local function apply(oldState: any, diffs: any): any end local function applyMutable(targetState: any, sourceState: any): any - if type(targetState) == "table" and type(sourceState) == "table" then - local targetIsArray = targetState[1] ~= nil - - for key, value in sourceState do - if targetIsArray and type(key) == "string" then - key = tonumber(key) or key - end - targetState[key] = applyMutable(targetState[key], value) - end - - return targetState - elseif isNone(sourceState) then + if isNone(sourceState) then return nil - else + elseif type(targetState) ~= "table" or type(sourceState) ~= "table" then return sourceState end + + local targetIsArray = targetState[1] ~= nil + + for key, value in sourceState do + if targetIsArray and type(key) == "string" then + key = tonumber(key) or key + end + targetState[key] = applyMutable(targetState[key], value) + end + + return targetState end return { From 391125b3a21e3f34f55e4f1fb1691698f3ea85a7 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 22 Nov 2025 22:04:27 -0800 Subject: [PATCH 083/160] chore: bump charm-sync version --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index c2a0c98..026c545 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0-rc.3", + "version": "0.4.0-rc.4", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 43ab546..9bd2dc0 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0-rc.3" +version = "0.4.0-rc.4" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ From aebe3d27ee3654444fd866801b807ce0f79cbb64 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 23 Nov 2025 16:28:11 -0800 Subject: [PATCH 084/160] docs: add fishing game example project --- README.md | 2 +- packages/charm/src/init.luau | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d2301ef..b5a7a2f 100644 --- a/README.md +++ b/README.md @@ -981,7 +981,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that ## Examples -Check out this simple [example project →](https://github.com/littensy/charm-example) +- https://github.com/littensy/fishing-minigame: Fisch clone using Charm for server and client state ### React Counter diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index e31702f..ca45ce9 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -889,7 +889,7 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - end function proxyApi.__newindex(self: ReactiveProxy, key: any, value: any) - if type(value) == "table" and getmetatable(value) == proxyApi then + if type(value) == "table" and type(value.__target) == "table" then value = value.__target end if self.__target[key] ~= value then @@ -932,13 +932,12 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] local function toRaw(value: T): T - if type(value) == "table" and getmetatable(value) ~= nil then - local value: ReactiveProxy = value - local api = getmetatable(value) - if api then - api.track() - return value.__target :: any + if type(value) == "table" and type(value.__target) == "table" then + -- Iteration starts tracking the reactive proxy + for _ in value do + break end + return value.__target end return value end From b3a76face9edd869eaa9d1d263133884fc28708a Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 25 Nov 2025 00:29:08 -0800 Subject: [PATCH 085/160] feat: sync signals that return reactive proxy --- packages/charm-sync/src/client.luau | 13 ++--- packages/charm-sync/src/patch.luau | 10 ++-- packages/charm-sync/src/server.luau | 87 +++++++++++------------------ packages/charm/src/index.d.ts | 9 +++ packages/charm/src/init.luau | 21 +++++-- 5 files changed, 69 insertions(+), 71 deletions(-) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 1435619..ba92e30 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -5,11 +5,10 @@ local types = require("./types") type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload -type SignalSetter = (T | (T) -> T) -> () -type ReactiveSetter = (T | (T) -> ()) -> () +type Setter = (T | (T) -> any) -> () -local trackedSignals: { [string]: SignalSetter } = {} -local trackedReactives: { [string]: ReactiveSetter } = {} +local trackedSignals: { [string]: Setter } = {} +local trackedReactives: { [string]: Setter } = {} --[[ Registers a map of setters to the state with the corresponding keys on the @@ -22,7 +21,7 @@ local trackedReactives: { [string]: ReactiveSetter } = {} @param signals A map of setter functions to sync with the server. ]] -local function addSignals(signals: { [string]: SignalSetter | {} }) +local function addSignals(signals: { [string]: Setter | {} }) for key, signal in signals do if type(signal) == "function" then trackedSignals[key] = signal @@ -55,7 +54,7 @@ end local function applyInitPayload(data: { [string]: any }) for key, value in data do - local trackedSignal: SignalSetter? = trackedSignals[key] or trackedReactives[key] + local trackedSignal: Setter? = trackedSignals[key] or trackedReactives[key] if trackedSignal then trackedSignal(value) end @@ -72,7 +71,7 @@ local function applyPatchPayload(data: { [string]: any }) return patch.apply(value, diffs) end) elseif trackedReactive then - trackedReactive(function(value) + trackedReactive(function(value): () patch.applyMutable(value, diffs) end) end diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 0d1684d..b9a3be1 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -111,16 +111,16 @@ local function apply(oldState: any, diffs: any): any return newState end -local function applyMutable(targetState: any, sourceState: any): any - if isNone(sourceState) then +local function applyMutable(targetState: any, diffs: any): any + if isNone(diffs) then return nil - elseif type(targetState) ~= "table" or type(sourceState) ~= "table" then - return sourceState + elseif type(targetState) ~= "table" or type(diffs) ~= "table" then + return diffs end local targetIsArray = targetState[1] ~= nil - for key, value in sourceState do + for key, value in diffs do if targetIsArray and type(key) == "string" then key = tonumber(key) or key end diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 3605521..7233fd7 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -12,10 +12,10 @@ type SyncStatus = number type ClientKeys = { [string]: SyncStatus } type TrackedState = { - target: any, + identifier: unknown, currentValue: T, lastSyncedValue: T, - isReactive: boolean?, + isReactive: boolean, dispose: () -> (), } @@ -35,75 +35,54 @@ local clients: { [any]: ClientKeys } = {} local connection: RBXScriptConnection? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? -local function trackSignal(key: string, getter: () -> T) +local function trackSignal(key: string, getter: () -> T, identifier: unknown) if trackedStates[key] then -- Ignore duplicates, but warn against name collisions - if trackedStates[key].target ~= getter then + if trackedStates[key].identifier ~= identifier then warn("[Charm] Attempted to sync different states with the same key:", key) end return end - local value = getter() - local trackedState = { - target = getter, - currentValue = value, - lastSyncedValue = value, - } + local trackedState: TrackedState + local initialValue = getter() + local rawInitialValue = Charm.toRaw(initialValue) + local initialIsReactive = initialValue ~= rawInitialValue + local snapshot = if initialIsReactive then utils.deepCopy(rawInitialValue) else initialValue + + local dispose = Charm.effect(function() + local newValue = getter() + local rawValue = Charm.toRaw(newValue) + local isReactive = newValue ~= rawValue + + if not trackedState then + return + end - trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue) -- When preserving history, we need to keep track of previous values -- so we can create granular patches for each change. if config.preserveHistory then - table.insert(globalUpdates, { - key = key, - newValue = newValue, - oldValue = oldValue, - }) - end - trackedState.currentValue = newValue - end) - - trackedStates[key] = trackedState -end - -local function trackReactive(key: string, proxy: ReactiveProxy) - if trackedStates[key] then - if trackedStates[key].target ~= proxy then - warn("[Charm] Attempted to sync different states with the same key:", key) - end - return - end - - local api = getmetatable(proxy) - local snapshot = utils.deepCopy(api.value) - local firstRun = true - - local trackedState = { - target = proxy, - currentValue = api.value, - lastSyncedValue = snapshot, - isReactive = true, - } - - trackedState.dispose = Charm.effect(function() - api.track() - if not config.preserveHistory then - return - end - if firstRun then - firstRun = false - else local oldSnapshot = snapshot - snapshot = utils.deepCopy(api.value) + snapshot = if isReactive then utils.deepCopy(rawValue) else newValue table.insert(globalUpdates, { key = key, newValue = snapshot, oldValue = oldSnapshot, }) end + + trackedState.currentValue = rawValue + trackedState.isReactive = isReactive end) + trackedState = { + identifier = identifier, + currentValue = rawInitialValue, + lastSyncedValue = snapshot, + isReactive = initialIsReactive, + dispose = dispose, + } + trackedStates[key] = trackedState end @@ -269,9 +248,11 @@ local function addSignalsToClient(client: any, signals: { [string]: {} | () -> a for key, signal in signals do if type(signal) == "function" then - trackSignal(key, signal) + trackSignal(key, signal, signal) else - trackReactive(key, signal :: any) + trackSignal(key, function() + return signal + end, signal) end if not clientKeys[key] then diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 247fe91..132a5be 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -309,6 +309,15 @@ export function reactive( initialValue: T, ): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>; +/** + * Determines if the provided value is a reactive proxy created by `reactive()`. + * + * @param value A value to test. + * @returns `true` if the value is a reactive proxy, `false` otherwise. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function isReactive(value: unknown): boolean; + /** * Unwraps a reactive proxy to get the original table. If the provided value * is not a proxy, it is returned as-is. diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index ca45ce9..4f0cd11 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -923,6 +923,17 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - return createProxy(initialValue), proxyApi.set end +--[[ + Determines if the provided value is a reactive proxy created by `reactive()`. + + @param value A value to test. + @return `true` if the value is a reactive proxy, `false` otherwise. + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function isReactive(value: any): boolean + return type(value) == "table" and type(value.__target) == "table" and getmetatable(value) ~= nil +end + --[[ Unwraps a reactive proxy to get the original table. If the provided value is not a proxy, it is returned as-is. @@ -932,12 +943,9 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] local function toRaw(value: T): T - if type(value) == "table" and type(value.__target) == "table" then - -- Iteration starts tracking the reactive proxy - for _ in value do - break - end - return value.__target + if isReactive(value) then + getmetatable(value :: ReactiveProxy).track() + return (value :: ReactiveProxy).__target end return value end @@ -980,5 +988,6 @@ return { mapper: (item: VI, key: KI) -> (VO, KO) ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), reactive = reactive, + isReactive = isReactive, toRaw = toRaw, } From b826401bf3856669574fa51d489c06369cf039b6 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 28 Nov 2025 01:18:57 -0800 Subject: [PATCH 086/160] fix: refine reactive proxy logic --- .../charm-sync/src/__tests__/server.spec.luau | 16 ++ packages/charm-sync/src/client.luau | 2 +- packages/charm-sync/src/server.luau | 11 +- packages/charm-sync/src/utils.luau | 5 +- .../charm/src/__tests__/reactive.spec.luau | 23 ++- packages/charm/src/init.luau | 190 +++++++++--------- 6 files changed, 145 insertions(+), 102 deletions(-) diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau index a74dd33..034fc54 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -102,6 +102,22 @@ test("should replicate partial state patches (reactive)", function() assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'") end) +test("should handle nested proxies", function() + local client = {} + local src = Charm.reactive({ (Charm.reactive({})) }) + local payloads: {}? = nil + + server.addSignalsToClient(client, { src = src }) + server.connect(function(_, newPayloads) + payloads = newPayloads + end) + server.flush() + + payloads = nil + server.flush() + assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy") +end) + test("should init new clients", function() local clientA = {} local clientB = {} diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index ba92e30..a51ec55 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -26,7 +26,7 @@ local function addSignals(signals: { [string]: Setter | {} }) if type(signal) == "function" then trackedSignals[key] = signal else - trackedReactives[key] = getmetatable(signal :: ReactiveProxy).set + trackedReactives[key] = ((signal :: any) :: ReactiveProxy).__set end end end diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 7233fd7..07ce4ba 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -33,7 +33,7 @@ local globalUpdates: { StateUpdate } = {} local clients: { [any]: ClientKeys } = {} local connection: RBXScriptConnection? -local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? +local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? local function trackSignal(key: string, getter: () -> T, identifier: unknown) if trackedStates[key] then @@ -47,8 +47,7 @@ local function trackSignal(key: string, getter: () -> T, identifier: unknown) local trackedState: TrackedState local initialValue = getter() local rawInitialValue = Charm.toRaw(initialValue) - local initialIsReactive = initialValue ~= rawInitialValue - local snapshot = if initialIsReactive then utils.deepCopy(rawInitialValue) else initialValue + local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue local dispose = Charm.effect(function() local newValue = getter() @@ -62,12 +61,12 @@ local function trackSignal(key: string, getter: () -> T, identifier: unknown) -- When preserving history, we need to keep track of previous values -- so we can create granular patches for each change. if config.preserveHistory then - local oldSnapshot = snapshot + local prevSnapshot = snapshot snapshot = if isReactive then utils.deepCopy(rawValue) else newValue table.insert(globalUpdates, { key = key, newValue = snapshot, - oldValue = oldSnapshot, + oldValue = prevSnapshot, }) end @@ -79,7 +78,7 @@ local function trackSignal(key: string, getter: () -> T, identifier: unknown) identifier = identifier, currentValue = rawInitialValue, lastSyncedValue = snapshot, - isReactive = initialIsReactive, + isReactive = initialValue ~= rawInitialValue, dispose = dispose, } diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index 198f482..a4dae84 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -49,12 +49,13 @@ local function signalToAtom(getter: () -> T, setter: (value: ((T) -> T) | T) end --[[ - Returns a deep copy of the given table. + Returns a deep copy of the given table. Skips tables with metatables to + prevent copying reactive proxies. ]] local function deepCopy(object: T): T local copy: any = table.clone(object :: any) for key, value in copy do - if type(value) == "table" then + if type(value) == "table" and getmetatable(value) == nil then copy[key] = deepCopy(value) end end diff --git a/packages/charm/src/__tests__/reactive.spec.luau b/packages/charm/src/__tests__/reactive.spec.luau index 48b4b58..79c2be0 100644 --- a/packages/charm/src/__tests__/reactive.spec.luau +++ b/packages/charm/src/__tests__/reactive.spec.luau @@ -122,7 +122,7 @@ test("should overwrite with table passed to setter", function() assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ",")) end) -test("should unwrap raw table", function() +test("should unwrap proxy", function() local raw = {} local src = reactive(raw) local runs = 0 @@ -140,4 +140,25 @@ test("should unwrap raw table", function() assert(runs == 2, runs) end) +test("should unwrap nested proxy", function() + local raw = {} + local inner = reactive(raw) + local src = reactive(inner) + local runs = 0 + + effect(function() + local _ = toRaw(src) + runs += 1 + end) + + assert(toRaw(src) == raw, "unwrap did not return the original table") + assert(runs == 1, runs) + + inner.x = 1 + assert(runs == 2, runs) + + src.y = 2 + assert(runs == 3, runs) +end) + return {} diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 4f0cd11..7cc3998 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -6,18 +6,15 @@ export type Link = system.Link export type Atom = (...((T) -> T) | T) -> T -type ReactiveProxyApi = { - value: T, - track: () -> (), - set: (T | (T) -> ()?) -> (), -} - -type ReactiveProxyProps = { - __target: T, - __signals: { [unknown]: () -> unknown }, -} - -export type ReactiveProxy = T & typeof(setmetatable({} :: ReactiveProxyProps, {} :: ReactiveProxyApi)) +export type ReactiveProxy = typeof(setmetatable( + {} :: { + __track: () -> (), + __set: (T | ((T) -> ())?) -> (), + __target: T, + __signals: { [unknown]: () -> unknown }, + }, + {} +)) type EffectScopeNode = ReactiveNode & { cleanups: { () -> () }, @@ -83,6 +80,9 @@ local queued: { EffectNode? } = {} local queuedLength = 0 local activeSub: ReactiveNode? +local ReactiveProxy = {} +local proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }) + local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then return fn @@ -827,6 +827,34 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: end) end +--[[ + Determines if the provided value is a reactive proxy created by `reactive()`. + + @param value A value to test. + @return `true` if the value is a reactive proxy, `false` otherwise. + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function isReactive(value: any): boolean + return type(value) == "table" and getmetatable(value) == ReactiveProxy +end + +--[[ + Unwraps a reactive proxy to get the original table. If the provided value + is not a proxy, it is returned as-is. + + @param value A reactive proxy or a non-proxy value. + @return The unwrapped target value. + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function toRaw(value: T): T + if isReactive(value) then + local value: ReactiveProxy = value :: any + value.__track() + return toRaw(value.__target) + end + return value +end + --[[ Creates a deep reactive value that tracks properties when they're accessed. Returns a proxy object that subscribes to changes on any nested property @@ -853,101 +881,79 @@ end @return A function for updating the original table. @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] -local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ()) - local track = signal(nil) - local proxyApi = { value = initialValue, track = track } - local proxyMap = setmetatable({} :: { [{}]: any }, { __mode = "v" }) +local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: (T) -> () | T?) -> ()) + initialValue = toRaw(initialValue) - local function trackProperty(cache: { [any]: () -> any }, target: {}, key: any) - if cache[key] then - return cache[key]() - end - cache[key] = computed(function() - track() - return target[key] + local proxy = proxyMap[initialValue] + + if not proxy then + local track = computed(function() + if trackParent then + trackParent() + end + return {} end) - return cache[key]() - end - local function createProxy(target: T & {}): ReactiveProxy - if proxyMap[target] then - return proxyMap[target] - end - proxyMap[target] = setmetatable({ - __target = target, - __signals = setmetatable({}, { __mode = "v" }), - }, proxyApi) - return proxyMap[target] - end - - function proxyApi.__index(self: ReactiveProxy, key: any): any - if activeSub then - trackProperty(self.__signals, self.__target, key) - end - local value = self.__target[key] - return if type(value) == "table" then createProxy(value) else value - end - - function proxyApi.__newindex(self: ReactiveProxy, key: any, value: any) - if type(value) == "table" and type(value.__target) == "table" then - value = value.__target - end - if self.__target[key] ~= value then - self.__target[key] = value + local function set(update: (T) -> () | T?) + if type(update) == "function" then + update(initialValue) + elseif type(update) == "table" then + table.clear(initialValue) + for key, value in update :: {} do + (initialValue :: {})[key] = value + end + end trigger(track) end + + proxy = setmetatable({ + __target = initialValue, + __signals = setmetatable({}, { __mode = "v" }) :: any, + __track = track, + __set = set, + }, ReactiveProxy) + + proxyMap[initialValue] = proxy end - function proxyApi.__iter(self: ReactiveProxy) - track() - return next, self.__target - end + return proxy :: any, proxy.__set +end - function proxyApi.__len(self: ReactiveProxy) - track() - return #self.__target - end +function ReactiveProxy.__index(self: ReactiveProxy, key: any): any + local track = self.__track + local signals = self.__signals + local target = self.__target + local value = target[key] - function proxyApi.set(update: ((T) -> ()) | T?) - if type(update) == "function" then - update(initialValue) - elseif type(update) == "table" then - table.clear(initialValue) - for key, value in update :: {} do - (initialValue :: {})[key] = value - end + if activeSub then + if not signals[key] then + signals[key] = computed(function() + track() + return target[key] + end) end - trigger(track) + signals[key]() end - return createProxy(initialValue), proxyApi.set + return if type(value) == "table" then reactive(value, track) else value end ---[[ - Determines if the provided value is a reactive proxy created by `reactive()`. - - @param value A value to test. - @return `true` if the value is a reactive proxy, `false` otherwise. - @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue -]] -local function isReactive(value: any): boolean - return type(value) == "table" and type(value.__target) == "table" and getmetatable(value) ~= nil -end - ---[[ - Unwraps a reactive proxy to get the original table. If the provided value - is not a proxy, it is returned as-is. - - @param value A reactive proxy or a non-proxy value. - @return The unwrapped target value. - @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue -]] -local function toRaw(value: T): T - if isReactive(value) then - getmetatable(value :: ReactiveProxy).track() - return (value :: ReactiveProxy).__target +function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any) + value = toRaw(value) + if self.__target[key] ~= value then + self.__target[key] = value + trigger(self.__track) end - return value +end + +function ReactiveProxy.__iter(self: ReactiveProxy) + self.__track() + return next, self.__target +end + +function ReactiveProxy.__len(self: ReactiveProxy) + self.__track() + return #self.__target end local reactiveSystem = system.createReactiveSystem({ From 9d4af8e669fe095c1f207c6ab77d9a183365ca15 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 28 Nov 2025 20:36:51 -0800 Subject: [PATCH 087/160] fix: prevent early reactive gc, alien-signals 3.1.1 --- packages/charm-sync/src/client.luau | 24 ++++--- packages/charm/src/__tests__/gc.spec.luau | 30 ++++++--- .../charm/src/__tests__/reactive.spec.luau | 8 ++- .../charm/src/__tests__/trigger.spec.luau | 16 +++++ packages/charm/src/init.luau | 65 ++++++++++--------- 5 files changed, 94 insertions(+), 49 deletions(-) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index a51ec55..677e7b9 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -8,7 +8,15 @@ type SyncPayload = types.SyncPayload type Setter = (T | (T) -> any) -> () local trackedSignals: { [string]: Setter } = {} -local trackedReactives: { [string]: Setter } = {} +local trackedReactives: { [string]: ReactiveProxy } = {} + +local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any }) + local target = reactive.__target + for key, value in source do + target[key] = value + end + Charm.trigger(reactive.__track) +end --[[ Registers a map of setters to the state with the corresponding keys on the @@ -26,7 +34,7 @@ local function addSignals(signals: { [string]: Setter | {} }) if type(signal) == "function" then trackedSignals[key] = signal else - trackedReactives[key] = ((signal :: any) :: ReactiveProxy).__set + trackedReactives[key] = (signal :: any) :: ReactiveProxy end end end @@ -54,9 +62,10 @@ end local function applyInitPayload(data: { [string]: any }) for key, value in data do - local trackedSignal: Setter? = trackedSignals[key] or trackedReactives[key] - if trackedSignal then - trackedSignal(value) + if trackedSignals[key] then + trackedSignals[key](value) + elseif trackedReactives[key] then + shallowMergeReactive(trackedReactives[key], value) end end end @@ -71,9 +80,8 @@ local function applyPatchPayload(data: { [string]: any }) return patch.apply(value, diffs) end) elseif trackedReactive then - trackedReactive(function(value): () - patch.applyMutable(value, diffs) - end) + patch.applyMutable(trackedReactive.__target, diffs) + Charm.trigger(trackedReactive.__track) end end end diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau index 3ab4dfd..d74196e 100644 --- a/packages/charm/src/__tests__/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -66,33 +66,34 @@ test("should not release effect for atom", function() assert(effectCalls == 2, "should call effect again") end) -test("should release reactive tables", function() +test("should release inner reactive proxy", function() local valueRef = weakRef({}) - local src = reactive({ value = valueRef.value }) + local src: any = reactive({ value = valueRef.value }) - effect(function() + local dispose = effect(function() local _ = src.value -- create proxy end) src.value = nil :: any -- release proxy + dispose() gc() - assert(valueRef.value == nil, "should release reactive table") - assert((src :: ReactiveProxy).__signals.value == nil, "should release reactive signals") + assert(src.__signals.value == nil, "should release signals") + assert(valueRef.value == nil, "should release inner proxy") end) test("should release reactive property deps", function() - local src = reactive({ foo = 1 }) + local src: any = reactive({ foo = 1 }) local c = computed(function() return src.foo end) c() - assert((src :: ReactiveProxy).__signals.foo ~= nil, "should have deps for foo") + assert(src.__signals.foo ~= nil, "should have deps for foo") c = nil :: any gc() - assert((src :: ReactiveProxy).__signals.foo == nil, "should release deps for foo") + assert(src.__signals.foo == nil, "should release deps for foo") end) test("should not release effect for reactive", function() @@ -110,4 +111,17 @@ test("should not release effect for reactive", function() assert(calls == 2, "should call effect again") end) +test("should not release tracked reactive", function() + local src = Charm.reactive({ users = {} :: { [string]: {}? } }) + local user + + effect(function() + user = src.users.user + end) + + gc() + src.users.user = {} + assert(user ~= nil, "src.users.user should not be released") +end) + return {} diff --git a/packages/charm/src/__tests__/reactive.spec.luau b/packages/charm/src/__tests__/reactive.spec.luau index 79c2be0..7e11af4 100644 --- a/packages/charm/src/__tests__/reactive.spec.luau +++ b/packages/charm/src/__tests__/reactive.spec.luau @@ -111,14 +111,18 @@ test("should not track unread properties", function() end) test("should overwrite with table passed to setter", function() - local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number }) + local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number? }) local calls = {} effect(function() table.insert(calls, `{src.a}{src.b}{src.c}`) end) - setSrc({ a = 2, c = 4 }) + setSrc(function(src) + src.a = 2 + src.b = nil + src.c = 4 + end) assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ",")) end) diff --git a/packages/charm/src/__tests__/trigger.spec.luau b/packages/charm/src/__tests__/trigger.spec.luau index 4055cbe..d0b1938 100644 --- a/packages/charm/src/__tests__/trigger.spec.luau +++ b/packages/charm/src/__tests__/trigger.spec.luau @@ -69,6 +69,22 @@ test("should trigger effect once", function() assert(triggers == 2, "effect should run twice") end) +test("should not notify the trigger function sub", function() + local src1 = signal({}) + local src2 = computed(function() + return src1() + end) + + effect(function() + src1() + src2() + end) + trigger(function() + src1() + src2() + end) +end) + test("should trigger updates for subscribe", function() local src = signal({} :: { number }) local triggers = 0 diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 7cc3998..d7bdc18 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,7 +1,9 @@ local system = require("@self/system") export type ReactiveFlags = system.ReactiveFlags + export type ReactiveNode = system.ReactiveNode + export type Link = system.Link export type Atom = (...((T) -> T) | T) -> T @@ -9,11 +11,10 @@ export type Atom = (...((T) -> T) | T) -> T export type ReactiveProxy = typeof(setmetatable( {} :: { __track: () -> (), - __set: (T | ((T) -> ())?) -> (), __target: T, __signals: { [unknown]: () -> unknown }, }, - {} + {} :: { __index: T } )) type EffectScopeNode = ReactiveNode & { @@ -73,6 +74,10 @@ local propagate: (link: Link) -> () local checkDirty: (link: Link, sub: ReactiveNode) -> boolean local shallowPropagate: (link: Link) -> () +local ReactiveProxy = { + proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), +} + local cycle = 0 local batchDepth = 0 local notifyIndex = 0 @@ -80,9 +85,6 @@ local queued: { EffectNode? } = {} local queuedLength = 0 local activeSub: ReactiveNode? -local ReactiveProxy = {} -local proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }) - local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then return fn @@ -504,13 +506,15 @@ local function trigger(fn: () -> ()) local prevSub = setActiveSub(sub) local success, result: any = pcall(noYield(fn)) activeSub = prevSub - while sub.deps do - local link = sub.deps :: Link + local link = sub.deps + while link do local dep = link.dep - unlink(link, sub) - if dep.subs then - propagate(dep.subs) - shallowPropagate(dep.subs) + link = unlink(link, sub) + local subs = dep.subs + if subs then + sub.flags = NONE + propagate(subs) + shallowPropagate(subs) end end if batchDepth == 0 then @@ -848,7 +852,7 @@ end ]] local function toRaw(value: T): T if isReactive(value) then - local value: ReactiveProxy = value :: any + local value = value :: ReactiveProxy & T value.__track() return toRaw(value.__target) end @@ -881,42 +885,41 @@ end @return A function for updating the original table. @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] -local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: (T) -> () | T?) -> ()) +local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) initialValue = toRaw(initialValue) - local proxy = proxyMap[initialValue] + local proxy = ReactiveProxy.proxyMap[initialValue] if not proxy then - local track = computed(function() + local track = computed(function(version: number?) + -- Reference the proxy to avoid GCing while an effect is actively + -- tracking this reactive object. + local _ref = proxy + -- Allow updates from the parent to propagate down when the + -- target is mutated directly through the setter. if trackParent then trackParent() end - return {} + return (version or 0) + 1 end) - local function set(update: (T) -> () | T?) - if type(update) == "function" then - update(initialValue) - elseif type(update) == "table" then - table.clear(initialValue) - for key, value in update :: {} do - (initialValue :: {})[key] = value - end - end - trigger(track) - end - proxy = setmetatable({ __target = initialValue, __signals = setmetatable({}, { __mode = "v" }) :: any, __track = track, - __set = set, }, ReactiveProxy) - proxyMap[initialValue] = proxy + ReactiveProxy.proxyMap[initialValue] = proxy end - return proxy :: any, proxy.__set + local function setValue(update: ((T) -> ())?) + if update then + update(proxy.__target) + end + trigger(proxy.__track) + end + + return proxy :: any, setValue end function ReactiveProxy.__index(self: ReactiveProxy, key: any): any From 73954f56e709b075c3e635b0dc847e3067ea52c9 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 28 Nov 2025 21:09:46 -0800 Subject: [PATCH 088/160] fix: wrap iterated values in `reactive()` --- .../charm/src/__tests__/reactive.spec.luau | 21 +++++++++++ packages/charm/src/init.luau | 37 +++++++++++++------ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/charm/src/__tests__/reactive.spec.luau b/packages/charm/src/__tests__/reactive.spec.luau index 7e11af4..a2c2d0c 100644 --- a/packages/charm/src/__tests__/reactive.spec.luau +++ b/packages/charm/src/__tests__/reactive.spec.luau @@ -81,6 +81,27 @@ test("should track iteration", function() assert(table.concat(calls) == "a=2;b=2;", table.concat(calls)) end) +test("should track iteration for nested tables", function() + local src = reactive({} :: { [string]: { value: number } }) + local calls = {} + + effect(function() + for key, obj in src do + table.insert(calls, `{key}={obj.value};`) + end + end) + + src.a = { value = 1 } + + table.clear(calls) + src.b = { value = 1 } + assert(table.concat(calls) == "a=1;b=1;", table.concat(calls)) + + table.clear(calls) + src.a.value = 2 + assert(table.concat(calls) == "a=2;b=1;", table.concat(calls)) +end) + test("should track array length", function() local src = reactive({ array = {} :: { number } }) local calls = {} diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index d7bdc18..886605d 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -224,7 +224,7 @@ local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) end local function computedOper(this: ComputedNode): () -> T - local function computedGetter(): T + local function readComputed(): T local flags = this.flags if bit32.btest(flags, DIRTY) @@ -253,14 +253,14 @@ local function computedOper(this: ComputedNode): () -> T end return this.value :: T end - return computedGetter + return readComputed end local function signalOper( this: SignalNode, equals: ((current: T, incoming: T) -> boolean)? ): (() -> T, (((T) -> T) | T) -> T) - local function signalSetter(update: ((T) -> T) | T): T + local function writeSignal(update: ((T) -> T) | T): T local value: T if type(update) == "function" then local prevSub = setActiveSub(nil) @@ -291,7 +291,7 @@ local function signalOper( return this.currentValue end - local function signalGetter(): T + local function readSignal(): T if bit32.btest(this.flags, DIRTY) then if updateSignal(this) then local subs = this.subs @@ -312,7 +312,7 @@ local function signalOper( return this.currentValue end - return signalGetter, signalSetter + return readSignal, writeSignal end local function effectOper(this: EffectScopeNode): () -> () @@ -406,9 +406,9 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals ]] local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom - local getter, setter = signal(initialValue, equals) + local get, set = signal(initialValue, equals) local function atomOper(...) - return if select("#", ...) == 0 then getter() else setter((...)) + return if select("#", ...) == 0 then get() else set((...)) end return atomOper end @@ -625,7 +625,7 @@ end ]] local function toSignal(fn: () -> T): () -> T local name = debug.info(fn, "n") - return if name == "signalGetter" or name == "computedGetter" or name == "atomOper" then fn else computed(fn) + return if name == "readSignal" or name == "readComputed" or name == "atomOper" then fn else computed(fn) end --[[ @@ -922,11 +922,22 @@ local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, return proxy :: any, setValue end +--[[ + Wraps a value in `reactive()` if it is a table; otherwise, returns the + value as-is. + + @param value A value to make reactive. + @param trackParent An optional tracking function to propagate deep updates. + @return A reactive proxy if the value is a table. +]] +local function toReactive(value: T, trackParent: (() -> ())?): T + return if type(value) == "table" then reactive(value, trackParent) else value +end + function ReactiveProxy.__index(self: ReactiveProxy, key: any): any local track = self.__track local signals = self.__signals local target = self.__target - local value = target[key] if activeSub then if not signals[key] then @@ -938,7 +949,7 @@ function ReactiveProxy.__index(self: ReactiveProxy, key: any): any signals[key]() end - return if type(value) == "table" then reactive(value, track) else value + return toReactive(target[key], track) end function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any) @@ -951,7 +962,11 @@ end function ReactiveProxy.__iter(self: ReactiveProxy) self.__track() - return next, self.__target + local function nextReactive(target: any, prevKey: any) + local nextKey, nextValue = next(target, prevKey) + return nextKey, toReactive(nextValue, self.__track) + end + return nextReactive, self.__target end function ReactiveProxy.__len(self: ReactiveProxy) From c1b417ec2b0e937423d5a15c5953e4509d180a27 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 29 Nov 2025 15:50:07 -0800 Subject: [PATCH 089/160] fix: loosen client signal types --- packages/charm-sync/src/client.luau | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 677e7b9..e02de4b 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -5,9 +5,7 @@ local types = require("./types") type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload -type Setter = (T | (T) -> any) -> () - -local trackedSignals: { [string]: Setter } = {} +local trackedSignals: { [string]: (any) -> any } = {} local trackedReactives: { [string]: ReactiveProxy } = {} local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any }) @@ -29,7 +27,7 @@ end @param signals A map of setter functions to sync with the server. ]] -local function addSignals(signals: { [string]: Setter | {} }) +local function addSignals(signals: { [string]: ((any) -> any) | {} }) for key, signal in signals do if type(signal) == "function" then trackedSignals[key] = signal From fd752d87d7f5fc4e1113497707a362ac4ec17443 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 29 Nov 2025 18:45:47 -0800 Subject: [PATCH 090/160] chore: bump release candidate --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 4 ++-- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/react-charm/package.json | 2 +- packages/react-charm/wally.toml | 4 ++-- packages/vide-charm/package.json | 2 +- packages/vide-charm/wally.toml | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 026c545..9c494b2 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0-rc.4", + "version": "0.4.0-rc.5", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 9bd2dc0..80e3061 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0-rc.4" +version = "0.4.0-rc.5" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@0.11.0-rc.2" +Charm = "littensy/charm@^0.11.0-rc.3" diff --git a/packages/charm/package.json b/packages/charm/package.json index 4bf25bf..56cf80a 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0-rc.2", + "version": "0.11.0-rc.3", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 2f4a5c9..482bcf6 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0-rc.2" +version = "0.11.0-rc.3" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index 22226c7..850b5e6 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.0-rc.2", + "version": "0.4.0-rc.3", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index d5b83f8..11d42ce 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.4.0-rc.2" +version = "0.4.0-rc.3" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@0.11.0-rc.2" +Charm = "littensy/charm@^0.11.0-rc.3" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 5bc98e8..e9e2560 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.0-rc.2", + "version": "0.4.0-rc.3", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 09d1ecb..a48b236 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.4.0-rc.2" +version = "0.4.0-rc.3" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@0.11.0-rc.2" +Charm = "littensy/charm@^0.11.0-rc.3" Vide = "centau/vide@^0.3" From 72b290219dc4b93e8f9fd1bbc2c0684a60ed8504 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:29:04 -0800 Subject: [PATCH 091/160] fix: remove unsynced signals immediately --- packages/charm-sync/src/server.luau | 52 ++++++++++++++--------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 07ce4ba..3a55854 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -5,6 +5,7 @@ local types = require("./types") local utils = require("./utils") type ReactiveProxy = Charm.ReactiveProxy + type SyncPayload = types.SyncPayload type SyncStatus = number @@ -227,6 +228,27 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any } return payloads end +local function startInterval(callback: () -> ()) + if not game then + return + end + + if connection then + connection:Disconnect() + end + + local timestamp = os.clock() + + connection = game:GetService("RunService").Heartbeat:Connect(function() + if config.interval >= 0 and os.clock() >= timestamp + config.interval then + timestamp = os.clock() + debug.profilebegin("CharmSync") + callback() + debug.profileend() + end + end) +end + --[[ Subscribes a client to the given signals. When an update occurs, the client will receive a partial state patch to merge into their local state. May be @@ -270,6 +292,7 @@ end ]] local function removeClient(client: any) clients[client] = nil + purgeUntracked() end --[[ @@ -292,6 +315,8 @@ local function removeSignalsFromClient(client: any, ...: string) if next(clientKeys) == nil then removeClient(client) + else + purgeUntracked() end end @@ -322,7 +347,6 @@ local function flush() end end - purgeUntracked() table.clear(globalUpdates) end @@ -334,32 +358,8 @@ end @param onSync Called when sending patches to a client. ]] local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ()) - if connection then - connection:Disconnect() - end - syncCallback = onSync - - if not game then - return - end - - local timer = 0 - - connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) - if config.interval < 0 then - return - end - - timer += deltaTime - - if timer >= config.interval then - timer = 0 - debug.profilebegin("CharmSync") - flush() - debug.profileend() - end - end) + startInterval(flush) end --[[ From a20ee3698a3e50936c41f8d54e81631494cc804b Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:29:39 -0800 Subject: [PATCH 092/160] test: use lute for tests --- .github/workflows/ci.yml | 6 +-- .gitmodules | 4 +- .luaurc | 14 ++++-- .lute/build.luau | 6 +++ .lute/check.luau | 30 ++++++++++++ .lute/test.luau | 1 + bin/build.sh | 4 -- bin/check.sh | 19 -------- default.project.json | 4 +- package.json | 6 +-- .../{client.spec.luau => client.test.luau} | 7 +-- .../{e2e.spec.luau => e2e.test.luau} | 7 +-- packages/charm-sync/src/__tests__/init.luau | 23 ++++++++++ .../{patch.spec.luau => patch.test.luau} | 7 +-- .../{server.spec.luau => server.test.luau} | 15 ++---- packages/charm-sync/src/__tests__/spec.luau | 23 ---------- .../{utils.spec.luau => utils.test.luau} | 4 +- ...{computed.spec.luau => computed.test.luau} | 3 +- .../{effect.spec.luau => effect.test.luau} | 3 +- ...tScope.spec.luau => effectScope.test.luau} | 3 +- .../__tests__/{gc.spec.luau => gc.test.luau} | 3 +- packages/charm/src/__tests__/init.luau | 26 +++++++++++ .../{listen.spec.luau => listen.test.luau} | 3 +- .../{mapped.spec.luau => mapped.test.luau} | 3 +- .../{observe.spec.luau => observe.test.luau} | 3 +- .../{peek.spec.luau => peek.test.luau} | 3 +- ...{reactive.spec.luau => reactive.test.luau} | 3 +- .../{signal.spec.luau => signal.test.luau} | 3 +- packages/charm/src/__tests__/spec.luau | 46 ------------------- ...ubscribe.spec.luau => subscribe.test.luau} | 3 +- ...{topology.spec.luau => topology.test.luau} | 8 ++-- .../{trigger.spec.luau => trigger.test.luau} | 9 ++-- ...ntracked.spec.luau => untracked.test.luau} | 3 +- packages/react-charm/src/__tests__/init.luau | 11 +++++ packages/react-charm/src/__tests__/spec.luau | 11 ----- ...g.spec.luau => useSignalBinding.test.luau} | 4 +- ...ate.spec.luau => useSignalState.test.luau} | 4 +- packages/vide-charm/src/__tests__/init.luau | 7 +++ packages/vide-charm/src/__tests__/spec.luau | 7 --- ...ate.spec.luau => useSignalState.test.luau} | 4 +- rokit.toml | 10 +--- selene.toml | 3 +- selene_defs.yml | 6 --- test/spec.server.luau | 23 ---------- test/suite | 1 - tests/run.server.luau | 29 ++++++++++++ tests/test | 1 + 47 files changed, 190 insertions(+), 236 deletions(-) create mode 100644 .lute/build.luau create mode 100644 .lute/check.luau create mode 100644 .lute/test.luau delete mode 100644 bin/build.sh delete mode 100644 bin/check.sh rename packages/charm-sync/src/__tests__/{client.spec.luau => client.test.luau} (96%) rename packages/charm-sync/src/__tests__/{e2e.spec.luau => e2e.test.luau} (95%) create mode 100644 packages/charm-sync/src/__tests__/init.luau rename packages/charm-sync/src/__tests__/{patch.spec.luau => patch.test.luau} (97%) rename packages/charm-sync/src/__tests__/{server.spec.luau => server.test.luau} (98%) delete mode 100644 packages/charm-sync/src/__tests__/spec.luau rename packages/charm-sync/src/__tests__/{utils.spec.luau => utils.test.luau} (89%) rename packages/charm/src/__tests__/{computed.spec.luau => computed.test.luau} (96%) rename packages/charm/src/__tests__/{effect.spec.luau => effect.test.luau} (99%) rename packages/charm/src/__tests__/{effectScope.spec.luau => effectScope.test.luau} (96%) rename packages/charm/src/__tests__/{gc.spec.luau => gc.test.luau} (97%) create mode 100644 packages/charm/src/__tests__/init.luau rename packages/charm/src/__tests__/{listen.spec.luau => listen.test.luau} (97%) rename packages/charm/src/__tests__/{mapped.spec.luau => mapped.test.luau} (97%) rename packages/charm/src/__tests__/{observe.spec.luau => observe.test.luau} (98%) rename packages/charm/src/__tests__/{peek.spec.luau => peek.test.luau} (97%) rename packages/charm/src/__tests__/{reactive.spec.luau => reactive.test.luau} (98%) rename packages/charm/src/__tests__/{signal.spec.luau => signal.test.luau} (94%) delete mode 100644 packages/charm/src/__tests__/spec.luau rename packages/charm/src/__tests__/{subscribe.spec.luau => subscribe.test.luau} (97%) rename packages/charm/src/__tests__/{topology.spec.luau => topology.test.luau} (98%) rename packages/charm/src/__tests__/{trigger.spec.luau => trigger.test.luau} (91%) rename packages/charm/src/__tests__/{untracked.spec.luau => untracked.test.luau} (98%) create mode 100644 packages/react-charm/src/__tests__/init.luau delete mode 100644 packages/react-charm/src/__tests__/spec.luau rename packages/react-charm/src/__tests__/{useSignalBinding.spec.luau => useSignalBinding.test.luau} (94%) rename packages/react-charm/src/__tests__/{useSignalState.spec.luau => useSignalState.test.luau} (94%) create mode 100644 packages/vide-charm/src/__tests__/init.luau delete mode 100644 packages/vide-charm/src/__tests__/spec.luau rename packages/vide-charm/src/__tests__/{useSignalState.spec.luau => useSignalState.test.luau} (95%) delete mode 100644 selene_defs.yml delete mode 100644 test/spec.server.luau delete mode 160000 test/suite create mode 100644 tests/run.server.luau create mode 160000 tests/test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af91488..de90279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,10 @@ jobs: - uses: CompeyDev/setup-rokit@v0.1.2 - name: Check code quality - run: sh bin/check.sh + run: pnpm check - name: Run tests - run: lune run test/spec.server.luau + run: pnpm test - name: Build models - run: sh bin/build.sh + run: pnpm build diff --git a/.gitmodules b/.gitmodules index e1cba9d..409278b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "test/suite"] - path = test/suite +[submodule "tests/test"] + path = tests/test url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4 diff --git a/.luaurc b/.luaurc index 4c8043d..c56c621 100644 --- a/.luaurc +++ b/.luaurc @@ -1,6 +1,10 @@ { - "languageMode": "strict", - "lint": { - "LocalShadow": false - } -} + "languageMode": "strict", + "aliases": { + "lute": "~/.lute/typedefs/0.1.0/lute", + "std": "~/.lute/typedefs/0.1.0/std" + }, + "lint": { + "LocalShadow": false + } +} \ No newline at end of file diff --git a/.lute/build.luau b/.lute/build.luau new file mode 100644 index 0000000..29ef585 --- /dev/null +++ b/.lute/build.luau @@ -0,0 +1,6 @@ +local fs = require("@std/fs") +local process = require("@lute/process") + +fs.createdirectory("build") + +process.run({ "rojo", "build", "--output=build/charm.rbxm" }, { stdio = "inherit" }) diff --git a/.lute/check.luau b/.lute/check.luau new file mode 100644 index 0000000..2eed288 --- /dev/null +++ b/.lute/check.luau @@ -0,0 +1,30 @@ +local fs = require("@std/fs") +local net = require("@lute/net") +local process = require("@lute/process") + +process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) + +fs.writestringtofile( + "roblox.d.luau", + net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body +) + +process.run({ + "luau-lsp", + "analyze", + "--sourcemap=sourcemap.json", + "--ignore=**/node_modules/**", + "--base-luaurc=.luaurc", + "--defs=roblox.d.luau", + "packages", + "tests", + "benches", +}, { stdio = "inherit" }) + +fs.remove("roblox.d.luau") + +process.run({ "selene", "packages", "tests", "benches" }, { stdio = "inherit" }) + +process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" }) + +process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" }) diff --git a/.lute/test.luau b/.lute/test.luau new file mode 100644 index 0000000..4abbbb9 --- /dev/null +++ b/.lute/test.luau @@ -0,0 +1 @@ +(require)("../tests/run.server") diff --git a/bin/build.sh b/bin/build.sh deleted file mode 100644 index f680537..0000000 --- a/bin/build.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash - -mkdir build -rojo build build.project.json -o build/charm.rbxm diff --git a/bin/check.sh b/bin/check.sh deleted file mode 100644 index 091c4a4..0000000 --- a/bin/check.sh +++ /dev/null @@ -1,19 +0,0 @@ -curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau - -rojo sourcemap -o sourcemap.json - -luau-lsp analyze \ - --defs=bin/roblox.d.luau \ - --flag:LuauFixIndexerSubtypingOrdering=true \ - --flag:LuauInstantiateInSubtyping=true \ - --sourcemap=sourcemap.json \ - --ignore="**/node_modules/**" \ - packages test benches - -selene packages test benches - -stylua --check packages test benches - -pnpm eslint packages - -rm bin/roblox.d.luau diff --git a/default.project.json b/default.project.json index 43a7d1a..e59761d 100644 --- a/default.project.json +++ b/default.project.json @@ -43,8 +43,8 @@ "@rbxts-js": { "$path": "node_modules/@rbxts-js" }, - "test": { - "$path": "test" + "tests": { + "$path": "tests" } } } diff --git a/package.json b/package.json index b9b8739..9c00145 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "private": true, "scripts": { - "test": "lune run test/spec.server.luau", - "build": "sh bin/build.sh", - "check": "sh bin/check.sh", + "test": "lute run test", + "build": "lute run build", + "check": "lute run check", "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.test.luau similarity index 96% rename from packages/charm-sync/src/__tests__/client.spec.luau rename to packages/charm-sync/src/__tests__/client.test.luau index 10164a2..3e16d9e 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.test.luau @@ -1,11 +1,8 @@ local Charm = require("../../Charm") local client = require("../client") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") -local test = suite.test -local afterEach = suite.afterEach - -afterEach(function() +test.afterEach(function() client.removeAllSignals() end) diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.test.luau similarity index 95% rename from packages/charm-sync/src/__tests__/e2e.spec.luau rename to packages/charm-sync/src/__tests__/e2e.test.luau index d8da3aa..bbc4720 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.test.luau @@ -2,12 +2,9 @@ local Charm = require("../../Charm") local client = require("../client") local config = require("../config") local server = require("../server") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") -local afterEach = suite.afterEach -local test = suite.test - -afterEach(function() +test.afterEach(function() server.reset() client.removeAllSignals() config.preserveHistory = false diff --git a/packages/charm-sync/src/__tests__/init.luau b/packages/charm-sync/src/__tests__/init.luau new file mode 100644 index 0000000..6f2f540 --- /dev/null +++ b/packages/charm-sync/src/__tests__/init.luau @@ -0,0 +1,23 @@ +local test = require("../../../tests/test") + +test.describe("client", function() + require("@self/client.test") +end) + +test.describe("e2e", function() + require("@self/e2e.test") +end) + +test.describe("utils", function() + require("@self/utils.test") +end) + +test.describe("patch", function() + require("@self/patch.test") +end) + +test.describe("server", function() + require("@self/server.test") +end) + +return {} diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.test.luau similarity index 97% rename from packages/charm-sync/src/__tests__/patch.spec.luau rename to packages/charm-sync/src/__tests__/patch.test.luau index 7ce89c2..bb86bdb 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.test.luau @@ -1,9 +1,6 @@ local config = require("../config") local patch = require("../patch") -local suite = require("../../../../test/suite") - -local test = suite.test -local afterEach = suite.afterEach +local test = require("../../../../tests/test") local function mockArraySerdes(object: {}): {} if object[1] then @@ -23,7 +20,7 @@ local function mockArraySerdes(object: {}): {} end end -afterEach(function() +test.afterEach(function() config.fixArrays = true config.validatePatches = true end) diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.test.luau similarity index 98% rename from packages/charm-sync/src/__tests__/server.spec.luau rename to packages/charm-sync/src/__tests__/server.test.luau index 034fc54..0a99b09 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.test.luau @@ -2,14 +2,9 @@ local Charm = require("../../Charm") local config = require("../config") local patch = require("../patch") local server = require("../server") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") -local afterEach = suite.afterEach -local beforeEach = suite.beforeEach -local describe = suite.describe -local test = suite.test - -afterEach(function() +test.afterEach(function() server.reset() end) @@ -252,12 +247,12 @@ test("should not send unchanged state", function() assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") end) -describe("historical", function() - beforeEach(function() +test.describe("historical", function() + test.beforeEach(function() config.preserveHistory = true end) - afterEach(function() + test.afterEach(function() config.preserveHistory = false end) diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau deleted file mode 100644 index 8d07f04..0000000 --- a/packages/charm-sync/src/__tests__/spec.luau +++ /dev/null @@ -1,23 +0,0 @@ -local suite = require("../../../../test/suite") - -suite.describe("client", function() - require("./client.spec") -end) - -suite.describe("e2e", function() - require("./e2e.spec") -end) - -suite.describe("utils", function() - require("./utils.spec") -end) - -suite.describe("patch", function() - require("./patch.spec") -end) - -suite.describe("server", function() - require("./server.spec") -end) - -return {} diff --git a/packages/charm-sync/src/__tests__/utils.spec.luau b/packages/charm-sync/src/__tests__/utils.test.luau similarity index 89% rename from packages/charm-sync/src/__tests__/utils.spec.luau rename to packages/charm-sync/src/__tests__/utils.test.luau index 47d2e39..f3a37ff 100644 --- a/packages/charm-sync/src/__tests__/utils.spec.luau +++ b/packages/charm-sync/src/__tests__/utils.test.luau @@ -1,9 +1,7 @@ local Charm = require("../../Charm") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local utils = require("../utils") -local test = suite.test - test("should flatten functions", function() local input = { a = Charm.atom(0), diff --git a/packages/charm/src/__tests__/computed.spec.luau b/packages/charm/src/__tests__/computed.test.luau similarity index 96% rename from packages/charm/src/__tests__/computed.spec.luau rename to packages/charm/src/__tests__/computed.test.luau index 3dd7284..691a7d9 100644 --- a/packages/charm/src/__tests__/computed.spec.luau +++ b/packages/charm/src/__tests__/computed.test.luau @@ -1,10 +1,9 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local computed = Charm.computed local effect = Charm.effect local signal = Charm.signal -local test = suite.test test("should correctly propagate changes through computed core", function() local src, setSrc = signal(0) diff --git a/packages/charm/src/__tests__/effect.spec.luau b/packages/charm/src/__tests__/effect.test.luau similarity index 99% rename from packages/charm/src/__tests__/effect.spec.luau rename to packages/charm/src/__tests__/effect.test.luau index 56fe9c1..b142ec8 100644 --- a/packages/charm/src/__tests__/effect.spec.luau +++ b/packages/charm/src/__tests__/effect.test.luau @@ -1,5 +1,5 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local computed = Charm.computed local effect = Charm.effect @@ -10,7 +10,6 @@ local recursive = Charm.recursive local setActiveSub = Charm.setActiveSub local signal = Charm.signal local startBatch = Charm.startBatch -local test = suite.test test("should clear subscriptions when untracked by all subscribers", function() local bRunTimes = 0 diff --git a/packages/charm/src/__tests__/effectScope.spec.luau b/packages/charm/src/__tests__/effectScope.test.luau similarity index 96% rename from packages/charm/src/__tests__/effectScope.spec.luau rename to packages/charm/src/__tests__/effectScope.test.luau index c484151..3367f79 100644 --- a/packages/charm/src/__tests__/effectScope.spec.luau +++ b/packages/charm/src/__tests__/effectScope.test.luau @@ -1,11 +1,10 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local effect = Charm.effect local effectScope = Charm.effectScope local signal = Charm.signal local onCleanup = Charm.onCleanup -local test = suite.test test("should not trigger after stop", function() local count, setCount = signal(1) diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.test.luau similarity index 97% rename from packages/charm/src/__tests__/gc.spec.luau rename to packages/charm/src/__tests__/gc.test.luau index d74196e..b0517b1 100644 --- a/packages/charm/src/__tests__/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.test.luau @@ -1,5 +1,5 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") type ReactiveProxy = Charm.ReactiveProxy @@ -8,7 +8,6 @@ local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope local reactive = Charm.reactive -local test = suite.test local function weakRef(value: T): { value: T? } return setmetatable({ value = value }, { __mode = "v" }) :: any diff --git a/packages/charm/src/__tests__/init.luau b/packages/charm/src/__tests__/init.luau new file mode 100644 index 0000000..90d8983 --- /dev/null +++ b/packages/charm/src/__tests__/init.luau @@ -0,0 +1,26 @@ +local test = require("../../../tests/test") + +local tests = { + "computed", + "effect", + "effectScope", + "gc", + "listen", + "mapped", + "observe", + "peek", + "reactive", + "signal", + "subscribe", + "topology", + "trigger", + "untracked", +} + +for _, name in tests do + test.describe(name, function() + (require)(`@self/{name}.test`) + end) +end + +return {} diff --git a/packages/charm/src/__tests__/listen.spec.luau b/packages/charm/src/__tests__/listen.test.luau similarity index 97% rename from packages/charm/src/__tests__/listen.spec.luau rename to packages/charm/src/__tests__/listen.test.luau index f3098d1..fc5626e 100644 --- a/packages/charm/src/__tests__/listen.spec.luau +++ b/packages/charm/src/__tests__/listen.test.luau @@ -1,10 +1,9 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local signal = Charm.signal local listen = Charm.listen local effect = Charm.effect -local test = suite.test test("should listen to selector", function() local source, setSource = signal(0) diff --git a/packages/charm/src/__tests__/mapped.spec.luau b/packages/charm/src/__tests__/mapped.test.luau similarity index 97% rename from packages/charm/src/__tests__/mapped.spec.luau rename to packages/charm/src/__tests__/mapped.test.luau index b812aea..dea2867 100644 --- a/packages/charm/src/__tests__/mapped.spec.luau +++ b/packages/charm/src/__tests__/mapped.test.luau @@ -1,9 +1,8 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local signal = Charm.signal local mapped = Charm.mapped -local test = suite.test test("should map state", function() local src, setSrc = signal({ 1, 2, 3 }) diff --git a/packages/charm/src/__tests__/observe.spec.luau b/packages/charm/src/__tests__/observe.test.luau similarity index 98% rename from packages/charm/src/__tests__/observe.spec.luau rename to packages/charm/src/__tests__/observe.test.luau index f308477..00386c0 100644 --- a/packages/charm/src/__tests__/observe.spec.luau +++ b/packages/charm/src/__tests__/observe.test.luau @@ -1,12 +1,11 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope local observe = Charm.observe -local test = suite.test test("should observe items", function() local items, setItems = signal({ 0, 0, 0 }) diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.test.luau similarity index 97% rename from packages/charm/src/__tests__/peek.spec.luau rename to packages/charm/src/__tests__/peek.test.luau index b786135..8699c7a 100644 --- a/packages/charm/src/__tests__/peek.spec.luau +++ b/packages/charm/src/__tests__/peek.test.luau @@ -1,11 +1,10 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local peek = Charm.peek -local test = suite.test test("should not run detached", function() local a, setA = signal(1) diff --git a/packages/charm/src/__tests__/reactive.spec.luau b/packages/charm/src/__tests__/reactive.test.luau similarity index 98% rename from packages/charm/src/__tests__/reactive.spec.luau rename to packages/charm/src/__tests__/reactive.test.luau index a2c2d0c..3e5b221 100644 --- a/packages/charm/src/__tests__/reactive.spec.luau +++ b/packages/charm/src/__tests__/reactive.test.luau @@ -1,10 +1,9 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local effect = Charm.effect local reactive = Charm.reactive local toRaw = Charm.toRaw -local test = suite.test test("should track nested properties", function() local src, setSrc = reactive({ a = { b = { c = 0 } } }) diff --git a/packages/charm/src/__tests__/signal.spec.luau b/packages/charm/src/__tests__/signal.test.luau similarity index 94% rename from packages/charm/src/__tests__/signal.spec.luau rename to packages/charm/src/__tests__/signal.test.luau index a9693f6..32ff5cd 100644 --- a/packages/charm/src/__tests__/signal.spec.luau +++ b/packages/charm/src/__tests__/signal.test.luau @@ -1,9 +1,8 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local signal = Charm.signal local effect = Charm.effect -local test = suite.test test("should accept equality function", function() local calls = {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau deleted file mode 100644 index 17dd1f2..0000000 --- a/packages/charm/src/__tests__/spec.luau +++ /dev/null @@ -1,46 +0,0 @@ -local suite = require("../../../../test/suite") - -suite.describe("computed", function() - require("./computed.spec") -end) -suite.describe("effect", function() - require("./effect.spec") -end) -suite.describe("effectScope", function() - require("./effectScope.spec") -end) -suite.describe("gc", function() - require("./gc.spec") -end) -suite.describe("listen", function() - require("./listen.spec") -end) -suite.describe("mapped", function() - require("./mapped.spec") -end) -suite.describe("observe", function() - require("./observe.spec") -end) -suite.describe("peek", function() - require("./peek.spec") -end) -suite.describe("reactive", function() - require("./reactive.spec") -end) -suite.describe("signal", function() - require("./signal.spec") -end) -suite.describe("subscribe", function() - require("./subscribe.spec") -end) -suite.describe("topology", function() - require("./topology.spec") -end) -suite.describe("trigger", function() - require("./trigger.spec") -end) -suite.describe("untracked", function() - require("./untracked.spec") -end) - -return {} diff --git a/packages/charm/src/__tests__/subscribe.spec.luau b/packages/charm/src/__tests__/subscribe.test.luau similarity index 97% rename from packages/charm/src/__tests__/subscribe.spec.luau rename to packages/charm/src/__tests__/subscribe.test.luau index 938931b..c1bce81 100644 --- a/packages/charm/src/__tests__/subscribe.spec.luau +++ b/packages/charm/src/__tests__/subscribe.test.luau @@ -1,12 +1,11 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local signal = Charm.signal local computed = Charm.computed local subscribe = Charm.subscribe local listen = Charm.listen local effect = Charm.effect -local test = suite.test test("should subscribe to selector", function() local source, setSource = signal(0) diff --git a/packages/charm/src/__tests__/topology.spec.luau b/packages/charm/src/__tests__/topology.test.luau similarity index 98% rename from packages/charm/src/__tests__/topology.spec.luau rename to packages/charm/src/__tests__/topology.test.luau index 6f1e1e0..95e20a5 100644 --- a/packages/charm/src/__tests__/topology.spec.luau +++ b/packages/charm/src/__tests__/topology.test.luau @@ -1,11 +1,9 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect -local describe = suite.describe -local test = suite.test --[[ Tests adopted with thanks from preact-core implementation at @@ -34,7 +32,7 @@ local test = suite.test SOFTWARE ]] -describe("graph updates", function() +test.describe("graph updates", function() test("should drop A->B->A updates", function() -- A -- / | @@ -441,7 +439,7 @@ describe("graph updates", function() end) end) -describe("error handling", function() +test.describe("error handling", function() test("should keep graph consistent on errors during activation", function() local a = atom(0) local b = computed(function() diff --git a/packages/charm/src/__tests__/trigger.spec.luau b/packages/charm/src/__tests__/trigger.test.luau similarity index 91% rename from packages/charm/src/__tests__/trigger.spec.luau rename to packages/charm/src/__tests__/trigger.test.luau index d0b1938..eb73ed9 100644 --- a/packages/charm/src/__tests__/trigger.spec.luau +++ b/packages/charm/src/__tests__/trigger.test.luau @@ -1,20 +1,17 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local computed = Charm.computed local effect = Charm.effect local signal = Charm.signal local subscribe = Charm.subscribe local trigger = Charm.trigger -local test = suite.test -local beforeEach = suite.beforeEach -local afterEach = suite.afterEach -beforeEach(function() +test.beforeEach(function() Charm.flags.frozen = false end) -afterEach(function() +test.afterEach(function() Charm.flags.frozen = true end) diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.test.luau similarity index 98% rename from packages/charm/src/__tests__/untracked.spec.luau rename to packages/charm/src/__tests__/untracked.test.luau index d4bfc4c..05ff894 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.test.luau @@ -1,12 +1,11 @@ local Charm = require("../") -local suite = require("../../../../test/suite") +local test = require("../../../../tests/test") local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope local untracked = Charm.untracked -local test = suite.test test("should run detached from effect", function() local a, setA = signal(1) diff --git a/packages/react-charm/src/__tests__/init.luau b/packages/react-charm/src/__tests__/init.luau new file mode 100644 index 0000000..fc7e258 --- /dev/null +++ b/packages/react-charm/src/__tests__/init.luau @@ -0,0 +1,11 @@ +local test = require("../../../tests/test") + +test.describe("useSignalState", function() + require("@self/useSignalState.test") +end) + +test.describe("useSignalBinding", function() + require("@self/useSignalBinding.test") +end) + +return {} diff --git a/packages/react-charm/src/__tests__/spec.luau b/packages/react-charm/src/__tests__/spec.luau deleted file mode 100644 index 69f078a..0000000 --- a/packages/react-charm/src/__tests__/spec.luau +++ /dev/null @@ -1,11 +0,0 @@ -local suite = require("../../../../test/suite") - -suite.describe("useSignalState", function() - require("./useSignalState.spec") -end) - -suite.describe("useSignalBinding", function() - require("./useSignalBinding.spec") -end) - -return {} diff --git a/packages/react-charm/src/__tests__/useSignalBinding.spec.luau b/packages/react-charm/src/__tests__/useSignalBinding.test.luau similarity index 94% rename from packages/react-charm/src/__tests__/useSignalBinding.spec.luau rename to packages/react-charm/src/__tests__/useSignalBinding.test.luau index 1ad6df3..3d4e856 100644 --- a/packages/react-charm/src/__tests__/useSignalBinding.spec.luau +++ b/packages/react-charm/src/__tests__/useSignalBinding.test.luau @@ -1,9 +1,7 @@ local Charm = require("../../Charm") local ReactCharm = require("../") local renderHook = require("./helpers/renderHook") -local suite = require("../../../../test/suite") - -local test = suite.test +local test = require("../../../../tests/test") test("tracks signal state", function() local getState, setState = Charm.signal(0) diff --git a/packages/react-charm/src/__tests__/useSignalState.spec.luau b/packages/react-charm/src/__tests__/useSignalState.test.luau similarity index 94% rename from packages/react-charm/src/__tests__/useSignalState.spec.luau rename to packages/react-charm/src/__tests__/useSignalState.test.luau index ca22e49..bb28579 100644 --- a/packages/react-charm/src/__tests__/useSignalState.spec.luau +++ b/packages/react-charm/src/__tests__/useSignalState.test.luau @@ -1,9 +1,7 @@ local Charm = require("../../Charm") local ReactCharm = require("../") local renderHook = require("./helpers/renderHook") -local suite = require("../../../../test/suite") - -local test = suite.test +local test = require("../../../../tests/test") test("tracks signal state", function() local getState, setState = Charm.signal(0) diff --git a/packages/vide-charm/src/__tests__/init.luau b/packages/vide-charm/src/__tests__/init.luau new file mode 100644 index 0000000..ae62c24 --- /dev/null +++ b/packages/vide-charm/src/__tests__/init.luau @@ -0,0 +1,7 @@ +local test = require("../../../tests/test") + +test.describe("useSignalState", function() + require("@self/useSignalState.test") +end) + +return {} diff --git a/packages/vide-charm/src/__tests__/spec.luau b/packages/vide-charm/src/__tests__/spec.luau deleted file mode 100644 index 79a4998..0000000 --- a/packages/vide-charm/src/__tests__/spec.luau +++ /dev/null @@ -1,7 +0,0 @@ -local suite = require("../../../../test/suite") - -suite.describe("useSignalState", function() - require("./useSignalState.spec") -end) - -return {} diff --git a/packages/vide-charm/src/__tests__/useSignalState.spec.luau b/packages/vide-charm/src/__tests__/useSignalState.test.luau similarity index 95% rename from packages/vide-charm/src/__tests__/useSignalState.spec.luau rename to packages/vide-charm/src/__tests__/useSignalState.test.luau index 6d13460..4fd624e 100644 --- a/packages/vide-charm/src/__tests__/useSignalState.spec.luau +++ b/packages/vide-charm/src/__tests__/useSignalState.test.luau @@ -1,9 +1,7 @@ local Charm = require("../../Charm") local Vide = require("../../Vide") local VideCharm = require("../") -local suite = require("../../../../test/suite") - -local test = suite.test +local test = require("../../../../tests/test") test("tracks signal state", function() local getState, setState = Charm.signal(0) diff --git a/rokit.toml b/rokit.toml index 17f523d..d110a91 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,13 +1,7 @@ -# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. -# For more information, see https://github.com/rojo-rbx/rokit - -# New tools can be added by running `rokit add ` in a terminal. - [tools] wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.6.1" stylua = "johnnymorganz/stylua@2.3.1" selene = "Kampfkarren/selene@0.29.0" -luau-lsp = "johnnymorganz/luau-lsp@1.56.2" -lune = "lune-org/lune@0.10.4" -lute = "luau-lang/lute@0.1.0-nightly.20251119" +luau-lsp = "johnnymorganz/luau-lsp@1.57.1" +lute = "luau-lang/lute@0.1.0-nightly.20251127" diff --git a/selene.toml b/selene.toml index 1b04ccf..5a029a0 100644 --- a/selene.toml +++ b/selene.toml @@ -1,6 +1,7 @@ -std = "selene_defs" +std = "roblox" exclude = ["*/node_modules/*"] [lints] global_usage = "allow" shadowing = "allow" +incorrect_standard_library_use = "allow" diff --git a/selene_defs.yml b/selene_defs.yml deleted file mode 100644 index 89d1377..0000000 --- a/selene_defs.yml +++ /dev/null @@ -1,6 +0,0 @@ -base: roblox -name: selene_defs -globals: - require: - args: - - type: string diff --git a/test/spec.server.luau b/test/spec.server.luau deleted file mode 100644 index dfb6d0b..0000000 --- a/test/spec.server.luau +++ /dev/null @@ -1,23 +0,0 @@ -local Charm = require("../packages/charm/src") -local suite = require("./suite") - -Charm.flags.strict = true -Charm.flags.frozen = true - -suite.describe("charm", function() - require("../packages/charm/src/__tests__/spec") -end) - -suite.describe("charm-sync", function() - require("../packages/charm-sync/src/__tests__/spec") -end) - -suite.describe.ingame("react-charm", function() - require("../packages/react-charm/src/__tests__/spec") -end) - -suite.describe.ingame("vide-charm", function() - require("../packages/vide-charm/src/__tests__/spec") -end) - -suite.finish() diff --git a/test/suite b/test/suite deleted file mode 160000 index eb95781..0000000 --- a/test/suite +++ /dev/null @@ -1 +0,0 @@ -Subproject commit eb95781a86e505cb6bff83ca568a67c7295ebb38 diff --git a/tests/run.server.luau b/tests/run.server.luau new file mode 100644 index 0000000..ee943e0 --- /dev/null +++ b/tests/run.server.luau @@ -0,0 +1,29 @@ +local Charm = require("../packages/charm/src") +local test = require("./test") + +Charm.flags.strict = true +Charm.flags.frozen = true + +local describeInRoblox = test.describeIf(function() + return game :: any ~= nil +end) + +test.describe("charm", function() + require("../packages/charm/src/__tests__") +end) + +test.describe("charm-sync", function() + require("../packages/charm-sync/src/__tests__") +end) + +describeInRoblox("react-charm", function() + require("../packages/react-charm/src/__tests__") +end) + +describeInRoblox("vide-charm", function() + require("../packages/vide-charm/src/__tests__") +end) + +test.finish() + +return {} diff --git a/tests/test b/tests/test new file mode 160000 index 0000000..7ef2b88 --- /dev/null +++ b/tests/test @@ -0,0 +1 @@ +Subproject commit 7ef2b88191af884d543e7392d50c7e2adc484e32 From 15b90c59860db5933d46eaf4041b2e01c5c6ee22 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:30:54 -0800 Subject: [PATCH 093/160] chore: bump versions --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 9c494b2..84e8a25 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0-rc.5", + "version": "0.4.0-rc.6", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 80e3061..73bbe5a 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0-rc.5" +version = "0.4.0-rc.6" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ From e08204a9d13a7602e0d1d11e970d4a0a9fcea255 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:12:28 -0800 Subject: [PATCH 094/160] ci: set up lute --- .github/workflows/ci.yml | 9 ++++++--- .github/workflows/release.yml | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de90279..2b5865f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,14 @@ jobs: run_install: true - uses: CompeyDev/setup-rokit@v0.1.2 + - name: Setup Lute + run: lute setup --with-luaurc + - name: Check code quality - run: pnpm check + run: lute run check - name: Run tests - run: pnpm test + run: lute run test - name: Build models - run: pnpm build + run: lute run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bdee21..3bd512d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,11 @@ jobs: - uses: actions/checkout@v4 - uses: CompeyDev/setup-rokit@v0.1.2 + - name: Setup Lute + run: lute setup --with-luaurc + - name: Build models - run: sh bin/build.sh + run: lute run build - name: Upload release assets uses: AButler/upload-release-assets@v3.0 From a44355b3b670735c12bcac8d9e4f7da0b18a2b97 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:26:21 -0800 Subject: [PATCH 095/160] refactor: improve source readability --- .lute/check.luau | 64 ++++- packages/charm/src/init.luau | 486 ++++++++++++++++++++++----------- packages/charm/src/system.luau | 30 +- rokit.toml | 2 +- 4 files changed, 389 insertions(+), 193 deletions(-) diff --git a/.lute/check.luau b/.lute/check.luau index 2eed288..de811b0 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -1,7 +1,47 @@ local fs = require("@std/fs") +local json = require("@std/json") local net = require("@lute/net") local process = require("@lute/process") +local CURRENT_FFLAGS = "https://clientsettingscdn.roblox.com/v1/settings/application?applicationName=PCStudioApp" +local FFLAG_KINDS = { "FFlag", "FInt" } + +local function analyze() + local args = { + "luau-lsp", + "analyze", + "--sourcemap=sourcemap.json", + "--ignore=**/node_modules/**", + "--base-luaurc=.luaurc", + "--definitions=roblox.d.luau", + "--no-flags-enabled", + } + + local response = net.request(CURRENT_FFLAGS, { method = "GET" }) + local data: any = json.deserialize(response.body) + + for name, value in data.applicationSettings do + for _, kind in FFLAG_KINDS do + if string.find(name, `^{kind}Luau`) then + table.insert(args, `--flag:{string.sub(name, #kind + 1)}={value}`) + end + end + end + + table.insert(args, "packages") + table.insert(args, "tests") + table.insert(args, "benches") + + local result = process.run(args) + local stderr = string.gsub(result.stderr, "Unknown FFlag[^\n]+\n", "") + + print(stderr) + + if result.exitcode == 1 then + process.exit(1) + end +end + process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) fs.writestringtofile( @@ -9,17 +49,19 @@ fs.writestringtofile( net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body ) -process.run({ - "luau-lsp", - "analyze", - "--sourcemap=sourcemap.json", - "--ignore=**/node_modules/**", - "--base-luaurc=.luaurc", - "--defs=roblox.d.luau", - "packages", - "tests", - "benches", -}, { stdio = "inherit" }) +analyze() + +-- process.run({ +-- "luau-lsp", +-- "analyze", +-- "--sourcemap=sourcemap.json", +-- "--ignore=**/node_modules/**", +-- "--base-luaurc=.luaurc", +-- "--defs=roblox.d.luau", +-- "packages", +-- "tests", +-- "benches", +-- }, { stdio = "inherit" }) fs.remove("roblox.d.luau") diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 886605d..a1058d0 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,10 +1,13 @@ +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/system.ts +]] + local system = require("@self/system") -export type ReactiveFlags = system.ReactiveFlags - -export type ReactiveNode = system.ReactiveNode - export type Link = system.Link +export type ReactiveFlags = system.ReactiveFlags +export type ReactiveNode = system.ReactiveNode export type Atom = (...((T) -> T) | T) -> T @@ -36,30 +39,34 @@ type SignalNode = ReactiveNode & { pendingValue: T, } -local NONE = 0b0000000 -local MUTABLE = 0b0000001 -local WATCHING = 0b0000010 -local RECURSED_CHECK = 0b0000100 -local DIRTY = 0b0010000 -local PENDING = 0b0100000 -local PEEKING = 0b1000000 +-- Bitmask flags for tracking the state of reactive nodes. Flags are combined +-- and checked using bitwise operations for efficiency. +local NONE = 0b0000000 -- Default state +local MUTABLE = 0b0000001 -- Can be changed (signal or computed) +local WATCHING = 0b0000010 -- Watching for changes (effects) +local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies +local DIRTY = 0b0010000 -- Value has changed and needs updating +local PENDING = 0b0100000 -- Might be dirty, pending verification +local PEEKING = 0b1000000 -- Blocking dependency tracking local function inlineTest(): string return debug.info(1, "n") end +local IS_O2 = inlineTest() ~= "inlineTest" + local flags = { --[[ Enforces synchronous, non-yielding behavior in signals and effects. Also enables state validation in Charm Sync to catch sync errors early. Enabled if the optimization level is below 2, which is true in studio. ]] - strict = inlineTest() == "inlineTest", + strict = not IS_O2, --[[ Enforces immutability of tables by deep-freezing table values. Enabled if the optimization level is below 2, which is true in studio. ]] - frozen = inlineTest() == "inlineTest", + frozen = not IS_O2, --[[ Whether parent effects should track inner effects and clean them up when the parent effect re-runs. This is usually desirable, but can be @@ -68,27 +75,45 @@ local flags = { trackInnerEffects = true, } +local ReactiveProxy = { + proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), +} + +-- Alien Signals reactive system local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () local unlink: (link: Link, sub: ReactiveNode?) -> Link? local propagate: (link: Link) -> () local checkDirty: (link: Link, sub: ReactiveNode) -> boolean local shallowPropagate: (link: Link) -> () -local ReactiveProxy = { - proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), -} - +-- Global version counter for dependency link deduplication. Incremented at the +-- start of each effect/computed run to ensure links created in the same cycle +-- are deduplicated. local cycle = 0 -local batchDepth = 0 -local notifyIndex = 0 +-- Effects runs are queued to prevent redundant executions when multiple +-- signals update in the same batch. local queued: { EffectNode? } = {} local queuedLength = 0 +local notifyIndex = 0 +-- Batch operations group multiple updates together, deferring effect runs +-- until the batch completes. Effects only run when the outermost batch ends. +local batchDepth = 0 +-- Tracks the currently active effect or computed signal during execution. +-- Dependencies accessed during this time will link to the active subscriber. local activeSub: ReactiveNode? +--[[ + Wraps a function to prevent yielding when in strict mode. If the function + attempts to yield, an error is thrown. + + @param fn The function to wrap. + @return A wrapped function that cannot yield in strict mode. +]] local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then return fn end + local function resultHandler(thread: thread, ok: boolean, ...) if not ok then error(debug.traceback(thread, tostring((...)))) @@ -97,31 +122,57 @@ local function noYield(fn: (Args...) -> Results...): (Args. end return ... end + return function(...) local thread = coroutine.create(fn) return resultHandler(thread, coroutine.resume(thread, ...)) end end -local function deepFreeze(object: any) - if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then - table.freeze(object) - for _, value in object do - deepFreeze(value) +--[[ + Recursively freezes a table and all nested tables to enforce immutability. + Tables with metatables are skipped, as they are assumed to be mutable. + + @param value Table to freeze +]] +local function deepFreeze(value: any) + if type(value) == "table" and not table.isfrozen(value) and getmetatable(value) == nil then + table.freeze(value) + for _, entry in value do + deepFreeze(entry) end end end +--[[ + Returns the currently active subscriber (effect or computed node). + + @return Current active subscriber or `nil` +]] local function getActiveSub(): ReactiveNode? return activeSub end +--[[ + Sets the currently active subscriber (effect or computed node) and returns + the previous subscriber. + + @param sub New subscriber to set as active + @return Previous active subscriber +]] local function setActiveSub(sub: ReactiveNode?) local prevSub = activeSub activeSub = sub return prevSub end +--[[ + Removes dependencies from the subscriber's dependency list. When called + after tracking is complete, this ensures that only currently tracked + dependencies remain linked. + + @param sub Effect or computed node +]] local function purgeDeps(sub: ReactiveNode) local depsTail = sub.depsTail local dep = if depsTail then depsTail.nextDep else sub.deps @@ -130,41 +181,58 @@ local function purgeDeps(sub: ReactiveNode) end end -local function runCleanups(effect: EffectScopeNode) - local cleanups = effect.cleanups +--[[ + Runs all cleanup functions registered with the effect node. Any errors + thrown during cleanup are caught and logged as warnings. + + @param cleanups List of cleanup functions +]] +local function runCleanups(cleanups: { () -> () }) for key, cleanup in cleanups do - local prevSub = setActiveSub(nil) - local success, result: any = pcall(cleanup) - activeSub = prevSub cleanups[key] = nil - if not success then - warn("An error occurred during effect cleanup:", result) - end + local prevSub = setActiveSub(nil) + xpcall(cleanup, warn or print) + activeSub = prevSub end end -local function run(e: EffectNode) - local flags = e.flags - if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then - runCleanups(e) +--[[ + Checks if the effect node needs to run based on its flags and dependencies. + Effects run if they are marked dirty or pending with dirty dependencies. + + @param effect Effect node to run +]] +local function run(effect: EffectNode) + local flags = effect.flags + + if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then + runCleanups(effect.cleanups) + cycle += 1 - e.depsTail = nil - e.flags = bit32.bor(WATCHING, RECURSED_CHECK) - local prevSub = setActiveSub(e) - local success, result = pcall(e.fn) + effect.depsTail = nil + effect.flags = bit32.bor(WATCHING, RECURSED_CHECK) + + local prevSub = setActiveSub(effect) + local success, result = pcall(effect.fn) activeSub = prevSub - e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) - purgeDeps(e) + + effect.flags = bit32.band(effect.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(effect) + if not success then error(result) elseif result then - table.insert(e.cleanups, noYield(result)) + table.insert(effect.cleanups, noYield(result)) end else - e.flags = WATCHING + effect.flags = WATCHING end end +--[[ + Processes the queued effect runs. Effects are run in the order they were + queued, ensuring that all updates are processed. +]] local function flush() while notifyIndex < queuedLength do local effect = queued[notifyIndex] :: EffectNode @@ -172,14 +240,23 @@ local function flush() notifyIndex += 1 run(effect) end + notifyIndex = 0 queuedLength = 0 end +--[[ + Starts a batch operation, incrementing the batch depth counter. Effects + will not run until the outermost batch ends. +]] local function startBatch() batchDepth += 1 end +--[[ + Ends a batch operation, decrementing the batch depth counter. If this is + the outermost batch, queued effects are flushed and run. +]] local function endBatch() batchDepth -= 1 if batchDepth == 0 then @@ -187,56 +264,88 @@ local function endBatch() end end -local function updateComputed(c: ComputedNode): boolean +--[[ + Updates a computed signal by re-evaluating its getter function. Returns + whether the computed value changed. + + @param computed Computed node to update + @return Boolean indicating whether the computed value changed +]] +local function updateComputed(computed: ComputedNode): boolean cycle += 1 - c.depsTail = nil - c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - local prevSub = setActiveSub(c) - local oldValue = c.value - local success, result = pcall(c.getter, oldValue) + computed.depsTail = nil + computed.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + + local prevSub = setActiveSub(computed) + local oldValue = computed.value + local success, newValue = pcall(computed.getter, oldValue) activeSub = prevSub - c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) - purgeDeps(c) - if not success then - error(result) + + computed.flags = bit32.band(computed.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(computed) + + if success then + computed.value = newValue + return oldValue ~= newValue + else + error(newValue) end - if oldValue ~= result then - c.value = result +end + +--[[ + Updates a signal by applying its pending value. Returns whether the + signal's value changed. + + @param signal Signal node to update + @return Boolean indicating whether the signal's value changed +]] +local function updateSignal(signal: SignalNode): boolean + signal.flags = MUTABLE + local pendingValue = signal.pendingValue + if signal.currentValue ~= pendingValue then + signal.currentValue = pendingValue return true else return false end end -local function updateSignal(s: SignalNode): boolean - s.flags = MUTABLE - local pendingValue = s.pendingValue - if s.currentValue ~= pendingValue then - s.currentValue = pendingValue - return true - else - return false - end -end +--[[ + Clears the pending flag from a subscriber. Used inside of if conditions to + inline the operation. -local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) + @param sub Effect or computed node + @param flags Subscriber's current flags +]] +local function clearPending(sub: ReactiveNode, flags: ReactiveFlags) sub.flags = bit32.band(flags, bit32.bnot(PENDING)) end +--[[ + Returns a function that evaluates the computed value when accessed. + Computed values are lazily evaluated and cached unless marked dirty or + pending with dirty dependencies. + + @param this Computed node + @return Operator to read the computed value +]] local function computedOper(this: ComputedNode): () -> T local function readComputed(): T local flags = this.flags + if bit32.btest(flags, DIRTY) - or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags))) + or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags))) then if updateComputed(this) then + -- Notify subscribers if the value changed local subs = this.subs if subs then shallowPropagate(subs) end end elseif flags == NONE then + -- First access should initialize the computed value this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) local prevSub = setActiveSub(this) local success, result = pcall(this.getter) @@ -248,31 +357,50 @@ local function computedOper(this: ComputedNode): () -> T error(result) end end + if activeSub then link(this, activeSub, cycle) end + return this.value :: T end + return readComputed end +--[[ + Returns functions to read and write a signal's value: + - Setter: Updates the signal's value and notifies subscribers + - Getter: Returns the current value and registers dependency + + @param this Signal node + @param equals Optional comparator function to check for value changes + @return Operator to get the signal value + @return Operator to set the signal value +]] local function signalOper( this: SignalNode, equals: ((current: T, incoming: T) -> boolean)? ): (() -> T, (((T) -> T) | T) -> T) local function writeSignal(update: ((T) -> T) | T): T local value: T + + -- Determine the new value based on whether the update is a function or + -- a direct value if type(update) == "function" then local prevSub = setActiveSub(nil) local success, result = pcall(noYield(update), this.pendingValue) activeSub = prevSub - if not success then + if success then + value = result + else error(result) end - value = result else value = update end + + -- Check if the new value is different from the pending value if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then if flags.frozen then deepFreeze(value) @@ -288,10 +416,12 @@ local function signalOper( end return value end + return this.currentValue end local function readSignal(): T + -- Check if the signal needs to be updated (for signals within effects) if bit32.btest(this.flags, DIRTY) then if updateSignal(this) then local subs = this.subs @@ -300,6 +430,8 @@ local function signalOper( end end end + + -- Register this signal as a dependency of the active subscriber local sub = activeSub while sub do if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then @@ -309,12 +441,25 @@ local function signalOper( local subs = sub.subs sub = if subs then subs.sub else nil end + return this.currentValue end return readSignal, writeSignal end +--[[ + Returns an effect cleanup operator that disposes of the effect. This + operator performs a thorough cleanup: + + 1. Removes all dependency links + 2. Unlinks from parent effects/scopes + 3. Clears state flags to mark the effect as inactive + 4. Runs all registered cleanup functions + + @param this Effect node + @return Effect cleanup operator +]] local function effectOper(this: EffectScopeNode): () -> () return function() this.depsTail = nil @@ -324,10 +469,16 @@ local function effectOper(this: EffectScopeNode): () -> () if sub then unlink(sub, this) end - runCleanups(this) + runCleanups(this.cleanups) end end +--[[ + Updates a signal or computed value and returns whether the value changed. + + @param node Signal or computed node + @return Boolean indicating whether the value changed +]] local function update(node: ReactiveNode): boolean if node.depsTail then return updateComputed(node :: ComputedNode) @@ -336,10 +487,17 @@ local function update(node: ReactiveNode): boolean end end +--[[ + Queues an effect for execution, ensuring inner effects are notified in the + correct order. + + @param effect Effect node to notify +]] local function notify(effect: ReactiveNode) local insertIndex = queuedLength local firstInsertedIndex = insertIndex + -- Collect inner effects in a chain repeat effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) queued[insertIndex] = effect :: EffectNode @@ -351,8 +509,10 @@ local function notify(effect: ReactiveNode) until false queuedLength = insertIndex - insertIndex -= 1 + + -- Reverse the collected effects to maintain correct order + -- Note: insertIndex should decrement before the next condition evaluation while firstInsertedIndex < insertIndex do local left = queued[firstInsertedIndex] queued[firstInsertedIndex] = queued[insertIndex] @@ -362,6 +522,15 @@ local function notify(effect: ReactiveNode) end end +--[[ + Called when a node is not being watched by any subscribers. Cleans up the + node's dependencies and performs the necessary cleanup operations: + + - Computed signals: Unlink dependencies and mark as dirty for potential future updates + - Effects/scopes: Perform full cleanup to free resources + + @param node Reactive node (computed or effect/scope) +]] local function unwatched(node: ReactiveNode) if bit32.band(node.flags, MUTABLE) == 0 then effectOper(node :: EffectScopeNode)() @@ -376,10 +545,10 @@ end Creates a reactive signal that stores a value and notifies subscribers when the value changes. The signal provides both a getter and a setter. - @param initialValue The initial value of the signal. - @param equals A comparator function to determine if the signal's value has changed. - @return A function to get the current value of the signal. - @return A function to set a new value and notify subscribers. + @param initialValue Initial value for the signal + @param equals Optional comparator function to check for value changes + @return Getter function to read the signal value + @return Setter function to update the signal value @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals ]] local function signal( @@ -397,12 +566,12 @@ local function signal( end --[[ - Creates a reactive atom that acts as both a signal getter and setter. - Calling the atom with an argument sets its value, while calling it with no - arguments returns its current value. + Creates a signal and returns a function that can act as both the getter and + setter. Calling the atom with one argument sets its value, while calling it + with no arguments returns its current value. - @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. - @param equals A comparator function to determine if the atom's value has changed. + @param initialValue Initial value for the atom + @param equals Optional comparator function to check for value changes @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals ]] local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom @@ -418,7 +587,8 @@ end other signals. The computed signal's value is automatically updated when a change is detected in any of its dependencies. - @param getter A function that produces the next value. + @param getter Function that calculates the next value + @return A function that returns the cached value @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter ]] local function computed(getter: (previousValue: T?) -> T): () -> T @@ -439,73 +609,88 @@ end If the effect is called within another effect, it will be disposed when the parent effect re-runs. - @param fn The function to run when dependencies change. - @return A function for disposing the effect. + @param fn Function to execute reactively + @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback ]] local function effect(fn: () -> ...(() -> ())?): () -> () - local e: EffectNode = { + local this: EffectNode = { fn = noYield(fn), flags = bit32.bor(WATCHING, RECURSED_CHECK), cleanups = {}, } - local prevSub = setActiveSub(e) - if prevSub and (flags.trackInnerEffects or prevSub.flags == NONE) then - link(e, prevSub, 0) + + if activeSub and (flags.trackInnerEffects or activeSub.flags == NONE) then + link(this, activeSub, 0) end - local success, result = pcall(e.fn) + + local prevSub = setActiveSub(this) + local success, result = pcall(this.fn) activeSub = prevSub - e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) + + this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) + if not success then error(result) elseif result then - table.insert(e.cleanups, noYield(result)) + table.insert(this.cleanups, noYield(result)) end - return effectOper(e) + + return effectOper(this) end --[[ Creates an effect scope that can capture reactive effects and computed signals created within it so that they can be disposed together. - @param fn A function that may create reactive effects. - @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. - @return A function for disposing all effects created within the scope. + @param fn Function that creates effects within the scope + @param detached If true, the scope will not be tracked by the parent effect/scope + @return A cleanup function that stops all effects in the scope @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached ]] local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () - local e: EffectScopeNode = { + local this: EffectScopeNode = { flags = NONE, cleanups = {}, } - local prevSub = setActiveSub(e) - if prevSub and not detached then - link(e, prevSub, 0) + + if activeSub and not detached then + link(this, activeSub, 0) end + + local prevSub = setActiveSub(this) local success, result = pcall(noYield(fn)) activeSub = prevSub + if not success then error(result) elseif result then - table.insert(e.cleanups, noYield(result)) + table.insert(this.cleanups, noYield(result)) end - return effectOper(e) + + return effectOper(this) end --[[ Allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter. - @param fn The dependency or a function that calls multiple dependencies. + @param fn A signal or function that accesses signals @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback ]] local function trigger(fn: () -> ()) local sub: ReactiveNode = { flags = WATCHING, } + local prevSub = setActiveSub(sub) local success, result: any = pcall(noYield(fn)) activeSub = prevSub + + if not success then + error(result) + end + local link = sub.deps while link do local dep = link.dep @@ -517,12 +702,10 @@ local function trigger(fn: () -> ()) shallowPropagate(subs) end end + if batchDepth == 0 then flush() end - if not success then - error(result) - end end --[[ @@ -530,8 +713,8 @@ end there is no active effect, a warning is logged, unless `failSilently` is set to `true`. - @param fn The cleanup function to register. - @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. + @param fn Function to run during cleanup + @param failSilently If true, silences the warning when called outside an effect/scope @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently ]] local function onCleanup(fn: () -> (), failSilently: boolean?) @@ -548,9 +731,9 @@ end For an alternative that still cleans up inner effects, use `peek` instead. - @param fn A function that may read signals or create effects. - @param ...args Arguments to pass to the function. - @return The return value of the function. + @param fn A signal or function that accesses signals + @param ...args Arguments to pass to the function + @return Values returned by the function @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback ]] local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... @@ -573,9 +756,9 @@ end To avoid capturing inner effects, use `untracked` instead. - @param fn A function that may read signals. - @param ...args Arguments to pass to the function. - @return The return value of the function. + @param fn A signal or function that accesses signals + @param ...args Arguments to pass to the function + @return Values returned by the function @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback ]] local function peek(fn: (Args...) -> Results..., ...: Args...): Results... @@ -600,9 +783,9 @@ end Combines multiple updates into a single "commit" that runs effects and computed signals after the provided callback finishes running. - @param fn A function that performs multiple updates. - @param ...args Arguments to pass to the function. - @return The return value of the function. + @param fn Function that updates signals + @param ...args Arguments to pass to the function + @return Values returned by the function @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback ]] local function batch(fn: (Args...) -> Results..., ...: Args...): Results... @@ -620,8 +803,8 @@ end signal, it is returned as-is. Useful for memoizing a getter function without creating unnecessary nested signals. - @param fn A function that returns the value to track. - @return A computed signal that tracks the value returned by the getter. + @param fn A signal or function that accesses signals + @return Computed signal if `fn` is not already a signal ]] local function toSignal(fn: () -> T): () -> T local name = debug.info(fn, "n") @@ -636,9 +819,9 @@ end Effects created within the callback are cleaned up when the listener runs or the listener is disposed. - @param getter A function that returns the value to watch. - @param fn The function to run when the value changes. - @return A function for disposing the effect. + @param getter A signal or function that accesses signals + @param fn Function to run when the value changes + @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () @@ -660,9 +843,9 @@ end Effects created within the callback are cleaned up when the listener runs or the listener is disposed. - @param getter A function that returns the value to watch. - @param fn The function to run when the value changes. - @return A function for disposing the effect. + @param getter A signal or function that accesses signals + @param fn Function to run when the value changes + @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () @@ -710,9 +893,9 @@ end key is removed or the `observe()` effect is disposed. Effects created during the callback will also be bound to the lifetime of the key. - @param getter A function that returns the table to observe. - @param fn A function that is called for each key in the table. - @return A function for disposing the observer. + @param getter Function that returns a table + @param fn Function that is called for each key added to the table + @return A cleanup function that disposes scopes for all keys @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> () @@ -772,8 +955,8 @@ end of an existing atom. If the key is omitted from the mapper's return value, the original key is preserved. - @param getter Returns the table to map over. - @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. + @param getter Function that returns a table + @param mapper Function that maps each entry from the source to a new entry in the mapped table @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper ]] local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } @@ -834,8 +1017,6 @@ end --[[ Determines if the provided value is a reactive proxy created by `reactive()`. - @param value A value to test. - @return `true` if the value is a reactive proxy, `false` otherwise. @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] local function isReactive(value: any): boolean @@ -846,8 +1027,8 @@ end Unwraps a reactive proxy to get the original table. If the provided value is not a proxy, it is returned as-is. - @param value A reactive proxy or a non-proxy value. - @return The unwrapped target value. + @param value A reactive proxy or a non-proxy value + @return The source table if `value` is a reactive proxy @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] local function toRaw(value: T): T @@ -880,9 +1061,9 @@ end end) ``` - @param initialValue The value to make deeply reactive. - @return A proxy table that tracks reads to nested properties. - @return A function for updating the original table. + @param initialValue Source table to make deeply reactive + @return A proxy table that tracks reads to nested properties + @return A function for batching mutations to the source table @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) @@ -926,9 +1107,9 @@ end Wraps a value in `reactive()` if it is a table; otherwise, returns the value as-is. - @param value A value to make reactive. - @param trackParent An optional tracking function to propagate deep updates. - @return A reactive proxy if the value is a table. + @param value Source value to make deeply reactive + @param trackParent Optional tracking function to propagate deep updates + @return A reactive proxy if the value is a table ]] local function toReactive(value: T, trackParent: (() -> ())?): T return if type(value) == "table" then reactive(value, trackParent) else value @@ -974,30 +1155,22 @@ function ReactiveProxy.__len(self: ReactiveProxy) return #self.__target end -local reactiveSystem = system.createReactiveSystem({ +link, unlink, propagate, checkDirty, shallowPropagate = system.createReactiveSystem({ update = update, notify = notify, unwatched = unwatched, }) -link = reactiveSystem.link -unlink = reactiveSystem.unlink -propagate = reactiveSystem.propagate -checkDirty = reactiveSystem.checkDirty -shallowPropagate = reactiveSystem.shallowPropagate - return { - ReactiveFlags = system.ReactiveFlags, - flags = flags, - getActiveSub = getActiveSub, - setActiveSub = setActiveSub, - startBatch = startBatch, - endBatch = endBatch, + -- Core reactive primitives signal = signal, computed = computed, effect = effect, effectScope = effectScope, trigger = trigger, + + -- Charm utilities + flags = flags, atom = atom, onCleanup = onCleanup, untracked = untracked, @@ -1011,7 +1184,16 @@ return { getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO) ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), + + -- Reactive proxies reactive = reactive, isReactive = isReactive, toRaw = toRaw, + + -- Advanced API + ReactiveFlags = system.ReactiveFlags, + getActiveSub = getActiveSub, + setActiveSub = setActiveSub, + startBatch = startBatch, + endBatch = endBatch, } diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 565419e..547ac3d 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -1,28 +1,6 @@ --[[ Reactive system adapted from alien-signals at https://github.com/stackblitz/alien-signals/blob/master/src/system.ts - - MIT License - - Copyright (c) 2024-present Johnson Chu - - 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 = { @@ -249,13 +227,7 @@ local function createReactiveSystem(props: { return false end - return { - link = link, - unlink = unlink, - propagate = propagate, - checkDirty = checkDirty, - shallowPropagate = shallowPropagate, - } + return link, unlink, propagate, checkDirty, shallowPropagate end return { diff --git a/rokit.toml b/rokit.toml index d110a91..f31bd52 100644 --- a/rokit.toml +++ b/rokit.toml @@ -4,4 +4,4 @@ rojo = "rojo-rbx/rojo@7.6.1" stylua = "johnnymorganz/stylua@2.3.1" selene = "Kampfkarren/selene@0.29.0" luau-lsp = "johnnymorganz/luau-lsp@1.57.1" -lute = "luau-lang/lute@0.1.0-nightly.20251127" +lute = "luau-lang/lute@0.1.0-nightly.20251211" From a9658a8aa2f92cf2c5dccd51ceb24870e2963f01 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:03:10 -0800 Subject: [PATCH 096/160] ci: upgrade lute --- .lute/check.luau | 4 ++++ rokit.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.lute/check.luau b/.lute/check.luau index de811b0..4f9e96a 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -21,6 +21,10 @@ local function analyze() local data: any = json.deserialize(response.body) for name, value in data.applicationSettings do + if type(name) ~= "string" then + continue + end + for _, kind in FFLAG_KINDS do if string.find(name, `^{kind}Luau`) then table.insert(args, `--flag:{string.sub(name, #kind + 1)}={value}`) diff --git a/rokit.toml b/rokit.toml index f31bd52..a9c0ebb 100644 --- a/rokit.toml +++ b/rokit.toml @@ -4,4 +4,4 @@ rojo = "rojo-rbx/rojo@7.6.1" stylua = "johnnymorganz/stylua@2.3.1" selene = "Kampfkarren/selene@0.29.0" luau-lsp = "johnnymorganz/luau-lsp@1.57.1" -lute = "luau-lang/lute@0.1.0-nightly.20251211" +lute = "luau-lang/lute@0.1.0-nightly.20251213" From 28872264a2c3319e1ef4c153138f115eefa912ae Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:34:05 -0800 Subject: [PATCH 097/160] build: loosen workspace version requirements --- package.json | 2 +- packages/charm-sync/package.json | 2 +- packages/react-charm/package.json | 2 +- packages/vide-charm/package.json | 2 +- pnpm-lock.yaml | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 9c00145..9ea7e24 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.22.0", + "packageManager": "pnpm@10.25.0", "devDependencies": { "@eslint/js": "^9.36.0", "@rbxts/compiler-types": "3.0.0-types.0", diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 84e8a25..49bc0bc 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -22,6 +22,6 @@ "publish:wally": "wally publish" }, "dependencies": { - "@rbxts/charm": "workspace:*" + "@rbxts/charm": "workspace:^" } } diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index 850b5e6..5d94e87 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -22,7 +22,7 @@ "publish:wally": "wally publish" }, "dependencies": { - "@rbxts/charm": "workspace:*" + "@rbxts/charm": "workspace:^" }, "peerDependencies": { "@rbxts/react": "*" diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index e9e2560..1c96512 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -22,7 +22,7 @@ "publish:wally": "wally publish" }, "dependencies": { - "@rbxts/charm": "workspace:*" + "@rbxts/charm": "workspace:^" }, "peerDependencies": { "@rbxts/vide": "*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 312a389..4cdfd32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,13 +65,13 @@ importers: packages/charm-sync: dependencies: '@rbxts/charm': - specifier: workspace:* + specifier: workspace:^ version: link:../charm packages/react-charm: dependencies: '@rbxts/charm': - specifier: workspace:* + specifier: workspace:^ version: link:../charm '@rbxts/react': specifier: '*' @@ -80,7 +80,7 @@ importers: packages/vide-charm: dependencies: '@rbxts/charm': - specifier: workspace:* + specifier: workspace:^ version: link:../charm '@rbxts/vide': specifier: '*' From 41d59cdfade19ec4dc9572c23f2576b52d12cfc6 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:24:36 -0800 Subject: [PATCH 098/160] chore: bump deps --- .lute/check.luau | 78 +++++++++--------------------------- README.md | 52 +++++++++++++----------- packages/charm/src/init.luau | 18 +++++---- rokit.toml | 4 +- 4 files changed, 58 insertions(+), 94 deletions(-) diff --git a/.lute/check.luau b/.lute/check.luau index 4f9e96a..4117286 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -1,51 +1,7 @@ local fs = require("@std/fs") -local json = require("@std/json") local net = require("@lute/net") local process = require("@lute/process") -local CURRENT_FFLAGS = "https://clientsettingscdn.roblox.com/v1/settings/application?applicationName=PCStudioApp" -local FFLAG_KINDS = { "FFlag", "FInt" } - -local function analyze() - local args = { - "luau-lsp", - "analyze", - "--sourcemap=sourcemap.json", - "--ignore=**/node_modules/**", - "--base-luaurc=.luaurc", - "--definitions=roblox.d.luau", - "--no-flags-enabled", - } - - local response = net.request(CURRENT_FFLAGS, { method = "GET" }) - local data: any = json.deserialize(response.body) - - for name, value in data.applicationSettings do - if type(name) ~= "string" then - continue - end - - for _, kind in FFLAG_KINDS do - if string.find(name, `^{kind}Luau`) then - table.insert(args, `--flag:{string.sub(name, #kind + 1)}={value}`) - end - end - end - - table.insert(args, "packages") - table.insert(args, "tests") - table.insert(args, "benches") - - local result = process.run(args) - local stderr = string.gsub(result.stderr, "Unknown FFlag[^\n]+\n", "") - - print(stderr) - - if result.exitcode == 1 then - process.exit(1) - end -end - process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) fs.writestringtofile( @@ -53,24 +9,26 @@ fs.writestringtofile( net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body ) -analyze() - --- process.run({ --- "luau-lsp", --- "analyze", --- "--sourcemap=sourcemap.json", --- "--ignore=**/node_modules/**", --- "--base-luaurc=.luaurc", --- "--defs=roblox.d.luau", --- "packages", --- "tests", --- "benches", --- }, { stdio = "inherit" }) +local analyze = process.run({ + "luau-lsp", + "analyze", + "--sourcemap=sourcemap.json", + "--ignore=**/node_modules/**", + "--base-luaurc=.luaurc", + "--definitions=roblox.d.luau", + "packages", + "tests", + "benches", +}, { stdio = "inherit" }) fs.remove("roblox.d.luau") -process.run({ "selene", "packages", "tests", "benches" }, { stdio = "inherit" }) +local selene = process.run({ "selene", "packages", "tests", "benches" }, { stdio = "inherit" }) -process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" }) +local stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" }) -process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" }) +local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" }) + +if not (analyze.ok and selene.ok and stylua.ok and eslint.ok) then + process.exit(1) +end diff --git a/README.md b/README.md index b5a7a2f..a31b977 100644 --- a/README.md +++ b/README.md @@ -233,10 +233,9 @@ Effects are fundamental to reactivity, allowing you to react to signal updates. ```luau local getCounter, setCounter = signal(0) --- Count is 0 effect(function() print(`Count is {getCounter()}`) -end) +end) -- Count is 0 setCounter(1) -- Count is 1 ``` @@ -257,22 +256,33 @@ setCounter(1) -- Cleanup 0 dispose() -- Cleanup 1 ``` -Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`. +### Nested Effects + +Effects can be nested inside other effects. When the outer effect re-runs, inner effects from the previous run are automatically cleaned up, and new inner effects are created if needed. The system ensures proper execution order; outer effects always run before their inner effects: ```luau -local getCounter, setCounter = signal(0) +local getShow, setShow = signal(true) +local getCount, setCount = signal(1) effect(function() - print(`Outer: {getCounter()}`) - effect(function() - print(`Inner: {getcounter()}`) - end) -end) + if getShow() then + -- This inner effect is created when show() is true + effect(function() + print(`Count is: {getCount()}`) + end) + end +end) -- Output: Count is: 1 -setCounter(1) -- Outer: 1, Inner: 1 -setCounter(2) -- Outer: 2, Inner: 2 +setCount(2) -- Count is: 2 + +-- When show becomes false, the inner effect is cleaned up +setShow(false) -- No output + +setCount(3) -- No output (inner effect no longer exists) ``` +Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen()`, `observe()`) because they are built upon effects. + > [!NOTE] > To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `flags.trackInnerEffects` flag to assist with debugging. @@ -289,17 +299,15 @@ local users, setUsers = reactive({ { name = "John", surname = "Doe" }, }) --- Output: John Doe effect(function() print(`{users[1].name} {users[1].surname}`) -end) +end) -- Output: John Doe --- Output: Jane Smith setUsers(function(state) state[1].name = "Jane" state[1].surname = "Smith" table.insert(state, { name = "Steve", surname = "Doe" }) -end) +end) -- Output: Jane Smith -- You can also mutate the reactive proxy directly: users[1].name = "John" -- John Smith @@ -327,10 +335,9 @@ In case you want to opt-out of dependency tracking in an effect, you can use `un local getTracked, setTracked = signal(0) local getUntracked, setUntracked = signal(0) --- Tracked: 0, Untracked: 0 effect(function() print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`) -end) +end) -- Output: Tracked: 0, Untracked: 0 setTracked(1) -- Tracked: 1, Untracked: 0 setUntracked(1) -- No output @@ -364,10 +371,9 @@ Similar to `untracked()`, but does not prevent the parent effect from tracking n ```luau local getCounter, setCounter = signal(0) --- Count is 0 local disposeOuter = effect(function() print(`Count is {peek(getCounter)}`) -end) +end) -- Output: Count is 0 setCounter(1) -- No output; count was accessed in peek() ``` @@ -430,7 +436,7 @@ The callback also receives the previous value, or `nil` when running for the fir ```luau local getCounter, setCounter = signal(0) --- Count is 0 (was nil) +-- Output: Count is 0 (was nil) listen(getCounter, function(count, prevCount) print(`Count is {count} (was {prevCount})`) end) @@ -595,9 +601,7 @@ effect(function() setCounter(function(count) return math.min(count + 1, 3) end) -end) - --- Output: 0, 1, 2, 3 +end) -- Output: 0, 1, 2, 3 ``` --- @@ -931,7 +935,7 @@ This means nilable values may be replaced with `None` in patches, and code worki Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that might help you migrate from an older version.
-Terminology changes +Terminology - Signal: A state container with one function to get the state, and another to update it - Atom: A signal with the getter and setter combined into one function diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index a1058d0..0fbacc7 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -191,7 +191,9 @@ local function runCleanups(cleanups: { () -> () }) for key, cleanup in cleanups do cleanups[key] = nil local prevSub = setActiveSub(nil) - xpcall(cleanup, warn or print) + xpcall(cleanup, function(err) + warn("Error occurred during effect cleanup:", debug.traceback(tostring(err), 2)) + end) activeSub = prevSub end end @@ -799,14 +801,14 @@ local function batch(fn: (Args...) -> Results..., ...: Args end --[[ - Converts a non-signal into a computed signal. If the function is already a - signal, it is returned as-is. Useful for memoizing a getter function - without creating unnecessary nested signals. + Wraps a function in a computed signal if it is not already a reactive + signal. Useful for memoizing a getter function without creating unnecessary + nested signals. @param fn A signal or function that accesses signals - @return Computed signal if `fn` is not already a signal + @return `fn` wrapped in a computed signal if needed ]] -local function toSignal(fn: () -> T): () -> T +local function toComputed(fn: () -> T): () -> T local name = debug.info(fn, "n") return if name == "readSignal" or name == "readComputed" or name == "atomOper" then fn else computed(fn) end @@ -825,7 +827,7 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () - local tracked = toSignal(getter) + local tracked = toComputed(getter) local current: T return effect(function() @@ -849,7 +851,7 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () - local tracked = toSignal(getter) + local tracked = toComputed(getter) local current: T local firstRun = true diff --git a/rokit.toml b/rokit.toml index a9c0ebb..3393915 100644 --- a/rokit.toml +++ b/rokit.toml @@ -3,5 +3,5 @@ wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.6.1" stylua = "johnnymorganz/stylua@2.3.1" selene = "Kampfkarren/selene@0.29.0" -luau-lsp = "johnnymorganz/luau-lsp@1.57.1" -lute = "luau-lang/lute@0.1.0-nightly.20251213" +luau-lsp = "johnnymorganz/luau-lsp@1.60.0" +lute = "luau-lang/lute@0.1.0-nightly.20260112" From 62fc690a7dcf762cde1011370e1c323a8744dfa4 Mon Sep 17 00:00:00 2001 From: safazi Date: Sun, 15 Feb 2026 23:06:55 -0600 Subject: [PATCH 099/160] fix: update signal overload for setter (#58) --- packages/charm/src/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 132a5be..c709f81 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -76,7 +76,7 @@ export function signal( equals?: Equals, ): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>; export function signal(): LuaTuple< - [getter: () => T | undefined, setter: (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined] + [getter: () => T | undefined, setter: (newValue: T | undefined | ((currentValue?: T) => T | undefined)) => T | undefined] >; /** From 1ac071b62ac5ee94ded4e51f8449c1709dd366d2 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:30:51 -0800 Subject: [PATCH 100/160] fix: add missing recursed check --- packages/charm/src/system.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 547ac3d..64f6096 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -184,7 +184,7 @@ local function createReactiveSystem(props: { local flags = sub.flags if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then sub.flags = bit32.bor(flags, DIRTY) - if bit32.btest(flags, WATCHING) then + if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then notify(sub) end end From 91ef244ef3c3a38d572501f49c996b33b1e77937 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:31:14 -0800 Subject: [PATCH 101/160] test: add important vue reactivity tests --- .../charm/src/__tests__/computed.test.luau | 101 ++++++++++++++++++ packages/charm/src/__tests__/effect.test.luau | 11 ++ 2 files changed, 112 insertions(+) diff --git a/packages/charm/src/__tests__/computed.test.luau b/packages/charm/src/__tests__/computed.test.luau index 691a7d9..4b11f0c 100644 --- a/packages/charm/src/__tests__/computed.test.luau +++ b/packages/charm/src/__tests__/computed.test.luau @@ -1,6 +1,7 @@ local Charm = require("../") local test = require("../../../../tests/test") +local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect local signal = Charm.signal @@ -102,4 +103,104 @@ test("should receive previous value", function() assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) end) +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L875 +test("should chained computeds keep reactivity when computed effect happens", function() + local v = atom("Hello") + local c = computed(function() + v(v() .. " World") + return v() + end) + local d = computed(function() + return c() + end) + local e = computed(function() + return d() + end) + local res = computed(function() + return d() .. " | " .. e() + end) + + assert(res() == "Hello World | Hello World", res()) + + v(v() .. " World") + assert(res() == "Hello World World World World | Hello World World World World", res()) +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L580 +test("should not trigger effect scheduler by recursive computed effect", function() + local v = atom("Hello") + local c = computed(function() + v(v() .. " World") + return v() + end) + local comp = computed(function() + return c() + end) + + assert(comp() == "Hello World", comp()) + + v(v() .. " World") + assert(comp() == "Hello World World World World", comp()) +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528 +test("should not override queried MaybeDirty result", function() + local Item = { + new = function() + return { v = atom(0) } + end, + } + local v1 = atom(nil :: any) + local v2 = atom(false) + local c1 = computed(function() + local c = v1() + if not v1() then + c = Item.new() + v1(c) + end + return c.v() + end) + local c2 = computed(function() + if not v2() then + return "no" + end + return if c1() then "yes" else "no" + end) + local c3 = computed(function() + return c2() + end) + + c3() + v2(true) + + c3() + v1().v(999) + + assert(c3() == "yes", "should return correct value after update") +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L503 +test("should trigger effect even computed already dirty", function() + local calls: { string } = {} + local v = atom(0) + local c1 = computed(function() + if v() == 0 then + v(1) + end + return "foo" + end) + local c2 = computed(function() + return v() .. c1() + end) + + effect(function() + table.insert(calls, c2()) + end) + assert(table.concat(calls, ",") == "0foo", table.concat(calls, ",")) + assert(v() == 1, v()) + v(2) + assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ",")) + assert(v() == 2, v()) +end) + return {} diff --git a/packages/charm/src/__tests__/effect.test.luau b/packages/charm/src/__tests__/effect.test.luau index b142ec8..79ef57f 100644 --- a/packages/charm/src/__tests__/effect.test.luau +++ b/packages/charm/src/__tests__/effect.test.luau @@ -387,4 +387,15 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) +test("should avoid infinite recursive loops with multiple effects", function() + local counter, setCounter = Charm.signal(0) + effect(function() + setCounter(counter() + 1) + end) + effect(function() + counter() + end) + setCounter(counter() + 1) +end) + return {} From 51579750911efcc5bc77de5a772d84a282035698 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:47:05 -0800 Subject: [PATCH 102/160] chore: fix react tests, run stylua --- packages/charm/src/index.d.ts | 5 ++++- packages/react-charm/src/__tests__/helpers/renderHook.luau | 2 -- packages/react-charm/src/__tests__/init.luau | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index c709f81..46696e9 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -76,7 +76,10 @@ export function signal( equals?: Equals, ): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>; export function signal(): LuaTuple< - [getter: () => T | undefined, setter: (newValue: T | undefined | ((currentValue?: T) => T | undefined)) => T | undefined] + [ + getter: () => T | undefined, + setter: (newValue: T | undefined | ((currentValue?: T) => T | undefined)) => T | undefined, + ] >; /** diff --git a/packages/react-charm/src/__tests__/helpers/renderHook.luau b/packages/react-charm/src/__tests__/helpers/renderHook.luau index 6a3ea48..4cbeb12 100644 --- a/packages/react-charm/src/__tests__/helpers/renderHook.luau +++ b/packages/react-charm/src/__tests__/helpers/renderHook.luau @@ -1,5 +1,3 @@ -_G.__ROACT_17_MOCK_SCHEDULER__ = true - local React = require("../../../React") local ReactRoblox = require("../../../ReactRoblox") diff --git a/packages/react-charm/src/__tests__/init.luau b/packages/react-charm/src/__tests__/init.luau index fc7e258..b3b5f51 100644 --- a/packages/react-charm/src/__tests__/init.luau +++ b/packages/react-charm/src/__tests__/init.luau @@ -1,5 +1,7 @@ local test = require("../../../tests/test") +_G.__ROACT_17_MOCK_SCHEDULER__ = true + test.describe("useSignalState", function() require("@self/useSignalState.test") end) From fe57482aaf622a652285e2881c502a721f20dd5f Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:47:29 -0800 Subject: [PATCH 103/160] chore: update vide --- package.json | 29 +- packages/vide-charm/wally.toml | 4 +- pnpm-lock.yaml | 792 +++++++++++++++++---------------- tsconfig.json | 4 +- 4 files changed, 424 insertions(+), 405 deletions(-) diff --git a/package.json b/package.json index 9ea7e24..daa0afa 100644 --- a/package.json +++ b/package.json @@ -7,24 +7,23 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.25.0", + "packageManager": "pnpm@10.30.0", "devDependencies": { - "@eslint/js": "^9.36.0", + "@eslint/js": "^9.39.2", "@rbxts/compiler-types": "3.0.0-types.0", - "@rbxts/react": "17.3.5-alpha.1", - "@rbxts/react-roblox": "17.3.5-alpha.1", - "@rbxts/types": "^1.0.883", - "@rbxts/vide": "^0.5.7", - "@typescript-eslint/eslint-plugin": "^8.44.1", - "@typescript-eslint/parser": "^8.44.1", - "eslint": "^9.36.0", + "@rbxts/react": "17.3.7-ts.1", + "@rbxts/react-roblox": "17.3.7-ts.1", + "@rbxts/types": "^1.0.907", + "@rbxts/vide": "^0.6.0", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.4", - "eslint-plugin-roblox-ts": "^1.2.1", - "jiti": "^2.6.0", - "prettier": "^3.6.2", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-roblox-ts": "^1.3.1", + "prettier": "^3.8.1", "roblox-ts": "^3.0.0", - "typescript": "^5.9.2", - "typescript-eslint": "^8.44.1" + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.0" } } diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index a48b236..576442e 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.4.0-rc.3" +version = "0.4.0-rc.4" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -14,4 +14,4 @@ exclude = [ [dependencies] Charm = "littensy/charm@^0.11.0-rc.3" -Vide = "centau/vide@^0.3" +Vide = "centau/vide@^0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cdfd32..cfc8e70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,56 +9,53 @@ importers: .: devDependencies: '@eslint/js': - specifier: ^9.36.0 - version: 9.36.0 + specifier: ^9.39.2 + version: 9.39.2 '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 '@rbxts/react': - specifier: 17.3.5-alpha.1 - version: 17.3.5-alpha.1 + specifier: 17.3.7-ts.1 + version: 17.3.7-ts.1 '@rbxts/react-roblox': - specifier: 17.3.5-alpha.1 - version: 17.3.5-alpha.1(@rbxts/react@17.3.5-alpha.1) + specifier: 17.3.7-ts.1 + version: 17.3.7-ts.1(@rbxts/react@17.3.7-ts.1) '@rbxts/types': - specifier: ^1.0.883 - version: 1.0.883 + specifier: ^1.0.907 + version: 1.0.907 '@rbxts/vide': - specifier: ^0.5.7 - version: 0.5.7 + specifier: ^0.6.0 + version: 0.6.0 '@typescript-eslint/eslint-plugin': - specifier: ^8.44.1 - version: 8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + specifier: ^8.56.0 + version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^8.44.1 - version: 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + specifier: ^8.56.0 + version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: - specifier: ^9.36.0 - version: 9.36.0(jiti@2.6.0) + specifier: ^9.39.2 + version: 9.39.2(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.36.0(jiti@2.6.0)) + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-prettier: - specifier: ^5.5.4 - version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.36.0(jiti@2.6.0)))(eslint@9.36.0(jiti@2.6.0))(prettier@3.6.2) + specifier: ^5.5.5 + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) eslint-plugin-roblox-ts: - specifier: ^1.2.1 - version: 1.2.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - jiti: - specifier: ^2.6.0 - version: 2.6.0 + specifier: ^1.3.1 + version: 1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) prettier: - specifier: ^3.6.2 - version: 3.6.2 + specifier: ^3.8.1 + version: 3.8.1 roblox-ts: specifier: ^3.0.0 version: 3.0.0 typescript: - specifier: ^5.9.2 - version: 5.9.2 + specifier: ^5.9.3 + version: 5.9.3 typescript-eslint: - specifier: ^8.44.1 - version: 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + specifier: ^8.56.0 + version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) packages/charm: {} @@ -75,7 +72,7 @@ importers: version: link:../charm '@rbxts/react': specifier: '*' - version: 17.2.3 + version: 17.3.7-ts.1 packages/vide-charm: dependencies: @@ -84,46 +81,46 @@ importers: version: link:../charm '@rbxts/vide': specifier: '*' - version: 0.5.7 + version: 0.6.0 packages: - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.0': - resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.3.1': - resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.15.2': - resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.36.0': - resolution: {integrity: sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==} + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.6': - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.3.5': - resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@humanfs/core@0.19.1': @@ -182,29 +179,29 @@ packages: '@rbxts-js/number@1.2.3-ts.1': resolution: {integrity: sha512-tPdt2s7fEBSxImVFBUvuSMtBKIvaZUCtlOjBl58Y0+GKB15VPXFIRW/5biVruXPnv9d4sDE3mh986ZxxN4UQ5w==} - '@rbxts-js/react-globals@17.3.5-ts.1': - resolution: {integrity: sha512-dbQ4E9WFtGg93cwElXk64KEYyd9gX2SCkTmYcm+Wty3qQdPkTWcaxu7c4SIMJpsvDTkt1nl+D0PdvVmQErcheg==} + '@rbxts-js/react-globals@17.3.7-ts.1': + resolution: {integrity: sha512-vwMHsc3Oy2MDAw2M212nQJ8A7nEG5O/lyXDyMGzYJp4832awhrf34YCkU4dxBE4sdMDcoWykhmuPjmzwrcS9Eg==} - '@rbxts-js/react-reconciler@17.3.5-ts.1': - resolution: {integrity: sha512-Hy/Z4K5epqRFjchd7RAKHnSinAp6vviqqPFmwIoW8RJDwxkvcu7GeXmh6SkFv0FeRatYIic6vTry/0cu/AluYg==} + '@rbxts-js/react-reconciler@17.3.7-ts.1': + resolution: {integrity: sha512-MxKU48i1gv4djcgHTq3geMvl6MzXPW85FO+itLg0YCvqT3WgE3jsMAc0nneOFi5Mwd5tqybQY6UFF1SbQtBopA==} - '@rbxts-js/react-roblox@17.3.5-ts.1': - resolution: {integrity: sha512-7AvkYe9lpIGcO+uHOnoqze46/8qMTpwKrqt+55gg6GLoWelVzseTkib5gGFRU4gi7CBpXvltiB8fY+T+IcPPOQ==} + '@rbxts-js/react-roblox@17.3.7-ts.1': + resolution: {integrity: sha512-xiJQXmJAho/mStcWYC7AXFqgUqW78whUMacaNeODMimQXgoVqaIfo0sEhLtLtS/N3qZRs1KR64nsNG/0n1r/NQ==} - '@rbxts-js/react@17.3.5-ts.1': - resolution: {integrity: sha512-Ln5r32y72s83T6qdpWX2hzd1sxoMf9GNx4PJnVVdlV+a3HJRC16huThTgMXyVD3mXlOnQwUKHs9ZLTFrSRNPUA==} + '@rbxts-js/react@17.3.7-ts.1': + resolution: {integrity: sha512-nRHYoBzk9DSbOvk281jmN5EM3TlmKv2jOzLgSIGtmNMuwZKi9yEjjxdluS1wHmjSvus1YiPHhBUwrKnbu01Obg==} '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': resolution: {integrity: sha512-H8fxKVJkRnM60yTZ/riO4X43GZD/MViFm+ftCzzAALkAhE4vSaBDNv9E7IUjZZoWi21bG6vZ8mGIiYfavaljvw==} - '@rbxts-js/safe-flags@17.3.5-ts.1': - resolution: {integrity: sha512-lzuUf2TNdmgxLeZuw9gfbrzNjju7LrmGwI4gQQxgXR3M+QhjjRaaXiLrHFkC83+VdBHJht70Re5gBK/7VtNOHg==} + '@rbxts-js/safe-flags@17.3.7-ts.1': + resolution: {integrity: sha512-aQ/5aQ/wQO5npifAbTEpR3jQRMeyGsVc91AQEyhBW461lPv6ZEg+Ad2HJFSzg4eeK2mb8Z9WyIMBrb01Jt+GnQ==} - '@rbxts-js/scheduler@17.3.5-ts.1': - resolution: {integrity: sha512-IT3dWnruzhEp7iShNOIsyVdy1jCCnHGV1B7Hhgusr7viwH8u5UrXZcOXazVuJM4ImdfPl1Yi9Bee+RKhW4zbuQ==} + '@rbxts-js/scheduler@17.3.7-ts.1': + resolution: {integrity: sha512-WCgGQqH9/+A5oJd6HpxMj7Vp1VGqhycnJK0CZTsJl/Ceyf/VH/vif8fo1FrBWkxYEgz/7y0q3LOeRj8ArhyDWQ==} - '@rbxts-js/shared@17.3.5-ts.1': - resolution: {integrity: sha512-0iwX5dD+gpwoRPBH0fpRCL3MVtnMrQqKX4IOg4xqeLewMzGXwP00+uXvewSN+eIPtLisMYjqH9GdGNUJOO7oGw==} + '@rbxts-js/shared@17.3.7-ts.1': + resolution: {integrity: sha512-B/OCkRmbR6ho1yuf0M0hQIb78coDY//2XdmNBaX2XCN5pKa7dquiX6ufk4dizJ/OMBoy9br/cnxqcI0PLvla3w==} '@rbxts-js/string@1.2.3-ts.1': resolution: {integrity: sha512-Je/WKgZbXF/sAHEsQPuns5n4rWzux327zjr0KRf9Gy62ieUzl2FRZZHjVYce3xdMtHif2JstIf/J3p513TUtRg==} @@ -218,29 +215,26 @@ packages: '@rbxts/compiler-types@3.0.0-types.0': resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} - '@rbxts/react-roblox@17.3.5-alpha.1': - resolution: {integrity: sha512-U7gb08OCDZTFp178gTVrgE5S1Ee6VBa3aHeIVcGhK1EJr8kn3dst2ugOtgeZIUQKPSBfxh885sKKcOfFScW5oA==} + '@rbxts/react-roblox@17.3.7-ts.1': + resolution: {integrity: sha512-lsH3lP+X0JUS/6Fg5MJfP8OjMn7bjxapbg3r1ajrpvwZGPGU1WOFcFDS/xMMk/2RK/GNYRmkduNM8pQ9vHw54Q==} peerDependencies: - '@rbxts/react': ^17.3.5-alpha.1 + '@rbxts/react': ^17.3.7-ts.1 - '@rbxts/react-vendor@17.2.3': - resolution: {integrity: sha512-dWCiVelvTK6h920geaFH/OQ5zoWHyhyqyfH51hKx2/iVZ6OiiyNVJ5UqEIkJMG4HsJ8JnkTORizV3+MtQ41BtA==} + '@rbxts/react@17.3.7-ts.1': + resolution: {integrity: sha512-/jqZ2TvyLSAPwC30h95loUmy4XN2PwPhv6fBARJKtZfyozFx2RuxC9FiQSjpEZ2uOOPzr60It0MBUf94D+tsMg==} - '@rbxts/react@17.2.3': - resolution: {integrity: sha512-IoOB8e2W8MxT0MKWFf0Ls0vT61ImYL6ctHQgz5+oazma6z8bqFtJjgPxOuy6jmMSuyfBe7rYp/muou0kIjZT+Q==} + '@rbxts/types@1.0.907': + resolution: {integrity: sha512-DZv8I1dDp5EY/o6Kt+e/dPUEVfxrTzudgr9vpC5Qn5Yhab/hfd4+vEh9i55ORko/yPhaAXDKEPKlpmuUYnAj3g==} - '@rbxts/react@17.3.5-alpha.1': - resolution: {integrity: sha512-qYhZZtfH0neWmzfQvo05sQFx9EYNQCH8aZomqDdoX40bbnB9XlK1P5kl98xYXCX1uBZCAjj6cWPIUCCfZVE8hw==} - - '@rbxts/types@1.0.883': - resolution: {integrity: sha512-X/SgSPqES8awoqSehdEfvOCQjFPcTGA4EflfWZ/FXyWvlfd7Y5/DWa3UfUJFGDHZu1D0iDIL66q17UYQbufpMg==} - - '@rbxts/vide@0.5.7': - resolution: {integrity: sha512-Q9R7ybzuHK1phtLicBHgAjQnlJ/dMsXDs2k296PIMLgLqK9aX/f1skOP7F1K/vo7IrSE0jCG3B3XTOtQL1Sb0Q==} + '@rbxts/vide@0.6.0': + resolution: {integrity: sha512-OLfs4kqV2IP79xS+gZAprMujuYEc+n0JwMhJF+puNVoxBXSzOv2kKlgeLJhscCy7Jdhr9TbqsaRNLJP5akbfog==} '@roblox-ts/luau-ast@2.0.0': resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} + '@roblox-ts/luau-ast@2.0.1': + resolution: {integrity: sha512-jej2mzRGwhR9wu4woAtgTwqsKADlegZYlQ+M5qVHRojvjoyJVFkLrSbhBKRr8HR+fVW5nCz0qX7kNa0tSpAY+A==} + '@roblox-ts/path-translator@1.1.0': resolution: {integrity: sha512-D0akTmnNYqBw+ZIek5JxocT3BjmbgGOuOy0x1nIIxHBPNLGCpzseToY8jyYs/0mlvnN2xnSP/k8Tv+jvGOQSwQ==} @@ -253,107 +247,107 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@typescript-eslint/eslint-plugin@8.44.1': - resolution: {integrity: sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==} + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.44.1 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/parser': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.44.1': - resolution: {integrity: sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==} + '@typescript-eslint/parser@8.56.0': + resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.46.4': + resolution: {integrity: sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.46.4': + resolution: {integrity: sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.56.0': + resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.46.4': + resolution: {integrity: sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.46.4': + resolution: {integrity: sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.44.0': - resolution: {integrity: sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==} + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.46.4': + resolution: {integrity: sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.46.4': + resolution: {integrity: sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.44.1': - resolution: {integrity: sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==} + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.44.0': - resolution: {integrity: sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/scope-manager@8.44.1': - resolution: {integrity: sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.44.0': - resolution: {integrity: sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/tsconfig-utils@8.44.1': - resolution: {integrity: sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.44.0': - resolution: {integrity: sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==} + '@typescript-eslint/utils@8.46.4': + resolution: {integrity: sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.44.1': - resolution: {integrity: sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==} + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.44.0': - resolution: {integrity: sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==} + '@typescript-eslint/visitor-keys@8.46.4': + resolution: {integrity: sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.44.1': - resolution: {integrity: sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.44.0': - resolution: {integrity: sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/typescript-estree@8.44.1': - resolution: {integrity: sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.44.0': - resolution: {integrity: sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.44.1': - resolution: {integrity: sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.44.0': - resolution: {integrity: sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/visitor-keys@8.44.1': - resolution: {integrity: sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==} + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: @@ -369,8 +363,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -463,8 +457,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-prettier@5.5.4: - resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' @@ -477,8 +471,8 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-roblox-ts@1.2.1: - resolution: {integrity: sha512-B6PyXsz2urPJIr2TxZBD3qQwCM0FN6lUOoddJTk1AcdRkWSTe1Vc6VuNP593u7Tbq9sStjW2k2iYjI1G5CFNiQ==} + eslint-plugin-roblox-ts@1.3.1: + resolution: {integrity: sha512-+cXNQUlZF3sg/nS55DciRZeFiKpexGMrqXcO56mHf6TBpYEbfK+v/VboBPi8ZdaFyN5LgspnTh+t/Xi1AIvFJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=22.5.1} peerDependencies: '@typescript-eslint/parser': '>=6.0.0' @@ -497,8 +491,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.36.0: - resolution: {integrity: sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==} + eslint-visitor-keys@5.0.0: + resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -511,8 +509,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -546,8 +544,17 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -568,8 +575,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - fs-extra@11.3.2: - resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} fsevents@2.3.3: @@ -599,9 +606,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -653,12 +657,12 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jiti@2.6.0: - resolution: {integrity: sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true json-buffer@3.0.1: @@ -750,16 +754,20 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.0: - resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} - prettier@3.6.2: - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -786,8 +794,8 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} hasBin: true @@ -802,8 +810,8 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -835,16 +843,20 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.11: - resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -853,11 +865,11 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.44.1: - resolution: {integrity: sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg==} + typescript-eslint@8.56.0: + resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' typescript@5.5.3: @@ -865,8 +877,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true @@ -908,28 +920,30 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.9.0(eslint@9.36.0(jiti@2.6.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: - eslint: 9.36.0(jiti@2.6.0) + eslint: 9.39.2(jiti@2.6.1) eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.12.1': {} + '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.0': + '@eslint/config-array@0.21.1': dependencies: - '@eslint/object-schema': 2.1.6 + '@eslint/object-schema': 2.1.7 debug: 4.4.3 minimatch: 3.1.2 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.3.1': {} + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 - '@eslint/core@0.15.2': + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 debug: 4.4.3 @@ -937,19 +951,19 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.36.0': {} + '@eslint/js@9.39.2': {} - '@eslint/object-schema@2.1.6': {} + '@eslint/object-schema@2.1.7': {} - '@eslint/plugin-kit@0.3.5': + '@eslint/plugin-kit@0.4.1': dependencies: - '@eslint/core': 0.15.2 + '@eslint/core': 0.17.0 levn: 0.4.1 '@humanfs/core@0.19.1': {} @@ -973,7 +987,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 '@pkgr/core@0.2.9': {} @@ -1011,51 +1025,51 @@ snapshots: '@rbxts-js/number@1.2.3-ts.1': {} - '@rbxts-js/react-globals@17.3.5-ts.1': + '@rbxts-js/react-globals@17.3.7-ts.1': dependencies: - '@rbxts-js/safe-flags': 17.3.5-ts.1 + '@rbxts-js/safe-flags': 17.3.7-ts.1 - '@rbxts-js/react-reconciler@17.3.5-ts.1': + '@rbxts-js/react-reconciler@17.3.7-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react': 17.3.5-ts.1 - '@rbxts-js/react-globals': 17.3.5-ts.1 + '@rbxts-js/react': 17.3.7-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 '@rbxts-js/roblox-lua-promise': 3.5.0-ts.2 - '@rbxts-js/safe-flags': 17.3.5-ts.1 - '@rbxts-js/scheduler': 17.3.5-ts.1 - '@rbxts-js/shared': 17.3.5-ts.1 + '@rbxts-js/safe-flags': 17.3.7-ts.1 + '@rbxts-js/scheduler': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 - '@rbxts-js/react-roblox@17.3.5-ts.1': + '@rbxts-js/react-roblox@17.3.7-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react': 17.3.5-ts.1 - '@rbxts-js/react-globals': 17.3.5-ts.1 - '@rbxts-js/react-reconciler': 17.3.5-ts.1 - '@rbxts-js/scheduler': 17.3.5-ts.1 - '@rbxts-js/shared': 17.3.5-ts.1 + '@rbxts-js/react': 17.3.7-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/react-reconciler': 17.3.7-ts.1 + '@rbxts-js/scheduler': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 - '@rbxts-js/react@17.3.5-ts.1': + '@rbxts-js/react@17.3.7-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react-globals': 17.3.5-ts.1 - '@rbxts-js/shared': 17.3.5-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': {} - '@rbxts-js/safe-flags@17.3.5-ts.1': {} + '@rbxts-js/safe-flags@17.3.7-ts.1': {} - '@rbxts-js/scheduler@17.3.5-ts.1': + '@rbxts-js/scheduler@17.3.7-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react-globals': 17.3.5-ts.1 - '@rbxts-js/safe-flags': 17.3.5-ts.1 - '@rbxts-js/shared': 17.3.5-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/safe-flags': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 - '@rbxts-js/shared@17.3.5-ts.1': + '@rbxts-js/shared@17.3.7-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react-globals': 17.3.5-ts.1 - '@rbxts-js/safe-flags': 17.3.5-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/safe-flags': 17.3.7-ts.1 '@rbxts-js/string@1.2.3-ts.1': dependencies: @@ -1070,197 +1084,191 @@ snapshots: '@rbxts/compiler-types@3.0.0-types.0': {} - '@rbxts/react-roblox@17.3.5-alpha.1(@rbxts/react@17.3.5-alpha.1)': + '@rbxts/react-roblox@17.3.7-ts.1(@rbxts/react@17.3.7-ts.1)': dependencies: - '@rbxts-js/react-roblox': 17.3.5-ts.1 - '@rbxts/react': 17.3.5-alpha.1 + '@rbxts-js/react-roblox': 17.3.7-ts.1 + '@rbxts/react': 17.3.7-ts.1 - '@rbxts/react-vendor@17.2.3': {} - - '@rbxts/react@17.2.3': + '@rbxts/react@17.3.7-ts.1': dependencies: - '@rbxts/react-vendor': 17.2.3 + '@rbxts-js/react': 17.3.7-ts.1 - '@rbxts/react@17.3.5-alpha.1': - dependencies: - '@rbxts-js/react': 17.3.5-ts.1 + '@rbxts/types@1.0.907': {} - '@rbxts/types@1.0.883': {} - - '@rbxts/vide@0.5.7': {} + '@rbxts/vide@0.6.0': {} '@roblox-ts/luau-ast@2.0.0': {} + '@roblox-ts/luau-ast@2.0.1': {} + '@roblox-ts/path-translator@1.1.0': dependencies: - ajv: 8.17.1 - fs-extra: 11.3.2 + ajv: 8.18.0 + fs-extra: 11.3.3 '@roblox-ts/rojo-resolver@1.1.0': dependencies: - ajv: 8.17.1 - fs-extra: 11.3.2 + ajv: 8.18.0 + fs-extra: 11.3.3 '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} - '@typescript-eslint/eslint-plugin@8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - '@typescript-eslint/scope-manager': 8.44.1 - '@typescript-eslint/type-utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - '@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 8.44.1 - eslint: 9.36.0(jiti@2.6.0) - graphemer: 1.4.0 + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': + '@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.44.1 - '@typescript-eslint/types': 8.44.1 - '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 8.44.1 + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - eslint: 9.36.0(jiti@2.6.0) - typescript: 5.9.2 + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.44.0(typescript@5.9.2)': + '@typescript-eslint/project-service@8.46.4(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2) - '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 debug: 4.4.3 - typescript: 5.9.2 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.44.1(typescript@5.9.2)': + '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.2) - '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 debug: 4.4.3 - typescript: 5.9.2 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.44.0': + '@typescript-eslint/scope-manager@8.46.4': dependencies: - '@typescript-eslint/types': 8.44.0 - '@typescript-eslint/visitor-keys': 8.44.0 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/visitor-keys': 8.46.4 - '@typescript-eslint/scope-manager@8.44.1': + '@typescript-eslint/scope-manager@8.56.0': dependencies: - '@typescript-eslint/types': 8.44.1 - '@typescript-eslint/visitor-keys': 8.44.1 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 - '@typescript-eslint/tsconfig-utils@8.44.0(typescript@5.9.2)': + '@typescript-eslint/tsconfig-utils@8.46.4(typescript@5.9.3)': dependencies: - typescript: 5.9.2 + typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.44.1(typescript@5.9.2)': + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: - typescript: 5.9.2 + typescript: 5.9.3 - '@typescript-eslint/type-utils@8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': + '@typescript-eslint/type-utils@8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.44.0 - '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.36.0(jiti@2.6.0) - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.44.1 - '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) - '@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.36.0(jiti@2.6.0) - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.44.0': {} + '@typescript-eslint/types@8.46.4': {} - '@typescript-eslint/types@8.44.1': {} + '@typescript-eslint/types@8.56.0': {} - '@typescript-eslint/typescript-estree@8.44.0(typescript@5.9.2)': + '@typescript-eslint/typescript-estree@8.46.4(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.44.0(typescript@5.9.2) - '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2) - '@typescript-eslint/types': 8.44.0 - '@typescript-eslint/visitor-keys': 8.44.0 + '@typescript-eslint/project-service': 8.46.4(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/visitor-keys': 8.46.4 debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 + semver: 7.7.4 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.44.1(typescript@5.9.2)': + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.44.1(typescript@5.9.2) - '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.2) - '@typescript-eslint/types': 8.44.1 - '@typescript-eslint/visitor-keys': 8.44.1 + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': + '@typescript-eslint/utils@8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0)) - '@typescript-eslint/scope-manager': 8.44.0 - '@typescript-eslint/types': 8.44.0 - '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2) - eslint: 9.36.0(jiti@2.6.0) - typescript: 5.9.2 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': + '@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0)) - '@typescript-eslint/scope-manager': 8.44.1 - '@typescript-eslint/types': 8.44.1 - '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) - eslint: 9.36.0(jiti@2.6.0) - typescript: 5.9.2 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.44.0': + '@typescript-eslint/visitor-keys@8.46.4': dependencies: - '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/types': 8.46.4 eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.44.1': + '@typescript-eslint/visitor-keys@8.56.0': dependencies: - '@typescript-eslint/types': 8.44.1 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.0 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -1275,7 +1283,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -1363,26 +1371,27 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.36.0(jiti@2.6.0)): + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: - eslint: 9.36.0(jiti@2.6.0) + eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.36.0(jiti@2.6.0)))(eslint@9.36.0(jiti@2.6.0))(prettier@3.6.2): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): dependencies: - eslint: 9.36.0(jiti@2.6.0) - prettier: 3.6.2 - prettier-linter-helpers: 1.0.0 - synckit: 0.11.11 + eslint: 9.39.2(jiti@2.6.1) + prettier: 3.8.1 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.36.0(jiti@2.6.0)) + eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-roblox-ts@1.2.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2): + eslint-plugin-roblox-ts@1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - '@typescript-eslint/type-utils': 8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - '@typescript-eslint/utils': 8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - eslint: 9.36.0(jiti@2.6.0) - typescript: 5.9.2 + '@roblox-ts/luau-ast': 2.0.1 + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1395,21 +1404,22 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.36.0(jiti@2.6.0): + eslint-visitor-keys@5.0.0: {} + + eslint@9.39.2(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0)) - '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.21.0 - '@eslint/config-helpers': 0.3.1 - '@eslint/core': 0.15.2 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.36.0 - '@eslint/plugin-kit': 0.3.5 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 @@ -1418,7 +1428,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -1433,7 +1443,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.0 + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -1443,7 +1453,7 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -1473,10 +1483,14 @@ snapshots: fast-uri@3.1.0: {} - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -1497,7 +1511,7 @@ snapshots: flatted@3.3.3: {} - fs-extra@11.3.2: + fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.0 @@ -1522,8 +1536,6 @@ snapshots: graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - has-flag@4.0.0: {} hasown@2.0.2: @@ -1561,9 +1573,10 @@ snapshots: isexe@2.0.0: {} - jiti@2.6.0: {} + jiti@2.6.1: + optional: true - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -1648,13 +1661,15 @@ snapshots: picomatch@2.3.1: {} + picomatch@4.0.3: {} + prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.0: + prettier-linter-helpers@1.0.1: dependencies: fast-diff: 1.3.0 - prettier@3.6.2: {} + prettier@3.8.1: {} punycode@2.3.1: {} @@ -1670,7 +1685,7 @@ snapshots: resolve-from@4.0.0: {} - resolve@1.22.10: + resolve@1.22.11: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 @@ -1684,9 +1699,9 @@ snapshots: '@roblox-ts/path-translator': 1.1.0 '@roblox-ts/rojo-resolver': 1.1.0 chokidar: 3.6.0 - fs-extra: 11.3.2 + fs-extra: 11.3.3 kleur: 4.1.5 - resolve: 1.22.10 + resolve: 1.22.11 typescript: 5.5.3 yargs: 17.7.2 @@ -1694,7 +1709,7 @@ snapshots: dependencies: queue-microtask: 1.2.3 - semver@7.7.2: {} + semver@7.7.4: {} shebang-command@2.0.0: dependencies: @@ -1720,36 +1735,41 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.11: + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ts-api-utils@2.1.0(typescript@5.9.2): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: - typescript: 5.9.2 + typescript: 5.9.3 type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2): + typescript-eslint@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - '@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) - '@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) - eslint: 9.36.0(jiti@2.6.0) - typescript: 5.9.2 + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color typescript@5.5.3: {} - typescript@5.9.2: {} + typescript@5.9.3: {} universalify@2.0.1: {} diff --git a/tsconfig.json b/tsconfig.json index c0db2ac..6aa47d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,8 +4,8 @@ "allowSyntheticDefaultImports": true, "downlevelIteration": true, "jsx": "react", - "jsxFactory": "Roact.createElement", - "jsxFragmentFactory": "Roact.createFragment", + "jsxFactory": "React.createElement", + "jsxFragmentFactory": "React.Fragment", "module": "commonjs", "moduleResolution": "Node", "ignoreDeprecations": "6.0", From fe7064acee438f67777faaeec561bc41d180e2cd Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:38:30 -0800 Subject: [PATCH 104/160] docs: improve intro and explain getters --- README.md | 114 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index a31b977..559db68 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Charm

- Manage state with reactive signals + Fine-grained reactivity for Roblox
npm package →

@@ -18,18 +18,17 @@ -**Charm** is a Roblox state management library inspired by the [reactive signals](https://preactjs.com/blog/introducing-signals/) from libraries like Preact and Solid. It's built on [alien-signals](https://github.com/stackblitz/alien-signals), modified for a wide range of use cases: from rendering user interfaces to handling game logic. +Charm is a state management library that brings the [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) approach — used in libraries like Vue, Preact, and Solid — to your Luau projects. -**Charm works under a few core principles.** +**Build complex game state from simple building blocks:** -- Manage state with reactive signals: state containers that hold a value -- React to state updates: reading a signal automatically subscribes to it -- Combine multiple signals: derive new values from existing state that stay up-to-date -- Fine-grained reactivity: make targeted updates in response to specific changes in the state -- Data should be immutable: values are compared directly (`==`) to optimize change detection +- Store state in [reactive signals](#signalinitialvalue-equals): state containers that hold a value +- React to state changes with [effects](#effectcallback): run code whenever a dependency updates +- Derive new values with [computed signals](#computedgetter): memoize a function with automatic dependency tracking **Want to learn more about signals?** +- https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf - https://preactjs.com/blog/introducing-signals - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity - https://angular.dev/guide/signals @@ -44,12 +43,14 @@ - [`signal(initialValue, equals?)`](#signalinitialvalue-equals) - [`computed(getter)`](#computedgetter) - [`effect(callback)`](#effectcallback) + - [Nested effects](#nested-effects) - [`reactive(initialValue)`](#reactiveinitialvalue) - [`untracked(callback)`](#untrackedcallback) - [`peek(callback)`](#peekcallback) - [`batch(callback)`](#batchcallback) - [`effectScope(callback)`](#effectscopecallback) - [`listen(getter, callback)`](#listengetter-callback) + - [Getter functions](#getter-functions) - [`subscribe(getter, callback)`](#subscribegetter-callback) - [`observe(getter, callback)`](#observegetter-callback) - [`mapped(getter, mapper)`](#mappedgetter-mapper) @@ -150,6 +151,7 @@ pnpm add @rbxts/charm ``` ```toml +# wally.toml [dependencies] Charm = "littensy/charm@VERSION" ``` @@ -187,42 +189,7 @@ setMax(-1) -- 2 ``` > [!NOTE] -> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a combined getter and setter. - ---- - -### `computed(getter)` - -The `computed` function creates a new signal with a value derived from multiple signals. The computed signal can be reacted to like a normal signal, and it returns the getter function's last return value. - -The computed signal will subscribe to signals accessed by the getter and track them as dependencies. Changes to these dependencies will re-execute the getter, and if it returns a new value, then the computed signal is updated to that value. - -```luau -local getName, setName = signal("John") -local getSurname, setSurname = signal("Doe") -local getFullName = computed(function() - return `{getName()} {getSurname()}` -end) - -print(getFullName()) -- "John Doe" -setName("Jane") -print(getFullName()) -- "Jane Doe" -``` - -The getter function also receives the previous state (or `nil` if running for the first time). You can use this for more complex state: - -```luau -local getCounter, setCounter = signal(10) -local getMax = computed(function(prevMax) - return math.max(getCounter(), prevMax or 0) -end) - -print(getMax()) -- 10 -setCounter(5) -print(getMax()) -- 10 -``` - -Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called _and_ if a dependency updated since the last call. +> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a unified getter and setter. --- @@ -288,6 +255,41 @@ Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen --- +### `computed(getter)` + +The `computed` function creates a new signal with a value derived from multiple signals. The computed signal can be reacted to like a normal signal, and it returns the getter function's last return value. + +The computed signal will subscribe to signals accessed by the getter and track them as dependencies. Changes to these dependencies will re-execute the getter, and if it returns a new value, then the computed signal is updated to that value. + +```luau +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local getFullName = computed(function() + return `{getName()} {getSurname()}` +end) + +print(getFullName()) -- "John Doe" +setName("Jane") +print(getFullName()) -- "Jane Doe" +``` + +The getter function also receives the previous state (or `nil` if running for the first time). You can use this for more complex state: + +```luau +local getCounter, setCounter = signal(10) +local getMax = computed(function(prevMax) + return math.max(getCounter(), prevMax or 0) +end) + +print(getMax()) -- 10 +setCounter(5) +print(getMax()) -- 10 +``` + +Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called _and_ if a dependency updated since the last call. + +--- + ### `reactive(initialValue)` By default, Charm's signals are _shallowly reactive_, meaning only the value itself is reactive, and table properties are not tracked. Signal values must also be **immutable** in order to track changes in tables. @@ -444,13 +446,33 @@ end) setCounter(1) -- Count is 1 (was 0) ``` +### Getter functions + +Reaction APIs like `listen()`, `subscribe()`, and `observe()` aren't only restricted to signals. You can listen to "getter" functions that call one or more signals, and they will automatically be memoized: + +```luau +local getCounter, setCounter = signal(0) + +local function floorCounter() + return math.floor(getCounter()) +end + +-- Output: Floor of count is 0 (was nil) +listen(floorCounter, function(count, prevCount) + print(`Floor of count is {count} (was {prevCount})`) +end) + +setCounter(0.5) -- Doesn't print anything, floor is still 0 +setCounter(1) -- Floor of count is 1 (was 0) +``` + Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed. --- ### `subscribe(getter, callback)` -The `subscribe` function is identical to `listen()`, but the callback does not run initially. The callback only runs when the value returned by the getter function changes. +The `subscribe` function is identical to `listen()`, but the effect callback skips the initial run. The callback only runs when the value returned by the getter function changes. ```luau local getCounter, setCounter = signal(0) @@ -463,8 +485,6 @@ end) setCounter(1) -- Count is 1 (was 0) ``` -Note that nested effects can be created inside `subscribe`, and they will clean up automatically when the subscription re-runs or gets disposed. - --- ### `observe(getter, callback)` From 787084ba59825e21eee7322fceab178133ff66af Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:45:00 -0800 Subject: [PATCH 105/160] docs: adjust ordering by importance --- README.md | 312 +++++++++++++++++++++++++++--------------------------- 1 file changed, 156 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index 559db68..bc304b5 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,17 @@ Charm is a state management library that brings the [fine-grained reactivity](ht - [`computed(getter)`](#computedgetter) - [`effect(callback)`](#effectcallback) - [Nested effects](#nested-effects) - - [`reactive(initialValue)`](#reactiveinitialvalue) - - [`untracked(callback)`](#untrackedcallback) - - [`peek(callback)`](#peekcallback) - - [`batch(callback)`](#batchcallback) - [`effectScope(callback)`](#effectscopecallback) - [`listen(getter, callback)`](#listengetter-callback) - [Getter functions](#getter-functions) - - [`subscribe(getter, callback)`](#subscribegetter-callback) - [`observe(getter, callback)`](#observegetter-callback) + - [`subscribe(getter, callback)`](#subscribegetter-callback) + - [`untracked(callback)`](#untrackedcallback) + - [`peek(callback)`](#peekcallback) + - [`batch(callback)`](#batchcallback) - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) + - [`reactive(initialValue)`](#reactiveinitialvalue) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`recursive()`](#recursive) - [`trigger(callback)`](#triggercallback) @@ -290,41 +290,129 @@ Computed signals are updated lazily, meaning the getter will only re-execute onc --- -### `reactive(initialValue)` +### `effectScope(callback)` -By default, Charm's signals are _shallowly reactive_, meaning only the value itself is reactive, and table properties are not tracked. Signal values must also be **immutable** in order to track changes in tables. - -You can opt-in to deep reactivity with **reactive proxies**. The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy. +Scopes allow you to dispose multiple effects at once. The `effectScope` function creates a scope that tracks inner effects, so effects created inside the callback clean up when the scope disposes. ```luau -local users, setUsers = reactive({ - { name = "John", surname = "Doe" }, -}) +local getCounter, setCounter = signal(0) -effect(function() - print(`{users[1].name} {users[1].surname}`) -end) -- Output: John Doe +local dispose = effectScope(function() + effect(function() + print(`Count 1 is {getCounter()}`) + end) + effect(function() + print(`Count 2 is {getCounter()}`) + end) +end) -setUsers(function(state) - state[1].name = "Jane" - state[1].surname = "Smith" - table.insert(state, { name = "Steve", surname = "Doe" }) -end) -- Output: Jane Smith - --- You can also mutate the reactive proxy directly: -users[1].name = "John" -- John Smith +setCounter(1) -- Count 1 is 1, Count 2 is 1 +dispose() +setCounter(2) -- No output; effects got disposed ``` -> [!WARNING] -> Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value. +Similar to `effect()`, the callback can return a cleanup function that runs when the effect scope is disposed. -If you need to access the raw table through the reactive proxy, use the `toRaw()` function: +--- + +### `listen(getter, callback)` + +The `listen` function creates an effect that only subscribes to the signals accessed by `getter`. Signals accessed by the callback will not be subscribed to, avoiding accidental subscriptions when you want to run side effects. + +The callback also receives the previous value, or `nil` when running for the first time. ```luau -local raw = {} -local proxy = reactive(raw) +local getCounter, setCounter = signal(0) -print(toRaw(proxy) == raw) -- true +-- Output: Count is 0 (was nil) +listen(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) +end) + +setCounter(1) -- Count is 1 (was 0) +``` + +### Getter functions + +Reaction APIs like `listen()`, `subscribe()`, and `observe()` aren't only restricted to signals. You can listen to "getter" functions that call one or more signals, and they will automatically be memoized: + +```luau +local getCounter, setCounter = signal(0) + +local function floorCounter() + return math.floor(getCounter()) +end + +-- Output: Floor of count is 0 (was nil) +listen(floorCounter, function(count, prevCount) + print(`Floor of count is {count} (was {prevCount})`) +end) + +setCounter(0.5) -- Doesn't print anything, floor is still 0 +setCounter(1) -- Floor of count is 1 (was 0) +``` + +Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed. + +--- + +### `observe(getter, callback)` + +[Observers](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) allow you to track the lifetime of a given state. The `observe` function executes the callback for every unique key added to a table, and disposes the callback when that key is removed. + +```luau +local getItems, setItems = signal({ a = 0, b = 0 }) + +-- Added a, Added b +observe(getItems, function(value, key) + print(`Added {key}`) + return function() + print(`Removed {key}`) + end +end) + +setItems({ a = 0, c = 0 }) -- Removed b, Added c +``` + +The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: + +```luau +local getItems, setItems = signal({ a = 0, b = 0 }) + +local dispose = observe(getItems, function(value, key) + local getValue = computed(function(prevValue) + return getItems()[key] or prevValue + end) + + effect(function() + local value = getValue() + print(`{key} = {value}`) + return function() + print(`Cleanup {key} = {value}`) + end + end) +end) + +setItems({ a = 1, b = 0 }) -- Cleanup a = 0, a = 1 +setItems({ a = 1 }) -- Cleanup b = 0 +dispose() -- Cleanup a = 1 +``` + +--- + +### `subscribe(getter, callback)` + +The `subscribe` function is identical to `listen()`, but the effect callback skips the initial run. The callback only runs when the value returned by the getter function changes. + +```luau +local getCounter, setCounter = signal(0) + +-- Does not output anything initially +subscribe(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) +end) + +setCounter(1) -- Count is 1 (was 0) ``` --- @@ -404,133 +492,6 @@ end) --- -### `effectScope(callback)` - -Scopes allow you to dispose multiple effects at once. The `effectScope` function creates an effect with no dependencies, and effects created inside the callback clean up when the effect scope disposes. - -```luau -local getCounter, setCounter = signal(0) - -local dispose = effectScope(function() - effect(function() - print(`Count 1 is {getCounter()}`) - end) - effect(function() - print(`Count 2 is {getCounter()}`) - end) -end) - -setCounter(1) -- Count 1 is 1, Count 2 is 1 -dispose() -setCounter(2) -- No output; effects got disposed -``` - -Similar to `effect()`, the callback can return a cleanup function that runs when the effect scope is disposed. - ---- - -### `listen(getter, callback)` - -The `listen` function creates an effect that only subscribes to the signals accessed by `getter`. Signals accessed by the callback will not be subscribed to, avoiding accidental subscriptions when you want to run side effects. - -The callback also receives the previous value, or `nil` when running for the first time. - -```luau -local getCounter, setCounter = signal(0) - --- Output: Count is 0 (was nil) -listen(getCounter, function(count, prevCount) - print(`Count is {count} (was {prevCount})`) -end) - -setCounter(1) -- Count is 1 (was 0) -``` - -### Getter functions - -Reaction APIs like `listen()`, `subscribe()`, and `observe()` aren't only restricted to signals. You can listen to "getter" functions that call one or more signals, and they will automatically be memoized: - -```luau -local getCounter, setCounter = signal(0) - -local function floorCounter() - return math.floor(getCounter()) -end - --- Output: Floor of count is 0 (was nil) -listen(floorCounter, function(count, prevCount) - print(`Floor of count is {count} (was {prevCount})`) -end) - -setCounter(0.5) -- Doesn't print anything, floor is still 0 -setCounter(1) -- Floor of count is 1 (was 0) -``` - -Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed. - ---- - -### `subscribe(getter, callback)` - -The `subscribe` function is identical to `listen()`, but the effect callback skips the initial run. The callback only runs when the value returned by the getter function changes. - -```luau -local getCounter, setCounter = signal(0) - --- Does not output anything initially -subscribe(getCounter, function(count, prevCount) - print(`Count is {count} (was {prevCount})`) -end) - -setCounter(1) -- Count is 1 (was 0) -``` - ---- - -### `observe(getter, callback)` - -[Observers](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) allow you to track the lifetime of a given state. The `observe` function executes the callback for every unique key added to a table, and disposes the callback when that key is removed. - -```luau -local getItems, setItems = signal({ a = 0, b = 0 }) - --- Added a, Added b -observe(getItems, function(value, key) - print(`Added {key}`) - return function() - print(`Removed {key}`) - end -end) - -setItems({ a = 0, c = 0 }) -- Removed b, Added c -``` - -The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: - -```luau -local getItems, setItems = signal({ a = 0, b = 0 }) - -local dispose = observe(getItems, function(value, key) - local getValue = computed(function(prevValue) - return getItems()[key] or prevValue - end) - - effect(function() - local value = getValue() - print(`{key} = {value}`) - return function() - print(`Cleanup {key} = {value}`) - end - end) -end) - -setItems({ a = 1, b = 0 }) -- Cleanup a = 0, a = 1 -setItems({ a = 1 }) -- Cleanup b = 0 -dispose() -- Cleanup a = 1 -``` - ---- - ### `mapped(getter, mapper)` The `mapped` function iterates over every key in a table and uses the mapper to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value. @@ -579,6 +540,45 @@ dispose() -- Cleaned up --- +### `reactive(initialValue)` + +By default, Charm's signals are _shallowly reactive_, meaning only the value itself is reactive, and table properties are not tracked. Signal values must also be **immutable** in order to track changes in tables. + +You can opt-in to deep reactivity with **reactive proxies**. The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy. + +```luau +local users, setUsers = reactive({ + { name = "John", surname = "Doe" }, +}) + +effect(function() + print(`{users[1].name} {users[1].surname}`) +end) -- Output: John Doe + +setUsers(function(state) + state[1].name = "Jane" + state[1].surname = "Smith" + table.insert(state, { name = "Steve", surname = "Doe" }) +end) -- Output: Jane Smith + +-- You can also mutate the reactive proxy directly: +users[1].name = "John" -- John Smith +``` + +> [!WARNING] +> Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value. + +If you need to access the raw table through the reactive proxy, use the `toRaw()` function: + +```luau +local raw = {} +local proxy = reactive(raw) + +print(toRaw(proxy) == raw) -- true +``` + +--- + ### `atom(initialValue, equals?)` The `atom` function creates a new reactive signal and returns a single function that acts as both a getter and setter. From 5aee237f112d28642fe4c79389a985fef0460cbc Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:46:30 -0800 Subject: [PATCH 106/160] docs: move listen nesting note --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bc304b5..8824d86 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,8 @@ end) setCounter(1) -- Count is 1 (was 0) ``` +Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed. + ### Getter functions Reaction APIs like `listen()`, `subscribe()`, and `observe()` aren't only restricted to signals. You can listen to "getter" functions that call one or more signals, and they will automatically be memoized: @@ -352,8 +354,6 @@ setCounter(0.5) -- Doesn't print anything, floor is still 0 setCounter(1) -- Floor of count is 1 (was 0) ``` -Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed. - --- ### `observe(getter, callback)` From 6cfdd46a81e4d3bb808482dff4b65f3cf4f4cbd1 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:46:37 -0800 Subject: [PATCH 107/160] build: fix react peer dep for ci --- package.json | 4 +- packages/react-charm/package.json | 2 +- pnpm-lock.yaml | 139 +++++++++++++++--------------- 3 files changed, 74 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index daa0afa..07b26da 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,13 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.30.0", + "packageManager": "pnpm@10.30.1", "devDependencies": { "@eslint/js": "^9.39.2", "@rbxts/compiler-types": "3.0.0-types.0", "@rbxts/react": "17.3.7-ts.1", "@rbxts/react-roblox": "17.3.7-ts.1", - "@rbxts/types": "^1.0.907", + "@rbxts/types": "^1.0.908", "@rbxts/vide": "^0.6.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index 5d94e87..ae62f9e 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -25,6 +25,6 @@ "@rbxts/charm": "workspace:^" }, "peerDependencies": { - "@rbxts/react": "*" + "@rbxts/react": ">=17.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfc8e70..eee1cd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,29 +21,29 @@ importers: specifier: 17.3.7-ts.1 version: 17.3.7-ts.1(@rbxts/react@17.3.7-ts.1) '@rbxts/types': - specifier: ^1.0.907 - version: 1.0.907 + specifier: ^1.0.908 + version: 1.0.908 '@rbxts/vide': specifier: ^0.6.0 version: 0.6.0 '@typescript-eslint/eslint-plugin': specifier: ^8.56.0 - version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^8.56.0 - version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(eslint@9.39.2)(typescript@5.9.3) eslint: specifier: ^9.39.2 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + version: 10.1.8(eslint@9.39.2) eslint-plugin-prettier: specifier: ^5.5.5 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2))(eslint@9.39.2)(prettier@3.8.1) eslint-plugin-roblox-ts: specifier: ^1.3.1 - version: 1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) prettier: specifier: ^3.8.1 version: 3.8.1 @@ -55,7 +55,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.56.0 - version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(eslint@9.39.2)(typescript@5.9.3) packages/charm: {} @@ -71,8 +71,8 @@ importers: specifier: workspace:^ version: link:../charm '@rbxts/react': - specifier: '*' - version: 17.3.7-ts.1 + specifier: ^17.0.0 + version: 17.2.3 packages/vide-charm: dependencies: @@ -220,11 +220,17 @@ packages: peerDependencies: '@rbxts/react': ^17.3.7-ts.1 + '@rbxts/react-vendor@17.2.3': + resolution: {integrity: sha512-dWCiVelvTK6h920geaFH/OQ5zoWHyhyqyfH51hKx2/iVZ6OiiyNVJ5UqEIkJMG4HsJ8JnkTORizV3+MtQ41BtA==} + + '@rbxts/react@17.2.3': + resolution: {integrity: sha512-IoOB8e2W8MxT0MKWFf0Ls0vT61ImYL6ctHQgz5+oazma6z8bqFtJjgPxOuy6jmMSuyfBe7rYp/muou0kIjZT+Q==} + '@rbxts/react@17.3.7-ts.1': resolution: {integrity: sha512-/jqZ2TvyLSAPwC30h95loUmy4XN2PwPhv6fBARJKtZfyozFx2RuxC9FiQSjpEZ2uOOPzr60It0MBUf94D+tsMg==} - '@rbxts/types@1.0.907': - resolution: {integrity: sha512-DZv8I1dDp5EY/o6Kt+e/dPUEVfxrTzudgr9vpC5Qn5Yhab/hfd4+vEh9i55ORko/yPhaAXDKEPKlpmuUYnAj3g==} + '@rbxts/types@1.0.908': + resolution: {integrity: sha512-b2nVXH4g/roLmOxVO3WYV2BdGaHnmX8BiGECwhuCmIClA3Do6t123/vhGmytiVhctHGUXD6VWMC1tX5rPlTGgQ==} '@rbxts/vide@0.6.0': resolution: {integrity: sha512-OLfs4kqV2IP79xS+gZAprMujuYEc+n0JwMhJF+puNVoxBXSzOv2kKlgeLJhscCy7Jdhr9TbqsaRNLJP5akbfog==} @@ -355,8 +361,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true @@ -657,10 +663,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -920,9 +922,9 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1089,11 +1091,17 @@ snapshots: '@rbxts-js/react-roblox': 17.3.7-ts.1 '@rbxts/react': 17.3.7-ts.1 + '@rbxts/react-vendor@17.2.3': {} + + '@rbxts/react@17.2.3': + dependencies: + '@rbxts/react-vendor': 17.2.3 + '@rbxts/react@17.3.7-ts.1': dependencies: '@rbxts-js/react': 17.3.7-ts.1 - '@rbxts/types@1.0.907': {} + '@rbxts/types@1.0.908': {} '@rbxts/vide@0.6.0': {} @@ -1115,15 +1123,15 @@ snapshots: '@types/json-schema@7.0.15': {} - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -1131,14 +1139,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1179,25 +1187,25 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.46.4(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.46.4 '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.2)(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -1238,24 +1246,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.46.4(eslint@9.39.2)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) '@typescript-eslint/scope-manager': 8.46.4 '@typescript-eslint/types': 8.46.4 '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@9.39.2)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1270,11 +1278,11 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.0 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn@8.15.0: {} + acorn@8.16.0: {} ajv@6.12.6: dependencies: @@ -1371,26 +1379,26 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.2): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2 - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2))(eslint@9.39.2)(prettier@3.8.1): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2 prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-config-prettier: 10.1.8(eslint@9.39.2) - eslint-plugin-roblox-ts@1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-roblox-ts@1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3): dependencies: '@roblox-ts/luau-ast': 2.0.1 - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.2)(typescript@5.9.3) + eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1406,9 +1414,9 @@ snapshots: eslint-visitor-keys@5.0.0: {} - eslint@9.39.2(jiti@2.6.1): + eslint@9.39.2: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -1442,15 +1450,13 @@ snapshots: minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 transitivePeerDependencies: - supports-color espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 esquery@1.7.0: @@ -1573,9 +1579,6 @@ snapshots: isexe@2.0.0: {} - jiti@2.6.1: - optional: true - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -1756,13 +1759,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.56.0(eslint@9.39.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2)(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color From d58d6ffd0babb2c1446560043e4e7c9ed3a3edd1 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:47:28 -0800 Subject: [PATCH 108/160] chore: update lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eee1cd8..b8c7bee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,7 +71,7 @@ importers: specifier: workspace:^ version: link:../charm '@rbxts/react': - specifier: ^17.0.0 + specifier: '>=17.0.0' version: 17.2.3 packages/vide-charm: From 69d47d580cf2ae143f2c6936c6e1bbe816ed81cf Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:55:11 -0800 Subject: [PATCH 109/160] ci: use javascript eslint config --- eslint.config.js | 20 ++++++++++++++++++++ eslint.config.ts | 20 -------------------- 2 files changed, 20 insertions(+), 20 deletions(-) create mode 100644 eslint.config.js delete mode 100644 eslint.config.ts diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..56b3b6d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,20 @@ +const eslint = require("@eslint/js"); +const { defineConfig, globalIgnores } = require("eslint/config"); +const prettierRecommended = require("eslint-plugin-prettier/recommended"); +const roblox = require("eslint-plugin-roblox-ts"); +const tseslint = require("typescript-eslint"); + +module.exports = defineConfig( + eslint.configs.recommended, + tseslint.configs.recommended, + roblox.configs.tsRecommendedCompat, + roblox.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + "roblox-ts/no-any": "off", + }, + }, + globalIgnores(["out/**", "config.js"]), + prettierRecommended, +); diff --git a/eslint.config.ts b/eslint.config.ts deleted file mode 100644 index 21ece76..0000000 --- a/eslint.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import eslint from "@eslint/js"; -import { defineConfig, globalIgnores } from "eslint/config"; -import prettierRecommended from "eslint-plugin-prettier/recommended"; -import roblox from "eslint-plugin-roblox-ts"; -import tseslint from "typescript-eslint"; - -export default defineConfig( - eslint.configs.recommended, - tseslint.configs.recommended, - roblox.configs.tsRecommendedCompat, - roblox.configs.recommended, - { - rules: { - "@typescript-eslint/no-explicit-any": "off", - "roblox-ts/no-any": "off", - }, - }, - globalIgnores(["out/**"]), - prettierRecommended, -); From 0d1e85754aef5acb300c4a62a6ffe952fb068df5 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:15:05 -0800 Subject: [PATCH 110/160] ci: ignore eslint config file --- eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 56b3b6d..bfccfb4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,6 @@ module.exports = defineConfig( "roblox-ts/no-any": "off", }, }, - globalIgnores(["out/**", "config.js"]), + globalIgnores(["out/**", "eslint.config.js"]), prettierRecommended, ); From 10521d05c27ff053538fcb8b054294f08a07d9dd Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:31:40 -0800 Subject: [PATCH 111/160] refactor: move test directories --- .lute/build.luau | 4 ++-- default.project.json | 6 +++++- .../charm-sync/{src => }/__tests__/client.test.luau | 6 +++--- packages/charm-sync/{src => }/__tests__/e2e.test.luau | 10 +++++----- packages/charm-sync/{src => }/__tests__/init.luau | 2 +- .../charm-sync/{src => }/__tests__/patch.test.luau | 6 +++--- .../charm-sync/{src => }/__tests__/server.test.luau | 10 +++++----- .../charm-sync/{src => }/__tests__/utils.test.luau | 6 +++--- packages/charm-sync/wally.toml | 2 +- packages/charm/{src => }/__tests__/computed.test.luau | 4 ++-- packages/charm/{src => }/__tests__/effect.test.luau | 4 ++-- .../charm/{src => }/__tests__/effectScope.test.luau | 4 ++-- packages/charm/{src => }/__tests__/gc.test.luau | 4 ++-- packages/charm/{src => }/__tests__/init.luau | 2 +- packages/charm/{src => }/__tests__/listen.test.luau | 4 ++-- packages/charm/{src => }/__tests__/mapped.test.luau | 4 ++-- packages/charm/{src => }/__tests__/observe.test.luau | 4 ++-- packages/charm/{src => }/__tests__/peek.test.luau | 4 ++-- packages/charm/{src => }/__tests__/reactive.test.luau | 4 ++-- packages/charm/{src => }/__tests__/signal.test.luau | 4 ++-- packages/charm/{src => }/__tests__/subscribe.test.luau | 4 ++-- packages/charm/{src => }/__tests__/topology.test.luau | 4 ++-- packages/charm/{src => }/__tests__/trigger.test.luau | 4 ++-- packages/charm/{src => }/__tests__/untracked.test.luau | 4 ++-- packages/charm/wally.toml | 2 +- .../{src => }/__tests__/helpers/renderHook.luau | 4 ++-- packages/react-charm/{src => }/__tests__/init.luau | 2 +- .../{src => }/__tests__/useSignalBinding.test.luau | 6 +++--- .../{src => }/__tests__/useSignalState.test.luau | 6 +++--- packages/react-charm/wally.toml | 2 +- packages/vide-charm/{src => }/__tests__/init.luau | 2 +- .../{src => }/__tests__/useSignalState.test.luau | 8 ++++---- packages/vide-charm/wally.toml | 2 +- rokit.toml | 6 +++--- tests/run.server.luau | 8 ++++---- 35 files changed, 81 insertions(+), 77 deletions(-) rename packages/charm-sync/{src => }/__tests__/client.test.luau (96%) rename packages/charm-sync/{src => }/__tests__/e2e.test.luau (92%) rename packages/charm-sync/{src => }/__tests__/init.luau (89%) rename packages/charm-sync/{src => }/__tests__/patch.test.luau (97%) rename packages/charm-sync/{src => }/__tests__/server.test.luau (98%) rename packages/charm-sync/{src => }/__tests__/utils.test.luau (82%) rename packages/charm/{src => }/__tests__/computed.test.luau (98%) rename packages/charm/{src => }/__tests__/effect.test.luau (99%) rename packages/charm/{src => }/__tests__/effectScope.test.luau (95%) rename packages/charm/{src => }/__tests__/gc.test.luau (97%) rename packages/charm/{src => }/__tests__/init.luau (87%) rename packages/charm/{src => }/__tests__/listen.test.luau (97%) rename packages/charm/{src => }/__tests__/mapped.test.luau (97%) rename packages/charm/{src => }/__tests__/observe.test.luau (98%) rename packages/charm/{src => }/__tests__/peek.test.luau (97%) rename packages/charm/{src => }/__tests__/reactive.test.luau (98%) rename packages/charm/{src => }/__tests__/signal.test.luau (93%) rename packages/charm/{src => }/__tests__/subscribe.test.luau (97%) rename packages/charm/{src => }/__tests__/topology.test.luau (99%) rename packages/charm/{src => }/__tests__/trigger.test.luau (96%) rename packages/charm/{src => }/__tests__/untracked.test.luau (97%) rename packages/react-charm/{src => }/__tests__/helpers/renderHook.luau (88%) rename packages/react-charm/{src => }/__tests__/init.luau (83%) rename packages/react-charm/{src => }/__tests__/useSignalBinding.test.luau (91%) rename packages/react-charm/{src => }/__tests__/useSignalState.test.luau (90%) rename packages/vide-charm/{src => }/__tests__/init.luau (69%) rename packages/vide-charm/{src => }/__tests__/useSignalState.test.luau (91%) diff --git a/.lute/build.luau b/.lute/build.luau index 29ef585..f1b5876 100644 --- a/.lute/build.luau +++ b/.lute/build.luau @@ -1,6 +1,6 @@ local fs = require("@std/fs") local process = require("@lute/process") -fs.createdirectory("build") +fs.createdirectory("build", { makeparents = true }) -process.run({ "rojo", "build", "--output=build/charm.rbxm" }, { stdio = "inherit" }) +process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" }) diff --git a/default.project.json b/default.project.json index e59761d..b9c6147 100644 --- a/default.project.json +++ b/default.project.json @@ -19,16 +19,19 @@ }, "charm": { "$className": "Folder", - "src": { "$path": "packages/charm/src" } + "src": { "$path": "packages/charm/src" }, + "__tests__": { "$path": "packages/charm/__tests__" } }, "charm-sync": { "$className": "Folder", "src": { "$path": "packages/charm-sync/src" }, + "__tests__": { "$path": "packages/charm-sync/__tests__" }, "Charm": { "$path": "packages/charm-sync/Charm.luau" } }, "react-charm": { "$className": "Folder", "src": { "$path": "packages/react-charm/src" }, + "__tests__": { "$path": "packages/react-charm/__tests__" }, "React": { "$path": "packages/react-charm/React.luau" }, "ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" }, "Charm": { "$path": "packages/react-charm/Charm.luau" } @@ -36,6 +39,7 @@ "vide-charm": { "$className": "Folder", "src": { "$path": "packages/vide-charm/src" }, + "__tests__": { "$path": "packages/vide-charm/__tests__" }, "Vide": { "$path": "packages/vide-charm/Vide.luau" }, "Charm": { "$path": "packages/vide-charm/Charm.luau" } } diff --git a/packages/charm-sync/src/__tests__/client.test.luau b/packages/charm-sync/__tests__/client.test.luau similarity index 96% rename from packages/charm-sync/src/__tests__/client.test.luau rename to packages/charm-sync/__tests__/client.test.luau index 3e16d9e..6cda992 100644 --- a/packages/charm-sync/src/__tests__/client.test.luau +++ b/packages/charm-sync/__tests__/client.test.luau @@ -1,6 +1,6 @@ -local Charm = require("../../Charm") -local client = require("../client") -local test = require("../../../../tests/test") +local Charm = require("../Charm") +local client = require("../src/client") +local test = require("../../../tests/test") test.afterEach(function() client.removeAllSignals() diff --git a/packages/charm-sync/src/__tests__/e2e.test.luau b/packages/charm-sync/__tests__/e2e.test.luau similarity index 92% rename from packages/charm-sync/src/__tests__/e2e.test.luau rename to packages/charm-sync/__tests__/e2e.test.luau index bbc4720..36227b2 100644 --- a/packages/charm-sync/src/__tests__/e2e.test.luau +++ b/packages/charm-sync/__tests__/e2e.test.luau @@ -1,8 +1,8 @@ -local Charm = require("../../Charm") -local client = require("../client") -local config = require("../config") -local server = require("../server") -local test = require("../../../../tests/test") +local Charm = require("../Charm") +local client = require("../src/client") +local config = require("../src/config") +local server = require("../src/server") +local test = require("../../../tests/test") test.afterEach(function() server.reset() diff --git a/packages/charm-sync/src/__tests__/init.luau b/packages/charm-sync/__tests__/init.luau similarity index 89% rename from packages/charm-sync/src/__tests__/init.luau rename to packages/charm-sync/__tests__/init.luau index 6f2f540..111dc1d 100644 --- a/packages/charm-sync/src/__tests__/init.luau +++ b/packages/charm-sync/__tests__/init.luau @@ -1,4 +1,4 @@ -local test = require("../../../tests/test") +local test = require("../../tests/test") test.describe("client", function() require("@self/client.test") diff --git a/packages/charm-sync/src/__tests__/patch.test.luau b/packages/charm-sync/__tests__/patch.test.luau similarity index 97% rename from packages/charm-sync/src/__tests__/patch.test.luau rename to packages/charm-sync/__tests__/patch.test.luau index bb86bdb..68d0483 100644 --- a/packages/charm-sync/src/__tests__/patch.test.luau +++ b/packages/charm-sync/__tests__/patch.test.luau @@ -1,6 +1,6 @@ -local config = require("../config") -local patch = require("../patch") -local test = require("../../../../tests/test") +local config = require("../src/config") +local patch = require("../src/patch") +local test = require("../../../tests/test") local function mockArraySerdes(object: {}): {} if object[1] then diff --git a/packages/charm-sync/src/__tests__/server.test.luau b/packages/charm-sync/__tests__/server.test.luau similarity index 98% rename from packages/charm-sync/src/__tests__/server.test.luau rename to packages/charm-sync/__tests__/server.test.luau index 0a99b09..559c4ce 100644 --- a/packages/charm-sync/src/__tests__/server.test.luau +++ b/packages/charm-sync/__tests__/server.test.luau @@ -1,8 +1,8 @@ -local Charm = require("../../Charm") -local config = require("../config") -local patch = require("../patch") -local server = require("../server") -local test = require("../../../../tests/test") +local Charm = require("../Charm") +local config = require("../src/config") +local patch = require("../src/patch") +local server = require("../src/server") +local test = require("../../../tests/test") test.afterEach(function() server.reset() diff --git a/packages/charm-sync/src/__tests__/utils.test.luau b/packages/charm-sync/__tests__/utils.test.luau similarity index 82% rename from packages/charm-sync/src/__tests__/utils.test.luau rename to packages/charm-sync/__tests__/utils.test.luau index f3a37ff..14c9734 100644 --- a/packages/charm-sync/src/__tests__/utils.test.luau +++ b/packages/charm-sync/__tests__/utils.test.luau @@ -1,6 +1,6 @@ -local Charm = require("../../Charm") -local test = require("../../../../tests/test") -local utils = require("../utils") +local Charm = require("../Charm") +local test = require("../../../tests/test") +local utils = require("../src/utils") test("should flatten functions", function() local input = { diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 73bbe5a..c266da8 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ "node_modules", - "src/__tests__", + "__tests__", "src/index.d.ts", "package.json", "Charm.luau", diff --git a/packages/charm/src/__tests__/computed.test.luau b/packages/charm/__tests__/computed.test.luau similarity index 98% rename from packages/charm/src/__tests__/computed.test.luau rename to packages/charm/__tests__/computed.test.luau index 4b11f0c..59b8530 100644 --- a/packages/charm/src/__tests__/computed.test.luau +++ b/packages/charm/__tests__/computed.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local atom = Charm.atom local computed = Charm.computed diff --git a/packages/charm/src/__tests__/effect.test.luau b/packages/charm/__tests__/effect.test.luau similarity index 99% rename from packages/charm/src/__tests__/effect.test.luau rename to packages/charm/__tests__/effect.test.luau index 79ef57f..c6a18f2 100644 --- a/packages/charm/src/__tests__/effect.test.luau +++ b/packages/charm/__tests__/effect.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local computed = Charm.computed local effect = Charm.effect diff --git a/packages/charm/src/__tests__/effectScope.test.luau b/packages/charm/__tests__/effectScope.test.luau similarity index 95% rename from packages/charm/src/__tests__/effectScope.test.luau rename to packages/charm/__tests__/effectScope.test.luau index 3367f79..932ebc1 100644 --- a/packages/charm/src/__tests__/effectScope.test.luau +++ b/packages/charm/__tests__/effectScope.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local effect = Charm.effect local effectScope = Charm.effectScope diff --git a/packages/charm/src/__tests__/gc.test.luau b/packages/charm/__tests__/gc.test.luau similarity index 97% rename from packages/charm/src/__tests__/gc.test.luau rename to packages/charm/__tests__/gc.test.luau index b0517b1..a03acac 100644 --- a/packages/charm/src/__tests__/gc.test.luau +++ b/packages/charm/__tests__/gc.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") type ReactiveProxy = Charm.ReactiveProxy diff --git a/packages/charm/src/__tests__/init.luau b/packages/charm/__tests__/init.luau similarity index 87% rename from packages/charm/src/__tests__/init.luau rename to packages/charm/__tests__/init.luau index 90d8983..6c443b0 100644 --- a/packages/charm/src/__tests__/init.luau +++ b/packages/charm/__tests__/init.luau @@ -1,4 +1,4 @@ -local test = require("../../../tests/test") +local test = require("../../tests/test") local tests = { "computed", diff --git a/packages/charm/src/__tests__/listen.test.luau b/packages/charm/__tests__/listen.test.luau similarity index 97% rename from packages/charm/src/__tests__/listen.test.luau rename to packages/charm/__tests__/listen.test.luau index fc5626e..6fa0912 100644 --- a/packages/charm/src/__tests__/listen.test.luau +++ b/packages/charm/__tests__/listen.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local signal = Charm.signal local listen = Charm.listen diff --git a/packages/charm/src/__tests__/mapped.test.luau b/packages/charm/__tests__/mapped.test.luau similarity index 97% rename from packages/charm/src/__tests__/mapped.test.luau rename to packages/charm/__tests__/mapped.test.luau index dea2867..6792469 100644 --- a/packages/charm/src/__tests__/mapped.test.luau +++ b/packages/charm/__tests__/mapped.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local signal = Charm.signal local mapped = Charm.mapped diff --git a/packages/charm/src/__tests__/observe.test.luau b/packages/charm/__tests__/observe.test.luau similarity index 98% rename from packages/charm/src/__tests__/observe.test.luau rename to packages/charm/__tests__/observe.test.luau index 00386c0..86efdc3 100644 --- a/packages/charm/src/__tests__/observe.test.luau +++ b/packages/charm/__tests__/observe.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local signal = Charm.signal local computed = Charm.computed diff --git a/packages/charm/src/__tests__/peek.test.luau b/packages/charm/__tests__/peek.test.luau similarity index 97% rename from packages/charm/src/__tests__/peek.test.luau rename to packages/charm/__tests__/peek.test.luau index 8699c7a..3fb1823 100644 --- a/packages/charm/src/__tests__/peek.test.luau +++ b/packages/charm/__tests__/peek.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local signal = Charm.signal local computed = Charm.computed diff --git a/packages/charm/src/__tests__/reactive.test.luau b/packages/charm/__tests__/reactive.test.luau similarity index 98% rename from packages/charm/src/__tests__/reactive.test.luau rename to packages/charm/__tests__/reactive.test.luau index 3e5b221..82b37c4 100644 --- a/packages/charm/src/__tests__/reactive.test.luau +++ b/packages/charm/__tests__/reactive.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local effect = Charm.effect local reactive = Charm.reactive diff --git a/packages/charm/src/__tests__/signal.test.luau b/packages/charm/__tests__/signal.test.luau similarity index 93% rename from packages/charm/src/__tests__/signal.test.luau rename to packages/charm/__tests__/signal.test.luau index 32ff5cd..ebc8e51 100644 --- a/packages/charm/src/__tests__/signal.test.luau +++ b/packages/charm/__tests__/signal.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local signal = Charm.signal local effect = Charm.effect diff --git a/packages/charm/src/__tests__/subscribe.test.luau b/packages/charm/__tests__/subscribe.test.luau similarity index 97% rename from packages/charm/src/__tests__/subscribe.test.luau rename to packages/charm/__tests__/subscribe.test.luau index c1bce81..9c02fd0 100644 --- a/packages/charm/src/__tests__/subscribe.test.luau +++ b/packages/charm/__tests__/subscribe.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local signal = Charm.signal local computed = Charm.computed diff --git a/packages/charm/src/__tests__/topology.test.luau b/packages/charm/__tests__/topology.test.luau similarity index 99% rename from packages/charm/src/__tests__/topology.test.luau rename to packages/charm/__tests__/topology.test.luau index 95e20a5..06ee3c2 100644 --- a/packages/charm/src/__tests__/topology.test.luau +++ b/packages/charm/__tests__/topology.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local atom = Charm.atom local computed = Charm.computed diff --git a/packages/charm/src/__tests__/trigger.test.luau b/packages/charm/__tests__/trigger.test.luau similarity index 96% rename from packages/charm/src/__tests__/trigger.test.luau rename to packages/charm/__tests__/trigger.test.luau index eb73ed9..c548a86 100644 --- a/packages/charm/src/__tests__/trigger.test.luau +++ b/packages/charm/__tests__/trigger.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local computed = Charm.computed local effect = Charm.effect diff --git a/packages/charm/src/__tests__/untracked.test.luau b/packages/charm/__tests__/untracked.test.luau similarity index 97% rename from packages/charm/src/__tests__/untracked.test.luau rename to packages/charm/__tests__/untracked.test.luau index 05ff894..cd75c63 100644 --- a/packages/charm/src/__tests__/untracked.test.luau +++ b/packages/charm/__tests__/untracked.test.luau @@ -1,5 +1,5 @@ -local Charm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../src") +local test = require("../../../tests/test") local signal = Charm.signal local computed = Charm.computed diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 482bcf6..5bb8611 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -3,6 +3,6 @@ name = "littensy/charm" version = "0.11.0-rc.3" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] +exclude = ["node_modules", "__tests__", "src/index.d.ts", "package.json"] [dependencies] diff --git a/packages/react-charm/src/__tests__/helpers/renderHook.luau b/packages/react-charm/__tests__/helpers/renderHook.luau similarity index 88% rename from packages/react-charm/src/__tests__/helpers/renderHook.luau rename to packages/react-charm/__tests__/helpers/renderHook.luau index 4cbeb12..a059fb2 100644 --- a/packages/react-charm/src/__tests__/helpers/renderHook.luau +++ b/packages/react-charm/__tests__/helpers/renderHook.luau @@ -1,5 +1,5 @@ -local React = require("../../../React") -local ReactRoblox = require("../../../ReactRoblox") +local React = require("../../React") +local ReactRoblox = require("../../ReactRoblox") type RenderedHook = { result: Result, diff --git a/packages/react-charm/src/__tests__/init.luau b/packages/react-charm/__tests__/init.luau similarity index 83% rename from packages/react-charm/src/__tests__/init.luau rename to packages/react-charm/__tests__/init.luau index b3b5f51..25679e5 100644 --- a/packages/react-charm/src/__tests__/init.luau +++ b/packages/react-charm/__tests__/init.luau @@ -1,4 +1,4 @@ -local test = require("../../../tests/test") +local test = require("../../tests/test") _G.__ROACT_17_MOCK_SCHEDULER__ = true diff --git a/packages/react-charm/src/__tests__/useSignalBinding.test.luau b/packages/react-charm/__tests__/useSignalBinding.test.luau similarity index 91% rename from packages/react-charm/src/__tests__/useSignalBinding.test.luau rename to packages/react-charm/__tests__/useSignalBinding.test.luau index 3d4e856..8b721ed 100644 --- a/packages/react-charm/src/__tests__/useSignalBinding.test.luau +++ b/packages/react-charm/__tests__/useSignalBinding.test.luau @@ -1,7 +1,7 @@ -local Charm = require("../../Charm") -local ReactCharm = require("../") +local Charm = require("../Charm") +local ReactCharm = require("../src") local renderHook = require("./helpers/renderHook") -local test = require("../../../../tests/test") +local test = require("../../../tests/test") test("tracks signal state", function() local getState, setState = Charm.signal(0) diff --git a/packages/react-charm/src/__tests__/useSignalState.test.luau b/packages/react-charm/__tests__/useSignalState.test.luau similarity index 90% rename from packages/react-charm/src/__tests__/useSignalState.test.luau rename to packages/react-charm/__tests__/useSignalState.test.luau index bb28579..9618577 100644 --- a/packages/react-charm/src/__tests__/useSignalState.test.luau +++ b/packages/react-charm/__tests__/useSignalState.test.luau @@ -1,7 +1,7 @@ -local Charm = require("../../Charm") -local ReactCharm = require("../") +local Charm = require("../Charm") +local ReactCharm = require("../src") local renderHook = require("./helpers/renderHook") -local test = require("../../../../tests/test") +local test = require("../../../tests/test") test("tracks signal state", function() local getState, setState = Charm.signal(0) diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 11d42ce..d04375a 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ "node_modules", - "src/__tests__", + "__tests__", "src/index.d.ts", "package.json", "React.luau", diff --git a/packages/vide-charm/src/__tests__/init.luau b/packages/vide-charm/__tests__/init.luau similarity index 69% rename from packages/vide-charm/src/__tests__/init.luau rename to packages/vide-charm/__tests__/init.luau index ae62c24..7b99c24 100644 --- a/packages/vide-charm/src/__tests__/init.luau +++ b/packages/vide-charm/__tests__/init.luau @@ -1,4 +1,4 @@ -local test = require("../../../tests/test") +local test = require("../../tests/test") test.describe("useSignalState", function() require("@self/useSignalState.test") diff --git a/packages/vide-charm/src/__tests__/useSignalState.test.luau b/packages/vide-charm/__tests__/useSignalState.test.luau similarity index 91% rename from packages/vide-charm/src/__tests__/useSignalState.test.luau rename to packages/vide-charm/__tests__/useSignalState.test.luau index 4fd624e..3bfc74f 100644 --- a/packages/vide-charm/src/__tests__/useSignalState.test.luau +++ b/packages/vide-charm/__tests__/useSignalState.test.luau @@ -1,7 +1,7 @@ -local Charm = require("../../Charm") -local Vide = require("../../Vide") -local VideCharm = require("../") -local test = require("../../../../tests/test") +local Charm = require("../Charm") +local Vide = require("../Vide") +local VideCharm = require("../src") +local test = require("../../../tests/test") test("tracks signal state", function() local getState, setState = Charm.signal(0) diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 576442e..3b9e5fe 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ "node_modules", - "src/__tests__", + "__tests__", "src/index.d.ts", "package.json", "Vide.luau", diff --git a/rokit.toml b/rokit.toml index 3393915..331e403 100644 --- a/rokit.toml +++ b/rokit.toml @@ -2,6 +2,6 @@ wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.6.1" stylua = "johnnymorganz/stylua@2.3.1" -selene = "Kampfkarren/selene@0.29.0" -luau-lsp = "johnnymorganz/luau-lsp@1.60.0" -lute = "luau-lang/lute@0.1.0-nightly.20260112" +selene = "Kampfkarren/selene@0.30.0" +luau-lsp = "johnnymorganz/luau-lsp@1.62.0" +lute = "luau-lang/lute@0.1.0-nightly.20260219" diff --git a/tests/run.server.luau b/tests/run.server.luau index ee943e0..a819a4a 100644 --- a/tests/run.server.luau +++ b/tests/run.server.luau @@ -9,19 +9,19 @@ local describeInRoblox = test.describeIf(function() end) test.describe("charm", function() - require("../packages/charm/src/__tests__") + require("../packages/charm/__tests__") end) test.describe("charm-sync", function() - require("../packages/charm-sync/src/__tests__") + require("../packages/charm-sync/__tests__") end) describeInRoblox("react-charm", function() - require("../packages/react-charm/src/__tests__") + require("../packages/react-charm/__tests__") end) describeInRoblox("vide-charm", function() - require("../packages/vide-charm/src/__tests__") + require("../packages/vide-charm/__tests__") end) test.finish() From 612612b1307bc9b8aebcffe52b7ed991a594f480 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:14:50 -0800 Subject: [PATCH 112/160] feat: allow recursion in effects by default --- README.md | 24 +--- packages/charm/__tests__/computed.test.luau | 7 +- packages/charm/__tests__/effect.test.luau | 41 ++----- packages/charm/src/index.d.ts | 42 +++++-- packages/charm/src/init.luau | 115 ++++++++++---------- tsconfig.json | 1 - 6 files changed, 104 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 8824d86..867b06c 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,6 @@ Charm is a state management library that brings the [fine-grained reactivity](ht - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`reactive(initialValue)`](#reactiveinitialvalue) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - - [`recursive()`](#recursive) - [`trigger(callback)`](#triggercallback) - [`flags`](#flags) - [Client-Server Sync](#client-server-sync) @@ -608,24 +607,6 @@ max(-1) -- 1 --- -### `recursive()` - -By design, Charm's reactive system [does not allow recursion](https://github.com/stackblitz/alien-signals/issues/90#issuecomment-3489711800) in effects or computed signals. In case you need to opt out of recursion checks for an effect, you can call `recursive()` at the top of the effect callback: - -```luau -local getCounter, setCounter = signal(0) - -effect(function() - recursive() - print(getCounter()) - setCounter(function(count) - return math.min(count + 1, 3) - end) -end) -- Output: 0, 1, 2, 3 -``` - ---- - ### `trigger(callback)` The `trigger()` function allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter: @@ -990,10 +971,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that > > This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. -5. Recursion is now disallowed in effects and computed signals by default. This change may introduce bugs in code relying on the old behavior. - - Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback. - -6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! +5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! - [`reactive()`](#reactiveinitialvalue): make mutable tables deeply reactive - [`signal()`](#signalinitialvalue-equals): make reads and writes more explicit - [`listen()`](#listengetter-callback): create a subscription that runs once immediately diff --git a/packages/charm/__tests__/computed.test.luau b/packages/charm/__tests__/computed.test.luau index 59b8530..4590807 100644 --- a/packages/charm/__tests__/computed.test.luau +++ b/packages/charm/__tests__/computed.test.luau @@ -145,17 +145,12 @@ end) -- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528 test("should not override queried MaybeDirty result", function() - local Item = { - new = function() - return { v = atom(0) } - end, - } local v1 = atom(nil :: any) local v2 = atom(false) local c1 = computed(function() local c = v1() if not v1() then - c = Item.new() + c = { v = atom(0) } v1(c) end return c.v() diff --git a/packages/charm/__tests__/effect.test.luau b/packages/charm/__tests__/effect.test.luau index c6a18f2..9b526c4 100644 --- a/packages/charm/__tests__/effect.test.luau +++ b/packages/charm/__tests__/effect.test.luau @@ -6,7 +6,6 @@ local effect = Charm.effect local effectScope = Charm.effectScope local endBatch = Charm.endBatch local onCleanup = Charm.onCleanup -local recursive = Charm.recursive local setActiveSub = Charm.setActiveSub local signal = Charm.signal local startBatch = Charm.startBatch @@ -269,33 +268,11 @@ test("should handle flags are indirectly updated during checkDirty", function() assert(triggers == 2, "did not update indirectly") end) -test("should handle effect recursion for the first execution", function() - local src1, setSrc1 = signal(0) - local src2, setSrc2 = signal(0) - - local triggers1 = 0 - local triggers2 = 0 - - effect(function() - triggers1 += 1 - setSrc1(math.min(src1() + 1, 5)) - end) - effect(function() - triggers2 += 1 - setSrc2(math.min(src2() + 1, 5)) - src2() - end) - - assert(triggers1 == 1, `bad triggers1: {triggers1}`) - assert(triggers2 == 1, `bad triggers2: {triggers2}`) -end) - -test("should opt in to recursion", function() +test("should allow recursion", function() local src, setSrc = signal(0) local order = {} effect(function() - recursive() table.insert(order, src()) setSrc(math.min(src() + 1, 5)) end) @@ -387,15 +364,15 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) -test("should avoid infinite recursive loops with multiple effects", function() - local counter, setCounter = Charm.signal(0) - effect(function() - setCounter(counter() + 1) +test("infinite recursion should eventually throw", function() + local counter, setCounter = signal(0) + + local success = pcall(function() + effect(function() + setCounter(counter() + 1) + end) end) - effect(function() - counter() - end) - setCounter(counter() + 1) + assert(not success, "should have thrown") end) return {} diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 46696e9..e7b3f1d 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -153,10 +153,11 @@ export function trigger(fn: () => void): void; export function onCleanup(fn: Cleanup, failSilently?: boolean): void; /** - * Runs the function without subscribing to signal updates or capturing inner - * effects in the parent effect or scope. + * Runs the function without subscribing to signal updates and prevents the + * current effect or scope from tracking inner effects created within the + * function. * - * For an alternative that still cleans up inner effects, use `peek` instead. + * For an alternative that allows nested effect cleanup, use `peek` instead. * * @param fn A function that may read signals or create effects. * @param args Arguments to pass to the function. @@ -166,10 +167,12 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void; export function untracked(fn: (...args: Args) => Result, ...args: Args): Result; /** - * Runs the function without subscribing to signal updates, but still captures - * inner effects so that they dispose with the parent effect. + * Runs the function without subscribing to signal updates, but still allows + * inner effects to be tracked by parent effects/scopes. * - * To avoid capturing inner effects, use `untracked` instead. + * This is useful for cases where you want to access the current value of a + * signal without creating a dependency, but still want any effects created + * within the function to be cleaned up properly. * * @param fn A function that may read signals. * @param args Arguments to pass to the function. @@ -270,14 +273,15 @@ export function observe( ): Cleanup; /** - * Disables recursive checks for the current effect or computed signal. - * Useful for effects that intentionally update signals they depend on. + * Disables recursive checks for the current reactive node. Currently, only + * computed signals use recursion checks, as effects allow recursion. * * @example * ```ts - * effect(() => { + * computed(() => { * recursive(); * setCount(getCount() + 1); + * return getCount(); * }) * ``` */ @@ -331,10 +335,30 @@ export function isReactive(value: unknown): boolean; */ export function toRaw(value: T): T; +/** + * Returns the currently active subscriber (effect or computed node). + * + * @return Current active subscriber or `nil` + */ export function getActiveSub(): ReactiveNode | undefined; +/** + * Sets the currently active subscriber (effect or computed node) and returns + * the previous subscriber. + * + * @param sub New subscriber to set as active + * @return Previous active subscriber + */ export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; +/** + * Starts a batch operation, incrementing the batch depth counter. Effects + * will not run until the outermost batch ends. + */ export function startBatch(): void; +/** + * Ends a batch operation, decrementing the batch depth counter. If this is + * the outermost batch, queued effects are flushed and run. + */ export function endBatch(): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 0fbacc7..b3c4844 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -9,6 +9,8 @@ export type Link = system.Link export type ReactiveFlags = system.ReactiveFlags export type ReactiveNode = system.ReactiveNode +type Cleanup = () -> () + export type Atom = (...((T) -> T) | T) -> T export type ReactiveProxy = typeof(setmetatable( @@ -21,12 +23,12 @@ export type ReactiveProxy = typeof(setmetatable( )) type EffectScopeNode = ReactiveNode & { - cleanups: { () -> () }, + cleanups: { Cleanup }, } type EffectNode = ReactiveNode & { - fn: () -> ...(() -> ())?, - cleanups: { () -> () }, + fn: () -> ...Cleanup?, + cleanups: { Cleanup }, } type ComputedNode = ReactiveNode & { @@ -39,16 +41,6 @@ type SignalNode = ReactiveNode & { pendingValue: T, } --- Bitmask flags for tracking the state of reactive nodes. Flags are combined --- and checked using bitwise operations for efficiency. -local NONE = 0b0000000 -- Default state -local MUTABLE = 0b0000001 -- Can be changed (signal or computed) -local WATCHING = 0b0000010 -- Watching for changes (effects) -local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies -local DIRTY = 0b0010000 -- Value has changed and needs updating -local PENDING = 0b0100000 -- Might be dirty, pending verification -local PEEKING = 0b1000000 -- Blocking dependency tracking - local function inlineTest(): string return debug.info(1, "n") end @@ -79,6 +71,16 @@ local ReactiveProxy = { proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), } +-- Bitmask flags for tracking the state of reactive nodes. Flags are combined +-- and checked using bitwise operations for efficiency. +local NONE = 0b0000000 -- Default state +local MUTABLE = 0b0000001 -- Can be changed (signal or computed) +local WATCHING = 0b0000010 -- Watching for changes (effects) +local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies +local DIRTY = 0b0010000 -- Value has changed and needs updating +local PENDING = 0b0100000 -- Might be dirty, pending verification +local PEEKING = 0b1000000 -- Blocking dependency tracking + -- Alien Signals reactive system local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () local unlink: (link: Link, sub: ReactiveNode?) -> Link? @@ -187,7 +189,7 @@ end @param cleanups List of cleanup functions ]] -local function runCleanups(cleanups: { () -> () }) +local function runCleanups(cleanups: { Cleanup }) for key, cleanup in cleanups do cleanups[key] = nil local prevSub = setActiveSub(nil) @@ -212,13 +214,12 @@ local function run(effect: EffectNode) cycle += 1 effect.depsTail = nil - effect.flags = bit32.bor(WATCHING, RECURSED_CHECK) + effect.flags = WATCHING local prevSub = setActiveSub(effect) local success, result = pcall(effect.fn) activeSub = prevSub - effect.flags = bit32.band(effect.flags, bit32.bnot(RECURSED_CHECK)) purgeDeps(effect) if not success then @@ -349,10 +350,13 @@ local function computedOper(this: ComputedNode): () -> T elseif flags == NONE then -- First access should initialize the computed value this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + local prevSub = setActiveSub(this) local success, result = pcall(this.getter) activeSub = prevSub + this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) + if success then this.value = result else @@ -393,6 +397,7 @@ local function signalOper( local prevSub = setActiveSub(nil) local success, result = pcall(noYield(update), this.pendingValue) activeSub = prevSub + if success then value = result else @@ -407,8 +412,10 @@ local function signalOper( if flags.frozen then deepFreeze(value) end + this.pendingValue = value this.flags = bit32.bor(MUTABLE, DIRTY) + local subs = this.subs if subs then propagate(subs) @@ -416,6 +423,7 @@ local function signalOper( flush() end end + return value end @@ -462,7 +470,7 @@ end @param this Effect node @return Effect cleanup operator ]] -local function effectOper(this: EffectScopeNode): () -> () +local function effectOper(this: EffectScopeNode): Cleanup return function() this.depsTail = nil this.flags = NONE @@ -511,16 +519,15 @@ local function notify(effect: ReactiveNode) until false queuedLength = insertIndex - insertIndex -= 1 - -- Reverse the collected effects to maintain correct order - -- Note: insertIndex should decrement before the next condition evaluation - while firstInsertedIndex < insertIndex do - local left = queued[firstInsertedIndex] - queued[firstInsertedIndex] = queued[insertIndex] - queued[insertIndex] = left - firstInsertedIndex += 1 + -- Reverse the collected effects to ensure outer effects run first + while true do insertIndex -= 1 + if firstInsertedIndex >= insertIndex then + break + end + queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex] + firstInsertedIndex += 1 end end @@ -615,10 +622,10 @@ end @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback ]] -local function effect(fn: () -> ...(() -> ())?): () -> () +local function effect(fn: () -> ...Cleanup?): Cleanup local this: EffectNode = { fn = noYield(fn), - flags = bit32.bor(WATCHING, RECURSED_CHECK), + flags = WATCHING, cleanups = {}, } @@ -630,8 +637,6 @@ local function effect(fn: () -> ...(() -> ())?): () -> () local success, result = pcall(this.fn) activeSub = prevSub - this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) - if not success then error(result) elseif result then @@ -650,7 +655,7 @@ end @return A cleanup function that stops all effects in the scope @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached ]] -local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () +local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup local this: EffectScopeNode = { flags = NONE, cleanups = {}, @@ -719,7 +724,7 @@ end @param failSilently If true, silences the warning when called outside an effect/scope @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently ]] -local function onCleanup(fn: () -> (), failSilently: boolean?) +local function onCleanup(fn: Cleanup, failSilently: boolean?) if activeSub and (activeSub :: EffectNode).cleanups then table.insert((activeSub :: EffectNode).cleanups, noYield(fn)) elseif not failSilently then @@ -728,10 +733,11 @@ local function onCleanup(fn: () -> (), failSilently: boolean?) end --[[ - Runs the function without subscribing to signal updates or capturing inner - effects in the parent effect or scope. + Runs the function without subscribing to signal updates and prevents the + current effect or scope from tracking inner effects created within the + function. - For an alternative that still cleans up inner effects, use `peek` instead. + For an alternative that allows nested effect cleanup, use `peek` instead. @param fn A signal or function that accesses signals @param ...args Arguments to pass to the function @@ -753,8 +759,12 @@ local function untracked(fn: (Args...) -> Results..., ...: end --[[ - Runs the function without subscribing to signal updates, but still captures - inner effects so that they dispose with the parent effect. + Runs the function without subscribing to signal updates, but still allows + inner effects to be tracked by parent effects/scopes. + + This is useful for cases where you want to access the current value of a + signal without creating a dependency, but still want any effects created + within the function to be cleaned up properly. To avoid capturing inner effects, use `untracked` instead. @@ -815,8 +825,8 @@ end --[[ Creates an effect that runs the callback one immediately, and then again - when the value returned by the getter changes. The callback receives the new - value and the previous value as arguments. + when the value returned by the getter changes. The callback receives the + new value and the previous value as arguments. Effects created within the callback are cleaned up when the listener runs or the listener is disposed. @@ -826,7 +836,7 @@ end @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] -local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () +local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): Cleanup local tracked = toComputed(getter) local current: T @@ -838,9 +848,9 @@ local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): ( end --[[ - Creates an effect that only runs the callback when the value returned by the - getter changes. The callback receives the new value and the previous value - as arguments. + Creates an effect that only runs the callback when the value returned by + the getter changes. The callback receives the new value and the previous + value as arguments. Effects created within the callback are cleaned up when the listener runs or the listener is disposed. @@ -850,7 +860,7 @@ end @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] -local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () +local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): Cleanup local tracked = toComputed(getter) local current: T local firstRun = true @@ -867,14 +877,15 @@ local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()) end --[[ - Disables recursive checks for the current effect or computed signal. Useful - for effects that intentionally update signals they depend on. + Disables recursive checks for the current reactive node. Currently, only + computed signals use recursion checks, as effects allow recursion. @example ```luau - effect(function() + computed(function() recursive() setCount(getCount() + 1) + return getCount() end) ``` ]] @@ -882,7 +893,7 @@ local function recursive() if activeSub then activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK)) else - warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2)) + warn(debug.traceback("recursive() can only be called inside a reactive node.", 2)) end end @@ -900,8 +911,8 @@ end @return A cleanup function that disposes scopes for all keys @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] -local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> () - local innerScopes: { [K]: () -> () } = {} +local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...Cleanup?): Cleanup + local innerScopes: { [K]: Cleanup } = {} local disposed = false local function updateScopes(data: { [K]: V }) @@ -930,9 +941,6 @@ local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> return effectScope(function() effect(function() - -- Allow recursive updates in case the callback creates or removes - -- items in the observed table. - recursive() -- Batch so that side effects run after all scopes are created. -- This ensures that a scope creating or removing items doesn't -- interfere with the current update cycle. @@ -940,9 +948,6 @@ local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> end) return function() - if disposed then - return - end disposed = true for key, dispose in innerScopes do innerScopes[key] = nil diff --git a/tsconfig.json b/tsconfig.json index 6aa47d8..bf99b80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,6 @@ "jsxFragmentFactory": "React.Fragment", "module": "commonjs", "moduleResolution": "Node", - "ignoreDeprecations": "6.0", "noLib": true, "resolveJsonModule": true, "experimentalDecorators": true, From 78dbaa476cfc79cd982201a9c4ee35cd0442d466 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:19:28 -0800 Subject: [PATCH 113/160] release: charm v0.11.0-rc.4 --- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/charm/package.json b/packages/charm/package.json index 56cf80a..feb5e93 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0-rc.3", + "version": "0.11.0-rc.4", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 5bb8611..f1d9bb9 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0-rc.3" +version = "0.11.0-rc.4" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "__tests__", "src/index.d.ts", "package.json"] From d741640d286164974232edc26f9fb136622d3cdf Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:21:57 -0800 Subject: [PATCH 114/160] refactor: remove recursive() --- packages/charm/src/index.d.ts | 15 --------------- packages/charm/src/init.luau | 22 ---------------------- 2 files changed, 37 deletions(-) diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index e7b3f1d..f1941e8 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -272,21 +272,6 @@ export function observe( fn: (value: V, key: K) => Cleanup | void, ): Cleanup; -/** - * Disables recursive checks for the current reactive node. Currently, only - * computed signals use recursion checks, as effects allow recursion. - * - * @example - * ```ts - * computed(() => { - * recursive(); - * setCount(getCount() + 1); - * return getCount(); - * }) - * ``` - */ -export function recursive(): void; - /** * Creates a deep reactive value that tracks properties when they're accessed. * Returns a proxy object that subscribes to changes on any nested property diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index b3c4844..ff30b1f 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -876,27 +876,6 @@ local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()) end) end ---[[ - Disables recursive checks for the current reactive node. Currently, only - computed signals use recursion checks, as effects allow recursion. - - @example - ```luau - computed(function() - recursive() - setCount(getCount() + 1) - return getCount() - end) - ``` -]] -local function recursive() - if activeSub then - activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK)) - else - warn(debug.traceback("recursive() can only be called inside a reactive node.", 2)) - end -end - --[[ Calls the callback function for each unique key in the table returned by the getter. The callback is called once for each key, and again when a new key @@ -1185,7 +1164,6 @@ return { batch = batch, subscribe = subscribe, listen = listen, - recursive = recursive, observe = observe, mapped = mapped :: (( getter: () -> { [KI]: VI }, From 89478f679c7578b64799852ad4c2555408c42bab Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:49:34 -0800 Subject: [PATCH 115/160] refactor: reorganize repo, create deep-charm --- .github/workflows/ci.yml | 2 +- .gitignore | 2 +- .lute/test.luau | 1 - LICENSE | 21 + LICENSE.md | 7 - README.md | 145 +++-- benches/reactive.luau | 3 +- build.project.json | 1 + default.project.json | 14 +- package.json | 4 +- packages/charm-sync/Charm.luau | 1 - packages/charm-sync/package.json | 4 +- packages/charm-sync/src/client.luau | 2 +- packages/charm-sync/src/server.luau | 20 +- packages/charm-sync/src/types.luau | 9 + .../{__tests__ => test}/client.test.luau | 3 +- .../{__tests__ => test}/e2e.test.luau | 9 +- .../charm-sync/{__tests__ => test}/init.luau | 0 .../{__tests__ => test}/patch.test.luau | 0 .../{__tests__ => test}/server.test.luau | 11 +- .../{__tests__ => test}/utils.test.luau | 0 packages/charm-sync/wally.toml | 2 +- packages/charm/package.json | 4 +- packages/charm/src/index.d.ts | 48 -- packages/charm/src/init.luau | 565 ++++++------------ packages/charm/src/system.luau | 312 +++++----- .../{__tests__ => test}/computed.test.luau | 0 .../{__tests__ => test}/effect.test.luau | 0 .../{__tests__ => test}/effectScope.test.luau | 0 .../charm/{__tests__ => test}/gc.test.luau | 61 -- packages/charm/{__tests__ => test}/init.luau | 1 - .../{__tests__ => test}/listen.test.luau | 0 .../{__tests__ => test}/mapped.test.luau | 0 .../{__tests__ => test}/observe.test.luau | 0 .../charm/{__tests__ => test}/peek.test.luau | 0 .../{__tests__ => test}/signal.test.luau | 0 .../{__tests__ => test}/subscribe.test.luau | 0 .../{__tests__ => test}/topology.test.luau | 0 .../{__tests__ => test}/trigger.test.luau | 0 .../{__tests__ => test}/untracked.test.luau | 0 packages/charm/wally.toml | 2 +- packages/deep-charm/Charm.luau | 9 + packages/deep-charm/default.project.json | 6 + packages/deep-charm/package.json | 27 + packages/deep-charm/src/index.d.ts | 53 ++ packages/deep-charm/src/init.luau | 165 +++++ packages/deep-charm/test/gc.test.luau | 79 +++ packages/deep-charm/test/init.luau | 11 + .../test}/reactive.test.luau | 7 +- packages/deep-charm/wally.toml | 9 + packages/react-charm/package.json | 4 +- packages/react-charm/src/init.luau | 4 +- .../helpers/renderHook.luau | 0 .../react-charm/{__tests__ => test}/init.luau | 0 .../useSignalBinding.test.luau | 0 .../useSignalState.test.luau | 0 packages/react-charm/wally.toml | 2 +- packages/vide-charm/package.json | 4 +- .../vide-charm/{__tests__ => test}/init.luau | 0 .../useSignalState.test.luau | 0 packages/vide-charm/wally.toml | 2 +- pnpm-lock.yaml | 6 + tests/init.luau | 1 + tests/run.server.luau | 14 +- 64 files changed, 913 insertions(+), 744 deletions(-) delete mode 100644 .lute/test.luau create mode 100644 LICENSE delete mode 100644 LICENSE.md rename packages/charm-sync/{__tests__ => test}/client.test.luau (96%) rename packages/charm-sync/{__tests__ => test}/e2e.test.luau (90%) rename packages/charm-sync/{__tests__ => test}/init.luau (100%) rename packages/charm-sync/{__tests__ => test}/patch.test.luau (100%) rename packages/charm-sync/{__tests__ => test}/server.test.luau (97%) rename packages/charm-sync/{__tests__ => test}/utils.test.luau (100%) rename packages/charm/{__tests__ => test}/computed.test.luau (100%) rename packages/charm/{__tests__ => test}/effect.test.luau (100%) rename packages/charm/{__tests__ => test}/effectScope.test.luau (100%) rename packages/charm/{__tests__ => test}/gc.test.luau (54%) rename packages/charm/{__tests__ => test}/init.luau (96%) rename packages/charm/{__tests__ => test}/listen.test.luau (100%) rename packages/charm/{__tests__ => test}/mapped.test.luau (100%) rename packages/charm/{__tests__ => test}/observe.test.luau (100%) rename packages/charm/{__tests__ => test}/peek.test.luau (100%) rename packages/charm/{__tests__ => test}/signal.test.luau (100%) rename packages/charm/{__tests__ => test}/subscribe.test.luau (100%) rename packages/charm/{__tests__ => test}/topology.test.luau (100%) rename packages/charm/{__tests__ => test}/trigger.test.luau (100%) rename packages/charm/{__tests__ => test}/untracked.test.luau (100%) create mode 100644 packages/deep-charm/Charm.luau create mode 100644 packages/deep-charm/default.project.json create mode 100644 packages/deep-charm/package.json create mode 100644 packages/deep-charm/src/index.d.ts create mode 100644 packages/deep-charm/src/init.luau create mode 100644 packages/deep-charm/test/gc.test.luau create mode 100644 packages/deep-charm/test/init.luau rename packages/{charm/__tests__ => deep-charm/test}/reactive.test.luau (96%) create mode 100644 packages/deep-charm/wally.toml rename packages/react-charm/{__tests__ => test}/helpers/renderHook.luau (100%) rename packages/react-charm/{__tests__ => test}/init.luau (100%) rename packages/react-charm/{__tests__ => test}/useSignalBinding.test.luau (100%) rename packages/react-charm/{__tests__ => test}/useSignalState.test.luau (100%) rename packages/vide-charm/{__tests__ => test}/init.luau (100%) rename packages/vide-charm/{__tests__ => test}/useSignalState.test.luau (100%) create mode 100644 tests/init.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b5865f..5cd5741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: run: lute run check - name: Run tests - run: lute run test + run: lute run tests - name: Build models run: lute run build diff --git a/.gitignore b/.gitignore index 52e0af1..d217e96 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ /.pesde /build /packages/**/wally.lock -/packages/**/LICENSE.md +/packages/**/LICENSE* /packages/**/README.md /sourcemap.json node_modules diff --git a/.lute/test.luau b/.lute/test.luau deleted file mode 100644 index 4abbbb9..0000000 --- a/.lute/test.luau +++ /dev/null @@ -1 +0,0 @@ -(require)("../tests/run.server") diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d10b611 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright 2024-present Littensy + +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. diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index ae1bb3d..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,7 +0,0 @@ -Copyright 2024 Littensy - -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. diff --git a/README.md b/README.md index 867b06c..814b92e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=main&logo=github) [![NPM Version](https://img.shields.io/npm/v/@rbxts/charm.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/charm) -[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md) +[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE) @@ -54,7 +54,6 @@ Charm is a state management library that brings the [fine-grained reactivity](ht - [`batch(callback)`](#batchcallback) - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - - [`reactive(initialValue)`](#reactiveinitialvalue) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`trigger(callback)`](#triggercallback) - [`flags`](#flags) @@ -64,6 +63,12 @@ Charm is a state management library that brings the [fine-grained reactivity](ht - [Server API](#config) - [Client API](#clientaddsignalssetters) - [Sync Caveats](#sync-caveats) +- [Deep Reactivity](#deep-reactivity) + - [Installation](#installation-2) + - [`reactive(initialValue)`](#reactiveinitialvalue) + - [Mutation vs. update function](#mutation-vs-update-function) + - [`toRaw(value)`](#torawvalue) + - [`isReactive(value)`](#isreactivevalue) - [Migration](#migration) - [Examples](#examples) @@ -539,45 +544,6 @@ dispose() -- Cleaned up --- -### `reactive(initialValue)` - -By default, Charm's signals are _shallowly reactive_, meaning only the value itself is reactive, and table properties are not tracked. Signal values must also be **immutable** in order to track changes in tables. - -You can opt-in to deep reactivity with **reactive proxies**. The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy. - -```luau -local users, setUsers = reactive({ - { name = "John", surname = "Doe" }, -}) - -effect(function() - print(`{users[1].name} {users[1].surname}`) -end) -- Output: John Doe - -setUsers(function(state) - state[1].name = "Jane" - state[1].surname = "Smith" - table.insert(state, { name = "Steve", surname = "Doe" }) -end) -- Output: Jane Smith - --- You can also mutate the reactive proxy directly: -users[1].name = "John" -- John Smith -``` - -> [!WARNING] -> Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value. - -If you need to access the raw table through the reactive proxy, use the `toRaw()` function: - -```luau -local raw = {} -local proxy = reactive(raw) - -print(toRaw(proxy) == raw) -- true -``` - ---- - ### `atom(initialValue, equals?)` The `atom` function creates a new reactive signal and returns a single function that acts as both a getter and setter. @@ -654,11 +620,11 @@ print(total()) -- 2 Charm exposes the following global flags to customize behavior: -| Flag | Default | Description | -| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | -| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. | -| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | +| Flag | Default | Description | +| ----------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| strict | `true` if not `O2` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | +| frozen | `true` if not `O2` | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | The `strict` and `frozen` flags are automatically enabled in Roblox Studio. More accurately, they are enabled for the Luau optimization levels `O1` and lower. @@ -736,7 +702,7 @@ To sync the client with the server's state, call `client.addSignals` with a tabl After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau --- Add signal setters, atoms, or reactive objects +-- Add signal setters, atoms, or reactive proxies client.addSignals({ name = nameStore.setName, surname = nameStore.setSurname, @@ -931,6 +897,87 @@ This means nilable values may be replaced with `None` in patches, and code worki --- +## Deep Reactivity + +By default, Charm's signals are not deeply reactive. This means that only the value itself is tracked, and table properties are not checked for changes. As a result, signal values must be **immutable** in order to detect changes in tables. + +With deep reactivity, you can mutate tables directly and detect changes in nested properties without cloning tables. In Charm, this is done by wrapping your tables in proxy tables that handle reactivity for you. + +You can opt-in to deep reactivity with **reactive proxies**: + +### Installation + +```sh +npm install @rbxts/deep-charm +yarn add @rbxts/deep-charm +pnpm add @rbxts/deep-charm +``` + +```toml +[dependencies] +DeepCharm = "littensy/deep-charm@VERSION" +``` + +--- + +### `reactive(initialValue)` + +The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy. + +```luau +local users, updateUsers = reactive({ + { name = "John", surname = "Doe" }, +}) + +effect(function() + print(`{users[1].name} {users[1].surname}`) +end) -- Output: John Doe + +updateUsers(function(state) + state[1].name = "Jane" + state[1].surname = "Smith" + table.insert(state, { name = "Steve", surname = "Doe" }) +end) -- Output: Jane Smith + +-- You can also mutate the reactive proxy directly: +users[1].name = "John" -- John Smith +``` + +### Mutation vs. update function + +Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value, which you can access through the update function returned by `reactive()`. + +This update function (`updateUsers()` in the example above) passes the raw table for you to mutate. Once your callback is done executing, the updater will manually notify subscriptions to the reactive proxy and its nested properties. This process does not use metatables, so you should use this for table operations or batching updates. + +--- + +### `toRaw(value)` + +If you need to access the raw table through the reactive proxy, use the `toRaw()` function: + +```luau +local raw = {} +local proxy = reactive(raw) + +print(toRaw(proxy) == raw) -- true +``` + +--- + +### `isReactive(value)` + +The `isReactive()` function returns `true` if the given value is a reactive proxy by checking its metatable. + +```lua +local raw = {} +local proxy = reactive(raw) + +print(isReactive(proxy)) -- true +print(isReactive(raw)) -- false +``` + +--- + ## Migration Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that might help you migrate from an older version. @@ -1028,11 +1075,11 @@ end ---

-Charm is released under the MIT License. +Charm is released under the MIT License.

-[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md) +[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE)
diff --git a/benches/reactive.luau b/benches/reactive.luau index 2e499ca..c272651 100644 --- a/benches/reactive.luau +++ b/benches/reactive.luau @@ -1,8 +1,9 @@ local Charm = require("../packages/charm/src") +local DeepCharm = require("../packages/deep-charm/src") local bench = require("./bench") local effect = Charm.effect -local reactive = Charm.reactive +local reactive = DeepCharm.reactive do local value = reactive({ a = 1 }) diff --git a/build.project.json b/build.project.json index 8141b86..53d32b4 100644 --- a/build.project.json +++ b/build.project.json @@ -4,6 +4,7 @@ "$className": "Folder", "Charm": { "$path": "packages/charm/src" }, "CharmSync": { "$path": "packages/charm-sync/src" }, + "DeepCharm": { "$path": "packages/deep-charm/src" }, "ReactCharm": { "$path": "packages/react-charm/src" }, "VideCharm": { "$path": "packages/vide-charm/src" } } diff --git a/default.project.json b/default.project.json index b9c6147..85d8f55 100644 --- a/default.project.json +++ b/default.project.json @@ -20,18 +20,24 @@ "charm": { "$className": "Folder", "src": { "$path": "packages/charm/src" }, - "__tests__": { "$path": "packages/charm/__tests__" } + "test": { "$path": "packages/charm/test" } + }, + "deep-charm": { + "$className": "Folder", + "src": { "$path": "packages/deep-charm/src" }, + "test": { "$path": "packages/deep-charm/test" }, + "Charm": { "$path": "packages/deep-charm/Charm.luau" } }, "charm-sync": { "$className": "Folder", "src": { "$path": "packages/charm-sync/src" }, - "__tests__": { "$path": "packages/charm-sync/__tests__" }, + "test": { "$path": "packages/charm-sync/test" }, "Charm": { "$path": "packages/charm-sync/Charm.luau" } }, "react-charm": { "$className": "Folder", "src": { "$path": "packages/react-charm/src" }, - "__tests__": { "$path": "packages/react-charm/__tests__" }, + "test": { "$path": "packages/react-charm/test" }, "React": { "$path": "packages/react-charm/React.luau" }, "ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" }, "Charm": { "$path": "packages/react-charm/Charm.luau" } @@ -39,7 +45,7 @@ "vide-charm": { "$className": "Folder", "src": { "$path": "packages/vide-charm/src" }, - "__tests__": { "$path": "packages/vide-charm/__tests__" }, + "test": { "$path": "packages/vide-charm/test" }, "Vide": { "$path": "packages/vide-charm/Vide.luau" }, "Charm": { "$path": "packages/vide-charm/Charm.luau" } } diff --git a/package.json b/package.json index 07b26da..c2815eb 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "private": true, "scripts": { - "test": "lute run test", + "test": "lute run tests", "build": "lute run build", "check": "lute run check", "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.30.1", + "packageManager": "pnpm@10.30.2", "devDependencies": { "@eslint/js": "^9.39.2", "@rbxts/compiler-types": "3.0.0-types.0", diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index a589bde..9a55fe0 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -5,6 +5,5 @@ end local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom -export type ReactiveProxy = Charm.ReactiveProxy return Charm diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 49bc0bc..db09da2 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -11,14 +11,14 @@ "!wally.toml", "!wally.lock", "!default.project.json", - "!**/__tests__" + "!test" ], "repository": { "type": "git", "url": "git+https://github.com/littensy/charm.git" }, "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "prepare": "cp ../../LICENSE . && cp ../../README.md .", "publish:wally": "wally publish" }, "dependencies": { diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index e02de4b..165bbc6 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,8 +2,8 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") -type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload +type ReactiveProxy = types.ReactiveProxy local trackedSignals: { [string]: (any) -> any } = {} local trackedReactives: { [string]: ReactiveProxy } = {} diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 3a55854..ce6fc9e 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -4,8 +4,6 @@ local patch = require("./patch") local types = require("./types") local utils = require("./utils") -type ReactiveProxy = Charm.ReactiveProxy - type SyncPayload = types.SyncPayload type SyncStatus = number @@ -36,9 +34,21 @@ local clients: { [any]: ClientKeys } = {} local connection: RBXScriptConnection? local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? +--[[ + Manually converts a reactive proxy to its raw representation to potentially + support custom implementations of reactive proxies. +]] +local function toRaw(value: T): T + if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then + value.__track() + return toRaw(value.__target) + end + return value +end + local function trackSignal(key: string, getter: () -> T, identifier: unknown) if trackedStates[key] then - -- Ignore duplicates, but warn against name collisions + -- Warn against name collisions with conflicting signals or proxies if trackedStates[key].identifier ~= identifier then warn("[Charm] Attempted to sync different states with the same key:", key) end @@ -47,12 +57,12 @@ local function trackSignal(key: string, getter: () -> T, identifier: unknown) local trackedState: TrackedState local initialValue = getter() - local rawInitialValue = Charm.toRaw(initialValue) + local rawInitialValue = toRaw(initialValue) local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue local dispose = Charm.effect(function() local newValue = getter() - local rawValue = Charm.toRaw(newValue) + local rawValue = toRaw(newValue) local isReactive = newValue ~= rawValue if not trackedState then diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 20af1f3..664ee61 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -18,4 +18,13 @@ export type SyncPayload = ]] export type None = { __none: "__none" } +export type ReactiveProxy = typeof(setmetatable( + {} :: { + __track: () -> (), + __target: T, + __signals: { [unknown]: () -> unknown }, + }, + {} :: { __index: T } +)) + return {} diff --git a/packages/charm-sync/__tests__/client.test.luau b/packages/charm-sync/test/client.test.luau similarity index 96% rename from packages/charm-sync/__tests__/client.test.luau rename to packages/charm-sync/test/client.test.luau index 6cda992..20c41a8 100644 --- a/packages/charm-sync/__tests__/client.test.luau +++ b/packages/charm-sync/test/client.test.luau @@ -1,4 +1,5 @@ local Charm = require("../Charm") +local DeepCharm = require("../../deep-charm/src") local client = require("../src/client") local test = require("../../../tests/test") @@ -38,7 +39,7 @@ test("should merge patch", function() end) test("should merge patch (reactive)", function() - local a = Charm.reactive({ b = 1, c = 2 }) + local a = DeepCharm.reactive({ b = 1, c = 2 }) local runs = 0 Charm.effect(function() diff --git a/packages/charm-sync/__tests__/e2e.test.luau b/packages/charm-sync/test/e2e.test.luau similarity index 90% rename from packages/charm-sync/__tests__/e2e.test.luau rename to packages/charm-sync/test/e2e.test.luau index 36227b2..62ca4a7 100644 --- a/packages/charm-sync/__tests__/e2e.test.luau +++ b/packages/charm-sync/test/e2e.test.luau @@ -1,4 +1,5 @@ local Charm = require("../Charm") +local DeepCharm = require("../../deep-charm/src") local client = require("../src/client") local config = require("../src/config") local server = require("../src/server") @@ -13,9 +14,9 @@ end) test("should replicate server state to client", function() local target = {} local serverA = Charm.atom({ count = 0 }) - local serverB = Charm.reactive({ count = 0 }) + local serverB = DeepCharm.reactive({ count = 0 }) local clientA = Charm.atom({ count = -1 }) - local clientB = Charm.reactive({ count = -1 }) + local clientB = DeepCharm.reactive({ count = -1 }) client.addSignals({ a = clientA, b = clientB }) server.addSignalsToClient(target, { a = serverA, b = serverB }) @@ -50,9 +51,9 @@ end) test("should replicate historical server state to client", function() local target = {} local serverA = Charm.atom({ count = 0 }) - local serverB = Charm.reactive({ count = 0 }) + local serverB = DeepCharm.reactive({ count = 0 }) local clientA = Charm.atom({ count = -1 }) - local clientB = Charm.reactive({ count = -1 }) + local clientB = DeepCharm.reactive({ count = -1 }) config.preserveHistory = true diff --git a/packages/charm-sync/__tests__/init.luau b/packages/charm-sync/test/init.luau similarity index 100% rename from packages/charm-sync/__tests__/init.luau rename to packages/charm-sync/test/init.luau diff --git a/packages/charm-sync/__tests__/patch.test.luau b/packages/charm-sync/test/patch.test.luau similarity index 100% rename from packages/charm-sync/__tests__/patch.test.luau rename to packages/charm-sync/test/patch.test.luau diff --git a/packages/charm-sync/__tests__/server.test.luau b/packages/charm-sync/test/server.test.luau similarity index 97% rename from packages/charm-sync/__tests__/server.test.luau rename to packages/charm-sync/test/server.test.luau index 559c4ce..02a9af3 100644 --- a/packages/charm-sync/__tests__/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -1,4 +1,5 @@ local Charm = require("../Charm") +local DeepCharm = require("../../deep-charm/src") local config = require("../src/config") local patch = require("../src/patch") local server = require("../src/server") @@ -66,7 +67,7 @@ end) test("should replicate partial state patches (reactive)", function() local client = {} - local a = Charm.reactive({ x = { y = 0 }, z = 0 :: number? }) + local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? }) local target, payloads server.addSignalsToClient(client, { a = a }) @@ -99,7 +100,7 @@ end) test("should handle nested proxies", function() local client = {} - local src = Charm.reactive({ (Charm.reactive({})) }) + local src = DeepCharm.reactive({ (DeepCharm.reactive({})) }) local payloads: {}? = nil server.addSignalsToClient(client, { src = src }) @@ -288,9 +289,9 @@ test.describe("historical", function() test("should replicate granular state changes (reactive)", function() local client = {} - local a = Charm.reactive({ value = 0 }) - local b = Charm.reactive({ value = 0 }) - local c = Charm.reactive({ value = 0 }) + local a = DeepCharm.reactive({ value = 0 }) + local b = DeepCharm.reactive({ value = 0 }) + local c = DeepCharm.reactive({ value = 0 }) local target, payloads server.addSignalsToClient(client, { a = a, b = b, c = c }) diff --git a/packages/charm-sync/__tests__/utils.test.luau b/packages/charm-sync/test/utils.test.luau similarity index 100% rename from packages/charm-sync/__tests__/utils.test.luau rename to packages/charm-sync/test/utils.test.luau diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index c266da8..55d4eea 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ "node_modules", - "__tests__", + "test", "src/index.d.ts", "package.json", "Charm.luau", diff --git a/packages/charm/package.json b/packages/charm/package.json index feb5e93..92079ec 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -11,14 +11,14 @@ "!wally.toml", "!wally.lock", "!default.project.json", - "!**/__tests__" + "!test" ], "repository": { "type": "git", "url": "git+https://github.com/littensy/charm.git" }, "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "prepare": "cp ../../LICENSE . && cp ../../README.md .", "publish:wally": "wally publish" } } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index f1941e8..554c327 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -272,54 +272,6 @@ export function observe( fn: (value: V, key: K) => Cleanup | void, ): Cleanup; -/** - * Creates a deep reactive value that tracks properties when they're accessed. - * Returns a proxy object that subscribes to changes on any nested property - * you read, and a function for mutating the original table. - * - * @example - * ```luau - * local users, setUsers = reactive({ - * user = { name = "John", surname = "Doe" }, - * }) - * - * effect(function() - * print(users.user and `{users.user.name} {users.user.surname}`) - * end) - * - * setUsers(function(state) - * state.user.name = "Jane" - * state.user.surname = "Smith" - * end) - * ``` - * - * @param initialValue The value to make deeply reactive. - * @returns A proxy table and a function to mutate the original table. - * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue - */ -export function reactive( - initialValue: T, -): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>; - -/** - * Determines if the provided value is a reactive proxy created by `reactive()`. - * - * @param value A value to test. - * @returns `true` if the value is a reactive proxy, `false` otherwise. - * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue - */ -export function isReactive(value: unknown): boolean; - -/** - * Unwraps a reactive proxy to get the original table. If the provided value - * is not a proxy, it is returned as-is. - * - * @param value A reactive proxy or a non-proxy value. - * @returns The unwrapped target value. - * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue - */ -export function toRaw(value: T): T; - /** * Returns the currently active subscriber (effect or computed node). * diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index ff30b1f..650eced 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -9,19 +9,17 @@ export type Link = system.Link export type ReactiveFlags = system.ReactiveFlags export type ReactiveNode = system.ReactiveNode +local link = system.link +local unlink = system.unlink +local propagate = system.propagate +local checkDirty = system.checkDirty +local shallowPropagate = system.shallowPropagate +local createReactiveSystem = system.createReactiveSystem + type Cleanup = () -> () export type Atom = (...((T) -> T) | T) -> T -export type ReactiveProxy = typeof(setmetatable( - {} :: { - __track: () -> (), - __target: T, - __signals: { [unknown]: () -> unknown }, - }, - {} :: { __index: T } -)) - type EffectScopeNode = ReactiveNode & { cleanups: { Cleanup }, } @@ -41,36 +39,6 @@ type SignalNode = ReactiveNode & { pendingValue: T, } -local function inlineTest(): string - return debug.info(1, "n") -end - -local IS_O2 = inlineTest() ~= "inlineTest" - -local flags = { - --[[ - Enforces synchronous, non-yielding behavior in signals and effects. - Also enables state validation in Charm Sync to catch sync errors early. - Enabled if the optimization level is below 2, which is true in studio. - ]] - strict = not IS_O2, - --[[ - Enforces immutability of tables by deep-freezing table values. Enabled - if the optimization level is below 2, which is true in studio. - ]] - frozen = not IS_O2, - --[[ - Whether parent effects should track inner effects and clean them up - when the parent effect re-runs. This is usually desirable, but can be - disabled to revert to the old behavior. Defaults to `true`. - ]] - trackInnerEffects = true, -} - -local ReactiveProxy = { - proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), -} - -- Bitmask flags for tracking the state of reactive nodes. Flags are combined -- and checked using bitwise operations for efficiency. local NONE = 0b0000000 -- Default state @@ -81,13 +49,6 @@ local DIRTY = 0b0010000 -- Value has changed and needs updating local PENDING = 0b0100000 -- Might be dirty, pending verification local PEEKING = 0b1000000 -- Blocking dependency tracking --- Alien Signals reactive system -local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () -local unlink: (link: Link, sub: ReactiveNode?) -> Link? -local propagate: (link: Link) -> () -local checkDirty: (link: Link, sub: ReactiveNode) -> boolean -local shallowPropagate: (link: Link) -> () - -- Global version counter for dependency link deduplication. Incremented at the -- start of each effect/computed run to ensure links created in the same cycle -- are deduplicated. @@ -104,6 +65,30 @@ local batchDepth = 0 -- Dependencies accessed during this time will link to the active subscriber. local activeSub: ReactiveNode? +local function isO2(): boolean + return debug.info(1, "n") ~= "isO2" +end + +local flags = { + --[[ + Enforces synchronous, non-yielding behavior in signals and effects. + Also enables state validation in Charm Sync to catch sync errors early. + Enabled if the optimization level is below 2, which is true in studio. + ]] + strict = not isO2(), + --[[ + Enforces immutability of tables by deep-freezing table values. Enabled + if the optimization level is below 2, which is true in studio. + ]] + frozen = not isO2(), + --[[ + Whether parent effects should track inner effects and clean them up + when the parent effect re-runs. This is usually desirable, but can be + disabled to revert to the old behavior. Defaults to `true`. + ]] + trackInnerEffects = true, +} + --[[ Wraps a function to prevent yielding when in strict mode. If the function attempts to yield, an error is thrown. @@ -191,8 +176,8 @@ end ]] local function runCleanups(cleanups: { Cleanup }) for key, cleanup in cleanups do - cleanups[key] = nil local prevSub = setActiveSub(nil) + cleanups[key] = nil xpcall(cleanup, function(err) warn("Error occurred during effect cleanup:", debug.traceback(tostring(err), 2)) end) @@ -325,69 +310,122 @@ local function clearPending(sub: ReactiveNode, flags: ReactiveFlags) end --[[ - Returns a function that evaluates the computed value when accessed. - Computed values are lazily evaluated and cached unless marked dirty or - pending with dirty dependencies. + Performs a thorough cleanup of the given effect or effect scope: - @param this Computed node - @return Operator to read the computed value + 1. Removes all dependency links + 2. Unlinks from parent effects/scopes + 3. Clears state flags to mark the effect as inactive + 4. Runs all registered cleanup functions + + @param this Effect node ]] -local function computedOper(this: ComputedNode): () -> T - local function readComputed(): T - local flags = this.flags +local function stopEffect(this: EffectScopeNode) + this.depsTail = nil + this.flags = NONE - if - bit32.btest(flags, DIRTY) - or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags))) - then - if updateComputed(this) then - -- Notify subscribers if the value changed - local subs = this.subs - if subs then - shallowPropagate(subs) - end - end - elseif flags == NONE then - -- First access should initialize the computed value - this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + purgeDeps(this) - local prevSub = setActiveSub(this) - local success, result = pcall(this.getter) - activeSub = prevSub - - this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) - - if success then - this.value = result - else - error(result) - end - end - - if activeSub then - link(this, activeSub, cycle) - end - - return this.value :: T + local sub = this.subs + if sub then + unlink(sub, this) end - return readComputed + runCleanups(this.cleanups) end --[[ - Returns functions to read and write a signal's value: - - Setter: Updates the signal's value and notifies subscribers - - Getter: Returns the current value and registers dependency + Updates a signal or computed value and returns whether the value changed. - @param this Signal node - @param equals Optional comparator function to check for value changes - @return Operator to get the signal value - @return Operator to set the signal value + @param node Signal or computed node + @return Boolean indicating whether the value changed ]] -local function signalOper( - this: SignalNode, +local function update(node: ReactiveNode): boolean + if node.depsTail then + return updateComputed(node :: ComputedNode) + else + return updateSignal(node :: SignalNode) + end +end + +--[[ + Queues an effect for execution, ensuring inner effects are notified in the + correct order. + + @param effect Effect node to notify +]] +local function notify(effect: ReactiveNode) + local insertIndex = queuedLength + local firstInsertedIndex = insertIndex + + -- Collect inner effects in a chain + repeat + queued[insertIndex] = effect :: EffectNode + insertIndex += 1 + + effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) + effect = (effect.subs and effect.subs.sub) :: EffectNode + + if not effect or bit32.band(effect.flags, WATCHING) == 0 then + break + end + until false + + queuedLength = insertIndex + + -- Reverse the collected effects to ensure outer effects run first + while true do + insertIndex -= 1 + if firstInsertedIndex >= insertIndex then + break + end + queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex] + firstInsertedIndex += 1 + end +end + +--[[ + Called when a node is not being watched by any subscribers. Cleans up the + node's dependencies and performs the necessary cleanup operations: + + - Computed signals: Unlink dependencies and mark as dirty for potential future updates + - Effects/scopes: Perform full cleanup to free resources + + @param node Reactive node (computed or effect/scope) +]] +local function unwatched(node: ReactiveNode) + if bit32.band(node.flags, MUTABLE) == 0 then + stopEffect(node :: EffectScopeNode) + elseif node.depsTail then + node.depsTail = nil + node.flags = bit32.bor(MUTABLE, DIRTY) + purgeDeps(node) + end +end + +--[[ + Creates a reactive signal that stores a value and notifies subscribers when + the value changes. The signal provides both a getter and a setter. + + @param initialValue Initial value for the signal + @param equals Optional comparator function to check for value changes + @return Getter function to read the signal value + @return Setter function to update the signal value + @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals +]] +local function signal( + initialValue: T, equals: ((current: T, incoming: T) -> boolean)? ): (() -> T, (((T) -> T) | T) -> T) + local this: SignalNode = { + currentValue = initialValue, + pendingValue = initialValue, + flags = MUTABLE, + } + + if flags.frozen then + deepFreeze(initialValue) + end + local function writeSignal(update: ((T) -> T) | T): T local value: T @@ -458,122 +496,6 @@ local function signalOper( return readSignal, writeSignal end ---[[ - Returns an effect cleanup operator that disposes of the effect. This - operator performs a thorough cleanup: - - 1. Removes all dependency links - 2. Unlinks from parent effects/scopes - 3. Clears state flags to mark the effect as inactive - 4. Runs all registered cleanup functions - - @param this Effect node - @return Effect cleanup operator -]] -local function effectOper(this: EffectScopeNode): Cleanup - return function() - this.depsTail = nil - this.flags = NONE - purgeDeps(this) - local sub = this.subs - if sub then - unlink(sub, this) - end - runCleanups(this.cleanups) - end -end - ---[[ - Updates a signal or computed value and returns whether the value changed. - - @param node Signal or computed node - @return Boolean indicating whether the value changed -]] -local function update(node: ReactiveNode): boolean - if node.depsTail then - return updateComputed(node :: ComputedNode) - else - return updateSignal(node :: SignalNode) - end -end - ---[[ - Queues an effect for execution, ensuring inner effects are notified in the - correct order. - - @param effect Effect node to notify -]] -local function notify(effect: ReactiveNode) - local insertIndex = queuedLength - local firstInsertedIndex = insertIndex - - -- Collect inner effects in a chain - repeat - effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) - queued[insertIndex] = effect :: EffectNode - insertIndex += 1 - effect = (effect.subs and effect.subs.sub) :: EffectNode - if not effect or bit32.band(effect.flags, WATCHING) == 0 then - break - end - until false - - queuedLength = insertIndex - - -- Reverse the collected effects to ensure outer effects run first - while true do - insertIndex -= 1 - if firstInsertedIndex >= insertIndex then - break - end - queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex] - firstInsertedIndex += 1 - end -end - ---[[ - Called when a node is not being watched by any subscribers. Cleans up the - node's dependencies and performs the necessary cleanup operations: - - - Computed signals: Unlink dependencies and mark as dirty for potential future updates - - Effects/scopes: Perform full cleanup to free resources - - @param node Reactive node (computed or effect/scope) -]] -local function unwatched(node: ReactiveNode) - if bit32.band(node.flags, MUTABLE) == 0 then - effectOper(node :: EffectScopeNode)() - elseif node.depsTail then - node.depsTail = nil - node.flags = bit32.bor(MUTABLE, DIRTY) - purgeDeps(node) - end -end - ---[[ - Creates a reactive signal that stores a value and notifies subscribers when - the value changes. The signal provides both a getter and a setter. - - @param initialValue Initial value for the signal - @param equals Optional comparator function to check for value changes - @return Getter function to read the signal value - @return Setter function to update the signal value - @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals -]] -local function signal( - initialValue: T, - equals: ((current: T, incoming: T) -> boolean)? -): (() -> T, (((T) -> T) | T) -> T) - if flags.frozen then - deepFreeze(initialValue) - end - return signalOper({ - currentValue = initialValue, - pendingValue = initialValue, - flags = MUTABLE, - }, equals) -end - --[[ Creates a signal and returns a function that can act as both the getter and setter. Calling the atom with one argument sets its value, while calling it @@ -601,10 +523,50 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter ]] local function computed(getter: (previousValue: T?) -> T): () -> T - return computedOper({ + local this: ComputedNode = { flags = NONE, getter = noYield(getter), - }) + } + + local function readComputed(): T + local flags = this.flags + + if + bit32.btest(flags, DIRTY) + or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags))) + then + if updateComputed(this) then + -- Notify subscribers if the value changed + local subs = this.subs + if subs then + shallowPropagate(subs) + end + end + elseif flags == NONE then + -- First access should initialize the computed value + this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + + local prevSub = setActiveSub(this) + local success, result = pcall(this.getter) + activeSub = prevSub + + this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) + + if success then + this.value = result + else + error(result) + end + end + + if activeSub then + link(this, activeSub, cycle) + end + + return this.value :: T + end + + return readComputed end --[[ @@ -643,7 +605,9 @@ local function effect(fn: () -> ...Cleanup?): Cleanup table.insert(this.cleanups, noYield(result)) end - return effectOper(this) + return function() + stopEffect(this) + end end --[[ @@ -675,7 +639,9 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup table.insert(this.cleanups, noYield(result)) end - return effectOper(this) + return function() + stopEffect(this) + end end --[[ @@ -1000,165 +966,17 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: end) end ---[[ - Determines if the provided value is a reactive proxy created by `reactive()`. - - @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue -]] -local function isReactive(value: any): boolean - return type(value) == "table" and getmetatable(value) == ReactiveProxy -end - ---[[ - Unwraps a reactive proxy to get the original table. If the provided value - is not a proxy, it is returned as-is. - - @param value A reactive proxy or a non-proxy value - @return The source table if `value` is a reactive proxy - @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue -]] -local function toRaw(value: T): T - if isReactive(value) then - local value = value :: ReactiveProxy & T - value.__track() - return toRaw(value.__target) - end - return value -end - ---[[ - Creates a deep reactive value that tracks properties when they're accessed. - Returns a proxy object that subscribes to changes on any nested property - you read, and a function for mutating the original table. - - @example - ```luau - local users, setUsers = reactive({ - { name = "John", surname = "Doe" }, - }) - - effect(function() - print(`{users[1].name} {users[1].surname}`) - end) - - setUsers(function(state) - state[1].name = "Jane" - state[1].surname = "Smith" - end) - ``` - - @param initialValue Source table to make deeply reactive - @return A proxy table that tracks reads to nested properties - @return A function for batching mutations to the source table - @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue -]] -local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) - initialValue = toRaw(initialValue) - - local proxy = ReactiveProxy.proxyMap[initialValue] - - if not proxy then - local track = computed(function(version: number?) - -- Reference the proxy to avoid GCing while an effect is actively - -- tracking this reactive object. - local _ref = proxy - -- Allow updates from the parent to propagate down when the - -- target is mutated directly through the setter. - if trackParent then - trackParent() - end - return (version or 0) + 1 - end) - - proxy = setmetatable({ - __target = initialValue, - __signals = setmetatable({}, { __mode = "v" }) :: any, - __track = track, - }, ReactiveProxy) - - ReactiveProxy.proxyMap[initialValue] = proxy - end - - local function setValue(update: ((T) -> ())?) - if update then - update(proxy.__target) - end - trigger(proxy.__track) - end - - return proxy :: any, setValue -end - ---[[ - Wraps a value in `reactive()` if it is a table; otherwise, returns the - value as-is. - - @param value Source value to make deeply reactive - @param trackParent Optional tracking function to propagate deep updates - @return A reactive proxy if the value is a table -]] -local function toReactive(value: T, trackParent: (() -> ())?): T - return if type(value) == "table" then reactive(value, trackParent) else value -end - -function ReactiveProxy.__index(self: ReactiveProxy, key: any): any - local track = self.__track - local signals = self.__signals - local target = self.__target - - if activeSub then - if not signals[key] then - signals[key] = computed(function() - track() - return target[key] - end) - end - signals[key]() - end - - return toReactive(target[key], track) -end - -function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any) - value = toRaw(value) - if self.__target[key] ~= value then - self.__target[key] = value - trigger(self.__track) - end -end - -function ReactiveProxy.__iter(self: ReactiveProxy) - self.__track() - local function nextReactive(target: any, prevKey: any) - local nextKey, nextValue = next(target, prevKey) - return nextKey, toReactive(nextValue, self.__track) - end - return nextReactive, self.__target -end - -function ReactiveProxy.__len(self: ReactiveProxy) - self.__track() - return #self.__target -end - -link, unlink, propagate, checkDirty, shallowPropagate = system.createReactiveSystem({ - update = update, - notify = notify, - unwatched = unwatched, -}) +createReactiveSystem(update, notify, unwatched) return { - -- Core reactive primitives + ReactiveFlags = system.ReactiveFlags, + flags = flags, + atom = atom, signal = signal, computed = computed, effect = effect, effectScope = effectScope, trigger = trigger, - - -- Charm utilities - flags = flags, - atom = atom, - onCleanup = onCleanup, untracked = untracked, peek = peek, batch = batch, @@ -1169,14 +987,7 @@ return { getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO) ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), - - -- Reactive proxies - reactive = reactive, - isReactive = isReactive, - toRaw = toRaw, - - -- Advanced API - ReactiveFlags = system.ReactiveFlags, + onCleanup = onCleanup, getActiveSub = getActiveSub, setActiveSub = setActiveSub, startBatch = startBatch, diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 64f6096..d67efb5 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -37,7 +37,7 @@ local DIRTY = 0b0010000 local PENDING = 0b0100000 local PEEKING = 0b1000000 -local ReactiveFlags = { +local ReactiveFlags = table.freeze({ None = NONE, Mutable = MUTABLE, Watching = WATCHING, @@ -45,161 +45,166 @@ local ReactiveFlags = { Recursed = RECURSED, Dirty = DIRTY, Pending = PENDING, -} - -local function createReactiveSystem(props: { - update: (sub: ReactiveNode) -> boolean, - notify: (sub: ReactiveNode) -> (), - unwatched: (sub: ReactiveNode) -> (), + Peeking = PEEKING, }) - local update = props.update - local notify = props.notify - local unwatched = props.unwatched - local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean - local link = sub.depsTail - while link do - if link == checkLink then - return true - end - link = link.prevDep +local update: (sub: ReactiveNode) -> boolean +local notify: (sub: ReactiveNode) -> () +local unwatched: (sub: ReactiveNode) -> () + +local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean + local link = sub.depsTail + while link do + if link == checkLink then + return true end - return false + link = link.prevDep end + return false +end - local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) - -- DEVIATION: Do not link signals to peeking subscribers - if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then - return - end - local prevDep = sub.depsTail - if prevDep and prevDep.dep == dep then - return - end - local nextDep = if prevDep then prevDep.nextDep else sub.deps - if nextDep and nextDep.dep == dep then - nextDep.version = version - sub.depsTail = nextDep - return - end - local prevSub = dep.subsTail - if prevSub and prevSub.version == version and prevSub.sub == sub then - return - end - local newLink: Link = { - version = version, - dep = dep, - sub = sub, - prevDep = prevDep, - nextDep = nextDep, - prevSub = prevSub, - } - sub.depsTail = newLink - dep.subsTail = newLink - if nextDep then - nextDep.prevDep = newLink - end - if prevDep then - prevDep.nextDep = newLink - else - sub.deps = newLink - end - if prevSub then - prevSub.nextSub = newLink - else - dep.subs = newLink +local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) + -- DEVIATION: Do not link signals to peeking subscribers + if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then + return + end + local prevDep = sub.depsTail + if prevDep and prevDep.dep == dep then + return + end + local nextDep = if prevDep then prevDep.nextDep else sub.deps + if nextDep and nextDep.dep == dep then + nextDep.version = version + sub.depsTail = nextDep + return + end + local prevSub = dep.subsTail + if prevSub and prevSub.version == version and prevSub.sub == sub then + return + end + local newLink: Link = { + version = version, + dep = dep, + sub = sub, + prevDep = prevDep, + nextDep = nextDep, + prevSub = prevSub, + } + sub.depsTail = newLink + dep.subsTail = newLink + if nextDep then + nextDep.prevDep = newLink + end + if prevDep then + prevDep.nextDep = newLink + else + sub.deps = newLink + end + if prevSub then + prevSub.nextSub = newLink + else + dep.subs = newLink + end +end + +local function unlink(link: Link, sub: ReactiveNode?): Link? + 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 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 +local function propagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + + if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then + sub.flags = bit32.bor(flags, PENDING) + elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then + flags = NONE + elseif bit32.band(flags, RECURSED_CHECK) == 0 then + sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) + elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then + sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) + flags = bit32.band(flags, MUTABLE) else - sub.depsTail = prevDep + -- selene: allow(if_same_then_else) + flags = NONE end - if prevDep then - prevDep.nextDep = nextDep - else - sub.deps = nextDep + + if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then + notify(sub) 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) + + if bit32.btest(flags, MUTABLE) then + local subSubs = sub.subs + if subSubs then + propagate(subSubs) end end - return nextDep - end - local function propagate(link: Link) - repeat - local sub = link.sub - local flags = sub.flags - - if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then - sub.flags = bit32.bor(flags, PENDING) - elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then - flags = NONE - elseif bit32.band(flags, RECURSED_CHECK) == 0 then - sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) - elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then - sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) - flags = bit32.band(flags, MUTABLE) - else - -- selene: allow(if_same_then_else) - flags = NONE - end + link = link.nextSub :: Link + until not link +end +local function shallowPropagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then + sub.flags = bit32.bor(flags, DIRTY) if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then notify(sub) end + end + link = link.nextSub :: Link + until not link +end - if bit32.btest(flags, MUTABLE) then - local subSubs = sub.subs - if subSubs then - propagate(subSubs) +local function checkDirty(link: Link, sub: ReactiveNode): boolean + repeat + local dep = link.dep + local depFlags = dep.flags + + if bit32.btest(sub.flags, DIRTY) then + return true + elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then + if update(dep) then + local subs = dep.subs :: Link + if subs.nextSub then + shallowPropagate(subs) end - end - - link = link.nextSub :: Link - until not link - end - - local function shallowPropagate(link: Link) - repeat - local sub = link.sub - local flags = sub.flags - if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then - sub.flags = bit32.bor(flags, DIRTY) - if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then - notify(sub) - end - end - link = link.nextSub :: Link - until not link - end - - local function checkDirty(link: Link, sub: ReactiveNode): boolean - repeat - local dep = link.dep - local depFlags = dep.flags - - if bit32.btest(sub.flags, DIRTY) then return true - elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then + end + elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then + if checkDirty(dep.deps :: Link, dep) then if update(dep) then local subs = dep.subs :: Link if subs.nextSub then @@ -207,30 +212,33 @@ local function createReactiveSystem(props: { end return true end - elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then - if checkDirty(dep.deps :: Link, dep) then - if update(dep) then - local subs = dep.subs :: Link - if subs.nextSub then - shallowPropagate(subs) - end - return true - end - else - dep.flags = bit32.band(depFlags, bit32.bnot(PENDING)) - end + else + dep.flags = bit32.band(depFlags, bit32.bnot(PENDING)) end + end - link = link.nextDep :: Link - until not link + link = link.nextDep :: Link + until not link - return false - end + return false +end - return link, unlink, propagate, checkDirty, shallowPropagate +local function createReactiveSystem( + updateFn: (sub: ReactiveNode) -> boolean, + notifyFn: (sub: ReactiveNode) -> (), + unwatchedFn: (sub: ReactiveNode) -> () +) + update = updateFn + notify = notifyFn + unwatched = unwatchedFn end return { ReactiveFlags = ReactiveFlags, + link = link, + unlink = unlink, + propagate = propagate, + checkDirty = checkDirty, + shallowPropagate = shallowPropagate, createReactiveSystem = createReactiveSystem, } diff --git a/packages/charm/__tests__/computed.test.luau b/packages/charm/test/computed.test.luau similarity index 100% rename from packages/charm/__tests__/computed.test.luau rename to packages/charm/test/computed.test.luau diff --git a/packages/charm/__tests__/effect.test.luau b/packages/charm/test/effect.test.luau similarity index 100% rename from packages/charm/__tests__/effect.test.luau rename to packages/charm/test/effect.test.luau diff --git a/packages/charm/__tests__/effectScope.test.luau b/packages/charm/test/effectScope.test.luau similarity index 100% rename from packages/charm/__tests__/effectScope.test.luau rename to packages/charm/test/effectScope.test.luau diff --git a/packages/charm/__tests__/gc.test.luau b/packages/charm/test/gc.test.luau similarity index 54% rename from packages/charm/__tests__/gc.test.luau rename to packages/charm/test/gc.test.luau index a03acac..2d53389 100644 --- a/packages/charm/__tests__/gc.test.luau +++ b/packages/charm/test/gc.test.luau @@ -1,13 +1,10 @@ local Charm = require("../src") local test = require("../../../tests/test") -type ReactiveProxy = Charm.ReactiveProxy - local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope -local reactive = Charm.reactive local function weakRef(value: T): { value: T? } return setmetatable({ value = value }, { __mode = "v" }) :: any @@ -65,62 +62,4 @@ test("should not release effect for atom", function() assert(effectCalls == 2, "should call effect again") end) -test("should release inner reactive proxy", function() - local valueRef = weakRef({}) - local src: any = reactive({ value = valueRef.value }) - - local dispose = effect(function() - local _ = src.value -- create proxy - end) - src.value = nil :: any -- release proxy - dispose() - - gc() - assert(src.__signals.value == nil, "should release signals") - assert(valueRef.value == nil, "should release inner proxy") -end) - -test("should release reactive property deps", function() - local src: any = reactive({ foo = 1 }) - - local c = computed(function() - return src.foo - end) - - c() - assert(src.__signals.foo ~= nil, "should have deps for foo") - - c = nil :: any - gc() - assert(src.__signals.foo == nil, "should release deps for foo") -end) - -test("should not release effect for reactive", function() - local src = reactive({ foo = 1 }) - local calls = 0 - - effect(function() - calls += 1 - local _ = src.foo - end) - assert(calls == 1, "should call effect once") - - gc() - src.foo += 1 - assert(calls == 2, "should call effect again") -end) - -test("should not release tracked reactive", function() - local src = Charm.reactive({ users = {} :: { [string]: {}? } }) - local user - - effect(function() - user = src.users.user - end) - - gc() - src.users.user = {} - assert(user ~= nil, "src.users.user should not be released") -end) - return {} diff --git a/packages/charm/__tests__/init.luau b/packages/charm/test/init.luau similarity index 96% rename from packages/charm/__tests__/init.luau rename to packages/charm/test/init.luau index 6c443b0..bd58a2f 100644 --- a/packages/charm/__tests__/init.luau +++ b/packages/charm/test/init.luau @@ -9,7 +9,6 @@ local tests = { "mapped", "observe", "peek", - "reactive", "signal", "subscribe", "topology", diff --git a/packages/charm/__tests__/listen.test.luau b/packages/charm/test/listen.test.luau similarity index 100% rename from packages/charm/__tests__/listen.test.luau rename to packages/charm/test/listen.test.luau diff --git a/packages/charm/__tests__/mapped.test.luau b/packages/charm/test/mapped.test.luau similarity index 100% rename from packages/charm/__tests__/mapped.test.luau rename to packages/charm/test/mapped.test.luau diff --git a/packages/charm/__tests__/observe.test.luau b/packages/charm/test/observe.test.luau similarity index 100% rename from packages/charm/__tests__/observe.test.luau rename to packages/charm/test/observe.test.luau diff --git a/packages/charm/__tests__/peek.test.luau b/packages/charm/test/peek.test.luau similarity index 100% rename from packages/charm/__tests__/peek.test.luau rename to packages/charm/test/peek.test.luau diff --git a/packages/charm/__tests__/signal.test.luau b/packages/charm/test/signal.test.luau similarity index 100% rename from packages/charm/__tests__/signal.test.luau rename to packages/charm/test/signal.test.luau diff --git a/packages/charm/__tests__/subscribe.test.luau b/packages/charm/test/subscribe.test.luau similarity index 100% rename from packages/charm/__tests__/subscribe.test.luau rename to packages/charm/test/subscribe.test.luau diff --git a/packages/charm/__tests__/topology.test.luau b/packages/charm/test/topology.test.luau similarity index 100% rename from packages/charm/__tests__/topology.test.luau rename to packages/charm/test/topology.test.luau diff --git a/packages/charm/__tests__/trigger.test.luau b/packages/charm/test/trigger.test.luau similarity index 100% rename from packages/charm/__tests__/trigger.test.luau rename to packages/charm/test/trigger.test.luau diff --git a/packages/charm/__tests__/untracked.test.luau b/packages/charm/test/untracked.test.luau similarity index 100% rename from packages/charm/__tests__/untracked.test.luau rename to packages/charm/test/untracked.test.luau diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index f1d9bb9..1578c0a 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -3,6 +3,6 @@ name = "littensy/charm" version = "0.11.0-rc.4" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["node_modules", "__tests__", "src/index.d.ts", "package.json"] +exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] [dependencies] diff --git a/packages/deep-charm/Charm.luau b/packages/deep-charm/Charm.luau new file mode 100644 index 0000000..9a55fe0 --- /dev/null +++ b/packages/deep-charm/Charm.luau @@ -0,0 +1,9 @@ +if not script then + return require("../charm/src") +end + +local Charm = require(script.Parent.Parent.charm.src) + +export type Atom = Charm.Atom + +return Charm diff --git a/packages/deep-charm/default.project.json b/packages/deep-charm/default.project.json new file mode 100644 index 0000000..1937acb --- /dev/null +++ b/packages/deep-charm/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "deep-charm", + "tree": { + "$path": "src" + } +} diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json new file mode 100644 index 0000000..96d06af --- /dev/null +++ b/packages/deep-charm/package.json @@ -0,0 +1,27 @@ +{ + "name": "@rbxts/deep-charm", + "version": "0.1.0-rc.1", + "description": "Use Charm with plain Luau tables", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:^" + } +} diff --git a/packages/deep-charm/src/index.d.ts b/packages/deep-charm/src/index.d.ts new file mode 100644 index 0000000..dc1aa58 --- /dev/null +++ b/packages/deep-charm/src/index.d.ts @@ -0,0 +1,53 @@ +export type ReactiveProxy = T & { + __track: () => void; + __target: T; + __signals: Map unknown>; +}; + +/** + * Creates a deep reactive value that tracks properties when they're accessed. + * Returns a proxy object that subscribes to changes on any nested property + * you read, and a function for mutating the original table. + * + * @example + * ```luau + * local users, updateUsers = reactive({ + * user = { name = "John", surname = "Doe" }, + * }) + * + * effect(function() + * print(users.user and `{users.user.name} {users.user.surname}`) + * end) + * + * updateUsers(function(state) + * state.user.name = "Jane" + * state.user.surname = "Smith" + * end) + * ``` + * + * @param initialValue The value to make deeply reactive. + * @returns A proxy table and a function to mutate the original table. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function reactive( + initialValue: T, +): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>; + +/** + * Determines if the provided value is a reactive proxy created by `reactive()`. + * + * @param value A value to test. + * @returns `true` if the value is a reactive proxy, `false` otherwise. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function isReactive(value: unknown): boolean; + +/** + * Unwraps a reactive proxy to get the original table. If the provided value + * is not a proxy, it is returned as-is. + * + * @param value A reactive proxy or a non-proxy value. + * @returns The unwrapped target value. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function toRaw(value: T): T; diff --git a/packages/deep-charm/src/init.luau b/packages/deep-charm/src/init.luau new file mode 100644 index 0000000..ef9ab05 --- /dev/null +++ b/packages/deep-charm/src/init.luau @@ -0,0 +1,165 @@ +local Charm = require("./Charm") + +local computed = Charm.computed +local trigger = Charm.trigger +local getActiveSub = Charm.getActiveSub + +export type ReactiveProxy = typeof(setmetatable( + {} :: { + __track: () -> (), + __target: T, + __signals: { [unknown]: () -> unknown }, + }, + {} :: { __index: T } +)) + +local ReactiveProxy = { + proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), +} + +--[[ + Determines if the provided value is a reactive proxy created by `reactive()`. + + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function isReactive(value: any): boolean + return type(value) == "table" and getmetatable(value) == ReactiveProxy +end + +--[[ + Unwraps a reactive proxy to get the original table. If the provided value + is not a proxy, it is returned as-is. + + @param value A reactive proxy or a non-proxy value + @return The source table if `value` is a reactive proxy + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function toRaw(value: T): T + if isReactive(value) then + local value = value :: ReactiveProxy & T + value.__track() + return toRaw(value.__target) + end + return value +end + +--[[ + Creates a deep reactive value that tracks properties when they're accessed. + Returns a proxy object that subscribes to changes on any nested property + you read, and a function for mutating the original table. + + @example + ```luau + local users, updateUsers = reactive({ + { name = "John", surname = "Doe" }, + }) + + effect(function() + print(`{users[1].name} {users[1].surname}`) + end) + + updateUsers(function(state) + state[1].name = "Jane" + state[1].surname = "Smith" + end) + ``` + + @param initialValue Source table to make deeply reactive + @return A proxy table that tracks reads to nested properties + @return A function for batching mutations to the source table + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) + initialValue = toRaw(initialValue) + + local proxy = ReactiveProxy.proxyMap[initialValue] + + if not proxy then + local track = computed(function(version: number?) + -- Reference the proxy to avoid GCing while an effect is actively + -- tracking this reactive object. + local _ref = proxy + -- Allow updates from the parent to propagate down when the + -- target is mutated directly through the setter. + if trackParent then + trackParent() + end + return (version or 0) + 1 + end) + + proxy = setmetatable({ + __target = initialValue, + __signals = setmetatable({}, { __mode = "v" }) :: any, + __track = track, + }, ReactiveProxy) + + ReactiveProxy.proxyMap[initialValue] = proxy + end + + local function setValue(update: ((T) -> ())?) + if update then + update(proxy.__target) + end + trigger(proxy.__track) + end + + return proxy :: any, setValue +end + +--[[ + Wraps a value in `reactive()` if it is a table; otherwise, returns the + value as-is. + + @param value Source value to make deeply reactive + @param trackParent Optional tracking function to propagate deep updates + @return A reactive proxy if the value is a table +]] +local function toReactive(value: T, trackParent: (() -> ())?): T + return if type(value) == "table" then reactive(value, trackParent) else value +end + +function ReactiveProxy.__index(self: ReactiveProxy, key: any): any + local track = self.__track + local signals = self.__signals + local target = self.__target + + if getActiveSub() then + if not signals[key] then + signals[key] = computed(function() + track() + return target[key] + end) + end + signals[key]() + end + + return toReactive(target[key], track) +end + +function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any) + value = toRaw(value) + if self.__target[key] ~= value then + self.__target[key] = value + trigger(self.__track) + end +end + +function ReactiveProxy.__iter(self: ReactiveProxy) + self.__track() + local function nextReactive(target: any, prevKey: any) + local nextKey, nextValue = next(target, prevKey) + return nextKey, toReactive(nextValue, self.__track) + end + return nextReactive, self.__target +end + +function ReactiveProxy.__len(self: ReactiveProxy) + self.__track() + return #self.__target +end + +return { + reactive = reactive, + isReactive = isReactive, + toRaw = toRaw, +} diff --git a/packages/deep-charm/test/gc.test.luau b/packages/deep-charm/test/gc.test.luau new file mode 100644 index 0000000..4fe9f3d --- /dev/null +++ b/packages/deep-charm/test/gc.test.luau @@ -0,0 +1,79 @@ +local Charm = require("../Charm") +local DeepCharm = require("../src") +local test = require("../../../tests/test") + +type ReactiveProxy = DeepCharm.ReactiveProxy + +local computed = Charm.computed +local effect = Charm.effect +local reactive = DeepCharm.reactive + +local function weakRef(value: T): { value: T? } + return setmetatable({ value = value }, { __mode = "v" }) :: any +end + +local function gc() + for _ = 1, 2000 do + local _ = table.create(100, 0) + end +end + +test("should release inner reactive proxy", function() + local valueRef = weakRef({}) + local src: any = reactive({ value = valueRef.value }) + + local dispose = effect(function() + local _ = src.value -- create proxy + end) + src.value = nil :: any -- release proxy + dispose() + + gc() + assert(src.__signals.value == nil, "should release signals") + assert(valueRef.value == nil, "should release inner proxy") +end) + +test("should release reactive property deps", function() + local src: any = reactive({ foo = 1 }) + + local c = computed(function() + return src.foo + end) + + c() + assert(src.__signals.foo ~= nil, "should have deps for foo") + + c = nil :: any + gc() + assert(src.__signals.foo == nil, "should release deps for foo") +end) + +test("should not release effect for reactive", function() + local src = reactive({ foo = 1 }) + local calls = 0 + + effect(function() + calls += 1 + local _ = src.foo + end) + assert(calls == 1, "should call effect once") + + gc() + src.foo += 1 + assert(calls == 2, "should call effect again") +end) + +test("should not release tracked reactive", function() + local src = reactive({ users = {} :: { [string]: {}? } }) + local user + + effect(function() + user = src.users.user + end) + + gc() + src.users.user = {} + assert(user ~= nil, "src.users.user should not be released") +end) + +return {} diff --git a/packages/deep-charm/test/init.luau b/packages/deep-charm/test/init.luau new file mode 100644 index 0000000..b7123d6 --- /dev/null +++ b/packages/deep-charm/test/init.luau @@ -0,0 +1,11 @@ +local test = require("../../tests/test") + +test.describe("reactive", function() + require(`@self/reactive.test`) +end) + +test.describe("gc", function() + require(`@self/gc.test`) +end) + +return {} diff --git a/packages/charm/__tests__/reactive.test.luau b/packages/deep-charm/test/reactive.test.luau similarity index 96% rename from packages/charm/__tests__/reactive.test.luau rename to packages/deep-charm/test/reactive.test.luau index 82b37c4..4b747c9 100644 --- a/packages/charm/__tests__/reactive.test.luau +++ b/packages/deep-charm/test/reactive.test.luau @@ -1,9 +1,10 @@ -local Charm = require("../src") +local Charm = require("../Charm") +local DeepCharm = require("../src") local test = require("../../../tests/test") local effect = Charm.effect -local reactive = Charm.reactive -local toRaw = Charm.toRaw +local reactive = DeepCharm.reactive +local toRaw = DeepCharm.toRaw test("should track nested properties", function() local src, setSrc = reactive({ a = { b = { c = 0 } } }) diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml new file mode 100644 index 0000000..7ef15ca --- /dev/null +++ b/packages/deep-charm/wally.toml @@ -0,0 +1,9 @@ +[package] +name = "littensy/deep-charm" +version = "0.1.0-rc.1" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] + +[dependencies] +Charm = "littensy/charm@^0.11.0-rc.3" diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index ae62f9e..4976c98 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -11,14 +11,14 @@ "!wally.toml", "!wally.lock", "!default.project.json", - "!**/__tests__" + "!test" ], "repository": { "type": "git", "url": "git+https://github.com/littensy/charm.git" }, "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "prepare": "cp ../../LICENSE . && cp ../../README.md .", "publish:wally": "wally publish" }, "dependencies": { diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 931a584..b626b3f 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -51,8 +51,8 @@ local function useSignalBinding(getter: () -> T, dependencies: { any }?) return binding end -return { +return table.freeze({ useSignalState = useSignalState, useSignalBinding = useSignalBinding, useAtom = useSignalState, -} +}) diff --git a/packages/react-charm/__tests__/helpers/renderHook.luau b/packages/react-charm/test/helpers/renderHook.luau similarity index 100% rename from packages/react-charm/__tests__/helpers/renderHook.luau rename to packages/react-charm/test/helpers/renderHook.luau diff --git a/packages/react-charm/__tests__/init.luau b/packages/react-charm/test/init.luau similarity index 100% rename from packages/react-charm/__tests__/init.luau rename to packages/react-charm/test/init.luau diff --git a/packages/react-charm/__tests__/useSignalBinding.test.luau b/packages/react-charm/test/useSignalBinding.test.luau similarity index 100% rename from packages/react-charm/__tests__/useSignalBinding.test.luau rename to packages/react-charm/test/useSignalBinding.test.luau diff --git a/packages/react-charm/__tests__/useSignalState.test.luau b/packages/react-charm/test/useSignalState.test.luau similarity index 100% rename from packages/react-charm/__tests__/useSignalState.test.luau rename to packages/react-charm/test/useSignalState.test.luau diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index d04375a..3a06c66 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ "node_modules", - "__tests__", + "test", "src/index.d.ts", "package.json", "React.luau", diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 1c96512..6a7ed72 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -11,14 +11,14 @@ "!wally.toml", "!wally.lock", "!default.project.json", - "!**/__tests__" + "!test" ], "repository": { "type": "git", "url": "git+https://github.com/littensy/charm.git" }, "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "prepare": "cp ../../LICENSE . && cp ../../README.md .", "publish:wally": "wally publish" }, "dependencies": { diff --git a/packages/vide-charm/__tests__/init.luau b/packages/vide-charm/test/init.luau similarity index 100% rename from packages/vide-charm/__tests__/init.luau rename to packages/vide-charm/test/init.luau diff --git a/packages/vide-charm/__tests__/useSignalState.test.luau b/packages/vide-charm/test/useSignalState.test.luau similarity index 100% rename from packages/vide-charm/__tests__/useSignalState.test.luau rename to packages/vide-charm/test/useSignalState.test.luau diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 3b9e5fe..fb4d378 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ "node_modules", - "__tests__", + "test", "src/index.d.ts", "package.json", "Vide.luau", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8c7bee..066affe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,12 @@ importers: specifier: workspace:^ version: link:../charm + packages/deep-charm: + dependencies: + '@rbxts/charm': + specifier: workspace:^ + version: link:../charm + packages/react-charm: dependencies: '@rbxts/charm': diff --git a/tests/init.luau b/tests/init.luau new file mode 100644 index 0000000..823aed6 --- /dev/null +++ b/tests/init.luau @@ -0,0 +1 @@ +(require)("@self/run.server") diff --git a/tests/run.server.luau b/tests/run.server.luau index a819a4a..44015cb 100644 --- a/tests/run.server.luau +++ b/tests/run.server.luau @@ -5,23 +5,27 @@ Charm.flags.strict = true Charm.flags.frozen = true local describeInRoblox = test.describeIf(function() - return game :: any ~= nil + return not not game end) test.describe("charm", function() - require("../packages/charm/__tests__") + require("../packages/charm/test") end) test.describe("charm-sync", function() - require("../packages/charm-sync/__tests__") + require("../packages/charm-sync/test") +end) + +test.describe("deep-charm", function() + require("../packages/deep-charm/test") end) describeInRoblox("react-charm", function() - require("../packages/react-charm/__tests__") + require("../packages/react-charm/test") end) describeInRoblox("vide-charm", function() - require("../packages/vide-charm/__tests__") + require("../packages/vide-charm/test") end) test.finish() From 3460618ee00a80763900c3d35bd6eae93e0dac22 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:31:17 -0800 Subject: [PATCH 116/160] docs: rephrase some sections --- README.md | 141 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 72 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 814b92e..7656245 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ -Charm is a state management library that brings the [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) approach — used in libraries like Vue, Preact, and Solid — to your Luau projects. +Charm is a state management library based on an approach to reactive programming called [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf). Store your game's state in signals, and your code will stay in sync with the data it depends on. -**Build complex game state from simple building blocks:** +**Build complex state from three reactive primitives:** -- Store state in [reactive signals](#signalinitialvalue-equals): state containers that hold a value -- React to state changes with [effects](#effectcallback): run code whenever a dependency updates -- Derive new values with [computed signals](#computedgetter): memoize a function with automatic dependency tracking +- Store state in [signals](#signalinitialvalue-equals): state containers that hold a value +- React to state changes with [effects](#effectcallback): run code whenever a signal updates +- Derive new values with [computed signals](#computedgetter): memoized functions with automatic dependency tracking **Want to learn more about signals?** @@ -33,7 +33,7 @@ Charm is a state management library that brings the [fine-grained reactivity](ht - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity - https://angular.dev/guide/signals -[Migrating from an older version?](#migration) +[Migrating from an older version of Charm?](#migration)
Table of Contents @@ -77,19 +77,19 @@ Charm is a state management library that brings the [fine-grained reactivity](ht ## At a Glance ```luau -local getTodos, setTodos = signal({} :: { [number]: string }) +local getTodos, setTodos = signal({} :: { string }) local getQuery, setQuery = signal("") -observe(getTodos, function(todo, index) +observe(getTodos, function(todo: string, index: number) local instance = Instance.new("TextLabel") - local getText = computed(function(prevText) - return getTodos()[index] or prevText + local getText = computed(function() + return getTodos()[index] or "" end) effect(function() instance.Text = getText() - instance.Visible = getText():match(getQuery()) ~= nil + instance.Visible = string.match(getText(), getQuery()) ~= nil end) instance.LayoutOrder = index @@ -102,30 +102,31 @@ observe(getTodos, function(todo, index) end) setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) -setQuery("^Buy") +setQuery("Buy") ```
Explain code ```luau -local getTodos, setTodos = signal({} :: { [number]: string }) +-- Declare state for a todo list and a search query +local getTodos, setTodos = signal({} :: { string }) local getQuery, setQuery = signal("") -- Create a text label when an item is added to the list observe(getTodos, function(todo, index) local instance = Instance.new("TextLabel") - -- Create a computed signal that stores the current text, or the previous - -- value if the item was removed - local getText = computed(function(prevText) - return getTodos()[index] or prevText + -- Create a memoized function that only re-runs effects when the todo list + -- updates or the function returns new text + local getText = computed(function() + return getTodos()[index] or "" end) - -- Set properties when the text or query updates + -- Update instance properties when the text or query updates effect(function() instance.Text = getText() - instance.Visible = getText():match(getQuery()) ~= nil + instance.Visible = string.match(getText(), getQuery()) ~= nil end) instance.LayoutOrder = index @@ -140,8 +141,8 @@ end) -- Add items to the todo list setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) --- Filter for items starting with "Buy" -setQuery("^Buy") +-- Filter for items containing "Buy" +setQuery("Buy") ```
@@ -232,24 +233,24 @@ dispose() -- Cleanup 1 Effects can be nested inside other effects. When the outer effect re-runs, inner effects from the previous run are automatically cleaned up, and new inner effects are created if needed. The system ensures proper execution order; outer effects always run before their inner effects: ```luau -local getShow, setShow = signal(true) +local getPrintCount, setPrintCount = signal(true) local getCount, setCount = signal(1) effect(function() - if getShow() then - -- This inner effect is created when show() is true + if getPrintCount() then + -- This inner effect is created when getPrintCount() is true effect(function() - print(`Count is: {getCount()}`) + print(`Count is {getCount()}`) end) end -end) -- Output: Count is: 1 +end) -- Count is 1 -setCount(2) -- Count is: 2 +setCount(2) -- Count is 2 --- When show becomes false, the inner effect is cleaned up -setShow(false) -- No output +-- This re-runs the outer effect and cleans up old inner effects +setPrintCount(false) -setCount(3) -- No output (inner effect no longer exists) +setCount(3) -- No output ``` Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen()`, `observe()`) because they are built upon effects. @@ -261,9 +262,7 @@ Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen ### `computed(getter)` -The `computed` function creates a new signal with a value derived from multiple signals. The computed signal can be reacted to like a normal signal, and it returns the getter function's last return value. - -The computed signal will subscribe to signals accessed by the getter and track them as dependencies. Changes to these dependencies will re-execute the getter, and if it returns a new value, then the computed signal is updated to that value. +The `computed` function creates a read-only signal whose value is derived from other signals. The computed signal caches the getter function's last result, and the value is only re-computed if a dependency has updated since the last computation. ```luau local getName, setName = signal("John") @@ -277,7 +276,7 @@ setName("Jane") print(getFullName()) -- "Jane Doe" ``` -The getter function also receives the previous state (or `nil` if running for the first time). You can use this for more complex state: +The getter function also receives the previous result (or `nil` during the initial run). You can use this for computed signals that depend on the previous result: ```luau local getCounter, setCounter = signal(10) @@ -290,13 +289,11 @@ setCounter(5) print(getMax()) -- 10 ``` -Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called _and_ if a dependency updated since the last call. - --- ### `effectScope(callback)` -Scopes allow you to dispose multiple effects at once. The `effectScope` function creates a scope that tracks inner effects, so effects created inside the callback clean up when the scope disposes. +Scopes allow you to dispose multiple effects at once. The `effectScope` function creates a scope that tracks inner effects, so effects created during the execution of the callback will clean up when the scope disposes. ```luau local getCounter, setCounter = signal(0) @@ -328,10 +325,9 @@ The callback also receives the previous value, or `nil` when running for the fir ```luau local getCounter, setCounter = signal(0) --- Output: Count is 0 (was nil) listen(getCounter, function(count, prevCount) print(`Count is {count} (was {prevCount})`) -end) +end) -- Count is 0 (was nil) setCounter(1) -- Count is 1 (was 0) ``` @@ -349,10 +345,9 @@ local function floorCounter() return math.floor(getCounter()) end --- Output: Floor of count is 0 (was nil) listen(floorCounter, function(count, prevCount) print(`Floor of count is {count} (was {prevCount})`) -end) +end) -- Floor of count is 0 (was nil) setCounter(0.5) -- Doesn't print anything, floor is still 0 setCounter(1) -- Floor of count is 1 (was 0) @@ -367,13 +362,12 @@ setCounter(1) -- Floor of count is 1 (was 0) ```luau local getItems, setItems = signal({ a = 0, b = 0 }) --- Added a, Added b observe(getItems, function(value, key) print(`Added {key}`) return function() print(`Removed {key}`) end -end) +end) -- Added a, Added b setItems({ a = 0, c = 0 }) -- Removed b, Added c ``` @@ -431,7 +425,7 @@ local getUntracked, setUntracked = signal(0) effect(function() print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`) -end) -- Output: Tracked: 0, Untracked: 0 +end) -- Tracked: 0, Untracked: 0 setTracked(1) -- Tracked: 1, Untracked: 0 setUntracked(1) -- No output @@ -467,7 +461,7 @@ local getCounter, setCounter = signal(0) local disposeOuter = effect(function() print(`Count is {peek(getCounter)}`) -end) -- Output: Count is 0 +end) -- Count is 0 setCounter(1) -- No output; count was accessed in peek() ``` @@ -530,7 +524,7 @@ print(getSwapped()) -- { a = 1, b = 2, c = 3 } The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect. -If there is no active effect, a warning is logged, unless `failSilently` is set to `true`. +Unless `failSilently` is set to `true`, this function will emit a warning if there is no active effect or scope. ```luau local dispose = effectScope(function() @@ -620,13 +614,13 @@ print(total()) -- 2 Charm exposes the following global flags to customize behavior: -| Flag | Default | Description | -| ----------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| strict | `true` if not `O2` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | -| frozen | `true` if not `O2` | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. | -| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | +| Flag | Default | Description | +| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| strict | `true`\* | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | +| frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | -The `strict` and `frozen` flags are automatically enabled in Roblox Studio. More accurately, they are enabled for the Luau optimization levels `O1` and lower. +The `strict` and `frozen` flags are automatically enabled in Roblox Studio and other development environments where the Luau optimization level is `O1` or lower. --- @@ -746,7 +740,7 @@ Players.PlayerAdded:Connect(function(player) end) ``` -You're also allowed to create new signals to sync to specific players, as long as the key is also unique to that player: +You're also allowed to create new signals to sync to specific players, as long as the key is unique to that player: ```luau server.addSignalsToClient(player, { @@ -899,11 +893,11 @@ This means nilable values may be replaced with `None` in patches, and code worki ## Deep Reactivity -By default, Charm's signals are not deeply reactive. This means that only the value itself is tracked, and table properties are not checked for changes. As a result, signal values must be **immutable** in order to detect changes in tables. +Charm's reactivity system is _shallow_ by default: only the top-level value is reactive, so table properties are not checked when determining whether a signal has updated. As a result, tables in Charm should be immutable (copied before writing) in order to signal that a table's properties have changed. -With deep reactivity, you can mutate tables directly and detect changes in nested properties without cloning tables. In Charm, this is done by wrapping your tables in proxy tables that handle reactivity for you. +Deep reactivity, on the other hand, uses proxy tables to perform dependency tracking on properties. You can subscribe to a property by indexing the proxy table, and setting a property will notify its subscribers. This approach to reactivity lets you work with mutable data, making state management more intuitive at the cost of added overhead. -You can opt-in to deep reactivity with **reactive proxies**: +You can opt-in to deep reactivity with the Deep Charm library: ### Installation @@ -922,7 +916,7 @@ DeepCharm = "littensy/deep-charm@VERSION" ### `reactive(initialValue)` -The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy. +The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and nested tables will also be wrapped in a reactive proxy. ```luau local users, updateUsers = reactive({ @@ -933,10 +927,10 @@ effect(function() print(`{users[1].name} {users[1].surname}`) end) -- Output: John Doe -updateUsers(function(state) - state[1].name = "Jane" - state[1].surname = "Smith" - table.insert(state, { name = "Steve", surname = "Doe" }) +updateUsers(function(raw) + raw[1].name = "Jane" + raw[1].surname = "Smith" + table.insert(raw, { name = "Steve", surname = "Doe" }) end) -- Output: Jane Smith -- You can also mutate the reactive proxy directly: @@ -1019,11 +1013,10 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that > This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. 5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! - - [`reactive()`](#reactiveinitialvalue): make mutable tables deeply reactive - - [`signal()`](#signalinitialvalue-equals): make reads and writes more explicit - - [`listen()`](#listengetter-callback): create a subscription that runs once immediately + - [`signal()`](#signalinitialvalue-equals): make atom reads and writes more explicit + - [`listen()`](#listengetter-callback): create a subscription that also runs once immediately - [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once - - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect + - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect or scope - [`trigger()`](#triggercallback): trigger updates for table mutations --- @@ -1035,10 +1028,14 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that ### React Counter ```luau -local getCounter, setCounter = signal(0) +local Charm = require("@packages/charm") +local ReactCharm = require("@packages/react-charm") +local React = require("@packages/react") + +local getCounter, setCounter = Charm.signal(0) local function Counter() - local count = useSignalState(getCounter) + local count = ReactCharm.useSignalState(getCounter) return React.createElement("TextButton", { [React.Event.Activated] = function() @@ -1053,10 +1050,16 @@ end ### Vide Counter ```luau -local getCounter, setCounter = signal(0) +local Charm = require("@packages/charm") +local VideCharm = require("@packages/vide-charm") +local Vide = require("@packages/vide") + +local create = Vide.create + +local getCounter, setCounter = Charm.signal(0) local function Counter() - local count = useSignalState(getCounter) + local count = VideCharm.useSignalState(getCounter) return create "TextButton" { Activated = function() From 00880bec5572dcdfa34a999068af0114cdefa4a8 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:32:23 -0800 Subject: [PATCH 117/160] feat: export getter and setter types --- packages/charm/src/index.d.ts | 43 ++++++++++++++++------------------- packages/charm/src/init.luau | 40 ++++++++++++++++---------------- 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 554c327..ebbacc9 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1,4 +1,5 @@ type Key = string | number | symbol; + type Cleanup = () => void; export enum ReactiveFlags { @@ -31,11 +32,15 @@ export interface Link { } export interface Atom { - (newValue: T | ((currentValue: T) => T)): T; + (update: Update): T; (): T; } -export type Setter = (newValue: T | ((currentValue: T) => T)) => T; +export type Getter = () => T; + +export type Setter = (update: Update) => T; + +export type Update = ((currentValue: T) => T) | T; export type Equals = (current: T, incoming: T) => boolean; @@ -71,16 +76,8 @@ export const flags: { * @returns A tuple containing the getter and setter functions for the signal. * @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals */ -export function signal( - initialValue: T, - equals?: Equals, -): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>; -export function signal(): LuaTuple< - [ - getter: () => T | undefined, - setter: (newValue: T | undefined | ((currentValue?: T) => T | undefined)) => T | undefined, - ] ->; +export function signal(initialValue: T, equals?: Equals): LuaTuple<[getter: Getter, setter: Setter]>; +export function signal(): LuaTuple<[getter: Getter, setter: Setter]>; /** * Creates a reactive atom that acts as both a signal getter and setter. @@ -102,7 +99,7 @@ export function atom(): Atom; * @param getter A function that produces the next value. * @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter */ -export function computed(getter: (previousValue?: T) => T): () => T; +export function computed(getter: (previousValue?: T) => T): Getter; /** * Creates an effect that runs a callback in response to signal state changes. @@ -205,7 +202,7 @@ export function batch(fn: (...args: Args) => Result, * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback */ -export function listen(getter: () => T, fn: (value: T, previousValue: T | undefined) => void): Cleanup; +export function listen(getter: Getter, fn: (value: T, previousValue: T | undefined) => void): Cleanup; /** * Creates an effect that only runs the callback when the value returned by the @@ -220,7 +217,7 @@ export function listen(getter: () => T, fn: (value: T, previousValue: T | und * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback */ -export function subscribe(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup; +export function subscribe(getter: Getter, fn: (value: T, previousValue: T) => void): Cleanup; /** * Creates a new read-only signal that is computed by mapping over the entries @@ -232,19 +229,19 @@ export function subscribe(getter: () => T, fn: (value: T, previousValue: T) = * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper */ export function mapped( - getter: () => Map | ReadonlyMap, + getter: Getter | ReadonlyMap>, mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, -): () => ReadonlyMap; +): Getter>; // Overload for arrays export function mapped( - getter: () => readonly VI[], + getter: Getter, mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, -): () => K extends number ? readonly VO[] : { readonly [P in K]: VO }; +): Getter; // Overload for objects export function mapped( - getter: () => { readonly [K in KI]: VI }, + getter: Getter<{ readonly [K in KI]: VI }>, mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, -): () => { readonly [K in KO]: VO }; +): Getter<{ readonly [K in KO]: VO }>; /** * Calls the callback function for each unique key in the table returned by the @@ -263,12 +260,12 @@ export function mapped( * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback */ export function observe( - getter: () => Map | ReadonlyMap | readonly V[], + getter: Getter | ReadonlyMap | readonly V[]>, fn: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( - getter: () => { readonly [K in Key]: V }, + getter: Getter<{ readonly [K in Key]: V }>, fn: (value: V, key: K) => Cleanup | void, ): Cleanup; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 650eced..ac90a3d 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -16,9 +16,12 @@ local checkDirty = system.checkDirty local shallowPropagate = system.shallowPropagate local createReactiveSystem = system.createReactiveSystem -type Cleanup = () -> () - -export type Atom = (...((T) -> T) | T) -> T +export type Getter = () -> T +export type Setter = (update: Update) -> T +export type Atom = (...Update) -> T +export type Update = ((current: T) -> T) | T +export type Equals = (current: T, incoming: T) -> boolean +export type Cleanup = () -> () type EffectScopeNode = ReactiveNode & { cleanups: { Cleanup }, @@ -412,10 +415,7 @@ end @return Setter function to update the signal value @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals ]] -local function signal( - initialValue: T, - equals: ((current: T, incoming: T) -> boolean)? -): (() -> T, (((T) -> T) | T) -> T) +local function signal(initialValue: T, equals: Equals?): (Getter, Setter) local this: SignalNode = { currentValue = initialValue, pendingValue = initialValue, @@ -426,7 +426,7 @@ local function signal( deepFreeze(initialValue) end - local function writeSignal(update: ((T) -> T) | T): T + local function writeSignal(update: Update): T local value: T -- Determine the new value based on whether the update is a function or @@ -505,7 +505,7 @@ end @param equals Optional comparator function to check for value changes @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals ]] -local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom +local function atom(initialValue: T, equals: Equals?): Atom local get, set = signal(initialValue, equals) local function atomOper(...) return if select("#", ...) == 0 then get() else set((...)) @@ -522,7 +522,7 @@ end @return A function that returns the cached value @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter ]] -local function computed(getter: (previousValue: T?) -> T): () -> T +local function computed(getter: (previousValue: T?) -> T): Getter local this: ComputedNode = { flags = NONE, getter = noYield(getter), @@ -784,7 +784,7 @@ end @param fn A signal or function that accesses signals @return `fn` wrapped in a computed signal if needed ]] -local function toComputed(fn: () -> T): () -> T +local function toComputed(fn: Getter): Getter local name = debug.info(fn, "n") return if name == "readSignal" or name == "readComputed" or name == "atomOper" then fn else computed(fn) end @@ -802,7 +802,7 @@ end @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] -local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): Cleanup +local function listen(getter: Getter, fn: (state: T, lastState: T?) -> ()): Cleanup local tracked = toComputed(getter) local current: T @@ -826,7 +826,7 @@ end @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] -local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): Cleanup +local function subscribe(getter: Getter, fn: (state: T, lastState: T) -> ()): Cleanup local tracked = toComputed(getter) local current: T local firstRun = true @@ -851,12 +851,12 @@ end key is removed or the `observe()` effect is disposed. Effects created during the callback will also be bound to the lifetime of the key. - @param getter Function that returns a table + @param getter A signal or function that returns a table @param fn Function that is called for each key added to the table @return A cleanup function that disposes scopes for all keys @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] -local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...Cleanup?): Cleanup +local function observe(getter: Getter<{ [K]: V }>, fn: (item: V, key: K) -> ...Cleanup?): Cleanup local innerScopes: { [K]: Cleanup } = {} local disposed = false @@ -907,11 +907,11 @@ end of an existing atom. If the key is omitted from the mapper's return value, the original key is preserved. - @param getter Function that returns a table + @param getter A signal or function that returns a table @param mapper Function that maps each entry from the source to a new entry in the mapped table @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper ]] -local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } +local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> local currentInput: { [KI]: VI } = {} local keyCache: { [KI]: KO } = {} @@ -984,9 +984,9 @@ return { listen = listen, observe = observe, mapped = mapped :: (( - getter: () -> { [KI]: VI }, - mapper: (item: VI, key: KI) -> (VO, KO) - ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), + getter: Getter<{ [KI]: VI }>, + mapper: (VI, KI) -> (VO, KO) + ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, mapper: (VI, K) -> VO) -> Getter<{ [K]: VO }>), onCleanup = onCleanup, getActiveSub = getActiveSub, setActiveSub = setActiveSub, From 50afcaf6e9ae108a5f24a84f3a267456e3a83a0c Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:38:46 -0700 Subject: [PATCH 118/160] docs: rephrase intro again --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7656245..4c5a3f7 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ -Charm is a state management library based on an approach to reactive programming called [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf). Store your game's state in signals, and your code will stay in sync with the data it depends on. +Charm is a state management library that brings the concept of [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) to Roblox. Connect behavior to data with reactive signals, ensuring games stay up-to-date with their underlying data while eliminating the need for manual updates. -**Build complex state from three reactive primitives:** +**Build game state from simple building blocks:** - Store state in [signals](#signalinitialvalue-equals): state containers that hold a value -- React to state changes with [effects](#effectcallback): run code whenever a signal updates -- Derive new values with [computed signals](#computedgetter): memoized functions with automatic dependency tracking +- React to state changes with [effects](#effectcallback): re-run code when a dependency updates +- Derive values from state with [computed signals](#computedgetter): memoized functions with dependency tracking **Want to learn more about signals?** @@ -172,11 +172,11 @@ Signals are the core of reactivity in Charm. The `signal` function creates a rea ```luau local getCounter, setCounter = signal(0) -print(getCounter()) -- 0 setCounter(1) setCounter(function(count) return count + 1 end) +print(getCounter()) -- 2 ``` Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately notify every effect and computed signal that depends on the signal, ensuring all of your state is correct and up-to-date. From 5c27052965e9a15cf45c9273a0a87111b42af3e2 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:42:30 -0700 Subject: [PATCH 119/160] feat!: run listen and subscribe callback in untracked --- README.md | 42 ++++++++-------------- packages/charm-sync/Charm.luau | 2 +- packages/charm/src/init.luau | 4 +-- packages/charm/test/listen.test.luau | 48 +++---------------------- packages/charm/test/subscribe.test.luau | 44 +---------------------- packages/deep-charm/Charm.luau | 6 +--- packages/react-charm/Charm.luau | 6 +--- packages/react-charm/React.luau | 11 ------ packages/react-charm/src/init.luau | 10 ++---- packages/vide-charm/Charm.luau | 6 +--- packages/vide-charm/src/init.luau | 5 +-- 11 files changed, 28 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index 4c5a3f7..dec25b6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ -Charm is a state management library that brings the concept of [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) to Roblox. Connect behavior to data with reactive signals, ensuring games stay up-to-date with their underlying data while eliminating the need for manual updates. +Charm is a state management library based on [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf). Connect behavior to data with reactive signals, ensuring games stay up-to-date with their underlying data while eliminating the need for manual updates. **Build game state from simple building blocks:** @@ -230,7 +230,7 @@ dispose() -- Cleanup 1 ### Nested Effects -Effects can be nested inside other effects. When the outer effect re-runs, inner effects from the previous run are automatically cleaned up, and new inner effects are created if needed. The system ensures proper execution order; outer effects always run before their inner effects: +An effect is _nested_ if it was created during the execution of another effect. In Charm, when an effect with nested effects re-runs or gets cleaned up, the nested effects from the previous run are automatically cleaned up and re-created if needed. This prevents memory leaks and ensures that outer effects always run before their inner effects: ```luau local getPrintCount, setPrintCount = signal(true) @@ -253,8 +253,6 @@ setPrintCount(false) setCount(3) -- No output ``` -Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen()`, `observe()`) because they are built upon effects. - > [!NOTE] > To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `flags.trackInnerEffects` flag to assist with debugging. @@ -332,11 +330,11 @@ end) -- Count is 0 (was nil) setCounter(1) -- Count is 1 (was 0) ``` -Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed. +Note that the listener callback runs in `untracked()`, so nested effects are not cleaned up. ### Getter functions -Reaction APIs like `listen()`, `subscribe()`, and `observe()` aren't only restricted to signals. You can listen to "getter" functions that call one or more signals, and they will automatically be memoized: +In most Charm APIs, you can also subscribe to getter functions that call one or more signals, and they will automatically be tracked: ```luau local getCounter, setCounter = signal(0) @@ -375,7 +373,7 @@ setItems({ a = 0, c = 0 }) -- Removed b, Added c The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: ```luau -local getItems, setItems = signal({ a = 0, b = 0 }) +local getItems, setItems = signal({}) local dispose = observe(getItems, function(value, key) local getValue = computed(function(prevValue) @@ -384,14 +382,15 @@ local dispose = observe(getItems, function(value, key) effect(function() local value = getValue() - print(`{key} = {value}`) + print(`Set {key} = {value}`) return function() print(`Cleanup {key} = {value}`) end end) end) -setItems({ a = 1, b = 0 }) -- Cleanup a = 0, a = 1 +setItems({ a = 0, b = 0 }) -- Set a = 0, Set b = 0 +setItems({ a = 1, b = 0 }) -- Cleanup a = 0, Set a = 1 setItems({ a = 1 }) -- Cleanup b = 0 dispose() -- Cleanup a = 1 ``` @@ -400,7 +399,7 @@ dispose() -- Cleanup a = 1 ### `subscribe(getter, callback)` -The `subscribe` function is identical to `listen()`, but the effect callback skips the initial run. The callback only runs when the value returned by the getter function changes. +The `subscribe` function is identical to `listen()`, but the callback does not run initially. The callback only runs when the value returned by the getter function changes. ```luau local getCounter, setCounter = signal(0) @@ -974,23 +973,15 @@ print(isReactive(raw)) -- false ## Migration -Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that might help you migrate from an older version. +Charm v0.11 introduces several breaking changes, so this section should help you migrate from an older version. -
-Terminology - -- Signal: A state container with one function to get the state, and another to update it -- Atom: A signal with the getter and setter combined into one function -- Effect: Main way to react to state changes -- `subscribe(getter, callback)`: Creates an effect that only subscribes to signals accessed in the getter - -
+For reference, a signal is a state container with a separated getter and setter function. Atoms are a signal with a single function for getting and setting the state. **What to look out for:** 1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: - The second arguments of `atom()` changed from an `options` table to an equality function - - Removed the second argument of `computed()` (you can do your own equality checks now) + - Removed the second argument of `computed()` (you can do your own equality checks since the computed callback now receives the previous value) - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`) 2. If you use Charm Sync, you'll have to rewrite a lot of your sync code. Fortunately, most of the changes should make your code _less_ complicated: @@ -1003,14 +994,9 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations 4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent. - - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`. + - This feature applies to the observer function in `observe()`, which also automatically cleans up inner effects to prevent memory leaks. However, this does not apply to `subscribe()` or `listen()`. - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). - - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`. - -> [!NOTE] -> An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. -> -> This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. + - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can temporarily disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`. 5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! - [`signal()`](#signalinitialvalue-equals): make atom reads and writes more explicit diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index 9a55fe0..480f2d5 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -4,6 +4,6 @@ end local Charm = require(script.Parent.Parent.charm.src) -export type Atom = Charm.Atom +export type Atom = Charm.Atom return Charm diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index ac90a3d..1342c99 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -809,7 +809,7 @@ local function listen(getter: Getter, fn: (state: T, lastState: T?) -> ()) return effect(function() local previous = current current = tracked() - peek(fn, current, previous) + untracked(fn, current, previous) end) end @@ -837,7 +837,7 @@ local function subscribe(getter: Getter, fn: (state: T, lastState: T) -> ( if firstRun then firstRun = false else - peek(fn, current, previous) + untracked(fn, current, previous) end end) end diff --git a/packages/charm/test/listen.test.luau b/packages/charm/test/listen.test.luau index 6fa0912..5d9c335 100644 --- a/packages/charm/test/listen.test.luau +++ b/packages/charm/test/listen.test.luau @@ -3,7 +3,6 @@ local test = require("../../../tests/test") local signal = Charm.signal local listen = Charm.listen -local effect = Charm.effect test("should listen to selector", function() local source, setSource = signal(0) @@ -26,32 +25,23 @@ test("should listen to selector", function() assert(current == 2 and previous == 1, "should not run after unsub") end) -test("should run in peek", function() +test("should run in untracked", function() local runs = 0 - local cleanups = 0 local a, setA = signal(0) local b, setB = signal(0) listen(a, function() runs += 1 b() - effect(function() - return function() - cleanups += 1 - end - end) end) assert(runs == 1, "should run once on init") - assert(cleanups == 0, "should not cleanup yet") - setA(1) - assert(runs == 2, "should run on a change") - assert(cleanups == 1, "should cleanup first call") - + assert(runs == 2, "should run once on change") setB(1) assert(runs == 2, "should not run on b change") - assert(cleanups == 1, "should not cleanup second call") + setA(2) + assert(runs == 3, "should run on a change") end) test("should run if first value is nil", function() @@ -67,36 +57,6 @@ test("should run if first value is nil", function() assert(runs == 2, "should run once on change") end) -test("should track inner subs", function() - local order = {} - local a, setA = signal(1) - - local unsub = listen(a, function() - table.insert(order, 1) - listen(a, function() - table.insert(order, 2) - end) - listen(a, function() - table.insert(order, 3) - end) - end) - - assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`) - - table.clear(order) - setA(a() + 1) - assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) - - table.clear(order) - setA(a() + 1) - assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) - - table.clear(order) - unsub() - setA(a() + 1) - assert(table.concat(order) == "", `bad order: {table.concat(order)}`) -end) - test("should skip redundant updates", function() local order = {} local a, setA = signal(0) diff --git a/packages/charm/test/subscribe.test.luau b/packages/charm/test/subscribe.test.luau index 9c02fd0..6936c7a 100644 --- a/packages/charm/test/subscribe.test.luau +++ b/packages/charm/test/subscribe.test.luau @@ -4,8 +4,6 @@ local test = require("../../../tests/test") local signal = Charm.signal local computed = Charm.computed local subscribe = Charm.subscribe -local listen = Charm.listen -local effect = Charm.effect test("should subscribe to selector", function() local source, setSource = signal(0) @@ -28,36 +26,23 @@ test("should subscribe to selector", function() assert(current == 2 and previous == 1, "should not run after unsub") end) -test("should run in peek", function() +test("should run in untracked", function() local runs = 0 - local cleanups = 0 local a, setA = signal(0) local b, setB = signal(0) subscribe(a, function() runs += 1 b() - effect(function() - return function() - cleanups += 1 - end - end) end) assert(runs == 0, "should not run once on init") - assert(cleanups == 0, "should not cleanup yet") - setA(1) assert(runs == 1, "should run once on change") - assert(cleanups == 0, "should not cleanup yet") - setB(1) assert(runs == 1, "should not run on b change") - assert(cleanups == 0, "should not cleanup first call") - setA(2) assert(runs == 2, "should run on a change") - assert(cleanups == 1, "should cleanup first call") end) test("should ensure correct execution order", function() @@ -86,33 +71,6 @@ test("should ensure correct execution order", function() assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) end) -test("should track inner subs", function() - local order = {} - local a, setA = signal(1) - - local unsub = subscribe(a, function() - table.insert(order, 1) - listen(a, function() - table.insert(order, 2) - end) - listen(a, function() - table.insert(order, 3) - end) - end) - - setA(a() + 1) - assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) - - table.clear(order) - setA(a() + 1) - assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) - - table.clear(order) - unsub() - setA(a() + 1) - assert(table.concat(order) == "", `bad order: {table.concat(order)}`) -end) - test("should skip redundant updates", function() local order = {} local a, setA = signal(0) diff --git a/packages/deep-charm/Charm.luau b/packages/deep-charm/Charm.luau index 9a55fe0..619a57a 100644 --- a/packages/deep-charm/Charm.luau +++ b/packages/deep-charm/Charm.luau @@ -2,8 +2,4 @@ if not script then return require("../charm/src") end -local Charm = require(script.Parent.Parent.charm.src) - -export type Atom = Charm.Atom - -return Charm +return require(script.Parent.Parent.charm.src) diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau index 9588118..49baa5a 100644 --- a/packages/react-charm/Charm.luau +++ b/packages/react-charm/Charm.luau @@ -1,5 +1 @@ -local Charm = require(script.Parent.Parent.charm.src) - -export type Atom = Charm.Atom - -return Charm +return require(script.Parent.Parent.charm.src) diff --git a/packages/react-charm/React.luau b/packages/react-charm/React.luau index dd59e59..1c7cfd8 100644 --- a/packages/react-charm/React.luau +++ b/packages/react-charm/React.luau @@ -1,12 +1 @@ -type CoreReactBinding = { - getValue: (self: CoreReactBinding) -> T, - _source: string?, -} - -type ReactBindingMap = { - map: (self: CoreReactBinding & ReactBindingMap, (T) -> U) -> ReactBindingMap & CoreReactBinding, -} - -export type Binding = CoreReactBinding & ReactBindingMap - return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React) diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index b626b3f..205514a 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -17,10 +17,7 @@ local function useSignalState(getter: () -> T, dependencies: { any }?): T local value, setValue = React.useState(getter) React.useLayoutEffect(function() - return Charm.effect(function() - -- Wrap in untracked to avoid tracking inner effect and atom calls - Charm.untracked(setValue, getter()) - end) + return Charm.listen(getter, setValue) end, dependencies or {}) return value @@ -42,10 +39,7 @@ local function useSignalBinding(getter: () -> T, dependencies: { any }?) local binding, setBinding = React.useBinding(initialValue) React.useLayoutEffect(function() - return Charm.effect(function() - -- Wrap in untracked to avoid tracking inner effect and atom calls - Charm.untracked(setBinding, getter()) - end) + return Charm.listen(getter, setBinding) end, dependencies or {}) return binding diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau index 9588118..49baa5a 100644 --- a/packages/vide-charm/Charm.luau +++ b/packages/vide-charm/Charm.luau @@ -1,5 +1 @@ -local Charm = require(script.Parent.Parent.charm.src) - -export type Atom = Charm.Atom - -return Charm +return require(script.Parent.Parent.charm.src) diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index 073e759..db9d2e7 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -11,10 +11,7 @@ local Vide = require("./Vide") local function useSignalState(getter: () -> State): () -> State local state = Vide.source(getter()) - Vide.cleanup(Charm.effect(function() - -- Wrap in untracked to avoid tracking inner effect and atom calls - Charm.untracked(state :: (State) -> State, getter()) - end)) + Vide.cleanup(Charm.listen(getter, state)) return state end From fd6b3efbb51e07268db2c7bb5a25d70fcf9685f6 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:08:28 -0700 Subject: [PATCH 120/160] refactor!: remove peek --- README.md | 17 ---- packages/charm/src/index.d.ts | 33 ++----- packages/charm/src/init.luau | 50 ++-------- packages/charm/src/system.luau | 20 ++-- packages/charm/test/init.luau | 66 +++++++++---- packages/charm/test/peek.test.luau | 146 ----------------------------- 6 files changed, 70 insertions(+), 262 deletions(-) delete mode 100644 packages/charm/test/peek.test.luau diff --git a/README.md b/README.md index dec25b6..4945d65 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,6 @@ Charm is a state management library based on [fine-grained reactivity](https://d - [`observe(getter, callback)`](#observegetter-callback) - [`subscribe(getter, callback)`](#subscribegetter-callback) - [`untracked(callback)`](#untrackedcallback) - - [`peek(callback)`](#peekcallback) - [`batch(callback)`](#batchcallback) - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) @@ -451,22 +450,6 @@ stopEffect() -- Cleaned up untracked effect --- -### `peek(callback)` - -Similar to `untracked()`, but does not prevent the parent effect from tracking nested effects created in the callback. If your callback reads a signal and creates effects, only the signals get untracked. - -```luau -local getCounter, setCounter = signal(0) - -local disposeOuter = effect(function() - print(`Count is {peek(getCounter)}`) -end) -- Count is 0 - -setCounter(1) -- No output; count was accessed in peek() -``` - ---- - ### `batch(callback)` Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index ebbacc9..ae37672 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -3,14 +3,13 @@ type Key = string | number | symbol; type Cleanup = () => void; export enum ReactiveFlags { - None = 0b0000000, - Mutable = 0b0000001, - Watching = 0b0000010, - RecursedCheck = 0b0000100, - Recursed = 0b0001000, - Dirty = 0b0010000, - Pending = 0b0100000, - Peeking = 0b1000000, + None = 0b000000, + Mutable = 0b000001, + Watching = 0b000010, + RecursedCheck = 0b000100, + Recursed = 0b001000, + Dirty = 0b010000, + Pending = 0b100000, } export interface ReactiveNode { @@ -154,7 +153,8 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void; * current effect or scope from tracking inner effects created within the * function. * - * For an alternative that allows nested effect cleanup, use `peek` instead. + * To avoid tracking signals while still allowing inner effects to be tracked + * by parent effects/scopes, use `effectScope()` instead. * * @param fn A function that may read signals or create effects. * @param args Arguments to pass to the function. @@ -163,21 +163,6 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void; */ export function untracked(fn: (...args: Args) => Result, ...args: Args): Result; -/** - * Runs the function without subscribing to signal updates, but still allows - * inner effects to be tracked by parent effects/scopes. - * - * This is useful for cases where you want to access the current value of a - * signal without creating a dependency, but still want any effects created - * within the function to be cleaned up properly. - * - * @param fn A function that may read signals. - * @param args Arguments to pass to the function. - * @returns The return value of the function. - * @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback - */ -export function peek(fn: (...args: Args) => Result, ...args: Args): Result; - /** * Combines multiple updates into a single "commit" that runs effects and * computed signals after the provided function finishes running. diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 1342c99..c2c9275 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -44,13 +44,12 @@ type SignalNode = ReactiveNode & { -- Bitmask flags for tracking the state of reactive nodes. Flags are combined -- and checked using bitwise operations for efficiency. -local NONE = 0b0000000 -- Default state -local MUTABLE = 0b0000001 -- Can be changed (signal or computed) -local WATCHING = 0b0000010 -- Watching for changes (effects) -local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies -local DIRTY = 0b0010000 -- Value has changed and needs updating -local PENDING = 0b0100000 -- Might be dirty, pending verification -local PEEKING = 0b1000000 -- Blocking dependency tracking +local NONE = 0b000000 -- Default state +local MUTABLE = 0b000001 -- Can be changed (signal or computed) +local WATCHING = 0b000010 -- Watching for changes (effects) +local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies +local DIRTY = 0b010000 -- Value has changed and needs updating +local PENDING = 0b100000 -- Might be dirty, pending verification -- Global version counter for dependency link deduplication. Incremented at the -- start of each effect/computed run to ensure links created in the same cycle @@ -703,7 +702,8 @@ end current effect or scope from tracking inner effects created within the function. - For an alternative that allows nested effect cleanup, use `peek` instead. + To avoid tracking signals while still allowing inner effects to be tracked + by parent effects/scopes, use `effectScope()` instead. @param fn A signal or function that accesses signals @param ...args Arguments to pass to the function @@ -724,39 +724,6 @@ local function untracked(fn: (Args...) -> Results..., ...: end end ---[[ - Runs the function without subscribing to signal updates, but still allows - inner effects to be tracked by parent effects/scopes. - - This is useful for cases where you want to access the current value of a - signal without creating a dependency, but still want any effects created - within the function to be cleaned up properly. - - To avoid capturing inner effects, use `untracked` instead. - - @param fn A signal or function that accesses signals - @param ...args Arguments to pass to the function - @return Values returned by the function - @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback -]] -local function peek(fn: (Args...) -> Results..., ...: Args...): Results... - if - activeSub - and bit32.band(activeSub.flags, PEEKING) == 0 - and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE)) - then - activeSub.flags = bit32.bor(activeSub.flags, PEEKING) - local results: { any } = { pcall(noYield(fn), ...) } - activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING)) - if not results[1] then - error(results[2]) - end - return unpack(results, 2) - else - return fn(...) - end -end - --[[ Combines multiple updates into a single "commit" that runs effects and computed signals after the provided callback finishes running. @@ -978,7 +945,6 @@ return { effectScope = effectScope, trigger = trigger, untracked = untracked, - peek = peek, batch = batch, subscribe = subscribe, listen = listen, diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index d67efb5..10ad273 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -28,14 +28,13 @@ type 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 PEEKING = 0b1000000 +local NONE = 0b000000 +local MUTABLE = 0b000001 +local WATCHING = 0b000010 +local RECURSED_CHECK = 0b000100 +local RECURSED = 0b001000 +local DIRTY = 0b010000 +local PENDING = 0b100000 local ReactiveFlags = table.freeze({ None = NONE, @@ -45,7 +44,6 @@ local ReactiveFlags = table.freeze({ Recursed = RECURSED, Dirty = DIRTY, Pending = PENDING, - Peeking = PEEKING, }) local update: (sub: ReactiveNode) -> boolean @@ -64,10 +62,6 @@ local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean end local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) - -- DEVIATION: Do not link signals to peeking subscribers - if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then - return - end local prevDep = sub.depsTail if prevDep and prevDep.dep == dep then return diff --git a/packages/charm/test/init.luau b/packages/charm/test/init.luau index bd58a2f..e13fab7 100644 --- a/packages/charm/test/init.luau +++ b/packages/charm/test/init.luau @@ -1,25 +1,51 @@ local test = require("../../tests/test") -local tests = { - "computed", - "effect", - "effectScope", - "gc", - "listen", - "mapped", - "observe", - "peek", - "signal", - "subscribe", - "topology", - "trigger", - "untracked", -} +test.describe("computed", function() + require("@self/computed.test") +end) -for _, name in tests do - test.describe(name, function() - (require)(`@self/{name}.test`) - end) -end +test.describe("effect", function() + require("@self/effect.test") +end) + +test.describe("effectScope", function() + require("@self/effectScope.test") +end) + +test.describe("gc", function() + require("@self/gc.test") +end) + +test.describe("listen", function() + require("@self/listen.test") +end) + +test.describe("mapped", function() + require("@self/mapped.test") +end) + +test.describe("observe", function() + require("@self/observe.test") +end) + +test.describe("signal", function() + require("@self/signal.test") +end) + +test.describe("subscribe", function() + require("@self/subscribe.test") +end) + +test.describe("topology", function() + require("@self/topology.test") +end) + +test.describe("trigger", function() + require("@self/trigger.test") +end) + +test.describe("untracked", function() + require("@self/untracked.test") +end) return {} diff --git a/packages/charm/test/peek.test.luau b/packages/charm/test/peek.test.luau deleted file mode 100644 index 3fb1823..0000000 --- a/packages/charm/test/peek.test.luau +++ /dev/null @@ -1,146 +0,0 @@ -local Charm = require("../src") -local test = require("../../../tests/test") - -local signal = Charm.signal -local computed = Charm.computed -local effect = Charm.effect -local peek = Charm.peek - -test("should not run detached", function() - local a, setA = signal(1) - local spyCalls = 0 - - effect(function() - a() - peek(function() - effect(function() - return function() - spyCalls += 1 - end - end) - end) - end) - - assert(spyCalls == 0, "should not be called yet") - setA(2) - assert(spyCalls == 1, "should be called once") - setA(3) - assert(spyCalls == 2, "should be called twice") -end) - -test("should block tracking inside effects", function() - local a, setA = signal(1) - local b, setB = signal(2) - local spyCalls = 0 - - effect(function() - peek(function() - spyCalls += 1 - return a() + b() - end) - end) - assert(spyCalls == 1, "should be called once") - - setA(10) - setB(20) - assert(spyCalls == 1, "should be called once") -end) - -test("should block tracking even when run inside effect run inside peek", function() - local s, setS = signal(1) - local spyCalls = 0 - - peek(function() - effect(function() - peek(function() - spyCalls += 1 - return s() - end) - end) - end) - assert(spyCalls == 1, "should be called once") - - setS(2) - assert(spyCalls == 1, "should be called once") -end) - -test("should not cause signal assignments to throw", function() - local a, setA = signal(1) - local aChangedTime, setAChangedTime = signal(0) - - local dispose = effect(function() - a() - peek(function() - setAChangedTime(aChangedTime() + 1) - end) - end) - - setA(2) - assert(aChangedTime() == 2, "aChangedTime should be 2") - setA(3) - assert(aChangedTime() == 3, "aChangedTime should be 3") - - dispose() -end) - -test("should block tracking inside computed signals", function() - local a, setA = signal(1) - local b, setB = signal(2) - local spyCalls = 0 - local c = computed(function() - return peek(function() - spyCalls += 1 - return a() + b() - end) - end) - - assert(spyCalls == 0, "should not be called yet") - assert(c() == 3, "c should be 3") - setA(10) - c() - setB(20) - c() - assert(spyCalls == 1, "should be called once") - assert(c() == 3, "c should be 3") -end) - -test("should track outside peek", function() - local a, setA = signal(1) - local b, setB = signal(2) - local c, setC = signal(3) - local spyCalls = 0 - - effect(function() - a() - peek(c) - b() - spyCalls += 1 - end) - - assert(spyCalls == 1, "should be called once") - setA(10) - assert(spyCalls == 2, "should be called twice") - setB(20) - assert(spyCalls == 3, "should be called three times") - setC(30) - assert(spyCalls == 3, "should not run on c change") -end) - -test("should stack peek", function() - local a, setA = signal(1) - local spyCalls = 0 - - effect(function() - peek(function() - peek(a) - return a() - end) - spyCalls += 1 - end) - - assert(spyCalls == 1, "should be called once") - setA(2) - assert(spyCalls == 1, "should not be called twice") -end) - -return {} From 9f7047d4846c35069d70400e95ee59bed0b23549 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:09:29 -0700 Subject: [PATCH 121/160] docs: note peek in migration guide --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4945d65..71d5318 100644 --- a/README.md +++ b/README.md @@ -963,6 +963,7 @@ For reference, a signal is a state container with a separated getter and setter **What to look out for:** 1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: + - `peek()` was changed to `untracked()` for parity with other state managers - The second arguments of `atom()` changed from an `options` table to an equality function - Removed the second argument of `computed()` (you can do your own equality checks since the computed callback now receives the previous value) - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`) From c5753ea71aeab9ad18828c3223ca082c7d220b5a Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:01:12 -0700 Subject: [PATCH 122/160] release: charm v0.11.0-rc.5 --- packages/charm-sync/wally.toml | 2 +- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/deep-charm/wally.toml | 2 +- packages/react-charm/wally.toml | 2 +- packages/vide-charm/wally.toml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 55d4eea..7fc9779 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.3" +Charm = "littensy/charm@^0.11.0-rc.5" diff --git a/packages/charm/package.json b/packages/charm/package.json index 92079ec..e0701d7 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0-rc.4", + "version": "0.11.0-rc.5", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 1578c0a..4c76e65 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0-rc.4" +version = "0.11.0-rc.5" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml index 7ef15ca..bbd35f0 100644 --- a/packages/deep-charm/wally.toml +++ b/packages/deep-charm/wally.toml @@ -6,4 +6,4 @@ realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.3" +Charm = "littensy/charm@^0.11.0-rc.5" diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 3a06c66..12dd094 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.3" +Charm = "littensy/charm@^0.11.0-rc.5" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index fb4d378..8a15227 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.3" +Charm = "littensy/charm@^0.11.0-rc.5" Vide = "centau/vide@^0.4" From 9d96c1bc05a353dd37ad1426f3a6d33399555494 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:07:58 -0700 Subject: [PATCH 123/160] release: charm-sync v0.4.0-rc.7 --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index db09da2..3efa745 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0-rc.6", + "version": "0.4.0-rc.7", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 7fc9779..8639cee 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0-rc.6" +version = "0.4.0-rc.7" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ From 81d547ae82cb3f8a923241db17089daacd311a62 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:11:08 -0700 Subject: [PATCH 124/160] release: react-charm v0.4.0-rc.4 --- packages/react-charm/package.json | 2 +- packages/react-charm/wally.toml | 2 +- packages/vide-charm/package.json | 2 +- packages/vide-charm/wally.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index 4976c98..d58d8c7 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.0-rc.3", + "version": "0.4.0-rc.4", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 12dd094..fb7fa9f 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.4.0-rc.3" +version = "0.4.0-rc.4" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 6a7ed72..2b4517a 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.0-rc.3", + "version": "0.4.0-rc.4", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 8a15227..6bb3d02 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.4.0-rc.4" +version = "0.4.0-rc.5" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ From e426e76ff1ef622458302711b5bb1b6af7fd75f9 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 27 Mar 2026 04:39:02 -0700 Subject: [PATCH 125/160] fix: remove old keys when updating key in `mapped()` --- packages/charm/src/init.luau | 72 +++++++++++++++++----------- packages/charm/test/mapped.test.luau | 21 ++++++++ 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index c2c9275..8dc0ff9 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -874,62 +874,76 @@ end of an existing atom. If the key is omitted from the mapper's return value, the original key is preserved. + This function is optimized to minimize unnecessary updates by only calling + the mapper for entries that have changed since the last run. + @param getter A signal or function that returns a table @param mapper Function that maps each entry from the source to a new entry in the mapped table @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper ]] local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> - local currentInput: { [KI]: VI } = {} - local keyCache: { [KI]: KO } = {} + local nextInput: { [KI]: VI } = {} + local keyMap: { [KI]: KO } = {} - local function map(value: VI, key: KI): (VO, KO) + local function callMapper(value: VI, key: KI): (VO, KO) local prevSub = setActiveSub(nil) local success, valueOut, keyOut = pcall(mapper, value, key) activeSub = prevSub if not success then error(`mapped() transform function failed: {valueOut}`) end - return valueOut, if keyOut == nil then key else keyOut :: any + return valueOut, if keyOut == nil then key :: any else keyOut end - return computed(function(previousOutput) - local currentOutput: { [KO]: VO } = previousOutput or {} - local previousInput = currentInput - currentInput = getter() + return computed(function(lastOutput) + local nextOutput = lastOutput or {} + local prevInput = nextInput + nextInput = getter() - for keyIn in previousInput do - if currentInput[keyIn] ~= nil then - continue - end - - local prevKeyOut = keyCache[keyIn] - - if prevKeyOut then - if currentOutput == previousOutput then - currentOutput = table.clone(currentOutput) + -- For keys that were removed from the input, remove the corresponding + -- keys from the output. + for prevKeyIn in prevInput do + if nextInput[prevKeyIn] == nil then + local prevKeyOut = keyMap[prevKeyIn] + if prevKeyOut ~= nil then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) + end + nextOutput[prevKeyOut] = nil + keyMap[prevKeyIn] = nil end - currentOutput[prevKeyOut] = nil - keyCache[keyIn] = nil end end - for keyIn, valueIn in currentInput do - if valueIn == previousInput[keyIn] then + -- For keys that are new or changed, pass it to the mapper and update + -- the output. If the mapper returns a new key that is different from + -- the previous call, the old mapped key should be removed from the + -- output. + for nextKeyIn, nextValueIn in nextInput do + if nextValueIn == prevInput[nextKeyIn] then continue end - local valueOut, keyOut = map(valueIn, keyIn) + local nextValueOut, nextKeyOut = callMapper(nextValueIn, nextKeyIn) + local prevKeyOut = keyMap[nextKeyIn] - if currentOutput[keyOut] ~= valueOut then - if currentOutput == previousOutput then - currentOutput = table.clone(currentOutput) + if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) end - currentOutput[keyOut] = valueOut - keyCache[keyIn] = keyOut + nextOutput[prevKeyOut] = nil + nextOutput[nextKeyOut] = nextValueOut + keyMap[nextKeyIn] = nextKeyOut + elseif nextOutput[nextKeyOut] ~= nextValueOut then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) + end + nextOutput[nextKeyOut] = nextValueOut + keyMap[nextKeyIn] = nextKeyOut end end - return currentOutput + return nextOutput end) end diff --git a/packages/charm/test/mapped.test.luau b/packages/charm/test/mapped.test.luau index 6792469..33620d4 100644 --- a/packages/charm/test/mapped.test.luau +++ b/packages/charm/test/mapped.test.luau @@ -104,4 +104,25 @@ test("should map keys", function() assert(state.D == 4, "should add d") end) +test("should remove old key if mapper returns a new key", function() + local src, setSrc = signal({ 1 }) + local map = mapped(src, function(value: number, index: number) + return true, `{index}={value}` + end) + + local state = map() + assert(state["1=1"], "should have mapped first element to key 1=1") + + setSrc({ 2 }) + state = map() + assert(state["1=1"] == nil, "should have removed old key 1=1") + assert(state["1=2"], "should have remapped element to key 1=2") + + setSrc({ 3, 1 }) + state = map() + assert(state["1=2"] == nil, "should have removed old key 1=2") + assert(state["1=3"], "should have remapped element to key 1=3") + assert(state["2=1"], "should have mapped second element to key 2=1") +end) + return {} From 890ff2cf0787ba340114cd1c21a05057a540f834 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:25:33 -0700 Subject: [PATCH 126/160] refactor: shorten error traceback, housekeeping --- packages/charm/src/index.d.ts | 6 -- packages/charm/src/init.luau | 133 ++++++++++++++------------ packages/charm/test/trigger.test.luau | 17 +++- 3 files changed, 86 insertions(+), 70 deletions(-) diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index ae37672..749f074 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -179,9 +179,6 @@ export function batch(fn: (...args: Args) => Result, * when the value returned by the getter changes. The callback receives the new * value and the previous value as arguments. * - * Effects created within the callback are cleaned up when the listener runs - * or the listener is disposed. - * * @param getter A function that returns the value to watch. * @param fn The function to run when the value changes. * @returns A function for disposing the effect. @@ -194,9 +191,6 @@ export function listen(getter: Getter, fn: (value: T, previousValue: T | u * getter changes. The callback receives the new value and the previous value * as arguments. * - * Effects created within the callback are cleaned up when the listener runs - * or the listener is disposed. - * * @param getter A function that returns the value to watch. * @param fn The function to run when the value changes. * @returns A function for disposing the effect. diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 8dc0ff9..6c9195e 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -103,18 +103,18 @@ local function noYield(fn: (Args...) -> Results...): (Args. return fn end - local function resultHandler(thread: thread, ok: boolean, ...) + local function noYieldHandler(thread: thread, ok: boolean, ...) if not ok then - error(debug.traceback(thread, tostring((...)))) + error(debug.traceback(thread, tostring((...))), 0) elseif coroutine.status(thread) ~= "dead" then - error(debug.traceback(thread, "Yielding is not allowed in strict mode")) + error(debug.traceback(thread, "Attempt to yield in an effect or computed signal"), 0) end return ... end return function(...) local thread = coroutine.create(fn) - return resultHandler(thread, coroutine.resume(thread, ...)) + return noYieldHandler(thread, coroutine.resume(thread, ...)) end end @@ -178,12 +178,15 @@ end ]] local function runCleanups(cleanups: { Cleanup }) for key, cleanup in cleanups do - local prevSub = setActiveSub(nil) cleanups[key] = nil - xpcall(cleanup, function(err) - warn("Error occurred during effect cleanup:", debug.traceback(tostring(err), 2)) - end) + + local prevSub = setActiveSub(nil) + local success, result: any = pcall(cleanup) activeSub = prevSub + + if not success then + warn("Error occurred during effect cleanup:", result) + end end end @@ -210,7 +213,7 @@ local function run(effect: EffectNode) purgeDeps(effect) if not success then - error(result) + error(result, 0) elseif result then table.insert(effect.cleanups, noYield(result)) end @@ -278,7 +281,7 @@ local function updateComputed(computed: ComputedNode): boolean computed.value = newValue return oldValue ~= newValue else - error(newValue) + error(newValue, 0) end end @@ -438,7 +441,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette if success then value = result else - error(result) + error(result, 0) end else value = update @@ -468,7 +471,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette end local function readSignal(): T - -- Check if the signal needs to be updated (for signals within effects) + -- Check if the signal has a pending update that needs to be applied if bit32.btest(this.flags, DIRTY) then if updateSignal(this) then local subs = this.subs @@ -478,7 +481,8 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette end end - -- Register this signal as a dependency of the active subscriber + -- Register this signal as a dependency of the first subscriber in the + -- chain that is a computed signal or an effect local sub = activeSub while sub do if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then @@ -527,7 +531,7 @@ local function computed(getter: (previousValue: T?) -> T): Getter getter = noYield(getter), } - local function readComputed(): T + local function computedOper(): T local flags = this.flags if @@ -554,7 +558,7 @@ local function computed(getter: (previousValue: T?) -> T): Getter if success then this.value = result else - error(result) + error(result, 0) end end @@ -565,7 +569,7 @@ local function computed(getter: (previousValue: T?) -> T): Getter return this.value :: T end - return readComputed + return computedOper end --[[ @@ -599,7 +603,7 @@ local function effect(fn: () -> ...Cleanup?): Cleanup activeSub = prevSub if not success then - error(result) + error(result, 0) elseif result then table.insert(this.cleanups, noYield(result)) end @@ -633,7 +637,7 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup activeSub = prevSub if not success then - error(result) + error(result, 0) elseif result then table.insert(this.cleanups, noYield(result)) end @@ -660,7 +664,7 @@ local function trigger(fn: () -> ()) activeSub = prevSub if not success then - error(result) + error(result, 0) end local link = sub.deps @@ -715,9 +719,11 @@ local function untracked(fn: (Args...) -> Results..., ...: local prevSub = setActiveSub(nil) local results: { any } = { pcall(noYield(fn), ...) } activeSub = prevSub + if not results[1] then - error(results[2]) + error(results[2], 0) end + return unpack(results, 2) else return fn(...) @@ -737,40 +743,39 @@ local function batch(fn: (Args...) -> Results..., ...: Args startBatch() local results: { any } = { pcall(noYield(fn), ...) } endBatch() + if not results[1] then - error(results[2]) + error(results[2], 0) end + return unpack(results, 2) end --[[ - Wraps a function in a computed signal if it is not already a reactive - signal. Useful for memoizing a getter function without creating unnecessary - nested signals. + Returns true if the given function is a signal getter, computed signal, or + atom function. Used for determining whether a function should be wrapped in + a computed signal so that it can be memoized in `listen()` and `subscribe()`. - @param fn A signal or function that accesses signals - @return `fn` wrapped in a computed signal if needed + @param fn Function to check + @return `true` if `fn` is a signal getter, computed signal, or atom function, `false` otherwise ]] -local function toComputed(fn: Getter): Getter +local function isSignalOrComputed(fn: Getter): boolean local name = debug.info(fn, "n") - return if name == "readSignal" or name == "readComputed" or name == "atomOper" then fn else computed(fn) + return name == "readSignal" or name == "computedOper" or name == "atomOper" end --[[ - Creates an effect that runs the callback one immediately, and then again + Creates an effect that runs the callback once immediately, and then again when the value returned by the getter changes. The callback receives the new value and the previous value as arguments. - Effects created within the callback are cleaned up when the listener runs - or the listener is disposed. - @param getter A signal or function that accesses signals @param fn Function to run when the value changes @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] local function listen(getter: Getter, fn: (state: T, lastState: T?) -> ()): Cleanup - local tracked = toComputed(getter) + local tracked: Getter = if isSignalOrComputed(getter) then getter else computed(getter) local current: T return effect(function() @@ -785,16 +790,13 @@ end the getter changes. The callback receives the new value and the previous value as arguments. - Effects created within the callback are cleaned up when the listener runs - or the listener is disposed. - @param getter A signal or function that accesses signals @param fn Function to run when the value changes @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] local function subscribe(getter: Getter, fn: (state: T, lastState: T) -> ()): Cleanup - local tracked = toComputed(getter) + local tracked: Getter = if isSignalOrComputed(getter) then getter else computed(getter) local current: T local firstRun = true @@ -824,29 +826,30 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] local function observe(getter: Getter<{ [K]: V }>, fn: (item: V, key: K) -> ...Cleanup?): Cleanup - local innerScopes: { [K]: Cleanup } = {} - local disposed = false + local scopes: { [K]: Cleanup } = {} + local stopObserving = false local function updateScopes(data: { [K]: V }) - for key, dispose in innerScopes do + for key, dispose in scopes do if data[key] == nil then - innerScopes[key] = nil + scopes[key] = nil dispose() - if disposed then + if stopObserving then return end end end for key, value in data do - if innerScopes[key] == nil then - local disposeScope = effectScope(function() + if scopes[key] == nil then + local dispose = effectScope(function() return fn(value, key) end, true) - if disposed then - return disposeScope() + if stopObserving then + dispose() + return end - innerScopes[key] = disposeScope + scopes[key] = dispose end end end @@ -860,9 +863,9 @@ local function observe(getter: Getter<{ [K]: V }>, fn: (item: V, key: K) - end) return function() - disposed = true - for key, dispose in innerScopes do - innerScopes[key] = nil + stopObserving = true + for key, dispose in scopes do + scopes[key] = nil dispose() end end @@ -882,16 +885,19 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper ]] local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> + local map = noYield(mapper) local nextInput: { [KI]: VI } = {} local keyMap: { [KI]: KO } = {} - local function callMapper(value: VI, key: KI): (VO, KO) + local function mapUntracked(value: VI, key: KI): (VO, KO) local prevSub = setActiveSub(nil) - local success, valueOut, keyOut = pcall(mapper, value, key) + local success, valueOut, keyOut = pcall(map, value, key) activeSub = prevSub + if not success then - error(`mapped() transform function failed: {valueOut}`) + error(`mapped() transform function failed: {valueOut}`, 0) end + return valueOut, if keyOut == nil then key :: any else keyOut end @@ -903,15 +909,18 @@ local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, -- For keys that were removed from the input, remove the corresponding -- keys from the output. for prevKeyIn in prevInput do - if nextInput[prevKeyIn] == nil then - local prevKeyOut = keyMap[prevKeyIn] - if prevKeyOut ~= nil then - if nextOutput == lastOutput then - nextOutput = table.clone(nextOutput) - end - nextOutput[prevKeyOut] = nil - keyMap[prevKeyIn] = nil + if nextInput[prevKeyIn] ~= nil then + continue + end + + local prevKeyOut = keyMap[prevKeyIn] + + if prevKeyOut ~= nil then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) end + nextOutput[prevKeyOut] = nil + keyMap[prevKeyIn] = nil end end @@ -924,7 +933,7 @@ local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, continue end - local nextValueOut, nextKeyOut = callMapper(nextValueIn, nextKeyIn) + local nextValueOut, nextKeyOut = mapUntracked(nextValueIn, nextKeyIn) local prevKeyOut = keyMap[nextKeyIn] if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then diff --git a/packages/charm/test/trigger.test.luau b/packages/charm/test/trigger.test.luau index c548a86..20ebf32 100644 --- a/packages/charm/test/trigger.test.luau +++ b/packages/charm/test/trigger.test.luau @@ -3,6 +3,7 @@ local test = require("../../../tests/test") local computed = Charm.computed local effect = Charm.effect +local listen = Charm.listen local signal = Charm.signal local subscribe = Charm.subscribe local trigger = Charm.trigger @@ -67,7 +68,7 @@ test("should trigger effect once", function() end) test("should not notify the trigger function sub", function() - local src1 = signal({}) + local src1 = signal({} :: { number }) local src2 = computed(function() return src1() end) @@ -90,9 +91,21 @@ test("should trigger updates for subscribe", function() triggers += 1 end) - table.insert(src(), 1) trigger(src) assert(triggers == 1, "subscribe callback should run after trigger") end) +test("should trigger updates for listen", function() + local src = signal({} :: { number }) + local triggers = 0 + + listen(src, function() + triggers += 1 + end) + assert(triggers == 1, "listen callback should run once initially") + + trigger(src) + assert(triggers == 2, "listen callback should run after trigger") +end) + return {} From 1f4c7c7a65b74c62b98fe6ef3f6ad97b265009f1 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:14:33 -0700 Subject: [PATCH 127/160] refactor: keep error level in some cases --- packages/charm/src/init.luau | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 6c9195e..dfaa6f4 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -441,7 +441,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette if success then value = result else - error(result, 0) + error(result, 2) end else value = update @@ -558,7 +558,7 @@ local function computed(getter: (previousValue: T?) -> T): Getter if success then this.value = result else - error(result, 0) + error(result, 2) end end @@ -603,7 +603,7 @@ local function effect(fn: () -> ...Cleanup?): Cleanup activeSub = prevSub if not success then - error(result, 0) + error(result, 2) elseif result then table.insert(this.cleanups, noYield(result)) end @@ -637,7 +637,7 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup activeSub = prevSub if not success then - error(result, 0) + error(result, 2) elseif result then table.insert(this.cleanups, noYield(result)) end @@ -664,7 +664,7 @@ local function trigger(fn: () -> ()) activeSub = prevSub if not success then - error(result, 0) + error(result, 2) end local link = sub.deps @@ -721,7 +721,7 @@ local function untracked(fn: (Args...) -> Results..., ...: activeSub = prevSub if not results[1] then - error(results[2], 0) + error(results[2], 2) end return unpack(results, 2) @@ -745,7 +745,7 @@ local function batch(fn: (Args...) -> Results..., ...: Args endBatch() if not results[1] then - error(results[2], 0) + error(results[2], 2) end return unpack(results, 2) From 4a48dacfbdd6d746fe2686d953bafcb4b52673fa Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Mon, 30 Mar 2026 04:11:57 -0700 Subject: [PATCH 128/160] fix: check if cleanup functions disposed effect --- packages/charm/src/init.luau | 8 +++ packages/charm/test/effect.test.luau | 76 ++++++++++++++++++++++++++++ rokit.toml | 2 +- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index dfaa6f4..2273f1b 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -202,6 +202,10 @@ local function run(effect: EffectNode) if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then runCleanups(effect.cleanups) + if effect.flags == NONE then + return + end + cycle += 1 effect.depsTail = nil effect.flags = WATCHING @@ -217,6 +221,10 @@ local function run(effect: EffectNode) elseif result then table.insert(effect.cleanups, noYield(result)) end + + if effect.flags == NONE then + runCleanups(effect.cleanups) + end else effect.flags = WATCHING end diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau index 9b526c4..6ed18fe 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -375,4 +375,80 @@ test("infinite recursion should eventually throw", function() assert(not success, "should have thrown") end) +test("should not retrigger the effect if the cleanup modifies one of the dependencies", function() + local a, setA = signal(0) + local calls = {} + + effect(function() + table.insert(calls, a()) + return function() + setA(2) + end + end) + assert(#calls == 1, "should have run once initially") + table.clear(calls) + + setA(1) + assert(#calls == 1, "should run once after an update") + assert(calls[1] == 2, `bad call: {calls[1]}`) +end) + +test("should run the cleanup if the effect disposes itself", function() + local a, setA = signal(0) + local calls = 0 + local dispose + + dispose = effect(function() + if a() > 0 then + dispose() + return function() + calls += 1 + end + end + end) + assert(calls == 0, "should not have called cleanup initially") + setA(1) + assert(calls == 1, "should have called cleanup once after first update") + setA(2) + assert(calls == 1, "should not call cleanup again after second update") +end) + +test("should not run the effect if the cleanup function disposes it", function() + local a, setA = signal(0) + local calls = 0 + local dispose + + dispose = effect(function() + a() + calls += 1 + return function() + dispose() + end + end) + assert(calls == 1, "should have run effect once initially") + setA(1) + assert(calls == 1, "should not run effect again after update") +end) + +test("should reset the cleanup if the effect throws", function() + local a, setA = signal(0) + local calls = 0 + + effect(function() + if a() == 0 then + return function() + calls += 1 + end + else + error("hello") + end + end) + assert(calls == 0, "should not have called cleanup initially") + local success, err = pcall(setA, 1) + assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`) + assert(calls == 1, "should have called cleanup once after throwing") + setA(0) + assert(calls == 1, "should not call cleanup again after resetting value") +end) + return {} diff --git a/rokit.toml b/rokit.toml index 331e403..5334d43 100644 --- a/rokit.toml +++ b/rokit.toml @@ -3,5 +3,5 @@ wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.6.1" stylua = "johnnymorganz/stylua@2.3.1" selene = "Kampfkarren/selene@0.30.0" -luau-lsp = "johnnymorganz/luau-lsp@1.62.0" +luau-lsp = "johnnymorganz/luau-lsp@1.63.0" lute = "luau-lang/lute@0.1.0-nightly.20260219" From 025d9b220887abeb850eddfbba3e13c8b047b138 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:16:16 -0700 Subject: [PATCH 129/160] refactor!: run effects in batched --- packages/charm/src/init.luau | 41 +++++++++---- packages/charm/test/effect.test.luau | 70 ++++++++++++++++++++++- packages/charm/test/effectScope.test.luau | 24 +++++++- rokit.toml | 8 +-- 4 files changed, 125 insertions(+), 18 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 2273f1b..9abd2fa 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -48,6 +48,7 @@ local NONE = 0b000000 -- Default state local MUTABLE = 0b000001 -- Can be changed (signal or computed) local WATCHING = 0b000010 -- Watching for changes (effects) local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies +local RECURSED = 0b001000 -- Has been visited in a recursion check local DIRTY = 0b010000 -- Value has changed and needs updating local PENDING = 0b100000 -- Might be dirty, pending verification @@ -235,15 +236,34 @@ end queued, ensuring that all updates are processed. ]] local function flush() + -- Bail out early if a flush is already in progress to allow effects to + -- complete before the next effect runs + if batchDepth ~= 0 or notifyIndex ~= 0 then + return + end + + local success, result = pcall(function() + while notifyIndex < queuedLength do + local effect = queued[notifyIndex] :: EffectNode + queued[notifyIndex] = nil + notifyIndex += 1 + run(effect) + end + end) + while notifyIndex < queuedLength do local effect = queued[notifyIndex] :: EffectNode queued[notifyIndex] = nil notifyIndex += 1 - run(effect) + effect.flags = bit32.bor(effect.flags, bit32.bor(WATCHING, RECURSED)) end notifyIndex = 0 queuedLength = 0 + + if not success then + error(result, 0) + end end --[[ @@ -260,9 +280,7 @@ end ]] local function endBatch() batchDepth -= 1 - if batchDepth == 0 then - flush() - end + flush() end --[[ @@ -467,9 +485,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette local subs = this.subs if subs then propagate(subs) - if batchDepth == 0 then - flush() - end + flush() end return value @@ -606,16 +622,23 @@ local function effect(fn: () -> ...Cleanup?): Cleanup link(this, activeSub, 0) end + -- Start a batch so that any signal updates triggered during the effect + -- execution are queued after the effect registers a cleanup function + startBatch() + local prevSub = setActiveSub(this) local success, result = pcall(this.fn) activeSub = prevSub if not success then + endBatch() error(result, 2) elseif result then table.insert(this.cleanups, noYield(result)) end + endBatch() + return function() stopEffect(this) end @@ -687,9 +710,7 @@ local function trigger(fn: () -> ()) end end - if batchDepth == 0 then - flush() - end + flush() end --[[ diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau index 6ed18fe..ef3c220 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -239,8 +239,11 @@ test("should handle side effect with inner effects", function() table.clear(order) setB(1) setA(1) - assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) end) + + -- DEVIATION: This assertion was moved outside of the effect so that it + -- runs after the batch updates have been processed + assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) end) test("should handle flags are indirectly updated during checkDirty", function() @@ -268,7 +271,48 @@ test("should handle flags are indirectly updated during checkDirty", function() assert(triggers == 2, "did not update indirectly") end) -test("should allow recursion", function() +test("should not execute skipped effects from previous failed flush when updating unrelated signal", function() + local a, setA = signal(0) + local b, setB = signal(0) + local c, setC = signal(0) + local d = computed(function() + c() + return 0 + end) + + local effect3Executed = false + + effect(function() + a() + end) + effect(function() + if a() == 2 then + error("Error in effect 2") + end + end) + effect(function() + a() + d() + effect3Executed = true + end) + effect(function() + b() + end) + + setA(1) + + effect3Executed = false + local success, err = pcall(setA, 2) + assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`) + + assert(not effect3Executed, "effect 3 should not have executed after failed flush") + setB(1) + assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b") + setC(1) + assert(effect3Executed, "effect 3 should have executed after updating signal c") +end) + +test("should allow finite recursion", function() local src, setSrc = signal(0) local order = {} @@ -364,7 +408,7 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) -test("infinite recursion should eventually throw", function() +test.skip("infinite recursion should eventually throw", function() local counter, setCounter = signal(0) local success = pcall(function() @@ -451,4 +495,24 @@ test("should reset the cleanup if the effect throws", function() assert(calls == 1, "should not call cleanup again after resetting value") end) +test("recursive effects should run and cleanup in order", function() + local a, setA = signal(0) + local order = {} + + effect(function() + local value = a() + table.insert(order, `+{value}`) + if value < 3 then + setA(value + 1) + end + return function() + table.insert(order, `-{value}`) + end + end) + assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`) + table.clear(order) + setA(100) + assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`) +end) + return {} diff --git a/packages/charm/test/effectScope.test.luau b/packages/charm/test/effectScope.test.luau index 932ebc1..da05572 100644 --- a/packages/charm/test/effectScope.test.luau +++ b/packages/charm/test/effectScope.test.luau @@ -70,10 +70,32 @@ test("should run indirect effect cleanup", function() assert(cleanups == 2, "should not cleanup after update") end) +test("should dispose inner effects if created in an effect", function() + local source, setSource = signal(1) + local triggers = 0 + local dispose + + effect(function() + dispose = effectScope(function() + effect(function() + source() + triggers += 1 + end) + end) + assert(triggers == 1, "did not run initially") + end) + + setSource(2) + assert(triggers == 2, "did not run after update") + dispose() + setSource(3) + assert(triggers == 2, "ran after dispose") +end) + test("should disallow yielding", function() local success = pcall(function() effectScope(function() - (if task then task.wait else coroutine.yield :: never)() + coroutine.yield() end) end) assert(not success, "should error on yield") diff --git a/rokit.toml b/rokit.toml index 5334d43..b8abcd6 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,7 +1,7 @@ [tools] wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.6.1" -stylua = "johnnymorganz/stylua@2.3.1" -selene = "Kampfkarren/selene@0.30.0" -luau-lsp = "johnnymorganz/luau-lsp@1.63.0" -lute = "luau-lang/lute@0.1.0-nightly.20260219" +stylua = "johnnymorganz/stylua@2.4.0" +selene = "Kampfkarren/selene@0.30.1" +luau-lsp = "johnnymorganz/luau-lsp@1.64.0" +lute = "luau-lang/lute@0.1.0-nightly.20260331" From e3e6209891df47160728d156d1f49011b40d4956 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:19:04 -0700 Subject: [PATCH 130/160] ci: check eslint and prettier --- .github/workflows/ci.yml | 5 + package.json | 16 +- pnpm-lock.yaml | 530 ++++++++++++++++++--------------------- 3 files changed, 253 insertions(+), 298 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cd5741..a02548c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,11 @@ jobs: - name: Check code quality run: lute run check + - name: Run ESLint and Prettier + run: | + pnpm eslint packages + pnpm prettier --check packages + - name: Run tests run: lute run tests diff --git a/package.json b/package.json index c2815eb..06671a3 100644 --- a/package.json +++ b/package.json @@ -9,21 +9,21 @@ }, "packageManager": "pnpm@10.30.2", "devDependencies": { - "@eslint/js": "^9.39.2", + "@eslint/js": "^9.39.4", "@rbxts/compiler-types": "3.0.0-types.0", "@rbxts/react": "17.3.7-ts.1", "@rbxts/react-roblox": "17.3.7-ts.1", - "@rbxts/types": "^1.0.908", - "@rbxts/vide": "^0.6.0", - "@typescript-eslint/eslint-plugin": "^8.56.0", - "@typescript-eslint/parser": "^8.56.0", - "eslint": "^9.39.2", + "@rbxts/types": "^1.0.913", + "@rbxts/vide": "^0.6.1", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", - "eslint-plugin-roblox-ts": "^1.3.1", + "eslint-plugin-roblox-ts": "^1.4.0", "prettier": "^3.8.1", "roblox-ts": "^3.0.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.56.0" + "typescript-eslint": "^8.58.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 066affe..3c0486d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@eslint/js': - specifier: ^9.39.2 - version: 9.39.2 + specifier: ^9.39.4 + version: 9.39.4 '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 @@ -21,29 +21,29 @@ importers: specifier: 17.3.7-ts.1 version: 17.3.7-ts.1(@rbxts/react@17.3.7-ts.1) '@rbxts/types': - specifier: ^1.0.908 - version: 1.0.908 + specifier: ^1.0.913 + version: 1.0.913 '@rbxts/vide': - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.6.1 + version: 0.6.1 '@typescript-eslint/eslint-plugin': - specifier: ^8.56.0 - version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.58.0 + version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^8.56.0 - version: 8.56.0(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.58.0 + version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) eslint: - specifier: ^9.39.2 - version: 9.39.2 + specifier: ^9.39.4 + version: 9.39.4 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2) + version: 10.1.8(eslint@9.39.4) eslint-plugin-prettier: specifier: ^5.5.5 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2))(eslint@9.39.2)(prettier@3.8.1) + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1) eslint-plugin-roblox-ts: - specifier: ^1.3.1 - version: 1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + specifier: ^1.4.0 + version: 1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) prettier: specifier: ^3.8.1 version: 3.8.1 @@ -54,8 +54,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.56.0 - version: 8.56.0(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.58.0 + version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) packages/charm: {} @@ -101,8 +101,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -113,12 +113,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -145,18 +145,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -235,12 +223,15 @@ packages: '@rbxts/react@17.3.7-ts.1': resolution: {integrity: sha512-/jqZ2TvyLSAPwC30h95loUmy4XN2PwPhv6fBARJKtZfyozFx2RuxC9FiQSjpEZ2uOOPzr60It0MBUf94D+tsMg==} - '@rbxts/types@1.0.908': - resolution: {integrity: sha512-b2nVXH4g/roLmOxVO3WYV2BdGaHnmX8BiGECwhuCmIClA3Do6t123/vhGmytiVhctHGUXD6VWMC1tX5rPlTGgQ==} + '@rbxts/types@1.0.913': + resolution: {integrity: sha512-AweuNYRVJb6y4PNajx2mnpqv+hm0+8GWtH9Dj4EMUuOVN5qcfcOMNSqjvQ4dmF5u0yQ89HKGeXa+4sLnaTIEng==} '@rbxts/vide@0.6.0': resolution: {integrity: sha512-OLfs4kqV2IP79xS+gZAprMujuYEc+n0JwMhJF+puNVoxBXSzOv2kKlgeLJhscCy7Jdhr9TbqsaRNLJP5akbfog==} + '@rbxts/vide@0.6.1': + resolution: {integrity: sha512-syFqx+HYXy0TauDCGLhc68K2cAlfgYookIJSRtJZ01W7mUI+EKyj80x2L91dQxos3BwAAGJqBD/ovWU4M//+Lg==} + '@roblox-ts/luau-ast@2.0.0': resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} @@ -259,26 +250,20 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@typescript-eslint/eslint-plugin@8.56.0': - resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.0 + '@typescript-eslint/parser': ^8.58.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.56.0': - resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.46.4': - resolution: {integrity: sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/project-service@8.56.0': resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} @@ -286,19 +271,19 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.46.4': - resolution: {integrity: sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==} + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/scope-manager@8.56.0': resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.46.4': - resolution: {integrity: sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/tsconfig-utils@8.56.0': resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} @@ -306,12 +291,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.46.4': - resolution: {integrity: sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==} + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/type-utils@8.56.0': resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} @@ -320,19 +304,20 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.46.4': - resolution: {integrity: sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==} + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/types@8.56.0': resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.46.4': - resolution: {integrity: sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/typescript-estree@8.56.0': resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} @@ -340,12 +325,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.4': - resolution: {integrity: sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==} + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/utils@8.56.0': resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} @@ -354,14 +338,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.46.4': - resolution: {integrity: sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==} + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/visitor-keys@8.56.0': resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -372,8 +363,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -396,15 +387,23 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -483,8 +482,8 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-roblox-ts@1.3.1: - resolution: {integrity: sha512-+cXNQUlZF3sg/nS55DciRZeFiKpexGMrqXcO56mHf6TBpYEbfK+v/VboBPi8ZdaFyN5LgspnTh+t/Xi1AIvFJg==} + eslint-plugin-roblox-ts@1.4.0: + resolution: {integrity: sha512-OWj+JfQlvGSiAJ5YOmiQc+M29ZJFc7VFYP0U1J3orZNT9GP5CWfpRNqPHuDhsvz+ni0BwoI4aqGv2bIPDueGrQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=22.5.1} peerDependencies: '@typescript-eslint/parser': '>=6.0.0' @@ -503,12 +502,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@5.0.0: - resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -543,10 +542,6 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -556,9 +551,6 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -584,11 +576,11 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - fs-extra@11.3.3: - resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} fsevents@2.3.3: @@ -706,19 +698,15 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} ms@2.1.3: @@ -758,12 +746,12 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} prelude-ls@1.2.1: @@ -783,9 +771,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -807,17 +792,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - roblox-ts@3.0.0: resolution: {integrity: sha512-hwAC2frIFlLJOtHd6F+5opMEhBgfAMK9z5l1mP+bykLBbMO5cn1q5lIwhhXbeh9Pq07rlhF8uGHlmeRLPd/3AA==} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -863,8 +841,8 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -873,12 +851,12 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.56.0: - resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript@5.5.3: resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} @@ -928,18 +906,18 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: - eslint: 9.39.2 + eslint: 9.39.4 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -951,21 +929,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.2': {} + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -985,18 +963,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - '@pkgr/core@0.2.9': {} '@rbxts-js/boolean@1.2.3-ts.1': @@ -1107,10 +1073,12 @@ snapshots: dependencies: '@rbxts-js/react': 17.3.7-ts.1 - '@rbxts/types@1.0.908': {} + '@rbxts/types@1.0.913': {} '@rbxts/vide@0.6.0': {} + '@rbxts/vide@0.6.1': {} + '@roblox-ts/luau-ast@2.0.0': {} '@roblox-ts/luau-ast@2.0.1': {} @@ -1118,50 +1086,41 @@ snapshots: '@roblox-ts/path-translator@1.1.0': dependencies: ajv: 8.18.0 - fs-extra: 11.3.3 + fs-extra: 11.3.4 '@roblox-ts/rojo-resolver@1.1.0': dependencies: ajv: 8.18.0 - fs-extra: 11.3.3 + fs-extra: 11.3.4 '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 - eslint: 9.39.2 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 9.39.4 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 - debug: 4.4.3 - eslint: 9.39.2 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.46.4(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) - '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 + eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1175,67 +1134,60 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.46.4': + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/visitor-keys': 8.46.4 + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color '@typescript-eslint/scope-manager@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 - '@typescript-eslint/tsconfig-utils@8.46.4(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.58.0': dependencies: - typescript: 5.9.3 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.46.4(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.2)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.2 - ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/type-utils@8.56.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2 - ts-api-utils: 2.4.0(typescript@5.9.3) + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.46.4': {} + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color '@typescript-eslint/types@8.56.0': {} - '@typescript-eslint/typescript-estree@8.46.4(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.46.4(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/visitor-keys': 8.46.4 - debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.4 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + '@typescript-eslint/types@8.58.0': {} '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: @@ -1244,45 +1196,60 @@ snapshots: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - minimatch: 9.0.5 + minimatch: 9.0.9 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.4(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) - '@typescript-eslint/scope-manager': 8.46.4 - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - eslint: 9.39.2 + eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.46.4': + '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.4 - eslint-visitor-keys: 4.2.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color '@typescript-eslint/visitor-keys@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 - eslint-visitor-keys: 5.0.0 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -1290,7 +1257,7 @@ snapshots: acorn@8.16.0: {} - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -1313,23 +1280,29 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 argparse@2.0.1: {} balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + binary-extensions@2.3.0: {} - brace-expansion@1.1.12: + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1385,26 +1358,26 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.2): + eslint-config-prettier@10.1.8(eslint@9.39.4): dependencies: - eslint: 9.39.2 + eslint: 9.39.4 - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2))(eslint@9.39.2)(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1): dependencies: - eslint: 9.39.2 + eslint: 9.39.4 prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.2) + eslint-config-prettier: 10.1.8(eslint@9.39.4) - eslint-plugin-roblox-ts@1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3): + eslint-plugin-roblox-ts@1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3): dependencies: '@roblox-ts/luau-ast': 2.0.1 - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1418,23 +1391,23 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@5.0.0: {} + eslint-visitor-keys@5.0.1: {} - eslint@9.39.2: + eslint@9.39.4: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -1453,7 +1426,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -1481,27 +1454,15 @@ snapshots: fast-diff@1.3.0: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} fast-uri@3.1.0: {} - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@8.0.0: dependencies: @@ -1518,12 +1479,12 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.2: {} - fs-extra@11.3.3: + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.0 @@ -1620,20 +1581,17 @@ snapshots: lodash.merge@4.6.2: {} - merge2@1.4.1: {} - - micromatch@4.0.8: + minimatch@10.2.5: dependencies: - braces: 3.0.3 - picomatch: 2.3.1 + brace-expansion: 5.0.5 - minimatch@3.1.2: + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.13 - minimatch@9.0.5: + minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.3 ms@2.1.3: {} @@ -1668,9 +1626,9 @@ snapshots: path-parse@1.0.7: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} prelude-ls@1.2.1: {} @@ -1682,11 +1640,9 @@ snapshots: punycode@2.3.1: {} - queue-microtask@1.2.3: {} - readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 require-directory@2.1.1: {} @@ -1700,24 +1656,18 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - reusify@1.1.0: {} - roblox-ts@3.0.0: dependencies: '@roblox-ts/luau-ast': 2.0.0 '@roblox-ts/path-translator': 1.1.0 '@roblox-ts/rojo-resolver': 1.1.0 chokidar: 3.6.0 - fs-extra: 11.3.3 + fs-extra: 11.3.4 kleur: 4.1.5 resolve: 1.22.11 typescript: 5.5.3 yargs: 17.7.2 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - semver@7.7.4: {} shebang-command@2.0.0: @@ -1750,14 +1700,14 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -1765,13 +1715,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.56.0(eslint@9.39.2)(typescript@5.9.3): + typescript-eslint@8.58.0(eslint@9.39.4)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color From 27c7f5a6fd0533ae4c0d62b926d398b72b22ed17 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:37:50 -0700 Subject: [PATCH 131/160] perf: run observe fn in untracked --- packages/charm/src/init.luau | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 9abd2fa..92be073 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -178,8 +178,8 @@ end @param cleanups List of cleanup functions ]] local function runCleanups(cleanups: { Cleanup }) - for key, cleanup in cleanups do - cleanups[key] = nil + for index, cleanup in cleanups do + cleanups[index] = nil local prevSub = setActiveSub(nil) local success, result: any = pcall(cleanup) @@ -418,10 +418,11 @@ end Called when a node is not being watched by any subscribers. Cleans up the node's dependencies and performs the necessary cleanup operations: - - Computed signals: Unlink dependencies and mark as dirty for potential future updates + - Computed signals: Unlink dependencies and mark as dirty for potential + future updates - Effects/scopes: Perform full cleanup to free resources - @param node Reactive node (computed or effect/scope) + @param node Reactive node (computed, effect, or effect scope) ]] local function unwatched(node: ReactiveNode) if bit32.band(node.flags, MUTABLE) == 0 then @@ -649,7 +650,7 @@ end signals created within it so that they can be disposed together. @param fn Function that creates effects within the scope - @param detached If true, the scope will not be tracked by the parent effect/scope + @param detached If true, the scope will not be tracked by the parent effect or scope @return A cleanup function that stops all effects in the scope @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached ]] @@ -719,7 +720,7 @@ end set to `true`. @param fn Function to run during cleanup - @param failSilently If true, silences the warning when called outside an effect/scope + @param failSilently If true, silences the warning when called outside an effect or scope @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently ]] local function onCleanup(fn: Cleanup, failSilently: boolean?) @@ -788,7 +789,7 @@ end @param fn Function to check @return `true` if `fn` is a signal getter, computed signal, or atom function, `false` otherwise ]] -local function isSignalOrComputed(fn: Getter): boolean +local function isSignal(fn: Getter): boolean local name = debug.info(fn, "n") return name == "readSignal" or name == "computedOper" or name == "atomOper" end @@ -804,7 +805,7 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] local function listen(getter: Getter, fn: (state: T, lastState: T?) -> ()): Cleanup - local tracked: Getter = if isSignalOrComputed(getter) then getter else computed(getter) + local tracked: Getter = if isSignal(getter) then getter else computed(getter) local current: T return effect(function() @@ -825,7 +826,7 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] local function subscribe(getter: Getter, fn: (state: T, lastState: T) -> ()): Cleanup - local tracked: Getter = if isSignalOrComputed(getter) then getter else computed(getter) + local tracked: Getter = if isSignal(getter) then getter else computed(getter) local current: T local firstRun = true @@ -841,9 +842,9 @@ local function subscribe(getter: Getter, fn: (state: T, lastState: T) -> ( end --[[ - Calls the callback function for each unique key in the table returned by the - getter. The callback is called once for each key, and again when a new key - is added. + Calls the callback function for each unique key in the table returned by + the getter. The callback is called once for each key, and again when a new + key is added. The callback may return a cleanup function, which will be called when the key is removed or the `observe()` effect is disposed. Effects created @@ -885,10 +886,9 @@ local function observe(getter: Getter<{ [K]: V }>, fn: (item: V, key: K) - return effectScope(function() effect(function() - -- Batch so that side effects run after all scopes are created. - -- This ensures that a scope creating or removing items doesn't - -- interfere with the current update cycle. - batch(updateScopes, getter()) + -- Wrap in untracked so that calling fn inside updateScopes does + -- not register dependencies to this effect + untracked(updateScopes, getter()) end) return function() @@ -930,8 +930,8 @@ local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, return valueOut, if keyOut == nil then key :: any else keyOut end - return computed(function(lastOutput) - local nextOutput = lastOutput or {} + return computed(function(lastOutput: { [KO]: VO }?): { [KO]: VO } + local nextOutput: { [KO]: VO } = lastOutput or {} local prevInput = nextInput nextInput = getter() From c52f55a55e96734fca0de5002adc88ab0bf8afc1 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:29:55 -0700 Subject: [PATCH 132/160] perf: call mapper as-is in `mapped()` --- benches/mapped.luau | 57 ++++++++++++++++++------------------ packages/charm/src/init.luau | 38 ++++++++---------------- 2 files changed, 42 insertions(+), 53 deletions(-) diff --git a/benches/mapped.luau b/benches/mapped.luau index 27ca6bd..695b7f5 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -3,55 +3,56 @@ local bench = require("./bench") local computed = Charm.computed local mapped = Charm.mapped -local setActiveSub = Charm.setActiveSub local signal = Charm.signal -type Entry = { number } +type Key = number +type Value = number local count = 512 -local initialState: { Entry } = table.create(count) +local initialState: { [Key]: Value } = table.create(count, 0) -for i = 1, count do - initialState[i] = table.create(64, i) +local function expensiveOperation() + for _ = 1, 100 do + continue + end end -local function mapper(value: Entry, key: number): (Entry, number) - return table.clone(value), key + 1 -end - -local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry } +local function simpleMapped( + source: () -> { [Key]: Value }, + mapper: (Value, Key) -> (Value, Key?) +): () -> { [Key]: Value } return computed(function() local state = source() local nextState = {} - for index, value in state do - local currentSub = setActiveSub(nil) - local success, valueOut, keyOut = pcall(mapper, value, index) - setActiveSub(currentSub) - - if success then - nextState[keyOut] = valueOut - end + for keyIn, valueIn in state do + local valueOut, keyOut = mapper(valueIn, keyIn) + nextState[if keyOut == nil then keyIn else keyOut] = valueOut end - return state + return nextState end) end -local function updatePercentage(percent: number): ({ Entry }) -> { Entry } +local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value } return function(state) local nextState = table.clone(state) - for index, value in state do - if math.random() < percent then - nextState[index] = table.clone(value) + for key in state do + if math.random() < updateChance then + nextState[key] += 1 end end return nextState end end -for _, percent in { 0.1, 0.5, 1 } do - bench.run(`mapped {percent}`, function() +for _, updateChance in { 0.1, 0.5, 1 } do + local function mapper(value: Value, key: Key): (Value, Key?) + expensiveOperation() + return value + 1, key + 1 + end + + bench.run(`mapped {updateChance}`, function() local src, setSrc = signal(initialState) local out = mapped(src, mapper) @@ -59,14 +60,14 @@ for _, percent in { 0.1, 0.5, 1 } do out() bench.profileend() - setSrc(updatePercentage(percent)) + setSrc(updateRandomly(updateChance)) bench.profilebegin("update") out() bench.profileend() end) - bench.run(`control {percent}`, function() + bench.run(`control {updateChance}`, function() local src, setSrc = signal(initialState) local out = simpleMapped(src, mapper) @@ -74,7 +75,7 @@ for _, percent in { 0.1, 0.5, 1 } do out() bench.profileend() - setSrc(updatePercentage(percent)) + setSrc(updateRandomly(updateChance)) bench.profilebegin("update") out() diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 92be073..46be53c 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -914,22 +914,9 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper ]] local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> - local map = noYield(mapper) local nextInput: { [KI]: VI } = {} local keyMap: { [KI]: KO } = {} - local function mapUntracked(value: VI, key: KI): (VO, KO) - local prevSub = setActiveSub(nil) - local success, valueOut, keyOut = pcall(map, value, key) - activeSub = prevSub - - if not success then - error(`mapped() transform function failed: {valueOut}`, 0) - end - - return valueOut, if keyOut == nil then key :: any else keyOut - end - return computed(function(lastOutput: { [KO]: VO }?): { [KO]: VO } local nextOutput: { [KO]: VO } = lastOutput or {} local prevInput = nextInput @@ -937,19 +924,19 @@ local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, -- For keys that were removed from the input, remove the corresponding -- keys from the output. - for prevKeyIn in prevInput do - if nextInput[prevKeyIn] ~= nil then + for keyIn in prevInput do + if nextInput[keyIn] ~= nil then continue end - local prevKeyOut = keyMap[prevKeyIn] + local keyOut = keyMap[keyIn] - if prevKeyOut ~= nil then + if keyOut ~= nil then if nextOutput == lastOutput then nextOutput = table.clone(nextOutput) end - nextOutput[prevKeyOut] = nil - keyMap[prevKeyIn] = nil + nextOutput[keyOut] = nil + keyMap[keyIn] = nil end end @@ -957,13 +944,14 @@ local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, -- the output. If the mapper returns a new key that is different from -- the previous call, the old mapped key should be removed from the -- output. - for nextKeyIn, nextValueIn in nextInput do - if nextValueIn == prevInput[nextKeyIn] then + for keyIn, nextValueIn in nextInput do + if nextValueIn == prevInput[keyIn] then continue end - local nextValueOut, nextKeyOut = mapUntracked(nextValueIn, nextKeyIn) - local prevKeyOut = keyMap[nextKeyIn] + local nextValueOut, keyOutOrNil = mapper(nextValueIn, keyIn) + local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn :: any + local prevKeyOut = keyMap[keyIn] if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then if nextOutput == lastOutput then @@ -971,13 +959,13 @@ local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, end nextOutput[prevKeyOut] = nil nextOutput[nextKeyOut] = nextValueOut - keyMap[nextKeyIn] = nextKeyOut + keyMap[keyIn] = nextKeyOut elseif nextOutput[nextKeyOut] ~= nextValueOut then if nextOutput == lastOutput then nextOutput = table.clone(nextOutput) end nextOutput[nextKeyOut] = nextValueOut - keyMap[nextKeyIn] = nextKeyOut + keyMap[keyIn] = nextKeyOut end end From 748dbc50144f6b973159e4150a8628e23442fc8d Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 2 Apr 2026 05:17:51 -0700 Subject: [PATCH 133/160] refactor: error for effect cleanup errors --- packages/charm/src/init.luau | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 46be53c..b226dd2 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -178,6 +178,9 @@ end @param cleanups List of cleanup functions ]] local function runCleanups(cleanups: { Cleanup }) + local hasErrors = false + local errors = {} + for index, cleanup in cleanups do cleanups[index] = nil @@ -186,9 +189,14 @@ local function runCleanups(cleanups: { Cleanup }) activeSub = prevSub if not success then - warn("Error occurred during effect cleanup:", result) + hasErrors = true + table.insert(errors, tostring(result)) end end + + if hasErrors then + error(`Errors occurred during effect cleanup:\n\n${table.concat(errors, "\n\n")}`, 0) + end end --[[ @@ -404,14 +412,14 @@ local function notify(effect: ReactiveNode) queuedLength = insertIndex -- Reverse the collected effects to ensure outer effects run first - while true do + repeat insertIndex -= 1 if firstInsertedIndex >= insertIndex then break end queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex] firstInsertedIndex += 1 - end + until false end --[[ From 22b440e35f10394069456332828e98ffee53221f Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 2 Apr 2026 06:49:54 -0700 Subject: [PATCH 134/160] refactor: use new type solver --- .github/workflows/ci.yml | 2 +- .luaurc | 18 ++-- .lute/check.luau | 1 + .lute/test.luau | 1 + README.md | 10 +- package.json | 2 +- packages/charm-sync/src/client.luau | 2 +- packages/charm-sync/src/patch.luau | 2 +- packages/charm-sync/src/server.luau | 25 +++-- packages/charm-sync/src/utils.luau | 10 +- packages/charm-sync/src/validate.luau | 5 +- packages/charm-sync/test/patch.test.luau | 2 +- packages/charm-sync/test/server.test.luau | 27 +++--- packages/charm-sync/test/utils.test.luau | 3 +- packages/charm/src/index.d.ts | 33 ++++--- packages/charm/src/init.luau | 92 ++++++++++--------- packages/charm/test/computed.test.luau | 6 +- packages/charm/test/effect.test.luau | 4 +- packages/charm/test/effectScope.test.luau | 6 +- packages/charm/test/gc.test.luau | 2 +- packages/charm/test/observe.test.luau | 8 +- packages/charm/test/signal.test.luau | 2 +- packages/charm/test/topology.test.luau | 16 ++-- packages/charm/test/untracked.test.luau | 2 +- packages/deep-charm/test/reactive.test.luau | 18 ++-- packages/react-charm/src/init.luau | 12 +-- .../react-charm/test/helpers/renderHook.luau | 3 +- packages/react-charm/test/init.luau | 1 + packages/vide-charm/src/init.luau | 2 +- .../vide-charm/test/useSignalState.test.luau | 4 +- selene.toml | 2 - tests/init.luau | 1 - 32 files changed, 169 insertions(+), 155 deletions(-) create mode 100644 .lute/test.luau delete mode 100644 tests/init.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a02548c..912f2ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: pnpm prettier --check packages - name: Run tests - run: lute run tests + run: lute run test - name: Build models run: lute run build diff --git a/.luaurc b/.luaurc index c56c621..91ac9c9 100644 --- a/.luaurc +++ b/.luaurc @@ -1,10 +1,10 @@ { - "languageMode": "strict", - "aliases": { - "lute": "~/.lute/typedefs/0.1.0/lute", - "std": "~/.lute/typedefs/0.1.0/std" - }, - "lint": { - "LocalShadow": false - } -} \ No newline at end of file + "languageMode": "strict", + "aliases": { + "lute": "~/.lute/typedefs/0.1.0/lute", + "std": "~/.lute/typedefs/0.1.0/std" + }, + "lint": { + "LocalShadow": false + } +} diff --git a/.lute/check.luau b/.lute/check.luau index 4117286..ef48a74 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -16,6 +16,7 @@ local analyze = process.run({ "--ignore=**/node_modules/**", "--base-luaurc=.luaurc", "--definitions=roblox.d.luau", + "--flag:LuauSolverV2=true", "packages", "tests", "benches", diff --git a/.lute/test.luau b/.lute/test.luau new file mode 100644 index 0000000..4abbbb9 --- /dev/null +++ b/.lute/test.luau @@ -0,0 +1 @@ +(require)("../tests/run.server") diff --git a/README.md b/README.md index 71d5318..c337ff1 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Charm is a state management library based on [fine-grained reactivity](https://d - [`subscribe(getter, callback)`](#subscribegetter-callback) - [`untracked(callback)`](#untrackedcallback) - [`batch(callback)`](#batchcallback) - - [`mapped(getter, mapper)`](#mappedgetter-mapper) + - [`mapped(getter, transform)`](#mappedgetter-transform) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`trigger(callback)`](#triggercallback) @@ -472,11 +472,11 @@ end) --- -### `mapped(getter, mapper)` +### `mapped(getter, transform)` -The `mapped` function iterates over every key in a table and uses the mapper to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value. +The `mapped` function iterates over every key in a table and uses the transform function to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, `transform` is called for that key and its current value. -The first value returned by the mapper is used as the new value: +The first value returned by the transform function is used as the new value: ```luau local getList, setList = signal({ "a", "b", "c" }) @@ -488,7 +488,7 @@ end) print(getUppercase()) -- { "A", "B", "C" } ``` -If the mapper returns two values, the second value is used as the new key: +If the transform function returns two values, the second value is used as the new key: ```luau local getList, setList = signal({ "a", "b", "c" }) diff --git a/package.json b/package.json index 06671a3..c7774c8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "scripts": { - "test": "lute run tests", + "test": "lute run test", "build": "lute run build", "check": "lute run check", "publish:npm": "pnpm publish -r --access public", diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 165bbc6..7b51359 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -3,7 +3,7 @@ local patch = require("./patch") local types = require("./types") type SyncPayload = types.SyncPayload -type ReactiveProxy = types.ReactiveProxy +type ReactiveProxy = types.ReactiveProxy local trackedSignals: { [string]: (any) -> any } = {} local trackedReactives: { [string]: ReactiveProxy } = {} diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index b9a3be1..d6b940f 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -39,7 +39,7 @@ end long as the target array is not sparse. This function ensures that the diff function produces a patch that can be correctly applied. ]] -local function stringifySparseArray(object: {}): { [string | number]: any } +local function stringifySparseArray(object: { [any]: any }): { [string | number]: any } local maxn = table.maxn(object) if maxn == 0 or maxn == #object then diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index ce6fc9e..0fea67b 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -39,8 +39,9 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? support custom implementations of reactive proxies. ]] local function toRaw(value: T): T + local value: any = value if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then - value.__track() + (value.__track :: () -> ())() return toRaw(value.__target) end return value @@ -153,15 +154,17 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { local status = clientKeys[key] if status == LISTENING_FOR_CHANGES then - table.insert(payloads, { + local payload: SyncPayload = { type = "patch", data = { [key] = patchData }, - }) + } + table.insert(payloads, payload) elseif status == PENDING_INITIAL_STATE then - table.insert(payloads, { + local payload: SyncPayload = { type = "init", data = { [key] = update.newValue }, - }) + } + table.insert(payloads, payload) clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -228,11 +231,19 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any } end if next(initData) ~= nil then - table.insert(payloads, { type = "init", data = initData }) + local payload: SyncPayload = { + type = "init", + data = initData, + } + table.insert(payloads, payload) end if next(patchData) ~= nil then - table.insert(payloads, { type = "patch", data = patchData }) + local payload: SyncPayload = { + type = "patch", + data = patchData, + } + table.insert(payloads, payload) end return payloads diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index a4dae84..4a49871 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -2,10 +2,6 @@ local Charm = require("../Charm") type Atom = Charm.Atom -type NestedTable = { - [any]: NestedTable | any, -} - --[[ Flattens a nested table into a single table with slash-separated keys. Useful for recursively collecting atoms returned by modules. @@ -13,15 +9,15 @@ type NestedTable = { @param input A nested table. @return A flattened table with slash-separated keys. ]] -local function flatten(input: {}): Flattened +local function flatten(input: { [any]: any }): Flattened local result = {} - local function visit(node: {}, path: string) + local function visit(node: { [any]: any }, path: string) for key, value in node do local location = if path == "" then tostring(key) else path .. "/" .. tostring(key) if type(value) == "table" then - visit(value :: {}, location) + visit(value, location) else result[location] = value end diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index ac21161..dcf5904 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,4 +1,4 @@ -local function isTableUnsafe(object: {}) +local function isTableUnsafe(object: { [any]: any }) local key = next(object) if key == nil then @@ -31,9 +31,8 @@ end with remote argument limitations, such as sparse arrays or mixed tables. @param value The table value. - @param key The key of the value. ]] -local function validate(value: {}) +local function validate(value: { [any]: any }) if isTableUnsafe(value) then warn( `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` diff --git a/packages/charm-sync/test/patch.test.luau b/packages/charm-sync/test/patch.test.luau index 68d0483..076544e 100644 --- a/packages/charm-sync/test/patch.test.luau +++ b/packages/charm-sync/test/patch.test.luau @@ -2,7 +2,7 @@ local config = require("../src/config") local patch = require("../src/patch") local test = require("../../../tests/test") -local function mockArraySerdes(object: {}): {} +local function mockArraySerdes(object: { [any]: any }): { [any]: any } if object[1] then local copy = {} for index = 1, #object do diff --git a/packages/charm-sync/test/server.test.luau b/packages/charm-sync/test/server.test.luau index 02a9af3..380fbb0 100644 --- a/packages/charm-sync/test/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -4,6 +4,7 @@ local config = require("../src/config") local patch = require("../src/patch") local server = require("../src/server") local test = require("../../../tests/test") +local types = require("../src/types") test.afterEach(function() server.reset() @@ -14,7 +15,7 @@ test("should replicate state changes", function() local a = Charm.atom(0) local b = Charm.atom(0) local c = Charm.atom(0) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -41,7 +42,7 @@ test("should replicate partial state patches", function() local client = {} local a = Charm.atom({ x = 0, y = 0 }) local b = Charm.atom(0 :: number?) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a, b = b }) server.connect(function(...) @@ -68,7 +69,7 @@ end) test("should replicate partial state patches (reactive)", function() local client = {} local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? }) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(...) @@ -118,7 +119,7 @@ test("should init new clients", function() local clientA = {} local clientB = {} local a = Charm.atom(0) - local payloadsA, payloadsB + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) @@ -147,7 +148,7 @@ test("should init new atoms", function() local client = {} local a = Charm.atom(0) local b = Charm.atom(0) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(...) @@ -172,7 +173,7 @@ test("should only replicate to synced clients", function() local clientB = {} local a = Charm.atom(0) local b = Charm.atom(0) - local payloadsA, payloadsB + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientB, { b = b }) @@ -214,7 +215,7 @@ end) test("should serialize state", function() local client = {} local a = Charm.atom({ 1, 2, 3, 4, 5 }) - local payloads + local payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -231,7 +232,7 @@ end) test("should not send unchanged state", function() local client = {} local a = Charm.atom(0) - local payloads + local payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -262,7 +263,7 @@ test.describe("historical", function() local a = Charm.atom(0) local b = Charm.atom(0) local c = Charm.atom(0) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -292,7 +293,7 @@ test.describe("historical", function() local a = DeepCharm.reactive({ value = 0 }) local b = DeepCharm.reactive({ value = 0 }) local c = DeepCharm.reactive({ value = 0 }) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -321,7 +322,7 @@ test.describe("historical", function() local clientA = {} local clientB = {} local a = Charm.atom(0) - local payloadsA, payloadsB + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) @@ -357,7 +358,7 @@ test.describe("historical", function() local client = {} local a = Charm.atom(0) local b = Charm.atom(0) - local payloads + local payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(_, ...) @@ -386,7 +387,7 @@ test.describe("historical", function() test("should not send unchanged state", function() local client = {} local a = Charm.atom(0) - local payloads + local payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) diff --git a/packages/charm-sync/test/utils.test.luau b/packages/charm-sync/test/utils.test.luau index 14c9734..15dcabf 100644 --- a/packages/charm-sync/test/utils.test.luau +++ b/packages/charm-sync/test/utils.test.luau @@ -8,7 +8,8 @@ test("should flatten functions", function() b = { c = Charm.atom(0) }, d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, } - local result = utils.flatten(input) + + local result = utils.flatten<<{ [string]: Charm.Atom }>>(input) assert(result.a == input.a, "should flatten the first level") assert(result["b/c"] == input.b.c, "should flatten the second level") diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 749f074..4e4ad8d 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -180,11 +180,11 @@ export function batch(fn: (...args: Args) => Result, * value and the previous value as arguments. * * @param getter A function that returns the value to watch. - * @param fn The function to run when the value changes. + * @param callback The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback */ -export function listen(getter: Getter, fn: (value: T, previousValue: T | undefined) => void): Cleanup; +export function listen(getter: Getter, callback: (value: T, previousValue: T | undefined) => void): Cleanup; /** * Creates an effect that only runs the callback when the value returned by the @@ -192,34 +192,37 @@ export function listen(getter: Getter, fn: (value: T, previousValue: T | u * as arguments. * * @param getter A function that returns the value to watch. - * @param fn The function to run when the value changes. + * @param callback The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback */ -export function subscribe(getter: Getter, fn: (value: T, previousValue: T) => void): Cleanup; +export function subscribe(getter: Getter, callback: (value: T, previousValue: T) => void): Cleanup; /** * Creates a new read-only signal that is computed by mapping over the entries - * of an existing atom. If the key is omitted from the mapper's return value, - * the original key is preserved. + * of the source table. If the key is omitted from the transform function's + * return value, the original key is preserved. * - * @param getter Returns the table to map over. - * @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. - * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper + * This function is optimized to minimize unnecessary updates by only calling + * the transform function for entries that have changed since the last run. + * + * @param getter A signal or function that returns a table + * @param transform Function that maps each entry from the source to a new entry in the mapped table + * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform */ export function mapped( getter: Getter | ReadonlyMap>, - mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, + transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): Getter>; // Overload for arrays export function mapped( getter: Getter, - mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, + transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, ): Getter; // Overload for objects export function mapped( getter: Getter<{ readonly [K in KI]: VI }>, - mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, + transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): Getter<{ readonly [K in KO]: VO }>; /** @@ -234,18 +237,18 @@ export function mapped( * from the table or when the callback is disposed. * * @param getter A function that returns the table to observe. - * @param fn A function that is called for each key in the table. + * @param callback A function that is called for each key in the table. * @returns A function for disposing the observer. * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback */ export function observe( getter: Getter | ReadonlyMap | readonly V[]>, - fn: (value: V, key: K) => Cleanup | void, + callback: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( getter: Getter<{ readonly [K in Key]: V }>, - fn: (value: V, key: K) => Cleanup | void, + callback: (value: V, key: K) => Cleanup | void, ): Cleanup; /** diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index b226dd2..f55c9ed 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -96,12 +96,12 @@ local flags = { Wraps a function to prevent yielding when in strict mode. If the function attempts to yield, an error is thrown. - @param fn The function to wrap. + @param callback The function to wrap. @return A wrapped function that cannot yield in strict mode. ]] -local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... +local function noYield(callback: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then - return fn + return callback end local function noYieldHandler(thread: thread, ok: boolean, ...) @@ -114,7 +114,7 @@ local function noYield(fn: (Args...) -> Results...): (Args. end return function(...) - local thread = coroutine.create(fn) + local thread = coroutine.create(callback) return noYieldHandler(thread, coroutine.resume(thread, ...)) end end @@ -195,7 +195,7 @@ local function runCleanups(cleanups: { Cleanup }) end if hasErrors then - error(`Errors occurred during effect cleanup:\n\n${table.concat(errors, "\n\n")}`, 0) + error(`Errors occurred during effect cleanup:\n\n{table.concat(errors, "\n\n")}`, 0) end end @@ -206,9 +206,12 @@ end @param effect Effect node to run ]] local function run(effect: EffectNode) - local flags = effect.flags + local effectFlags = effect.flags - if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then + if + bit32.btest(effectFlags, DIRTY) + or (bit32.btest(effectFlags, PENDING) and checkDirty(effect.deps :: Link, effect)) + then runCleanups(effect.cleanups) if effect.flags == NONE then @@ -342,10 +345,10 @@ end inline the operation. @param sub Effect or computed node - @param flags Subscriber's current flags + @param subFlags Subscriber's current flags ]] -local function clearPending(sub: ReactiveNode, flags: ReactiveFlags) - sub.flags = bit32.band(flags, bit32.bnot(PENDING)) +local function clearPending(sub: ReactiveNode, subFlags: ReactiveFlags) + sub.flags = bit32.band(subFlags, bit32.bnot(PENDING)) end --[[ @@ -463,14 +466,14 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette deepFreeze(initialValue) end - local function writeSignal(update: Update): T + local function writeSignal(newValue: Update): T local value: T -- Determine the new value based on whether the update is a function or -- a direct value - if type(update) == "function" then + if type(newValue) == "function" then local prevSub = setActiveSub(nil) - local success, result = pcall(noYield(update), this.pendingValue) + local success, result = pcall(noYield(newValue), this.pendingValue) activeSub = prevSub if success then @@ -479,7 +482,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette error(result, 2) end else - value = update + value = newValue end -- Check if the new value is different from the pending value @@ -565,11 +568,14 @@ local function computed(getter: (previousValue: T?) -> T): Getter } local function computedOper(): T - local flags = this.flags + local computedFlags = this.flags if - bit32.btest(flags, DIRTY) - or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags))) + bit32.btest(computedFlags, DIRTY) + or ( + bit32.btest(computedFlags, PENDING) + and (checkDirty(this.deps :: Link, this) or clearPending(this, computedFlags)) + ) then if updateComputed(this) then -- Notify subscribers if the value changed @@ -578,7 +584,7 @@ local function computed(getter: (previousValue: T?) -> T): Getter shallowPropagate(subs) end end - elseif flags == NONE then + elseif computedFlags == NONE then -- First access should initialize the computed value this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) @@ -694,7 +700,7 @@ end @param fn A signal or function that accesses signals @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback ]] -local function trigger(fn: () -> ()) +local function trigger(fn: () -> ...any) local sub: ReactiveNode = { flags = WATCHING, } @@ -707,10 +713,10 @@ local function trigger(fn: () -> ()) error(result, 2) end - local link = sub.deps - while link do - local dep = link.dep - link = unlink(link, sub) + local currentLink = sub.deps + while currentLink do + local dep = currentLink.dep + currentLink = unlink(currentLink, sub) local subs = dep.subs if subs then sub.flags = NONE @@ -808,18 +814,18 @@ end new value and the previous value as arguments. @param getter A signal or function that accesses signals - @param fn Function to run when the value changes + @param callback Function to run when the value changes @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] -local function listen(getter: Getter, fn: (state: T, lastState: T?) -> ()): Cleanup - local tracked: Getter = if isSignal(getter) then getter else computed(getter) +local function listen(getter: Getter, callback: (state: T, lastState: T?) -> ()): Cleanup + local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) local current: T return effect(function() local previous = current current = tracked() - untracked(fn, current, previous) + untracked(callback, current, previous) end) end @@ -829,12 +835,12 @@ end value as arguments. @param getter A signal or function that accesses signals - @param fn Function to run when the value changes + @param callback Function to run when the value changes @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] -local function subscribe(getter: Getter, fn: (state: T, lastState: T) -> ()): Cleanup - local tracked: Getter = if isSignal(getter) then getter else computed(getter) +local function subscribe(getter: Getter, callback: (state: T, lastState: T) -> ()): Cleanup + local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) local current: T local firstRun = true @@ -844,7 +850,7 @@ local function subscribe(getter: Getter, fn: (state: T, lastState: T) -> ( if firstRun then firstRun = false else - untracked(fn, current, previous) + untracked(callback, current, previous) end end) end @@ -859,11 +865,11 @@ end during the callback will also be bound to the lifetime of the key. @param getter A signal or function that returns a table - @param fn Function that is called for each key added to the table + @param callback Function that is called for each key added to the table @return A cleanup function that disposes scopes for all keys @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] -local function observe(getter: Getter<{ [K]: V }>, fn: (item: V, key: K) -> ...Cleanup?): Cleanup +local function observe(getter: Getter<{ [K]: V }>, callback: (item: V, key: K) -> ...Cleanup?): Cleanup local scopes: { [K]: Cleanup } = {} local stopObserving = false @@ -881,7 +887,7 @@ local function observe(getter: Getter<{ [K]: V }>, fn: (item: V, key: K) - for key, value in data do if scopes[key] == nil then local dispose = effectScope(function() - return fn(value, key) + return callback(value, key) end, true) if stopObserving then dispose() @@ -911,17 +917,17 @@ end --[[ Creates a new read-only signal that is computed by mapping over the entries - of an existing atom. If the key is omitted from the mapper's return value, - the original key is preserved. + of the source table. If the key is omitted from the transform function's + return value, the original key is preserved. This function is optimized to minimize unnecessary updates by only calling - the mapper for entries that have changed since the last run. + the transform function for entries that have changed since the last run. @param getter A signal or function that returns a table - @param mapper Function that maps each entry from the source to a new entry in the mapped table - @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper + @param transform Function that maps each entry from the source to a new entry in the mapped table + @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform ]] -local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> +local function mapped(getter: Getter<{ [KI]: VI }>, transform: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> local nextInput: { [KI]: VI } = {} local keyMap: { [KI]: KO } = {} @@ -957,7 +963,7 @@ local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, continue end - local nextValueOut, keyOutOrNil = mapper(nextValueIn, keyIn) + local nextValueOut, keyOutOrNil = transform(nextValueIn, keyIn) local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn :: any local prevKeyOut = keyMap[keyIn] @@ -999,8 +1005,8 @@ return { observe = observe, mapped = mapped :: (( getter: Getter<{ [KI]: VI }>, - mapper: (VI, KI) -> (VO, KO) - ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, mapper: (VI, K) -> VO) -> Getter<{ [K]: VO }>), + transform: (VI, KI) -> (VO, KO) + ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, transform: (VI, K) -> VO) -> Getter<{ [K]: VO }>), onCleanup = onCleanup, getActiveSub = getActiveSub, setActiveSub = setActiveSub, diff --git a/packages/charm/test/computed.test.luau b/packages/charm/test/computed.test.luau index 4590807..8985db2 100644 --- a/packages/charm/test/computed.test.luau +++ b/packages/charm/test/computed.test.luau @@ -84,7 +84,7 @@ end) test("should receive previous value", function() local calls = {} local src, setSrc = signal(0) - local c1 = computed(function(current): number + local c1 = computed(function(current: number?): number local incoming = src() return if current == incoming or incoming % 2 == 1 then current or incoming else incoming end) @@ -192,10 +192,10 @@ test("should trigger effect even computed already dirty", function() table.insert(calls, c2()) end) assert(table.concat(calls, ",") == "0foo", table.concat(calls, ",")) - assert(v() == 1, v()) + assert(v() == 1, `expected 1 for v(), got {v()}`) v(2) assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ",")) - assert(v() == 2, v()) + assert(v() == 2, `expected 2 for v(), got {v()}`) end) return {} diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau index ef3c220..5eb4c94 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -440,7 +440,7 @@ end) test("should run the cleanup if the effect disposes itself", function() local a, setA = signal(0) local calls = 0 - local dispose + local dispose: () -> () dispose = effect(function() if a() > 0 then @@ -460,7 +460,7 @@ end) test("should not run the effect if the cleanup function disposes it", function() local a, setA = signal(0) local calls = 0 - local dispose + local dispose: () -> () dispose = effect(function() a() diff --git a/packages/charm/test/effectScope.test.luau b/packages/charm/test/effectScope.test.luau index da05572..c1c06c2 100644 --- a/packages/charm/test/effectScope.test.luau +++ b/packages/charm/test/effectScope.test.luau @@ -31,8 +31,8 @@ end) test("should run indirect scope cleanup", function() local cleanups = 0 + local cleanup: () -> () - local cleanup local stopScope = effectScope(function() cleanup = effectScope(function() onCleanup(function() @@ -50,8 +50,8 @@ end) test("should run indirect effect cleanup", function() local cleanups = 0 - local a, setA = signal(false) + local stopScope = effectScope(function() effect(function() a() @@ -73,7 +73,7 @@ end) test("should dispose inner effects if created in an effect", function() local source, setSource = signal(1) local triggers = 0 - local dispose + local dispose: () -> () effect(function() dispose = effectScope(function() diff --git a/packages/charm/test/gc.test.luau b/packages/charm/test/gc.test.luau index 2d53389..aed0af1 100644 --- a/packages/charm/test/gc.test.luau +++ b/packages/charm/test/gc.test.luau @@ -26,7 +26,7 @@ end immediately closing it, causing alien-signals to unlink the computed value from its dependencies and allow it to be garbage collected. ]] -local function unlink(getter: () -> ()) +local function unlink(getter: () -> unknown) effectScope(function() getter() end)() diff --git a/packages/charm/test/observe.test.luau b/packages/charm/test/observe.test.luau index 86efdc3..c328f16 100644 --- a/packages/charm/test/observe.test.luau +++ b/packages/charm/test/observe.test.luau @@ -95,7 +95,7 @@ test("should handle recursive additions and removals", function() observe(items, function(item) if item < 5 then - setItems(function(state) + setItems(function(state: { number }) state = table.clone(state) table.insert(state, item + 1) return state @@ -117,7 +117,7 @@ test("should handle self removal", function() local edits = {} observe(items, function(item, index) - setItems(function(state) + setItems(function(state: { number }) state = table.clone(state) state[index] = nil return state @@ -134,7 +134,7 @@ end) test("should allow disposal during addition", function() local items, setItems = signal({}) local edits = {} - local dispose + local dispose: () -> () dispose = observe(items, function(item) if item == 1 then @@ -153,7 +153,7 @@ end) test("should allow disposal during removal", function() local items, setItems = signal({ 1, 2, 3, 4 }) local edits = {} - local dispose + local dispose: () -> () dispose = observe(items, function(item) table.insert(edits, `+{item}`) diff --git a/packages/charm/test/signal.test.luau b/packages/charm/test/signal.test.luau index ebc8e51..3d77f6c 100644 --- a/packages/charm/test/signal.test.luau +++ b/packages/charm/test/signal.test.luau @@ -6,7 +6,7 @@ local effect = Charm.effect test("should accept equality function", function() local calls = {} - local src, setSrc = signal(0, function(current, incoming) + local src, setSrc = signal(0, function(current: number, incoming: number) return current == incoming or incoming % 2 == 1 end) diff --git a/packages/charm/test/topology.test.luau b/packages/charm/test/topology.test.luau index 06ee3c2..3675b3d 100644 --- a/packages/charm/test/topology.test.luau +++ b/packages/charm/test/topology.test.luau @@ -442,18 +442,16 @@ end) test.describe("error handling", function() test("should keep graph consistent on errors during activation", function() local a = atom(0) - local b = computed(function() + local b = computed(function(): nil error("fail") end) local c = computed(function() return a() end) - local success, result = pcall(function() - b() - end) + local success, result = pcall(b) assert(not success, "should throw") - assert(string.match(result, "fail"), "should throw fail") + assert(string.match(tostring(result), "fail"), "should throw fail") a(1) assert(c() == 1, "c should recover and return 1") @@ -461,7 +459,7 @@ test.describe("error handling", function() test("should keep graph consistent on errors in computeds", function() local a = atom(0) - local b = computed(function() + local b = computed(function(): number if a() == 1 then error("fail") end @@ -474,11 +472,9 @@ test.describe("error handling", function() assert(c() == 0, "c should be 0") a(1) - local success, result = pcall(function() - b() - end) + local success, result = pcall(b) assert(not success, "should throw") - assert(string.match(result, "fail"), "should throw fail") + assert(string.match(tostring(result), "fail"), "should throw fail") a(2) assert(c() == 2, "c should recover and return 2") diff --git a/packages/charm/test/untracked.test.luau b/packages/charm/test/untracked.test.luau index cd75c63..3c7fea4 100644 --- a/packages/charm/test/untracked.test.luau +++ b/packages/charm/test/untracked.test.luau @@ -38,7 +38,7 @@ end) test("should run detached from effect scope", function() local effects = 0 - local stopEffect + local stopEffect: () -> () local stopScope = effectScope(function() untracked(function() diff --git a/packages/deep-charm/test/reactive.test.luau b/packages/deep-charm/test/reactive.test.luau index 4b747c9..b663147 100644 --- a/packages/deep-charm/test/reactive.test.luau +++ b/packages/deep-charm/test/reactive.test.luau @@ -49,10 +49,10 @@ test("should unwrap proxy assignments", function() end) src.a, src.b = src.a, src.b - assert(runs == 1, runs) + assert(runs == 1, `bad runs: {runs}`) src.a, src.b = src.b, src.a - assert(runs == 3, runs) + assert(runs == 3, `bad runs: {runs}`) end) test("should track iteration", function() @@ -125,10 +125,10 @@ test("should not track unread properties", function() local _ = src.value.a calls += 1 end) - assert(calls == 1, calls) + assert(calls == 1, `bad calls: {calls}`) src.value.b = 3 - assert(calls == 1, calls) + assert(calls == 1, `bad calls: {calls}`) end) test("should overwrite with table passed to setter", function() @@ -159,10 +159,10 @@ test("should unwrap proxy", function() assert(toRaw(src) == raw, "unwrap did not return the original table") assert(toRaw(raw) == raw, "unwrap modified a non-proxy value") - assert(runs == 1, runs) + assert(runs == 1, `bad runs: {runs}`) src.x = 1 - assert(runs == 2, runs) + assert(runs == 2, `bad runs: {runs}`) end) test("should unwrap nested proxy", function() @@ -177,13 +177,13 @@ test("should unwrap nested proxy", function() end) assert(toRaw(src) == raw, "unwrap did not return the original table") - assert(runs == 1, runs) + assert(runs == 1, `bad runs: {runs}`) inner.x = 1 - assert(runs == 2, runs) + assert(runs == 2, `bad runs: {runs}`) src.y = 2 - assert(runs == 3, runs) + assert(runs == 3, `bad runs: {runs}`) end) return {} diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 205514a..2d0cb9c 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -13,12 +13,12 @@ local React = require("./React") @param dependencies An array of values used to memoize the getter. @return The current state. ]] -local function useSignalState(getter: () -> T, dependencies: { any }?): T +local function useSignalState(getter: () -> T, dependencies: {}?): T local value, setValue = React.useState(getter) React.useLayoutEffect(function() - return Charm.listen(getter, setValue) - end, dependencies or {}) + return Charm.listen(getter, setValue :: (T, T?) -> ()) + end, dependencies :: { any } or {}) return value end @@ -34,13 +34,13 @@ end @param dependencies An array of values used to memoize the getter. @return A binding that stores the current state. ]] -local function useSignalBinding(getter: () -> T, dependencies: { any }?) +local function useSignalBinding(getter: () -> T, dependencies: {}?) local initialValue = React.useMemo(getter, {}) local binding, setBinding = React.useBinding(initialValue) React.useLayoutEffect(function() - return Charm.listen(getter, setBinding) - end, dependencies or {}) + return Charm.listen(getter, setBinding :: (T, T?) -> ()) + end, dependencies :: { any } or {}) return binding end diff --git a/packages/react-charm/test/helpers/renderHook.luau b/packages/react-charm/test/helpers/renderHook.luau index a059fb2..661d2e0 100644 --- a/packages/react-charm/test/helpers/renderHook.luau +++ b/packages/react-charm/test/helpers/renderHook.luau @@ -13,12 +13,13 @@ local function renderHook(callback: (Props) -> Result, props: Pro local function Component(props: Props) hook.result = callback(props) + return nil end function hook.rerender(newProps: Props?) props = newProps or props ReactRoblox.act(function() - root:render(React.createElement(Component, props)) + root:render(React.createElement(Component, props) :: any) end) end diff --git a/packages/react-charm/test/init.luau b/packages/react-charm/test/init.luau index 25679e5..1871b99 100644 --- a/packages/react-charm/test/init.luau +++ b/packages/react-charm/test/init.luau @@ -1,5 +1,6 @@ local test = require("../../tests/test") +-- selene: allow(global_usage) _G.__ROACT_17_MOCK_SCHEDULER__ = true test.describe("useSignalState", function() diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index db9d2e7..5a27f54 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -11,7 +11,7 @@ local Vide = require("./Vide") local function useSignalState(getter: () -> State): () -> State local state = Vide.source(getter()) - Vide.cleanup(Charm.listen(getter, state)) + Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ())) return state end diff --git a/packages/vide-charm/test/useSignalState.test.luau b/packages/vide-charm/test/useSignalState.test.luau index 3bfc74f..9661501 100644 --- a/packages/vide-charm/test/useSignalState.test.luau +++ b/packages/vide-charm/test/useSignalState.test.luau @@ -5,7 +5,7 @@ local test = require("../../../tests/test") test("tracks signal state", function() local getState, setState = Charm.signal(0) - local source + local source: () -> number local cleanup = Vide.root(function() source = VideCharm.useSignalState(getState) end) @@ -17,7 +17,7 @@ end) test("accepts a selector", function() local getState, setState = Charm.signal(0) - local source + local source: () -> number local cleanup = Vide.root(function() source = VideCharm.useSignalState(function() return 2 * getState() diff --git a/selene.toml b/selene.toml index 5a029a0..ecdec9c 100644 --- a/selene.toml +++ b/selene.toml @@ -2,6 +2,4 @@ std = "roblox" exclude = ["*/node_modules/*"] [lints] -global_usage = "allow" shadowing = "allow" -incorrect_standard_library_use = "allow" diff --git a/tests/init.luau b/tests/init.luau deleted file mode 100644 index 823aed6..0000000 --- a/tests/init.luau +++ /dev/null @@ -1 +0,0 @@ -(require)("@self/run.server") From 3134136e704821bc0305ff44576f887e24e22668 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:08:08 -0700 Subject: [PATCH 135/160] ci: disable new solver flag until 0.714 --- .lute/check.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lute/check.luau b/.lute/check.luau index ef48a74..3daa063 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -16,7 +16,7 @@ local analyze = process.run({ "--ignore=**/node_modules/**", "--base-luaurc=.luaurc", "--definitions=roblox.d.luau", - "--flag:LuauSolverV2=true", + -- "--flag:LuauSolverV2=true", "packages", "tests", "benches", From c842926abe109b29c6e60115016965ec08e2a143 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:26:22 -0700 Subject: [PATCH 136/160] chore: housekeeping --- README.md | 46 +++++++++++++------------ packages/charm-sync/src/config.luau | 11 +++--- packages/charm-sync/src/index.d.ts | 13 ++++--- packages/charm/test/effect.test.luau | 2 +- packages/charm/test/observe.test.luau | 4 +-- packages/charm/test/subscribe.test.luau | 4 +-- 6 files changed, 44 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index c337ff1..da3fd8f 100644 --- a/README.md +++ b/README.md @@ -640,25 +640,19 @@ return { } ``` -When a player joins on the server, call `server.addSignalsToClient` with the keyed signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. +When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. -Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to. +Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to: ```luau -local function onPlayerAdded(player: Player) - -- Add signal getters, computed signals, atoms, or reactive objects +playerReadyEvent.OnServerEvent:Connect(function(player) + -- Sync signal getters, computed signals, atoms, or reactive proxies server.addSignalsToClient(player, { name = nameStore.getName, surname = nameStore.getSurname, age = nameStore.ageAtom, }) -end - -for _, player in Players:GetPlayers() do - onPlayerAdded(player) -end - -Players.PlayerAdded:Connect(onPlayerAdded) +end) Players.PlayerRemoving:Connect(function(player) server.removeClient(player) @@ -671,7 +665,7 @@ end) ``` > [!NOTE] -> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. +> On the server, make sure each key corresponds to the same data across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. @@ -701,8 +695,8 @@ A configuration table that customizes the behavior of Charm Sync on the server. | --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. | | preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. | -| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | -| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will throw an error. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | +| fixArrays | `true` | When `true`, Charm will attempt to work around remote event limitations that cause data loss in array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | +| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will emit a warning. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | --- @@ -713,7 +707,7 @@ The `addSignalsToClient` function subscribes a client to updates in the given si You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. ```luau -Players.PlayerAdded:Connect(function(player) +playerReadyEvent.OnServerEvent:Connect(function(player) server.addSignalsToClient(player, { name = nameStore.getName, surname = nameStore.getSurname, @@ -722,14 +716,22 @@ Players.PlayerAdded:Connect(function(player) end) ``` -You're also allowed to create new signals to sync to specific players, as long as the key is unique to that player: +You're also allowed to create new signals to sync to specific players, as long as the key is unique to that data: ```luau -server.addSignalsToClient(player, { - [`data-{player.UserId}`] = computed(function() - return getPlayerData(player.UserId) - end), -}) +playerReadyEvent.OnServerEvent:Connect(function(player) + server.addSignalsToClient(player, { + -- 🟢 Good: Player-specific data is synced with a unique key + [`data-{player.UserId}`] = computed(function() + return getPlayerData(player.UserId) + end), + + -- 🔴 Bad: Syncing different data with the same keys does not work + playerData = computed(function() + return getPlayerData(player.UserId) + end), + }) +end) ``` --- @@ -754,7 +756,7 @@ server.removeSignalsFromClient(player, "surname") The `removeClient` function unsubscribes a client from receiving all state updates from the server. You should call this function when a player leaves the game. ```luau -Players.PlayerAdded:Connect(function(player) +playerReadyEvent.OnServerEvent:Connect(function(player) server.addSignalsToClient(player, signals) end) diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau index a101f05..c3ce27c 100644 --- a/packages/charm-sync/src/config.luau +++ b/packages/charm-sync/src/config.luau @@ -9,19 +9,22 @@ return { Whether the history of state changes since the client's last update should be preserved. This is useful for values that change multiple times per frame, where each individual change is important. Defaults - to `false`. + to `false` for performance reasons. ]] preserveHistory = false, --[[ - When `true`, Charm will apply validation and serialize unsafe arrays - to address remote event argument limitations. Defaults to `true`. + When `true`, Charm will modify sparse arrays to prevent data loss from + remote event limitations. When an unsafe array is detected, numeric + indices will be converted to string keys so that the array can be sent + safely and then converted back to an array on the receiving end. + This option should be disabled if your network library uses a custom serialization method (i.e. Zap, ByteNet) to prevent interference. ]] fixArrays = true, --[[ When `true`, synced state containing unsafe sparse arrays or mixed - tables will throw an error. Only checked in strict mode and if + tables will emit a warning. Only checked in strict mode and if `fixArrays` is enabled. Defaults to `true`. ]] validatePatches = true, diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 6519951..bb40eba 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -78,14 +78,17 @@ export const config: { * Whether the history of state changes since the client's last update * should be preserved. This is useful for values that change multiple * times per frame, where each individual change is important. Defaults - * to `false`. + * to `false` for performance reasons. * * @server */ preserveHistory: boolean; /** - * When `true`, Charm will apply validation and serialize unsafe arrays - * to address remote event argument limitations. Defaults to `true`. + * When `true`, Charm will modify sparse arrays to prevent data loss from + * remote event limitations. When an unsafe array is detected, numeric + * indices will be converted to string keys so that the array can be sent + * safely and then converted back to an array on the receiving end. + * * This option should be disabled if your network library uses a custom * serialization method (i.e. Zap, ByteNet) to prevent interference. * @@ -94,7 +97,7 @@ export const config: { fixArrays: boolean; /** * When `true`, synced state containing unsafe sparse arrays or mixed - * tables will throw an error. Only checked in strict mode and if + * tables will emit a warning. Only checked in strict mode and if * `fixArrays` is enabled. Defaults to `true`. * * @server @@ -242,7 +245,7 @@ export namespace patch { * @param statePatch The patches to apply. * @returns The new state with the patch applied. */ - export function apply( + export function applyImmutable( currentState: State, statePatch: SyncPatch, ): State; diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau index 5eb4c94..def4c68 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -497,7 +497,7 @@ end) test("recursive effects should run and cleanup in order", function() local a, setA = signal(0) - local order = {} + local order: { string } = {} effect(function() local value = a() diff --git a/packages/charm/test/observe.test.luau b/packages/charm/test/observe.test.luau index c328f16..223b1b0 100644 --- a/packages/charm/test/observe.test.luau +++ b/packages/charm/test/observe.test.luau @@ -91,7 +91,7 @@ end) test("should handle recursive additions and removals", function() local items, setItems = signal({ 1 }) - local edits = {} + local edits: { string } = {} observe(items, function(item) if item < 5 then @@ -114,7 +114,7 @@ end) test("should handle self removal", function() local items, setItems = signal({ 1, 2, 3, 4 }) - local edits = {} + local edits: { string } = {} observe(items, function(item, index) setItems(function(state: { number }) diff --git a/packages/charm/test/subscribe.test.luau b/packages/charm/test/subscribe.test.luau index 6936c7a..10ab932 100644 --- a/packages/charm/test/subscribe.test.luau +++ b/packages/charm/test/subscribe.test.luau @@ -46,7 +46,7 @@ test("should run in untracked", function() end) test("should ensure correct execution order", function() - local order = {} + local order: { number } = {} local n1, setN1 = signal(0) local n2, setN2 = signal(0) local sum = computed(function() @@ -72,7 +72,7 @@ test("should ensure correct execution order", function() end) test("should skip redundant updates", function() - local order = {} + local order: { "even" | "odd" } = {} local a, setA = signal(0) subscribe(function(): "even" | "odd" From ed6dd74512dd3ef965fdfbe880894f8acc6bdada Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 5 Apr 2026 03:17:27 -0700 Subject: [PATCH 137/160] refactor: improve errors in nonstrict, optimize noyield --- packages/charm/src/init.luau | 42 +++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index f55c9ed..28752d5 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -104,19 +104,21 @@ local function noYield(callback: (Args...) -> Results...): return callback end - local function noYieldHandler(thread: thread, ok: boolean, ...) - if not ok then - error(debug.traceback(thread, tostring((...))), 0) - elseif coroutine.status(thread) ~= "dead" then - error(debug.traceback(thread, "Attempt to yield in an effect or computed signal"), 0) - end - return ... + local function packResults(callback: (Args...) -> ...any, ...: Args...): { any } + return { callback(...) } end - return function(...) - local thread = coroutine.create(callback) - return noYieldHandler(thread, coroutine.resume(thread, ...)) + local function noYieldCallback(...): Results... + if coroutine.isyieldable() then + for results in packResults, callback, ... do + return unpack(results) + end + else + return callback(...) + end end + + return noYieldCallback end --[[ @@ -185,7 +187,7 @@ local function runCleanups(cleanups: { Cleanup }) cleanups[index] = nil local prevSub = setActiveSub(nil) - local success, result: any = pcall(cleanup) + local success, result = xpcall(cleanup :: () -> nil, debug.traceback) activeSub = prevSub if not success then @@ -223,7 +225,7 @@ local function run(effect: EffectNode) effect.flags = WATCHING local prevSub = setActiveSub(effect) - local success, result = pcall(effect.fn) + local success, result = xpcall(effect.fn, debug.traceback) activeSub = prevSub purgeDeps(effect) @@ -308,7 +310,7 @@ local function updateComputed(computed: ComputedNode): boolean local prevSub = setActiveSub(computed) local oldValue = computed.value - local success, newValue = pcall(computed.getter, oldValue) + local success, newValue = xpcall(computed.getter, debug.traceback, oldValue) activeSub = prevSub computed.flags = bit32.band(computed.flags, bit32.bnot(RECURSED_CHECK)) @@ -473,7 +475,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette -- a direct value if type(newValue) == "function" then local prevSub = setActiveSub(nil) - local success, result = pcall(noYield(newValue), this.pendingValue) + local success, result = xpcall(noYield(newValue), debug.traceback, this.pendingValue) activeSub = prevSub if success then @@ -589,7 +591,7 @@ local function computed(getter: (previousValue: T?) -> T): Getter this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) local prevSub = setActiveSub(this) - local success, result = pcall(this.getter) + local success, result = xpcall(this.getter, debug.traceback) activeSub = prevSub this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) @@ -642,7 +644,7 @@ local function effect(fn: () -> ...Cleanup?): Cleanup startBatch() local prevSub = setActiveSub(this) - local success, result = pcall(this.fn) + local success, result = xpcall(this.fn, debug.traceback) activeSub = prevSub if not success then @@ -679,7 +681,7 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup end local prevSub = setActiveSub(this) - local success, result = pcall(noYield(fn)) + local success, result = xpcall(noYield(fn), debug.traceback) activeSub = prevSub if not success then @@ -706,7 +708,7 @@ local function trigger(fn: () -> ...any) } local prevSub = setActiveSub(sub) - local success, result: any = pcall(noYield(fn)) + local success, result = xpcall(noYield(fn), debug.traceback) activeSub = prevSub if not success then @@ -761,7 +763,7 @@ end local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub then local prevSub = setActiveSub(nil) - local results: { any } = { pcall(noYield(fn), ...) } + local results: { any } = { xpcall(noYield(fn), debug.traceback, ...) } activeSub = prevSub if not results[1] then @@ -785,7 +787,7 @@ end ]] local function batch(fn: (Args...) -> Results..., ...: Args...): Results... startBatch() - local results: { any } = { pcall(noYield(fn), ...) } + local results: { any } = { xpcall(noYield(fn), debug.traceback, ...) } endBatch() if not results[1] then From 1740a440d2378fe22bc4d58c51c786c6941aad91 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 8 Apr 2026 06:50:39 -0700 Subject: [PATCH 138/160] chore: housekeeping --- README.md | 10 +-- packages/charm/src/init.luau | 123 +++++++++++++++++------------------ 2 files changed, 65 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index da3fd8f..2bf8269 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,15 @@ -Charm is a state management library based on [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf). Connect behavior to data with reactive signals, ensuring games stay up-to-date with their underlying data while eliminating the need for manual updates. +Charm is a reactive state management library designed for storing data in discrete containers called _signals_. Connect behavior to data with signals, ensuring systems stay up-to-date with their underlying data while reactivity eliminates the need for manual updates. **Build game state from simple building blocks:** -- Store state in [signals](#signalinitialvalue-equals): state containers that hold a value -- React to state changes with [effects](#effectcallback): re-run code when a dependency updates -- Derive values from state with [computed signals](#computedgetter): memoized functions with dependency tracking +- Store data in [signals](#signalinitialvalue-equals): reactive state containers that hold a value +- React to state changes with [effects](#effectcallback): re-run code when signals update +- Derive new values from data with [computed signals](#computedgetter): memoize functions that access signals -**Want to learn more about signals?** +**Want to learn more about reactivity?** - https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf - https://preactjs.com/blog/introducing-signals diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 28752d5..3692612 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -45,11 +45,11 @@ type SignalNode = ReactiveNode & { -- Bitmask flags for tracking the state of reactive nodes. Flags are combined -- and checked using bitwise operations for efficiency. local NONE = 0b000000 -- Default state -local MUTABLE = 0b000001 -- Can be changed (signal or computed) -local WATCHING = 0b000010 -- Watching for changes (effects) +local MUTABLE = 0b000001 -- Can be written to (signal or computed) +local WATCHING = 0b000010 -- Notified for changes in dependencies (effects) local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies local RECURSED = 0b001000 -- Has been visited in a recursion check -local DIRTY = 0b010000 -- Value has changed and needs updating +local DIRTY = 0b010000 -- Value is outdated and needs recomputation local PENDING = 0b100000 -- Might be dirty, pending verification -- Global version counter for dependency link deduplication. Incremented at the @@ -104,21 +104,20 @@ local function noYield(callback: (Args...) -> Results...): return callback end - local function packResults(callback: (Args...) -> ...any, ...: Args...): { any } - return { callback(...) } - end - - local function noYieldCallback(...): Results... + return function(...: Args...): Results... if coroutine.isyieldable() then - for results in packResults, callback, ... do + for results in + function(...) + return { callback(...) } + end, + ... + do return unpack(results) end else return callback(...) end end - - return noYieldCallback end --[[ @@ -264,6 +263,7 @@ local function flush() end end) + -- Skip over any remaining queued effects that were not run due to an error while notifyIndex < queuedLength do local effect = queued[notifyIndex] :: EffectNode queued[notifyIndex] = nil @@ -354,27 +354,24 @@ local function clearPending(sub: ReactiveNode, subFlags: ReactiveFlags) end --[[ - Performs a thorough cleanup of the given effect or effect scope: + Clears all dependencies and subscribers from the effect node. After calling + this, the node will no longer be subscribed to any dependencies and will be + inactive, with all cleanup functions run and resources released. - 1. Removes all dependency links - 2. Unlinks from parent effects/scopes - 3. Clears state flags to mark the effect as inactive - 4. Runs all registered cleanup functions - - @param this Effect node + @param node Effect node ]] -local function stopEffect(this: EffectScopeNode) - this.depsTail = nil - this.flags = NONE +local function stopEffect(node: EffectScopeNode) + node.depsTail = nil + node.flags = NONE - purgeDeps(this) + purgeDeps(node) - local sub = this.subs + local sub = node.subs if sub then - unlink(sub, this) + unlink(sub, node) end - runCleanups(this.cleanups) + runCleanups(node.cleanups) end --[[ @@ -458,7 +455,7 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals ]] local function signal(initialValue: T, equals: Equals?): (Getter, Setter) - local this: SignalNode = { + local node: SignalNode = { currentValue = initialValue, pendingValue = initialValue, flags = MUTABLE, @@ -475,7 +472,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette -- a direct value if type(newValue) == "function" then local prevSub = setActiveSub(nil) - local success, result = xpcall(noYield(newValue), debug.traceback, this.pendingValue) + local success, result = xpcall(noYield(newValue), debug.traceback, node.pendingValue) activeSub = prevSub if success then @@ -488,15 +485,15 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette end -- Check if the new value is different from the pending value - if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then + if if equals then not equals(node.pendingValue, value) else node.pendingValue ~= value then if flags.frozen then deepFreeze(value) end - this.pendingValue = value - this.flags = bit32.bor(MUTABLE, DIRTY) + node.pendingValue = value + node.flags = bit32.bor(MUTABLE, DIRTY) - local subs = this.subs + local subs = node.subs if subs then propagate(subs) flush() @@ -505,14 +502,14 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette return value end - return this.currentValue + return node.currentValue end local function readSignal(): T -- Check if the signal has a pending update that needs to be applied - if bit32.btest(this.flags, DIRTY) then - if updateSignal(this) then - local subs = this.subs + if bit32.btest(node.flags, DIRTY) then + if updateSignal(node) then + local subs = node.subs if subs then shallowPropagate(subs) end @@ -524,14 +521,14 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette local sub = activeSub while sub do if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then - link(this, sub, cycle) + link(node, sub, cycle) break end local subs = sub.subs sub = if subs then subs.sub else nil end - return this.currentValue + return node.currentValue end return readSignal, writeSignal @@ -564,50 +561,50 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter ]] local function computed(getter: (previousValue: T?) -> T): Getter - local this: ComputedNode = { + local node: ComputedNode = { flags = NONE, getter = noYield(getter), } local function computedOper(): T - local computedFlags = this.flags + local nodeFlags = node.flags if - bit32.btest(computedFlags, DIRTY) + bit32.btest(nodeFlags, DIRTY) or ( - bit32.btest(computedFlags, PENDING) - and (checkDirty(this.deps :: Link, this) or clearPending(this, computedFlags)) + bit32.btest(nodeFlags, PENDING) + and (checkDirty(node.deps :: Link, node) or clearPending(node, nodeFlags)) ) then - if updateComputed(this) then + if updateComputed(node) then -- Notify subscribers if the value changed - local subs = this.subs + local subs = node.subs if subs then shallowPropagate(subs) end end - elseif computedFlags == NONE then + elseif nodeFlags == NONE then -- First access should initialize the computed value - this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + node.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - local prevSub = setActiveSub(this) - local success, result = xpcall(this.getter, debug.traceback) + local prevSub = setActiveSub(node) + local success, result = xpcall(node.getter, debug.traceback) activeSub = prevSub - this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) + node.flags = bit32.band(node.flags, bit32.bnot(RECURSED_CHECK)) if success then - this.value = result + node.value = result else error(result, 2) end end if activeSub then - link(this, activeSub, cycle) + link(node, activeSub, cycle) end - return this.value :: T + return node.value :: T end return computedOper @@ -629,35 +626,35 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback ]] local function effect(fn: () -> ...Cleanup?): Cleanup - local this: EffectNode = { + local node: EffectNode = { fn = noYield(fn), flags = WATCHING, cleanups = {}, } if activeSub and (flags.trackInnerEffects or activeSub.flags == NONE) then - link(this, activeSub, 0) + link(node, activeSub, 0) end -- Start a batch so that any signal updates triggered during the effect -- execution are queued after the effect registers a cleanup function startBatch() - local prevSub = setActiveSub(this) - local success, result = xpcall(this.fn, debug.traceback) + local prevSub = setActiveSub(node) + local success, result = xpcall(node.fn, debug.traceback) activeSub = prevSub if not success then endBatch() error(result, 2) elseif result then - table.insert(this.cleanups, noYield(result)) + table.insert(node.cleanups, noYield(result)) end endBatch() return function() - stopEffect(this) + stopEffect(node) end end @@ -671,27 +668,27 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached ]] local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup - local this: EffectScopeNode = { + local node: EffectScopeNode = { flags = NONE, cleanups = {}, } if activeSub and not detached then - link(this, activeSub, 0) + link(node, activeSub, 0) end - local prevSub = setActiveSub(this) + local prevSub = setActiveSub(node) local success, result = xpcall(noYield(fn), debug.traceback) activeSub = prevSub if not success then error(result, 2) elseif result then - table.insert(this.cleanups, noYield(result)) + table.insert(node.cleanups, noYield(result)) end return function() - stopEffect(this) + stopEffect(node) end end From ee6237c4a259bce613901c059db25807e11d4e43 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:13:31 -0700 Subject: [PATCH 139/160] refactor: satisfy new solver --- .lute/check.luau | 2 +- package.json | 2 +- packages/charm/src/init.luau | 41 ++++++++++++++++++------------------ rokit.toml | 4 ++-- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.lute/check.luau b/.lute/check.luau index 3daa063..ef48a74 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -16,7 +16,7 @@ local analyze = process.run({ "--ignore=**/node_modules/**", "--base-luaurc=.luaurc", "--definitions=roblox.d.luau", - -- "--flag:LuauSolverV2=true", + "--flag:LuauSolverV2=true", "packages", "tests", "benches", diff --git a/package.json b/package.json index c7774c8..28c3abc 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.30.2", + "packageManager": "pnpm@10.33.0", "devDependencies": { "@eslint/js": "^9.39.4", "@rbxts/compiler-types": "3.0.0-types.0", diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 3692612..9a2ae5b 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -92,6 +92,8 @@ local flags = { trackInnerEffects = true, } +local traceback = debug.traceback :: (string?) -> string + --[[ Wraps a function to prevent yielding when in strict mode. If the function attempts to yield, an error is thrown. @@ -104,14 +106,13 @@ local function noYield(callback: (Args...) -> Results...): return callback end + local iterator = function(...): { any } + return { callback(...) } + end + return function(...: Args...): Results... if coroutine.isyieldable() then - for results in - function(...) - return { callback(...) } - end, - ... - do + for results in iterator, ... do return unpack(results) end else @@ -186,12 +187,12 @@ local function runCleanups(cleanups: { Cleanup }) cleanups[index] = nil local prevSub = setActiveSub(nil) - local success, result = xpcall(cleanup :: () -> nil, debug.traceback) + local success, err = xpcall(cleanup :: () -> nil, traceback) activeSub = prevSub if not success then hasErrors = true - table.insert(errors, tostring(result)) + table.insert(errors, tostring(err)) end end @@ -224,7 +225,7 @@ local function run(effect: EffectNode) effect.flags = WATCHING local prevSub = setActiveSub(effect) - local success, result = xpcall(effect.fn, debug.traceback) + local success, result = xpcall(effect.fn, traceback) activeSub = prevSub purgeDeps(effect) @@ -254,7 +255,7 @@ local function flush() return end - local success, result = pcall(function() + local success, err = pcall(function() while notifyIndex < queuedLength do local effect = queued[notifyIndex] :: EffectNode queued[notifyIndex] = nil @@ -275,7 +276,7 @@ local function flush() queuedLength = 0 if not success then - error(result, 0) + error(err, 0) end end @@ -310,7 +311,7 @@ local function updateComputed(computed: ComputedNode): boolean local prevSub = setActiveSub(computed) local oldValue = computed.value - local success, newValue = xpcall(computed.getter, debug.traceback, oldValue) + local success, newValue = xpcall(computed.getter, traceback, oldValue) activeSub = prevSub computed.flags = bit32.band(computed.flags, bit32.bnot(RECURSED_CHECK)) @@ -472,7 +473,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette -- a direct value if type(newValue) == "function" then local prevSub = setActiveSub(nil) - local success, result = xpcall(noYield(newValue), debug.traceback, node.pendingValue) + local success, result = xpcall(noYield(newValue), traceback, node.pendingValue) activeSub = prevSub if success then @@ -588,7 +589,7 @@ local function computed(getter: (previousValue: T?) -> T): Getter node.flags = bit32.bor(MUTABLE, RECURSED_CHECK) local prevSub = setActiveSub(node) - local success, result = xpcall(node.getter, debug.traceback) + local success, result = xpcall(node.getter, traceback) activeSub = prevSub node.flags = bit32.band(node.flags, bit32.bnot(RECURSED_CHECK)) @@ -641,7 +642,7 @@ local function effect(fn: () -> ...Cleanup?): Cleanup startBatch() local prevSub = setActiveSub(node) - local success, result = xpcall(node.fn, debug.traceback) + local success, result = xpcall(node.fn, traceback) activeSub = prevSub if not success then @@ -678,7 +679,7 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup end local prevSub = setActiveSub(node) - local success, result = xpcall(noYield(fn), debug.traceback) + local success, result = xpcall(noYield(fn), traceback) activeSub = prevSub if not success then @@ -705,11 +706,11 @@ local function trigger(fn: () -> ...any) } local prevSub = setActiveSub(sub) - local success, result = xpcall(noYield(fn), debug.traceback) + local success, err = xpcall(noYield(fn), traceback) activeSub = prevSub if not success then - error(result, 2) + error(err, 2) end local currentLink = sub.deps @@ -760,7 +761,7 @@ end local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub then local prevSub = setActiveSub(nil) - local results: { any } = { xpcall(noYield(fn), debug.traceback, ...) } + local results: { any } = { xpcall(noYield(fn), traceback, ...) } activeSub = prevSub if not results[1] then @@ -784,7 +785,7 @@ end ]] local function batch(fn: (Args...) -> Results..., ...: Args...): Results... startBatch() - local results: { any } = { xpcall(noYield(fn), debug.traceback, ...) } + local results: { any } = { xpcall(noYield(fn), traceback, ...) } endBatch() if not results[1] then diff --git a/rokit.toml b/rokit.toml index b8abcd6..2473ae6 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,7 +1,7 @@ [tools] wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.6.1" -stylua = "johnnymorganz/stylua@2.4.0" +stylua = "johnnymorganz/stylua@2.4.1" selene = "Kampfkarren/selene@0.30.1" -luau-lsp = "johnnymorganz/luau-lsp@1.64.0" +luau-lsp = "johnnymorganz/luau-lsp@1.66.0" lute = "luau-lang/lute@0.1.0-nightly.20260331" From eab89077f6267ec6435c881a75c57d023d85f5e9 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:06:53 -0700 Subject: [PATCH 140/160] refactor: improve error traceback --- packages/charm/src/init.luau | 89 ++++++---- packages/charm/test/effect.test.luau | 11 -- packages/charm/test/effectScope.test.luau | 9 - packages/charm/test/errors.test.luau | 200 ++++++++++++++++++++++ packages/charm/test/init.luau | 4 + packages/charm/test/observe.test.luau | 28 +++ 6 files changed, 285 insertions(+), 56 deletions(-) create mode 100644 packages/charm/test/errors.test.luau diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 9a2ae5b..ec4570c 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -92,32 +92,43 @@ local flags = { trackInnerEffects = true, } -local traceback = debug.traceback :: (string?) -> string - --[[ Wraps a function to prevent yielding when in strict mode. If the function - attempts to yield, an error is thrown. + was created by this module, it will be excluded to omit unnecessary Charm + internals from stack traces. - @param callback The function to wrap. + @param callback The user-created function to wrap. @return A wrapped function that cannot yield in strict mode. ]] -local function noYield(callback: (Args...) -> Results...): (Args...) -> Results... +local function wrapUserSpace(callback: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then return callback end - local iterator = function(...): { any } - return { callback(...) } + -- If the callback is already wrapped, avoid double-wrapping to simplify + -- stack traces + if debug.info(1, "s") == debug.info(callback, "s") then + return callback + end + + local handler = function(thread: thread, ok: boolean, ...: Results...): Results... + if not ok then + local err = (...) + if type(err) == "string" then + error(debug.traceback(thread, err), 0) + else + error(tostring(err), 0) + end + elseif coroutine.status(thread) ~= "dead" then + error(debug.traceback(thread, "Attempted to yield in an effect or scope"), 0) + else + return ... + end end return function(...: Args...): Results... - if coroutine.isyieldable() then - for results in iterator, ... do - return unpack(results) - end - else - return callback(...) - end + local thread = coroutine.create(callback) + return handler(thread, coroutine.resume(thread, ...)) end end @@ -187,7 +198,7 @@ local function runCleanups(cleanups: { Cleanup }) cleanups[index] = nil local prevSub = setActiveSub(nil) - local success, err = xpcall(cleanup :: () -> nil, traceback) + local success, err = pcall(cleanup) activeSub = prevSub if not success then @@ -225,7 +236,7 @@ local function run(effect: EffectNode) effect.flags = WATCHING local prevSub = setActiveSub(effect) - local success, result = xpcall(effect.fn, traceback) + local success, result = pcall(effect.fn) activeSub = prevSub purgeDeps(effect) @@ -233,7 +244,7 @@ local function run(effect: EffectNode) if not success then error(result, 0) elseif result then - table.insert(effect.cleanups, noYield(result)) + table.insert(effect.cleanups, wrapUserSpace(result)) end if effect.flags == NONE then @@ -311,7 +322,7 @@ local function updateComputed(computed: ComputedNode): boolean local prevSub = setActiveSub(computed) local oldValue = computed.value - local success, newValue = xpcall(computed.getter, traceback, oldValue) + local success, newValue = pcall(computed.getter, oldValue) activeSub = prevSub computed.flags = bit32.band(computed.flags, bit32.bnot(RECURSED_CHECK)) @@ -466,14 +477,14 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette deepFreeze(initialValue) end - local function writeSignal(newValue: Update): T + local function signalSetter(update: Update): T local value: T -- Determine the new value based on whether the update is a function or -- a direct value - if type(newValue) == "function" then + if type(update) == "function" then local prevSub = setActiveSub(nil) - local success, result = xpcall(noYield(newValue), traceback, node.pendingValue) + local success, result = pcall(wrapUserSpace(update), node.pendingValue) activeSub = prevSub if success then @@ -482,7 +493,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette error(result, 2) end else - value = newValue + value = update end -- Check if the new value is different from the pending value @@ -506,7 +517,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette return node.currentValue end - local function readSignal(): T + local function signalGetter(): T -- Check if the signal has a pending update that needs to be applied if bit32.btest(node.flags, DIRTY) then if updateSignal(node) then @@ -532,7 +543,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette return node.currentValue end - return readSignal, writeSignal + return signalGetter, signalSetter end --[[ @@ -564,7 +575,7 @@ end local function computed(getter: (previousValue: T?) -> T): Getter local node: ComputedNode = { flags = NONE, - getter = noYield(getter), + getter = wrapUserSpace(getter), } local function computedOper(): T @@ -589,7 +600,7 @@ local function computed(getter: (previousValue: T?) -> T): Getter node.flags = bit32.bor(MUTABLE, RECURSED_CHECK) local prevSub = setActiveSub(node) - local success, result = xpcall(node.getter, traceback) + local success, result = pcall(node.getter) activeSub = prevSub node.flags = bit32.band(node.flags, bit32.bnot(RECURSED_CHECK)) @@ -628,7 +639,7 @@ end ]] local function effect(fn: () -> ...Cleanup?): Cleanup local node: EffectNode = { - fn = noYield(fn), + fn = wrapUserSpace(fn), flags = WATCHING, cleanups = {}, } @@ -642,14 +653,14 @@ local function effect(fn: () -> ...Cleanup?): Cleanup startBatch() local prevSub = setActiveSub(node) - local success, result = xpcall(node.fn, traceback) + local success, result = pcall(node.fn) activeSub = prevSub if not success then endBatch() error(result, 2) elseif result then - table.insert(node.cleanups, noYield(result)) + table.insert(node.cleanups, wrapUserSpace(result)) end endBatch() @@ -679,13 +690,13 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup end local prevSub = setActiveSub(node) - local success, result = xpcall(noYield(fn), traceback) + local success, result = pcall(wrapUserSpace(fn)) activeSub = prevSub if not success then error(result, 2) elseif result then - table.insert(node.cleanups, noYield(result)) + table.insert(node.cleanups, wrapUserSpace(result)) end return function() @@ -706,7 +717,7 @@ local function trigger(fn: () -> ...any) } local prevSub = setActiveSub(sub) - local success, err = xpcall(noYield(fn), traceback) + local success, err = pcall(wrapUserSpace(fn)) activeSub = prevSub if not success then @@ -739,7 +750,7 @@ end ]] local function onCleanup(fn: Cleanup, failSilently: boolean?) if activeSub and (activeSub :: EffectNode).cleanups then - table.insert((activeSub :: EffectNode).cleanups, noYield(fn)) + table.insert((activeSub :: EffectNode).cleanups, wrapUserSpace(fn)) elseif not failSilently then warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) end @@ -761,7 +772,7 @@ end local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub then local prevSub = setActiveSub(nil) - local results: { any } = { xpcall(noYield(fn), traceback, ...) } + local results: { any } = { pcall(wrapUserSpace(fn), ...) } activeSub = prevSub if not results[1] then @@ -785,7 +796,7 @@ end ]] local function batch(fn: (Args...) -> Results..., ...: Args...): Results... startBatch() - local results: { any } = { xpcall(noYield(fn), traceback, ...) } + local results: { any } = { pcall(wrapUserSpace(fn), ...) } endBatch() if not results[1] then @@ -805,7 +816,7 @@ end ]] local function isSignal(fn: Getter): boolean local name = debug.info(fn, "n") - return name == "readSignal" or name == "computedOper" or name == "atomOper" + return name == "signalGetter" or name == "computedOper" or name == "atomOper" end --[[ @@ -819,6 +830,7 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] local function listen(getter: Getter, callback: (state: T, lastState: T?) -> ()): Cleanup + local callback = wrapUserSpace(callback) local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) local current: T @@ -840,6 +852,7 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] local function subscribe(getter: Getter, callback: (state: T, lastState: T) -> ()): Cleanup + local callback = wrapUserSpace(callback) local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) local current: T local firstRun = true @@ -870,6 +883,8 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] local function observe(getter: Getter<{ [K]: V }>, callback: (item: V, key: K) -> ...Cleanup?): Cleanup + local getter = wrapUserSpace(getter) + local callback = wrapUserSpace(callback) local scopes: { [K]: Cleanup } = {} local stopObserving = false @@ -928,6 +943,8 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform ]] local function mapped(getter: Getter<{ [KI]: VI }>, transform: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> + local getter = wrapUserSpace(getter) + local transform = wrapUserSpace(transform) local nextInput: { [KI]: VI } = {} local keyMap: { [KI]: KO } = {} diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau index def4c68..d697513 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -408,17 +408,6 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) -test.skip("infinite recursion should eventually throw", function() - local counter, setCounter = signal(0) - - local success = pcall(function() - effect(function() - setCounter(counter() + 1) - end) - end) - assert(not success, "should have thrown") -end) - test("should not retrigger the effect if the cleanup modifies one of the dependencies", function() local a, setA = signal(0) local calls = {} diff --git a/packages/charm/test/effectScope.test.luau b/packages/charm/test/effectScope.test.luau index c1c06c2..964b7d5 100644 --- a/packages/charm/test/effectScope.test.luau +++ b/packages/charm/test/effectScope.test.luau @@ -92,13 +92,4 @@ test("should dispose inner effects if created in an effect", function() assert(triggers == 2, "ran after dispose") end) -test("should disallow yielding", function() - local success = pcall(function() - effectScope(function() - coroutine.yield() - end) - end) - assert(not success, "should error on yield") -end) - return {} diff --git a/packages/charm/test/errors.test.luau b/packages/charm/test/errors.test.luau new file mode 100644 index 0000000..151a0d5 --- /dev/null +++ b/packages/charm/test/errors.test.luau @@ -0,0 +1,200 @@ +--!optimize 1 + +local Charm = require("../src") +local test = require("../../../tests/test") + +test("should disallow yielding", function() + local success = pcall(function() + Charm.effectScope(function() + coroutine.yield() + end) + end) + assert(not success, "should error on yield") +end) + +test.describe("traceback", function() + local DEFAULT_ERROR = "Test error" + + local function fail(message: string?) + error(message or DEFAULT_ERROR, 0) + end + + local function indirectError(message: string?) + fail(message) + end + + local function assertStackTrace(err: unknown, expectation: string?) + expectation = expectation or DEFAULT_ERROR + assert(type(err) == "string", "Expected error to be a string") + assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`) + assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`) + assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`) + end + + test("signal update", function() + local _, err = pcall(function() + local _, setSrc = Charm.signal(0) + setSrc(function() + indirectError() + return 1 + end) + end) + assertStackTrace(err) + end) + + test("atom update", function() + local _, err = pcall(function() + local src = Charm.atom(0) + src(function() + indirectError() + return 1 + end) + end) + assertStackTrace(err) + end) + + test("computed", function() + local _, err = pcall(function() + local src = Charm.computed(function() + indirectError() + end) + src() + end) + assertStackTrace(err) + end) + + test("effect init", function() + local _, err = pcall(function() + Charm.effect(function() + indirectError() + end) + end) + assertStackTrace(err) + end) + + test("effect run", function() + local _, err = pcall(function() + local src, setSrc = Charm.signal(0) + Charm.effect(function() + if src() == 1 then + indirectError() + end + end) + setSrc(1) + end) + assertStackTrace(err) + end) + + test("effect cleanups", function() + local _, err = pcall(function() + local dispose = Charm.effect(function() + Charm.onCleanup(function() + indirectError("onCleanup") + end) + return function() + indirectError("returned cleanup") + end + end) + dispose() + end) + assertStackTrace(err, "onCleanup") + assertStackTrace(err, "returned cleanup") + end) + + test("trigger", function() + local _, err = pcall(function() + Charm.trigger(function() + indirectError() + end) + end) + assertStackTrace(err) + end) + + test("untracked", function() + local _, err = pcall(function() + Charm.effectScope(function() + Charm.untracked(function() + indirectError() + end) + end) + end) + assertStackTrace(err) + end) + + test("batch", function() + local _, err = pcall(function() + Charm.batch(function() + indirectError() + end) + end) + assertStackTrace(err) + end) + + test("subscribe getter", function() + local _, err = pcall(function() + Charm.subscribe(function() + indirectError() + end, function() end) + end) + assertStackTrace(err) + end) + + test("subscribe callback", function() + local _, err = pcall(function() + local src, setSrc = Charm.signal(0) + Charm.subscribe(src, function() + indirectError() + end) + setSrc(1) + end) + assertStackTrace(err) + end) + + test("listen getter", function() + local _, err = pcall(function() + Charm.listen(function() + indirectError() + end, function() end) + end) + assertStackTrace(err) + end) + + test("listen callback", function() + local _, err = pcall(function() + local src, setSrc = Charm.signal(0) + Charm.listen(src, function() + indirectError() + end) + setSrc(1) + end) + assertStackTrace(err) + end) + + test("mapped getter", function() + local _, err = pcall(function() + local src = Charm.mapped(function() + indirectError() + return { 1 } + end, function() + return 1 + end) + src() + end) + assertStackTrace(err) + end) + + test("mapped transform", function() + local _, err = pcall(function() + local src = Charm.mapped(function() + return { 1 } + end, function() + indirectError() + return 1 + end) + src() + end) + assertStackTrace(err) + end) +end) + +return {} diff --git a/packages/charm/test/init.luau b/packages/charm/test/init.luau index e13fab7..52da435 100644 --- a/packages/charm/test/init.luau +++ b/packages/charm/test/init.luau @@ -12,6 +12,10 @@ test.describe("effectScope", function() require("@self/effectScope.test") end) +test.describe("error", function() + require("@self/errors.test") +end) + test.describe("gc", function() require("@self/gc.test") end) diff --git a/packages/charm/test/observe.test.luau b/packages/charm/test/observe.test.luau index 223b1b0..cd01b83 100644 --- a/packages/charm/test/observe.test.luau +++ b/packages/charm/test/observe.test.luau @@ -167,4 +167,32 @@ test("should allow disposal during removal", function() assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) end) +test("should not hang on error after recursive update", function() + local items, setItems = signal({}) + + observe(items, function() + setItems({ {} }) + error("test error") + end) + + local success, err = pcall(function() + setItems({ {} }) + end) + assert(not success, "should error") + assert(string.find(tostring(err), "test error"), `bad error: {err}`) +end) + +test("should not hang on error after recursive init", function() + local items, setItems = signal({ {} }) + + local success, err = pcall(function() + observe(items, function() + setItems({ {} }) + error("test error") + end) + end) + assert(not success, "should error") + assert(string.find(tostring(err), "test error"), `bad error: {err}`) +end) + return {} From 6a4b2ded3ef14f2d5bdc9ea984ba7bcc8e1e4844 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:18:18 -0700 Subject: [PATCH 141/160] docs: link roblox signals --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2bf8269..07b361d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Charm is a reactive state management library designed for storing data in discre - https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf - https://preactjs.com/blog/introducing-signals - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity -- https://angular.dev/guide/signals +- https://github.com/roblox/signals [Migrating from an older version of Charm?](#migration) From 71d155e947796eb62b6a97b24b463f2e0365614c Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:46:17 -0700 Subject: [PATCH 142/160] chore: housekeeping --- .luaurc | 7 +- .lute/build.luau | 2 +- .lute/check.luau | 4 +- benches/bench.luau | 4 +- packages/charm-sync/src/client.luau | 4 +- packages/charm-sync/src/server.luau | 4 +- packages/charm-sync/src/validate.luau | 9 ++- packages/charm/src/init.luau | 95 +++++++++------------------ packages/charm/src/system.luau | 2 +- packages/deep-charm/src/init.luau | 2 +- rokit.toml | 2 +- 11 files changed, 49 insertions(+), 86 deletions(-) diff --git a/.luaurc b/.luaurc index 91ac9c9..c48b0f2 100644 --- a/.luaurc +++ b/.luaurc @@ -1,10 +1,7 @@ { "languageMode": "strict", "aliases": { - "lute": "~/.lute/typedefs/0.1.0/lute", - "std": "~/.lute/typedefs/0.1.0/std" - }, - "lint": { - "LocalShadow": false + "lute": "~/.lute/typedefs/1.0.0/lute", + "std": "~/.lute/typedefs/1.0.0/std" } } diff --git a/.lute/build.luau b/.lute/build.luau index f1b5876..b708003 100644 --- a/.lute/build.luau +++ b/.lute/build.luau @@ -1,6 +1,6 @@ local fs = require("@std/fs") local process = require("@lute/process") -fs.createdirectory("build", { makeparents = true }) +fs.createDirectory("build") process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" }) diff --git a/.lute/check.luau b/.lute/check.luau index ef48a74..2c4ce7d 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -1,10 +1,10 @@ local fs = require("@std/fs") -local net = require("@lute/net") +local net = require("@std/net") local process = require("@lute/process") process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) -fs.writestringtofile( +fs.writeStringToFile( "roblox.d.luau", net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body ) diff --git a/benches/bench.luau b/benches/bench.luau index 9c24c6e..ffb839d 100644 --- a/benches/bench.luau +++ b/benches/bench.luau @@ -20,8 +20,8 @@ local function run(name: string, test: () -> ()) table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) - for name, elapsed in times do - table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) + for name, duration in times do + table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`) end print(table.concat(results, " | ")) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 7b51359..a514b75 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -90,7 +90,7 @@ end @param payloads The state updates received from the server. ]] -local function patch(payloads: { SyncPayload }) +local function applyPatches(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then Charm.batch(applyInitPayload, payload.data) @@ -104,5 +104,5 @@ return { addSignals = addSignals, removeSignals = removeSignals, removeAllSignals = removeAllSignals, - patch = patch, + patch = applyPatches, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 0fea67b..ca04ba7 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -39,9 +39,9 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? support custom implementations of reactive proxies. ]] local function toRaw(value: T): T - local value: any = value + value = value :: types.ReactiveProxy if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then - (value.__track :: () -> ())() + value.__track() return toRaw(value.__target) end return value diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index dcf5904..dfba716 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,11 +1,11 @@ local function isTableUnsafe(object: { [any]: any }) - local key = next(object) + local firstKey = next(object) - if key == nil then + if firstKey == nil then return false end - local keyType = type(key) + local keyType = type(firstKey) local maxKey = 0 -- Keys must all be the same type @@ -35,8 +35,7 @@ end local function validate(value: { [any]: any }) if isTableUnsafe(value) then warn( - `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` - .. "This can be for the following reasons:\n" + "Cannot sync tables unsupported by remote events! This can be for the following reasons:\n" .. "1. The table is a sparse array with non-sequential indices\n" .. "2. The table is a mixed table (not all keys have the same type)\n\n" .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n", diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index ec4570c..24a7cd7 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -17,10 +17,15 @@ local shallowPropagate = system.shallowPropagate local createReactiveSystem = system.createReactiveSystem export type Getter = () -> T + export type Setter = (update: Update) -> T + export type Atom = (...Update) -> T + export type Update = ((current: T) -> T) | T + export type Equals = (current: T, incoming: T) -> boolean + export type Cleanup = () -> () type EffectScopeNode = ReactiveNode & { @@ -93,20 +98,16 @@ local flags = { } --[[ - Wraps a function to prevent yielding when in strict mode. If the function - was created by this module, it will be excluded to omit unnecessary Charm - internals from stack traces. - - @param callback The user-created function to wrap. - @return A wrapped function that cannot yield in strict mode. + Wraps a user-created function to prevent yielding when in strict mode. To + shorten error stack traces, only functions created outside of this module + are wrapped. ]] local function wrapUserSpace(callback: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then return callback end - -- If the callback is already wrapped, avoid double-wrapping to simplify - -- stack traces + -- If the sources match, the function was created in this module if debug.info(1, "s") == debug.info(callback, "s") then return callback end @@ -135,8 +136,6 @@ end --[[ Recursively freezes a table and all nested tables to enforce immutability. Tables with metatables are skipped, as they are assumed to be mutable. - - @param value Table to freeze ]] local function deepFreeze(value: any) if type(value) == "table" and not table.isfrozen(value) and getmetatable(value) == nil then @@ -149,8 +148,6 @@ end --[[ Returns the currently active subscriber (effect or computed node). - - @return Current active subscriber or `nil` ]] local function getActiveSub(): ReactiveNode? return activeSub @@ -159,9 +156,6 @@ end --[[ Sets the currently active subscriber (effect or computed node) and returns the previous subscriber. - - @param sub New subscriber to set as active - @return Previous active subscriber ]] local function setActiveSub(sub: ReactiveNode?) local prevSub = activeSub @@ -173,8 +167,6 @@ end Removes dependencies from the subscriber's dependency list. When called after tracking is complete, this ensures that only currently tracked dependencies remain linked. - - @param sub Effect or computed node ]] local function purgeDeps(sub: ReactiveNode) local depsTail = sub.depsTail @@ -186,13 +178,12 @@ end --[[ Runs all cleanup functions registered with the effect node. Any errors - thrown during cleanup are caught and logged as warnings. - - @param cleanups List of cleanup functions + thrown during cleanup are caught and logged in a single error after all + cleanups are complete. ]] local function runCleanups(cleanups: { Cleanup }) local hasErrors = false - local errors = {} + local errors: { string } = {} for index, cleanup in cleanups do cleanups[index] = nil @@ -215,8 +206,6 @@ end --[[ Checks if the effect node needs to run based on its flags and dependencies. Effects run if they are marked dirty or pending with dirty dependencies. - - @param effect Effect node to run ]] local function run(effect: EffectNode) local effectFlags = effect.flags @@ -311,9 +300,6 @@ end --[[ Updates a computed signal by re-evaluating its getter function. Returns whether the computed value changed. - - @param computed Computed node to update - @return Boolean indicating whether the computed value changed ]] local function updateComputed(computed: ComputedNode): boolean cycle += 1 @@ -339,9 +325,6 @@ end --[[ Updates a signal by applying its pending value. Returns whether the signal's value changed. - - @param signal Signal node to update - @return Boolean indicating whether the signal's value changed ]] local function updateSignal(signal: SignalNode): boolean signal.flags = MUTABLE @@ -369,28 +352,23 @@ end Clears all dependencies and subscribers from the effect node. After calling this, the node will no longer be subscribed to any dependencies and will be inactive, with all cleanup functions run and resources released. - - @param node Effect node ]] -local function stopEffect(node: EffectScopeNode) - node.depsTail = nil - node.flags = NONE +local function stopEffect(effect: EffectScopeNode) + effect.depsTail = nil + effect.flags = NONE - purgeDeps(node) + purgeDeps(effect) - local sub = node.subs + local sub = effect.subs if sub then - unlink(sub, node) + unlink(sub, effect) end - runCleanups(node.cleanups) + runCleanups(effect.cleanups) end --[[ Updates a signal or computed value and returns whether the value changed. - - @param node Signal or computed node - @return Boolean indicating whether the value changed ]] local function update(node: ReactiveNode): boolean if node.depsTail then @@ -403,8 +381,6 @@ end --[[ Queues an effect for execution, ensuring inner effects are notified in the correct order. - - @param effect Effect node to notify ]] local function notify(effect: ReactiveNode) local insertIndex = queuedLength @@ -443,8 +419,6 @@ end - Computed signals: Unlink dependencies and mark as dirty for potential future updates - Effects/scopes: Perform full cleanup to free resources - - @param node Reactive node (computed, effect, or effect scope) ]] local function unwatched(node: ReactiveNode) if bit32.band(node.flags, MUTABLE) == 0 then @@ -719,24 +693,24 @@ local function trigger(fn: () -> ...any) local prevSub = setActiveSub(sub) local success, err = pcall(wrapUserSpace(fn)) activeSub = prevSub - - if not success then - error(err, 2) - end + sub.flags = NONE local currentLink = sub.deps while currentLink do local dep = currentLink.dep - currentLink = unlink(currentLink, sub) local subs = dep.subs + currentLink = unlink(currentLink, sub) if subs then - sub.flags = NONE propagate(subs) shallowPropagate(subs) end end flush() + + if not success then + error(err, 2) + end end --[[ @@ -774,11 +748,9 @@ local function untracked(fn: (Args...) -> Results..., ...: local prevSub = setActiveSub(nil) local results: { any } = { pcall(wrapUserSpace(fn), ...) } activeSub = prevSub - if not results[1] then error(results[2], 2) end - return unpack(results, 2) else return fn(...) @@ -798,11 +770,9 @@ local function batch(fn: (Args...) -> Results..., ...: Args startBatch() local results: { any } = { pcall(wrapUserSpace(fn), ...) } endBatch() - if not results[1] then error(results[2], 2) end - return unpack(results, 2) end @@ -810,9 +780,6 @@ end Returns true if the given function is a signal getter, computed signal, or atom function. Used for determining whether a function should be wrapped in a computed signal so that it can be memoized in `listen()` and `subscribe()`. - - @param fn Function to check - @return `true` if `fn` is a signal getter, computed signal, or atom function, `false` otherwise ]] local function isSignal(fn: Getter): boolean local name = debug.info(fn, "n") @@ -830,7 +797,6 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] local function listen(getter: Getter, callback: (state: T, lastState: T?) -> ()): Cleanup - local callback = wrapUserSpace(callback) local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) local current: T @@ -852,7 +818,6 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] local function subscribe(getter: Getter, callback: (state: T, lastState: T) -> ()): Cleanup - local callback = wrapUserSpace(callback) local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) local current: T local firstRun = true @@ -883,8 +848,9 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] local function observe(getter: Getter<{ [K]: V }>, callback: (item: V, key: K) -> ...Cleanup?): Cleanup - local getter = wrapUserSpace(getter) - local callback = wrapUserSpace(callback) + getter = wrapUserSpace(getter) + callback = wrapUserSpace(callback) + local scopes: { [K]: Cleanup } = {} local stopObserving = false @@ -943,8 +909,9 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform ]] local function mapped(getter: Getter<{ [KI]: VI }>, transform: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> - local getter = wrapUserSpace(getter) - local transform = wrapUserSpace(transform) + getter = wrapUserSpace(getter) + transform = wrapUserSpace(transform) + local nextInput: { [KI]: VI } = {} local keyMap: { [KI]: KO } = {} diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 10ad273..787ba02 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -102,7 +102,7 @@ local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) end local function unlink(link: Link, sub: ReactiveNode?): Link? - local sub = sub or link.sub + sub = sub or link.sub local dep = link.dep local prevDep = link.prevDep local nextDep = link.nextDep diff --git a/packages/deep-charm/src/init.luau b/packages/deep-charm/src/init.luau index ef9ab05..9eb9943 100644 --- a/packages/deep-charm/src/init.luau +++ b/packages/deep-charm/src/init.luau @@ -36,7 +36,7 @@ end ]] local function toRaw(value: T): T if isReactive(value) then - local value = value :: ReactiveProxy & T + value = value :: ReactiveProxy value.__track() return toRaw(value.__target) end diff --git a/rokit.toml b/rokit.toml index 2473ae6..60a6daf 100644 --- a/rokit.toml +++ b/rokit.toml @@ -4,4 +4,4 @@ rojo = "rojo-rbx/rojo@7.6.1" stylua = "johnnymorganz/stylua@2.4.1" selene = "Kampfkarren/selene@0.30.1" luau-lsp = "johnnymorganz/luau-lsp@1.66.0" -lute = "luau-lang/lute@0.1.0-nightly.20260331" +lute = "luau-lang/lute@1.0.0" From b00e6760c0e05b2ca5bde21df3888ba43d458627 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:32:53 -0700 Subject: [PATCH 143/160] perf: remove `clearPending` inline operation --- packages/charm/src/init.luau | 98 ++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 24a7cd7..ce06814 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -17,15 +17,10 @@ local shallowPropagate = system.shallowPropagate local createReactiveSystem = system.createReactiveSystem export type Getter = () -> T - export type Setter = (update: Update) -> T - export type Atom = (...Update) -> T - export type Update = ((current: T) -> T) | T - export type Equals = (current: T, incoming: T) -> boolean - export type Cleanup = () -> () type EffectScopeNode = ReactiveNode & { @@ -63,7 +58,7 @@ local PENDING = 0b100000 -- Might be dirty, pending verification local cycle = 0 -- Effects runs are queued to prevent redundant executions when multiple -- signals update in the same batch. -local queued: { EffectNode? } = {} +local queued: { EffectNode } = {} local queuedLength = 0 local notifyIndex = 0 -- Batch operations group multiple updates together, deferring effect runs @@ -216,6 +211,7 @@ local function run(effect: EffectNode) then runCleanups(effect.cleanups) + -- Cancel the run if the effect was disposed during cleanup if effect.flags == NONE then return end @@ -236,6 +232,8 @@ local function run(effect: EffectNode) table.insert(effect.cleanups, wrapUserSpace(result)) end + -- If the effect was disposed during execution but before a cleanup was + -- was registered, run the cleanups that would have been missed if effect.flags == NONE then runCleanups(effect.cleanups) end @@ -257,18 +255,18 @@ local function flush() local success, err = pcall(function() while notifyIndex < queuedLength do + notifyIndex += 1 local effect = queued[notifyIndex] :: EffectNode queued[notifyIndex] = nil - notifyIndex += 1 run(effect) end end) -- Skip over any remaining queued effects that were not run due to an error while notifyIndex < queuedLength do + notifyIndex += 1 local effect = queued[notifyIndex] :: EffectNode queued[notifyIndex] = nil - notifyIndex += 1 effect.flags = bit32.bor(effect.flags, bit32.bor(WATCHING, RECURSED)) end @@ -337,17 +335,6 @@ local function updateSignal(signal: SignalNode): boolean end end ---[[ - Clears the pending flag from a subscriber. Used inside of if conditions to - inline the operation. - - @param sub Effect or computed node - @param subFlags Subscriber's current flags -]] -local function clearPending(sub: ReactiveNode, subFlags: ReactiveFlags) - sub.flags = bit32.band(subFlags, bit32.bnot(PENDING)) -end - --[[ Clears all dependencies and subscribers from the effect node. After calling this, the node will no longer be subscribed to any dependencies and will be @@ -359,6 +346,7 @@ local function stopEffect(effect: EffectScopeNode) purgeDeps(effect) + -- Unlink from the parent effect, if it exists local sub = effect.subs if sub then unlink(sub, effect) @@ -383,15 +371,18 @@ end correct order. ]] local function notify(effect: ReactiveNode) - local insertIndex = queuedLength - local firstInsertedIndex = insertIndex + local startIndex = queuedLength + 1 -- Collect inner effects in a chain repeat - queued[insertIndex] = effect :: EffectNode - insertIndex += 1 + queuedLength += 1 + queued[queuedLength] = effect :: EffectNode + -- Mark as not watching to prevent duplicates in the same flush effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) + + -- Traverse to the parent effect, if it exists and is not queued, to + -- ensure it gets notified before the child effect effect = (effect.subs and effect.subs.sub) :: EffectNode if not effect or bit32.band(effect.flags, WATCHING) == 0 then @@ -399,17 +390,15 @@ local function notify(effect: ReactiveNode) end until false - queuedLength = insertIndex + local endIndex = queuedLength - -- Reverse the collected effects to ensure outer effects run first - repeat - insertIndex -= 1 - if firstInsertedIndex >= insertIndex then - break - end - queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex] - firstInsertedIndex += 1 - until false + -- Reverse the collected effects since they were collected from child to + -- parent, but should be notified from parent to child + while startIndex < endIndex do + queued[startIndex], queued[endIndex] = queued[endIndex], queued[startIndex] + startIndex += 1 + endIndex -= 1 + end end --[[ @@ -555,20 +544,24 @@ local function computed(getter: (previousValue: T?) -> T): Getter local function computedOper(): T local nodeFlags = node.flags - if - bit32.btest(nodeFlags, DIRTY) - or ( - bit32.btest(nodeFlags, PENDING) - and (checkDirty(node.deps :: Link, node) or clearPending(node, nodeFlags)) - ) - then + if bit32.btest(nodeFlags, DIRTY) then if updateComputed(node) then - -- Notify subscribers if the value changed local subs = node.subs if subs then shallowPropagate(subs) end end + elseif bit32.btest(nodeFlags, PENDING) then + if checkDirty(node.deps :: Link, node) then + if updateComputed(node) then + local subs = node.subs + if subs then + shallowPropagate(subs) + end + end + else + node.flags = bit32.band(nodeFlags, bit32.bnot(PENDING)) + end elseif nodeFlags == NONE then -- First access should initialize the computed value node.flags = bit32.bor(MUTABLE, RECURSED_CHECK) @@ -590,7 +583,7 @@ local function computed(getter: (previousValue: T?) -> T): Getter link(node, activeSub, cycle) end - return node.value :: T + return node.value end return computedOper @@ -618,27 +611,31 @@ local function effect(fn: () -> ...Cleanup?): Cleanup cleanups = {}, } + -- Inner effects are tracked as a dependency of the parent effect, so that + -- they can be automatically cleaned up when the parent effect re-runs, and + -- to ensure the correct execution order when nested effects are notified if activeSub and (flags.trackInnerEffects or activeSub.flags == NONE) then link(node, activeSub, 0) end - -- Start a batch so that any signal updates triggered during the effect - -- execution are queued after the effect registers a cleanup function + -- Start a batch so that the effect can register a cleanup function before + -- it can receive notifications, ensuring the cleanup always runs startBatch() local prevSub = setActiveSub(node) local success, result = pcall(node.fn) activeSub = prevSub - if not success then + if success then + if result then + table.insert(node.cleanups, wrapUserSpace(result)) + end + endBatch() + else endBatch() error(result, 2) - elseif result then - table.insert(node.cleanups, wrapUserSpace(result)) end - endBatch() - return function() stopEffect(node) end @@ -659,6 +656,7 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup cleanups = {}, } + -- Inner effect scopes are tracked as a dependency of the parent effect if activeSub and not detached then link(node, activeSub, 0) end @@ -948,7 +946,7 @@ local function mapped(getter: Getter<{ [KI]: VI }>, transform: ( end local nextValueOut, keyOutOrNil = transform(nextValueIn, keyIn) - local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn :: any + local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn local prevKeyOut = keyMap[keyIn] if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then From e460846dea19ff04c948c4a71b67c7346e9dc855 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:35:35 -0700 Subject: [PATCH 144/160] refactor: check type of cleanup --- packages/charm/src/init.luau | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index ce06814..aadbb52 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -226,10 +226,12 @@ local function run(effect: EffectNode) purgeDeps(effect) - if not success then + if success then + if type(result) == "function" then + table.insert(effect.cleanups, wrapUserSpace(result)) + end + else error(result, 0) - elseif result then - table.insert(effect.cleanups, wrapUserSpace(result)) end -- If the effect was disposed during execution but before a cleanup was @@ -627,7 +629,7 @@ local function effect(fn: () -> ...Cleanup?): Cleanup activeSub = prevSub if success then - if result then + if type(result) == "function" then table.insert(node.cleanups, wrapUserSpace(result)) end endBatch() @@ -665,10 +667,12 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup local success, result = pcall(wrapUserSpace(fn)) activeSub = prevSub - if not success then + if success then + if type(result) == "function" then + table.insert(node.cleanups, wrapUserSpace(result)) + end + else error(result, 2) - elseif result then - table.insert(node.cleanups, wrapUserSpace(result)) end return function() From 6499a0064fe14af81cd969126858a28ad92db845 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 5 May 2026 21:17:51 -0700 Subject: [PATCH 145/160] ci: remove selene --- .lute/check.luau | 4 +--- packages/charm/src/system.luau | 1 - packages/react-charm/test/init.luau | 1 - rokit.toml | 3 +-- selene.toml | 5 ----- 5 files changed, 2 insertions(+), 12 deletions(-) delete mode 100644 selene.toml diff --git a/.lute/check.luau b/.lute/check.luau index 2c4ce7d..b5ad31b 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -24,12 +24,10 @@ local analyze = process.run({ fs.remove("roblox.d.luau") -local selene = process.run({ "selene", "packages", "tests", "benches" }, { stdio = "inherit" }) - local stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" }) local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" }) -if not (analyze.ok and selene.ok and stylua.ok and eslint.ok) then +if not (analyze.ok and stylua.ok and eslint.ok) then process.exit(1) end diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 787ba02..a01aa5e 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -149,7 +149,6 @@ local function propagate(link: Link) sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) flags = bit32.band(flags, MUTABLE) else - -- selene: allow(if_same_then_else) flags = NONE end diff --git a/packages/react-charm/test/init.luau b/packages/react-charm/test/init.luau index 1871b99..25679e5 100644 --- a/packages/react-charm/test/init.luau +++ b/packages/react-charm/test/init.luau @@ -1,6 +1,5 @@ local test = require("../../tests/test") --- selene: allow(global_usage) _G.__ROACT_17_MOCK_SCHEDULER__ = true test.describe("useSignalState", function() diff --git a/rokit.toml b/rokit.toml index 60a6daf..30b60e4 100644 --- a/rokit.toml +++ b/rokit.toml @@ -2,6 +2,5 @@ wally = "UpliftGames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.6.1" stylua = "johnnymorganz/stylua@2.4.1" -selene = "Kampfkarren/selene@0.30.1" -luau-lsp = "johnnymorganz/luau-lsp@1.66.0" +luau-lsp = "johnnymorganz/luau-lsp@1.66.1" lute = "luau-lang/lute@1.0.0" diff --git a/selene.toml b/selene.toml deleted file mode 100644 index ecdec9c..0000000 --- a/selene.toml +++ /dev/null @@ -1,5 +0,0 @@ -std = "roblox" -exclude = ["*/node_modules/*"] - -[lints] -shadowing = "allow" From ccdd539b282a229a6431478af155a7b47db76f18 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 5 May 2026 21:23:45 -0700 Subject: [PATCH 146/160] fix: always return pending value in signal setter --- packages/charm/src/init.luau | 89 ++++++++++++++-------------- packages/charm/test/signal.test.luau | 18 ++++++ 2 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index aadbb52..d47ede5 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -226,11 +226,9 @@ local function run(effect: EffectNode) purgeDeps(effect) - if success then - if type(result) == "function" then - table.insert(effect.cleanups, wrapUserSpace(result)) - end - else + if success and result then + table.insert(effect.cleanups, wrapUserSpace(result)) + elseif not success then error(result, 0) end @@ -442,6 +440,32 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette deepFreeze(initialValue) end + local function signalGetter(): T + -- Check if the signal has a pending update that needs to be applied + if bit32.btest(node.flags, DIRTY) then + if updateSignal(node) then + local subs = node.subs + if subs then + shallowPropagate(subs) + end + end + end + + -- Register this signal as a dependency of the first subscriber in the + -- chain that is a computed signal or an effect + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(node, sub, cycle) + break + end + local subs = sub.subs + sub = if subs then subs.sub else nil + end + + return node.currentValue + end + local function signalSetter(update: Update): T local value: T @@ -475,37 +499,9 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette propagate(subs) flush() end - - return value end - return node.currentValue - end - - local function signalGetter(): T - -- Check if the signal has a pending update that needs to be applied - if bit32.btest(node.flags, DIRTY) then - if updateSignal(node) then - local subs = node.subs - if subs then - shallowPropagate(subs) - end - end - end - - -- Register this signal as a dependency of the first subscriber in the - -- chain that is a computed signal or an effect - local sub = activeSub - while sub do - if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then - link(node, sub, cycle) - break - end - local subs = sub.subs - sub = if subs then subs.sub else nil - end - - return node.currentValue + return node.pendingValue end return signalGetter, signalSetter @@ -522,9 +518,11 @@ end ]] local function atom(initialValue: T, equals: Equals?): Atom local get, set = signal(initialValue, equals) + local function atomOper(...) return if select("#", ...) == 0 then get() else set((...)) end + return atomOper end @@ -628,16 +626,15 @@ local function effect(fn: () -> ...Cleanup?): Cleanup local success, result = pcall(node.fn) activeSub = prevSub - if success then - if type(result) == "function" then - table.insert(node.cleanups, wrapUserSpace(result)) - end - endBatch() - else + if success and result then + table.insert(node.cleanups, wrapUserSpace(result)) + elseif not success then endBatch() error(result, 2) end + endBatch() + return function() stopEffect(node) end @@ -667,11 +664,9 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup local success, result = pcall(wrapUserSpace(fn)) activeSub = prevSub - if success then - if type(result) == "function" then - table.insert(node.cleanups, wrapUserSpace(result)) - end - else + if success and result then + table.insert(node.cleanups, wrapUserSpace(result)) + elseif not success then error(result, 2) end @@ -750,9 +745,11 @@ local function untracked(fn: (Args...) -> Results..., ...: local prevSub = setActiveSub(nil) local results: { any } = { pcall(wrapUserSpace(fn), ...) } activeSub = prevSub + if not results[1] then error(results[2], 2) end + return unpack(results, 2) else return fn(...) @@ -772,9 +769,11 @@ local function batch(fn: (Args...) -> Results..., ...: Args startBatch() local results: { any } = { pcall(wrapUserSpace(fn), ...) } endBatch() + if not results[1] then error(results[2], 2) end + return unpack(results, 2) end diff --git a/packages/charm/test/signal.test.luau b/packages/charm/test/signal.test.luau index 3d77f6c..21c1746 100644 --- a/packages/charm/test/signal.test.luau +++ b/packages/charm/test/signal.test.luau @@ -46,4 +46,22 @@ test("should not freeze values with metatables", function() ) end) +test("should setter return same value as getter even if equal", function() + local src, setSrc = signal(0, function() + return true + end) + assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`) +end) + +test("should setter return latest value", function() + local src, setSrc = signal(0) + effect(function() + if src() == 1 then + setSrc(2) + end + end) + assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`) + assert(src() == 2, `src() expected 2, got {src()}`) +end) + return {} From c9e42158d6a770b4e1a74e89e4ae65cc56a6330f Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 5 May 2026 21:24:04 -0700 Subject: [PATCH 147/160] test: add multi benchmark --- benches/multi.luau | 188 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 benches/multi.luau diff --git a/benches/multi.luau b/benches/multi.luau new file mode 100644 index 0000000..6c8235c --- /dev/null +++ b/benches/multi.luau @@ -0,0 +1,188 @@ +local Charm = require("../packages/charm/src") +local bench = require("./bench") + +type Atom = Charm.Atom + +local signal = Charm.atom +local computed = Charm.computed +local effect = Charm.effect + +local function busy(): number + local a = 0 + for i = 1, 100 do + a += 1 + end + return a +end + +do + local head = signal(0) + local computed1 = computed(function() + return head() + end) + local computed2 = computed(function() + computed1() + return 0 + end) + local computed3 = computed(function() + busy() -- heavy computation + return computed2() + 1 + end) + local computed4 = computed(function() + return computed3() + 2 + end) + local computed5 = computed(function() + return computed4() + 3 + end) + effect(function() + computed5() + busy() -- heavy side effect + end) + + bench.run("avoidablePropagation", function() + head(1) + assert(computed5() == 6) + for i = 0, 999 do + head(i) + end + assert(computed5() == 6) + end) +end + +do + local head = signal(0) + local double = computed(function() + return head() * 2 + end) + local inverse = computed(function() + return -head() + end) + local current = computed(function() + local result = 0 + for i = 0, 19 do + result += if head() % 2 == 1 then double() else inverse() + end + return result + end) + + local callCounter = 0 + effect(function() + current() + callCounter += 1 + end) + + bench.run("unstable", function() + head(1) + assert(current() == 40) + callCounter = 0 + for i = 0, 99 do + head(i) + end + assert(callCounter == 100) + end) +end + +do + local head = signal(0) + local double = computed(function() + return head() * 2 + end) + local inverse = computed(function() + return -head() + end) + local current = computed(function() + local result = 0 + for i = 0, 19 do + result += if head() % 2 == 1 and i % 2 == 1 then double() else inverse() + end + return result + end) + + local callCounter = 0 + effect(function() + current() + callCounter += 1 + end) + + bench.run("unstable 50%", function() + head(1) + assert(current() == 10) + callCounter = 0 + for i = 0, 99 do + head(i) + end + assert(callCounter == 100) + end) +end + +do + local head = signal(0) + local last: () -> number = head + local callCounter = 0 + + for i = 0, 49 do + local current = computed(function() + return head() + i + end) + local current2 = computed(function() + return current() + 1 + end) + effect(function() + current2() + callCounter += 1 + end) + last = current2 + end + + bench.run("broadPropagation", function() + head(1) + callCounter = 0 + for i = 0, 49 do + head(i) + assert(last() == i + 50) + end + assert(callCounter == 50 * 50) + end) +end + +do + local heads = {} + for index = 0, 99 do + heads[index] = signal(0) + end + + local mux = computed(function() + local result = {} + for index = 0, 99 do + result[index] = heads[index]() + end + return result + end) + + local splited = {} + for index = 0, 99 do + local x = computed(function() + return mux()[index] + end) + splited[index] = computed(function() + return x() + 1 + end) + end + + for _, x in splited do + effect(function() + x() + end) + end + + bench.run("mux", function() + for i = 0, 9 do + heads[i](i) + assert(splited[i]() == i + 1) + end + for i = 0, 9 do + heads[i](i * 2) + assert(splited[i]() == i * 2 + 1) + end + end) +end From aea9e83c8bc2cc7362714fb297d50f82abb69810 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 5 May 2026 22:14:53 -0700 Subject: [PATCH 148/160] release: charm v0.11.0-rc.6 --- package.json | 2 +- packages/charm-sync/wally.toml | 2 +- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/deep-charm/wally.toml | 2 +- packages/react-charm/wally.toml | 2 +- packages/vide-charm/wally.toml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 28c3abc..d5f8c09 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@10.33.3", "devDependencies": { "@eslint/js": "^9.39.4", "@rbxts/compiler-types": "3.0.0-types.0", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 8639cee..28a6fc9 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.5" +Charm = "littensy/charm@^0.11.0-rc.6" diff --git a/packages/charm/package.json b/packages/charm/package.json index e0701d7..28f5c7c 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0-rc.5", + "version": "0.11.0-rc.6", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 4c76e65..6298b39 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0-rc.5" +version = "0.11.0-rc.6" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml index bbd35f0..f1a2b11 100644 --- a/packages/deep-charm/wally.toml +++ b/packages/deep-charm/wally.toml @@ -6,4 +6,4 @@ realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.5" +Charm = "littensy/charm@^0.11.0-rc.6" diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index fb7fa9f..a1de6d4 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.5" +Charm = "littensy/charm@^0.11.0-rc.6" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 6bb3d02..5c59947 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.5" +Charm = "littensy/charm@^0.11.0-rc.6" Vide = "centau/vide@^0.4" From 573533058f831a0b033ef31ec7cc76050c335228 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:51:17 -0700 Subject: [PATCH 149/160] feat!: use alien-signals for reactivity (#50) * feat: use alien-signals * refactor: remove pnpm, fix tests * fix: `effectScope` should set scope * fix: check previous value before calling listener * refactor: improve imports * refactor: exclude re-exports from wally * refactor: embrace scope cleanup, restructure code * fix: update pkgs dir references * ci: simplify pesde install * build: install dev deps with wally * feat: add detached, add more tests * test: simplify mapped test update * chore: rephrase doc comment * perf: optimize subscribe and mapped * refactor: avoid ambiguous optional function type * refactor: add assertion error messages * perf: optimize mapped, add benches * chore: delete redundant benchmarks * perf: simplify mapped logic * test: prettify test library * test: try different gray * test: color brackets * refactor: sync 2.0.4 * refactor: use setCurrentSub instead of pauseTracking * test: improve test suite * test: separate parent from root * refactor: iterate over previous data in mapped * refactor: improve logic * style: use `---` doc comments * refactor: improve logic * chore: housekeeping * ci: add pnpm step * ci: init submodules * test: use lune * refactor: improve structure and alien-signals integration * ci: update test file path * feat: add peek and roblox-ts types * feat: add deep freeze flag, misc refactors * refactor: reduce type casting, housekeeping * refactor: rewrite untracked, update alien-signals * chore: update eslint, housekeeping * refactor: start charm-sync rewrite * fix: send init payload even if atom did not update * test: add all sync tests * feat: prepare for alpha release * feat: add peek, make untracked escape scope * fix: wrap hooks in untracked, update types * feat: add flag for inner effect tracking * refactor: add docs, remove some modules * refactor: align with alien-signals 3.0 * feat: add signal api * refactor: update charm sync api * feat: update alien-signals * refactor: remove unnecessary features, update docs * feat: finalize sync docs * docs: update sync section * docs: simplify examples * docs: capitalize headers * docs: add migration tips * docs: fix whitespace * docs: rephrase computed arg change * refactor: rename batch, misc fixes & perf updates * refactor: simplify charm sync validation * feat: update alien-signals, add recursion flag * feat: add `recursive()` * fix: allow dispose in observers * feat: add `trigger()`, refactor source * docs: update readme * chore: prepare release candidate * test: make some tests more reliable * docs: fix typo * feat: add `reactive()` * chore: bump versions * docs: reword reactive docs * docs: fix typo * feat: add `toRaw()` * fix: check for nil diffs * chore: bump versions * perf: do not create proxies for iteration * fix: make `toRaw` track all calls * fix: check none before patching mutable tables * chore: bump charm-sync version * docs: add fishing game example project * feat: sync signals that return reactive proxy * fix: refine reactive proxy logic * fix: prevent early reactive gc, alien-signals 3.1.1 * fix: wrap iterated values in `reactive()` * fix: loosen client signal types * chore: bump release candidate * fix: remove unsynced signals immediately * test: use lute for tests * chore: bump versions * ci: set up lute * refactor: improve source readability * ci: upgrade lute * build: loosen workspace version requirements * chore: bump deps * fix: update signal overload for setter (#58) * fix: add missing recursed check * test: add important vue reactivity tests * chore: fix react tests, run stylua * chore: update vide * docs: improve intro and explain getters * docs: adjust ordering by importance * docs: move listen nesting note * build: fix react peer dep for ci * chore: update lockfile * ci: use javascript eslint config * ci: ignore eslint config file * refactor: move test directories * feat: allow recursion in effects by default * release: charm v0.11.0-rc.4 * refactor: remove recursive() * refactor: reorganize repo, create deep-charm * docs: rephrase some sections * feat: export getter and setter types * docs: rephrase intro again * feat!: run listen and subscribe callback in untracked * refactor!: remove peek * docs: note peek in migration guide * release: charm v0.11.0-rc.5 * release: charm-sync v0.4.0-rc.7 * release: react-charm v0.4.0-rc.4 * fix: remove old keys when updating key in `mapped()` * refactor: shorten error traceback, housekeeping * refactor: keep error level in some cases * fix: check if cleanup functions disposed effect * refactor!: run effects in batched * ci: check eslint and prettier * perf: run observe fn in untracked * perf: call mapper as-is in `mapped()` * refactor: error for effect cleanup errors * refactor: use new type solver * ci: disable new solver flag until 0.714 * chore: housekeeping * refactor: improve errors in nonstrict, optimize noyield * chore: housekeeping * refactor: satisfy new solver * refactor: improve error traceback * docs: link roblox signals * chore: housekeeping * perf: remove `clearPending` inline operation * refactor: check type of cleanup * ci: remove selene * fix: always return pending value in signal setter * test: add multi benchmark * release: charm v0.11.0-rc.6 * test: add cleanup order tests * chore: bump deps * docs: add warning for deep-charm and note for `observe()` --------- Co-authored-by: safazi --- .eslintrc | 26 - .gitattributes | 1 - .github/workflows/ci.yml | 31 +- .github/workflows/release.yml | 8 +- .gitignore | 10 +- .gitmodules | 3 + .luaurc | 6 +- .lute/build.luau | 6 + .lute/check.luau | 33 + .lute/test.luau | 1 + .npmrc | 1 + LICENSE | 21 + LICENSE.md | 7 - README.md | 1346 ++++++++++------ {images => assets}/logo.png | Bin benches/bench.luau | 48 + benches/mapped.luau | 84 + benches/multi.luau | 188 +++ benches/propagate.luau | 47 + benches/reactive.luau | 88 + bin/build.sh | 5 - bin/check.sh | 29 - bin/install.sh | 4 - bin/testez.d.luau | 24 - build.project.json | 11 + default.project.json | 61 +- eslint.config.js | 20 + modules/wally.lock | 103 -- modules/wally.toml | 11 - package.json | 33 +- packages/charm-sync/Charm.luau | 9 + packages/charm-sync/default.project.json | 2 +- packages/charm-sync/package.json | 23 +- packages/charm-sync/src/client.luau | 179 ++- packages/charm-sync/src/config.luau | 31 + packages/charm-sync/src/flatten.luau | 28 - packages/charm-sync/src/index.d.ts | 462 +++--- packages/charm-sync/src/init.luau | 21 +- packages/charm-sync/src/interval.luau | 30 - packages/charm-sync/src/patch.luau | 142 +- packages/charm-sync/src/server.luau | 635 +++++--- packages/charm-sync/src/types.luau | 44 +- packages/charm-sync/src/utils.luau | 65 + packages/charm-sync/src/validate.luau | 85 +- packages/charm-sync/test/client.test.luau | 137 ++ packages/charm-sync/test/e2e.test.luau | 97 ++ packages/charm-sync/test/init.luau | 23 + packages/charm-sync/test/patch.test.luau | 133 ++ packages/charm-sync/test/server.test.luau | 408 +++++ packages/charm-sync/test/utils.test.luau | 22 + packages/charm-sync/wally.toml | 11 +- packages/charm/default.project.json | 2 +- packages/charm/package.json | 19 +- packages/charm/src/atom.luau | 42 - packages/charm/src/computed.luau | 37 - packages/charm/src/effect.luau | 53 - packages/charm/src/index.d.ts | 459 +++--- packages/charm/src/init.luau | 1011 +++++++++++- packages/charm/src/mapped.luau | 71 - packages/charm/src/observe.luau | 50 - packages/charm/src/store.luau | 214 --- packages/charm/src/subscribe.luau | 43 - packages/charm/src/system.luau | 237 +++ packages/charm/src/types.luau | 43 - packages/charm/test/computed.test.luau | 220 +++ packages/charm/test/effect.test.luau | 576 +++++++ packages/charm/test/effectScope.test.luau | 95 ++ packages/charm/test/errors.test.luau | 200 +++ packages/charm/test/gc.test.luau | 65 + packages/charm/test/init.luau | 55 + packages/charm/test/listen.test.luau | 77 + packages/charm/test/mapped.test.luau | 128 ++ packages/charm/test/observe.test.luau | 198 +++ packages/charm/test/signal.test.luau | 67 + packages/charm/test/subscribe.test.luau | 91 ++ packages/charm/test/topology.test.luau | 484 ++++++ packages/charm/test/trigger.test.luau | 111 ++ packages/charm/test/untracked.test.luau | 176 ++ packages/charm/wally.toml | 3 +- packages/deep-charm/Charm.luau | 5 + .../default.project.json | 2 +- packages/deep-charm/package.json | 27 + packages/deep-charm/src/index.d.ts | 53 + packages/deep-charm/src/init.luau | 165 ++ packages/deep-charm/test/gc.test.luau | 79 + packages/deep-charm/test/init.luau | 11 + packages/deep-charm/test/reactive.test.luau | 232 +++ packages/deep-charm/wally.toml | 9 + packages/react-charm/Charm.luau | 1 + packages/react-charm/React.luau | 1 + packages/react-charm/ReactRoblox.luau | 1 + packages/react-charm/default.project.json | 6 + packages/react-charm/package.json | 30 + packages/react-charm/src/index.d.ts | 31 + packages/react-charm/src/init.luau | 52 + .../react-charm/test/helpers}/renderHook.luau | 15 +- packages/react-charm/test/init.luau | 13 + .../test/useSignalBinding.test.luau | 44 + .../react-charm/test/useSignalState.test.luau | 44 + packages/react-charm/wally.toml | 17 + packages/react/package.json | 17 - packages/react/src/index.d.ts | 15 - packages/react/src/init.luau | 31 - packages/react/wally.toml | 9 - packages/vide-charm/Charm.luau | 1 + packages/vide-charm/Vide.luau | 1 + .../{vide => vide-charm}/default.project.json | 2 +- packages/vide-charm/package.json | 30 + packages/vide-charm/src/index.d.ts | 11 + packages/vide-charm/src/init.luau | 22 + packages/vide-charm/test/init.luau | 7 + .../vide-charm/test/useSignalState.test.luau | 59 + packages/vide-charm/wally.toml | 17 + packages/vide/package.json | 17 - packages/vide/src/index.d.ts | 9 - packages/vide/src/init.luau | 25 - packages/vide/wally.toml | 9 - pnpm-lock.yaml | 1425 +++++++++-------- rokit.toml | 14 +- selene.toml | 5 - stylua.toml | 3 + testez-companion.toml | 1 - testez.yml | 53 - tests/benchmarks/atom.bench.luau | 30 - tests/benchmarks/patch.bench.luau | 48 - 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/useAtom.spec.luau | 46 - tests/run.server.luau | 33 + tests/test | 1 + tests/utils/collect.luau | 12 - tests/utils/count.luau | 15 - tests/vide/useAtom.spec.luau | 36 - tsconfig.json | 9 +- 148 files changed, 9222 insertions(+), 4384 deletions(-) delete mode 100644 .eslintrc delete mode 100644 .gitattributes create mode 100644 .gitmodules create mode 100644 .lute/build.luau create mode 100644 .lute/check.luau create mode 100644 .lute/test.luau create mode 100644 .npmrc create mode 100644 LICENSE delete mode 100644 LICENSE.md rename {images => assets}/logo.png (100%) create mode 100644 benches/bench.luau create mode 100644 benches/mapped.luau create mode 100644 benches/multi.luau create mode 100644 benches/propagate.luau create mode 100644 benches/reactive.luau delete mode 100644 bin/build.sh delete mode 100644 bin/check.sh delete mode 100644 bin/install.sh delete mode 100644 bin/testez.d.luau create mode 100644 build.project.json create mode 100644 eslint.config.js delete mode 100644 modules/wally.lock delete mode 100644 modules/wally.toml create mode 100644 packages/charm-sync/Charm.luau create mode 100644 packages/charm-sync/src/config.luau delete mode 100644 packages/charm-sync/src/flatten.luau delete mode 100644 packages/charm-sync/src/interval.luau create mode 100644 packages/charm-sync/src/utils.luau create mode 100644 packages/charm-sync/test/client.test.luau create mode 100644 packages/charm-sync/test/e2e.test.luau create mode 100644 packages/charm-sync/test/init.luau create mode 100644 packages/charm-sync/test/patch.test.luau create mode 100644 packages/charm-sync/test/server.test.luau create mode 100644 packages/charm-sync/test/utils.test.luau delete mode 100644 packages/charm/src/atom.luau delete mode 100644 packages/charm/src/computed.luau delete mode 100644 packages/charm/src/effect.luau delete mode 100644 packages/charm/src/mapped.luau delete mode 100644 packages/charm/src/observe.luau delete mode 100644 packages/charm/src/store.luau delete mode 100644 packages/charm/src/subscribe.luau create mode 100644 packages/charm/src/system.luau delete mode 100644 packages/charm/src/types.luau create mode 100644 packages/charm/test/computed.test.luau create mode 100644 packages/charm/test/effect.test.luau create mode 100644 packages/charm/test/effectScope.test.luau create mode 100644 packages/charm/test/errors.test.luau create mode 100644 packages/charm/test/gc.test.luau create mode 100644 packages/charm/test/init.luau create mode 100644 packages/charm/test/listen.test.luau create mode 100644 packages/charm/test/mapped.test.luau create mode 100644 packages/charm/test/observe.test.luau create mode 100644 packages/charm/test/signal.test.luau create mode 100644 packages/charm/test/subscribe.test.luau create mode 100644 packages/charm/test/topology.test.luau create mode 100644 packages/charm/test/trigger.test.luau create mode 100644 packages/charm/test/untracked.test.luau create mode 100644 packages/deep-charm/Charm.luau rename packages/{react => deep-charm}/default.project.json (60%) create mode 100644 packages/deep-charm/package.json create mode 100644 packages/deep-charm/src/index.d.ts create mode 100644 packages/deep-charm/src/init.luau create mode 100644 packages/deep-charm/test/gc.test.luau create mode 100644 packages/deep-charm/test/init.luau create mode 100644 packages/deep-charm/test/reactive.test.luau create mode 100644 packages/deep-charm/wally.toml create mode 100644 packages/react-charm/Charm.luau create mode 100644 packages/react-charm/React.luau create mode 100644 packages/react-charm/ReactRoblox.luau create mode 100644 packages/react-charm/default.project.json create mode 100644 packages/react-charm/package.json create mode 100644 packages/react-charm/src/index.d.ts create mode 100644 packages/react-charm/src/init.luau rename {tests/utils => packages/react-charm/test/helpers}/renderHook.luau (62%) create mode 100644 packages/react-charm/test/init.luau create mode 100644 packages/react-charm/test/useSignalBinding.test.luau create mode 100644 packages/react-charm/test/useSignalState.test.luau create mode 100644 packages/react-charm/wally.toml delete mode 100644 packages/react/package.json delete mode 100644 packages/react/src/index.d.ts delete mode 100644 packages/react/src/init.luau delete mode 100644 packages/react/wally.toml create mode 100644 packages/vide-charm/Charm.luau create mode 100644 packages/vide-charm/Vide.luau rename packages/{vide => vide-charm}/default.project.json (60%) create mode 100644 packages/vide-charm/package.json create mode 100644 packages/vide-charm/src/index.d.ts create mode 100644 packages/vide-charm/src/init.luau create mode 100644 packages/vide-charm/test/init.luau create mode 100644 packages/vide-charm/test/useSignalState.test.luau create mode 100644 packages/vide-charm/wally.toml delete mode 100644 packages/vide/package.json delete mode 100644 packages/vide/src/index.d.ts delete mode 100644 packages/vide/src/init.luau delete mode 100644 packages/vide/wally.toml delete mode 100644 selene.toml delete mode 100644 testez-companion.toml delete mode 100644 testez.yml delete mode 100644 tests/benchmarks/atom.bench.luau delete mode 100644 tests/benchmarks/patch.bench.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 delete mode 100644 tests/react/useAtom.spec.luau create mode 100644 tests/run.server.luau create mode 160000 tests/test delete mode 100644 tests/utils/collect.luau delete mode 100644 tests/utils/count.luau delete mode 100644 tests/vide/useAtom.spec.luau diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 8f628fe..0000000 --- a/.eslintrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "jsx": true, - "useJSXTextNode": true, - "ecmaVersion": 2018, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "plugins": [ - "@typescript-eslint", - "roblox-ts", - "prettier" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:roblox-ts/recommended", - "plugin:prettier/recommended" - ], - "rules": { - "prettier/prettier": "warn", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unsafe-declaration-merging": "off" - } -} diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 6313b56..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4369391..912f2ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,31 +1,32 @@ name: CI -on: - push: - branches: - - main - - pull_request: - branches: - - main +on: [push, pull_request] jobs: analyze: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: pnpm/action-setup@v4 with: run_install: true + - uses: CompeyDev/setup-rokit@v0.1.2 - - name: Install Rokit - uses: CompeyDev/setup-rokit@v0.1.2 - - - name: Install Wally dependencies - run: pnpm wally + - name: Setup Lute + run: lute setup --with-luaurc - name: Check code quality - run: pnpm check + run: lute run check + + - name: Run ESLint and Prettier + run: | + pnpm eslint packages + pnpm prettier --check packages + + - name: Run tests + run: lute run test - name: Build models - run: pnpm build + run: lute run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07b75ad..3bd512d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,13 +9,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: CompeyDev/setup-rokit@v0.1.2 - - name: Install Rokit - uses: CompeyDev/setup-rokit@v0.1.2 + - name: Setup Lute + run: lute setup --with-luaurc - name: Build models - run: pnpm build + run: lute run build - name: Upload release assets uses: AButler/upload-release-assets@v3.0 diff --git a/.gitignore b/.gitignore index f3a27c4..d217e96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,13 @@ /.vscode -/node_modules +/.pesde /build -/modules/DevPackages -/packages/**/node_modules /packages/**/wally.lock +/packages/**/LICENSE* /packages/**/README.md -/packages/**/LICENSE.md /sourcemap.json +node_modules *.tsbuildinfo -*.tgz *.rbxm +*.tgz .DS_Store +roblox.d.luau diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..409278b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tests/test"] + path = tests/test + url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4 diff --git a/.luaurc b/.luaurc index e2b625c..c48b0f2 100644 --- a/.luaurc +++ b/.luaurc @@ -1,3 +1,7 @@ { - "languageMode": "strict" + "languageMode": "strict", + "aliases": { + "lute": "~/.lute/typedefs/1.0.0/lute", + "std": "~/.lute/typedefs/1.0.0/std" + } } diff --git a/.lute/build.luau b/.lute/build.luau new file mode 100644 index 0000000..b708003 --- /dev/null +++ b/.lute/build.luau @@ -0,0 +1,6 @@ +local fs = require("@std/fs") +local process = require("@lute/process") + +fs.createDirectory("build") + +process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" }) diff --git a/.lute/check.luau b/.lute/check.luau new file mode 100644 index 0000000..b5ad31b --- /dev/null +++ b/.lute/check.luau @@ -0,0 +1,33 @@ +local fs = require("@std/fs") +local net = require("@std/net") +local process = require("@lute/process") + +process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) + +fs.writeStringToFile( + "roblox.d.luau", + net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body +) + +local analyze = process.run({ + "luau-lsp", + "analyze", + "--sourcemap=sourcemap.json", + "--ignore=**/node_modules/**", + "--base-luaurc=.luaurc", + "--definitions=roblox.d.luau", + "--flag:LuauSolverV2=true", + "packages", + "tests", + "benches", +}, { stdio = "inherit" }) + +fs.remove("roblox.d.luau") + +local stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" }) + +local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" }) + +if not (analyze.ok and stylua.ok and eslint.ok) then + process.exit(1) +end diff --git a/.lute/test.luau b/.lute/test.luau new file mode 100644 index 0000000..4abbbb9 --- /dev/null +++ b/.lute/test.luau @@ -0,0 +1 @@ +(require)("../tests/run.server") diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b644580 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=*@rbxts* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d10b611 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright 2024-present Littensy + +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. diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index ae1bb3d..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,7 +0,0 @@ -Copyright 2024 Littensy - -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. diff --git a/README.md b/README.md index 8ea132b..35895ac 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

- Logo + Logo

Charm

- Atomic state management for Roblox. + Fine-grained reactivity for Roblox
npm package →

@@ -14,29 +14,139 @@ ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=main&logo=github) [![NPM Version](https://img.shields.io/npm/v/@rbxts/charm.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/charm) -[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md) +[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE) -**Charm** is an atomic and immutable state management library, inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Store your state in atoms, and write your own functions to read, write, and observe state. +Charm is a reactive state management library designed for storing data in discrete containers called _signals_. Connect behavior to data with signals, ensuring systems stay up-to-date with their underlying data while reactivity eliminates the need for manual updates. -See an example of Charm's features in [this example repository](https://github.com/littensy/charm-example). +**Build game state from simple building blocks:** -## 🍀 Features +- Store data in [signals](#signalinitialvalue-equals): reactive state containers that hold a value +- React to state changes with [effects](#effectcallback): re-run code when signals update +- Derive new values from data with [computed signals](#computedgetter): memoize functions that access signals -- ⚛️ **Manage state with _atoms_.** Decompose state into independent containers called _atoms_, as opposed to combining them into a single store. +**Want to learn more about reactivity?** -- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. +- https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf +- https://preactjs.com/blog/introducing-signals +- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity +- https://github.com/roblox/signals -- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. +[Migrating from an older version of Charm?](#migration) -- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. +
+Table of Contents ---- +- [Installation](#installation) +- [Reference](#reference) + - [`signal(initialValue, equals?)`](#signalinitialvalue-equals) + - [`computed(getter)`](#computedgetter) + - [`effect(callback)`](#effectcallback) + - [Nested effects](#nested-effects) + - [`effectScope(callback)`](#effectscopecallback) + - [`listen(getter, callback)`](#listengetter-callback) + - [Getter functions](#getter-functions) + - [`observe(getter, callback)`](#observegetter-callback) + - [`subscribe(getter, callback)`](#subscribegetter-callback) + - [`untracked(callback)`](#untrackedcallback) + - [`batch(callback)`](#batchcallback) + - [`mapped(getter, transform)`](#mappedgetter-transform) + - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) + - [`atom(initialValue, equals?)`](#atominitialvalue-equals) + - [`trigger(callback)`](#triggercallback) + - [`flags`](#flags) +- [Client-Server Sync](#client-server-sync) + - [Installation](#installation-1) + - [Quick Start](#quick-start) + - [Server API](#config) + - [Client API](#clientaddsignalssetters) + - [Sync Caveats](#sync-caveats) +- [Deep Reactivity](#deep-reactivity) + - [Installation](#installation-2) + - [`reactive(initialValue)`](#reactiveinitialvalue) + - [Mutation vs. update function](#mutation-vs-update-function) + - [`toRaw(value)`](#torawvalue) + - [`isReactive(value)`](#isreactivevalue) +- [Migration](#migration) +- [Examples](#examples) -## 📦 Setup +
-Install Charm for roblox-ts using your package manager of choice. +## At a Glance + +```luau +local getTodos, setTodos = signal({} :: { string }) +local getQuery, setQuery = signal("") + +observe(getTodos, function(todo: string, index: number) + local instance = Instance.new("TextLabel") + + local getText = computed(function() + return getTodos()[index] or "" + end) + + effect(function() + instance.Text = getText() + instance.Visible = string.match(getText(), getQuery()) ~= nil + end) + + instance.LayoutOrder = index + instance.Size = UDim2.new(1, 0, 0, 40) + instance.Parent = screenGui + + return function() + instance:Destroy() + end +end) + +setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) +setQuery("Buy") +``` + +
+Explain code + +```luau +-- Declare state for a todo list and a search query +local getTodos, setTodos = signal({} :: { string }) +local getQuery, setQuery = signal("") + +-- Create a text label when an item is added to the list +observe(getTodos, function(todo, index) + local instance = Instance.new("TextLabel") + + -- Create a memoized function that only re-runs effects when the todo list + -- updates or the function returns new text + local getText = computed(function() + return getTodos()[index] or "" + end) + + -- Update instance properties when the text or query updates + effect(function() + instance.Text = getText() + instance.Visible = string.match(getText(), getQuery()) ~= nil + end) + + instance.LayoutOrder = index + instance.Size = UDim2.new(1, 0, 0, 40) + instance.Parent = screenGui + + -- Destroy the instance when this item is removed + return function() + instance:Destroy() + end +end) + +-- Add items to the todo list +setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) +-- Filter for items containing "Buy" +setQuery("Buy") +``` + +
+ +## Installation ```sh npm install @rbxts/charm @@ -44,391 +154,461 @@ yarn add @rbxts/charm pnpm add @rbxts/charm ``` -Alternatively, add `littensy/charm` to your `wally.toml` file. - ```toml +# wally.toml [dependencies] Charm = "littensy/charm@VERSION" ``` --- -## 🐛 Debugging +## Reference -Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` before importing Charm. +### `signal(initialValue, equals?)` -Enabling `__DEV__` adds a few helpful features: - -- Better error handling for selectors, subscriptions, and batched functions: - - - Errors provide the function's name and line number. - - Yielding in certain functions will throw an error. - -- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. - -Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead. - ---- - -## 📚 Reference - -### `atom(state, options?)` - -Atoms are the building blocks of Charm. They are functions that hold a single value, and calling them can read or write to that value. Atoms, or any function that reads from atoms, can also be [subscribed](#subscribecallback-listener) to. - -Call `atom` to create a state container initialized with the value `state`. +Signals are the core of reactivity in Charm. The `signal` function creates a reactive signal that acts as a container for a value. It returns a function to access the value, and another to update the value. ```luau -local nameAtom = atom("John") -local todosAtom: Atom<{ string }> = atom({}) +local getCounter, setCounter = signal(0) + +setCounter(1) +setCounter(function(count) + return count + 1 +end) +print(getCounter()) -- 2 ``` -#### Parameters +Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately notify every effect and computed signal that depends on the signal, ensuring all of your state is correct and up-to-date. -- `state`: The value to assign to the atom initially. - -- **optional** `options`: An object that configures the behavior of this atom. - - - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. - -#### Returns - -The `atom` constructor returns an atom function with two possible operations: - -1. **Read the state.** Call the atom without arguments to get the current state. -2. **Set the state.** Pass a new value or an updater function to change the state. +You can also pass a custom equality function to only update the signal if the equality function returns `false`: ```luau -local function newTodo() - nameAtom("Jane") - - todosAtom(function(todos) - todos = table.clone(todos) - table.insert(todos, "Buy milk") - return todos - end) - - print(nameAtom()) --> "Jane" -end -``` - ---- - -### `subscribe(callback, listener)` - -Call `subscribe` to listen for changes in an atom or selector function. When the function's result changes, subscriptions are immediately called with the new state and the previous state. - -```luau -local nameAtom = atom("John") - -local cleanup = subscribe(nameAtom, function(name, prevName) - print(name) +local getMax, setMax = signal(0, function(current, incoming) + return incoming <= current end) -nameAtom("Jane") --> "Jane" +setMax(1) -- 1 +setMax(2) -- 2 +setMax(-1) -- 2 ``` -You may also pass a selector function that calls other atoms. The function will be memoized and only runs when its atom dependencies update. - -```luau -local function getUppercase() - return string.upper(nameAtom()) -end - -local cleanup = subscribe(getUppercase, function(name) - print(name) -end) - -nameAtom("Jane") --> "JANE" -``` - -#### Parameters - -- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. - -- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. - -#### Returns - -`subscribe` returns a cleanup function. +> [!NOTE] +> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a unified getter and setter. --- ### `effect(callback)` -Call `effect` to track state changes in all atoms read within the callback. The callback will run once to retrieve its dependencies, and then again whenever they change. Your callback may return a cleanup function to run when the effect is removed or about to re-run. +Effects are fundamental to reactivity, allowing you to react to signal updates. The `effect` function subscribes to signals accessed by the effect callback, and when a dependency updates, the callback will re-execute. ```luau -local nameAtom = atom("John") +local getCounter, setCounter = signal(0) -local cleanup = effect(function() - print(nameAtom()) +effect(function() + print(`Count is {getCounter()}`) +end) -- Count is 0 + +setCounter(1) -- Count is 1 +``` + +You can also return a cleanup function that will run once, either before the effect re-runs or when the effect is disposed: + +```luau +local getCounter, setCounter = signal(0) + +local dispose = effect(function() + local count = getCounter() return function() - print("Cleanup function called!") + print(`Cleanup {count}`) end end) + +setCounter(1) -- Cleanup 0 +dispose() -- Cleanup 1 ``` -Because `effect` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. +### Nested Effects -#### Parameters - -- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. - -#### Returns - -`effect` returns a cleanup function. - -#### Caveats - -- **If your effect should disconnect itself, use the `cleanup` argument.** Because effects run immediately, your effect may run before a `cleanup` function is returned. To disconnect an effect from the inside, use the argument passed to your effect instead: - ```lua - effect(function(cleanup) - if condition() then - cleanup() - end - end) - ``` - ---- - -### `computed(callback, options?)` - -Call `computed` when you want to derive a new atom from one or more atoms. The callback will be memoized, meaning that subsequent calls to the atom return a cached value that is only re-calculated when the dependencies change. +An effect is _nested_ if it was created during the execution of another effect. In Charm, when an effect with nested effects re-runs or gets cleaned up, the nested effects from the previous run are automatically cleaned up and re-created if needed. This prevents memory leaks and ensures that outer effects always run before their inner effects: ```luau -local todosAtom: Atom<{ string }> = atom({}) -local mapToUppercase = computed(function() - local result = table.clone(todosAtom()) - for key, todo in result do - result[key] = string.upper(todo) +local getPrintCount, setPrintCount = signal(true) +local getCount, setCount = signal(1) + +effect(function() + if getPrintCount() then + -- This inner effect is created when getPrintCount() is true + effect(function() + print(`Count is {getCount()}`) + end) end - return result -end) +end) -- Count is 1 + +setCount(2) -- Count is 2 + +-- This re-runs the outer effect and cleans up old inner effects +setPrintCount(false) + +setCount(3) -- No output ``` -Because `computed` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. - -This function is also useful for optimizing `effect` calls that depend on multiple atoms. For instance, if an effect derives some value from two atoms, it will run twice if both atoms change at the same time. Using `computed` can group these dependencies together and avoid re-running side effects. - -#### Parameters - -- `callback`: A function that returns a new value depending on one or more atoms. - -- **optional** [`options`](#parameters): An object that configures the behavior of this atom. - -#### Returns - -`computed` returns a read-only atom. - ---- - -### `observe(callback, factory)` - -Call `observe` to create an instance of `factory` for each key present in a dictionary or array. Your factory can return a cleanup function to run when the key is removed or the observer is cleaned up. - > [!NOTE] -> Because `observe` tracks the lifetime of each key in your data, your keys must be unique and unchanging. If your data is not keyed by unique and stable identifiers, consider using [`mapped`](#mappedcallback-mapper) to transform it into a keyed object before passing it to `observe`. +> To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `flags.trackInnerEffects` flag to assist with debugging. + +--- + +### `computed(getter)` + +The `computed` function creates a read-only signal whose value is derived from other signals. The computed signal caches the getter function's last result, and the value is only re-computed if a dependency has updated since the last computation. ```luau -local todosAtom: Atom<{ [string]: Todo }> = atom({}) +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local getFullName = computed(function() + return `{getName()} {getSurname()}` +end) -local cleanup = observe(todosAtom, function(todo, key) - print(`Added {key}: {todo.name}`) +print(getFullName()) -- "John Doe" +setName("Jane") +print(getFullName()) -- "Jane Doe" +``` + +The getter function also receives the previous result (or `nil` during the initial run). You can use this for computed signals that depend on the previous result: + +```luau +local getCounter, setCounter = signal(10) +local getMax = computed(function(prevMax) + return math.max(getCounter(), prevMax or 0) +end) + +print(getMax()) -- 10 +setCounter(5) +print(getMax()) -- 10 +``` + +--- + +### `effectScope(callback)` + +Scopes allow you to dispose multiple effects at once. The `effectScope` function creates a scope that tracks inner effects, so effects created during the execution of the callback will clean up when the scope disposes. + +```luau +local getCounter, setCounter = signal(0) + +local dispose = effectScope(function() + effect(function() + print(`Count 1 is {getCounter()}`) + end) + effect(function() + print(`Count 2 is {getCounter()}`) + end) +end) + +setCounter(1) -- Count 1 is 1, Count 2 is 1 +dispose() +setCounter(2) -- No output; effects got disposed +``` + +Similar to `effect()`, the callback can return a cleanup function that runs when the effect scope is disposed. + +--- + +### `listen(getter, callback)` + +The `listen` function creates an effect that only subscribes to the signals accessed by `getter`. Signals accessed by the callback will not be subscribed to, avoiding accidental subscriptions when you want to run side effects. + +The callback also receives the previous value, or `nil` when running for the first time. + +```luau +local getCounter, setCounter = signal(0) + +listen(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) +end) -- Count is 0 (was nil) + +setCounter(1) -- Count is 1 (was 0) +``` + +Note that the listener callback runs in `untracked()`, so nested effects are not cleaned up. + +### Getter functions + +In most Charm APIs, you can also subscribe to getter functions that call one or more signals, and they will automatically be tracked: + +```luau +local getCounter, setCounter = signal(0) + +local function floorCounter() + return math.floor(getCounter()) +end + +listen(floorCounter, function(count, prevCount) + print(`Floor of count is {count} (was {prevCount})`) +end) -- Floor of count is 0 (was nil) + +setCounter(0.5) -- Doesn't print anything, floor is still 0 +setCounter(1) -- Floor of count is 1 (was 0) +``` + +--- + +### `observe(getter, callback)` + +[Observers](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) allow you to track the lifetime of a given state. The `observe` function executes the callback for every unique key added to a table, and disposes the callback when that key is removed. + +```luau +local getItems, setItems = signal({ a = 0, b = 0 }) + +observe(getItems, function(value, key) + print(`Added {key}`) return function() print(`Removed {key}`) end -end) +end) -- Added a, Added b + +setItems({ a = 0, c = 0 }) -- Removed b, Added c ``` -#### Parameters +The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: -- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. +```luau +local getItems, setItems = signal({}) -- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. +local dispose = observe(getItems, function(value, key) + local getValue = computed(function(prevValue) + return getItems()[key] or prevValue + end) -#### Returns + effect(function() + local value = getValue() + print(`Set {key} = {value}`) + return function() + print(`Cleanup {key} = {value}`) + end + end) +end) -`observe` returns a cleanup function. +setItems({ a = 0, b = 0 }) -- Set a = 0, Set b = 0 +setItems({ a = 1, b = 0 }) -- Cleanup a = 0, Set a = 1 +setItems({ a = 1 }) -- Cleanup b = 0 +dispose() -- Cleanup a = 1 +``` --- -### `mapped(callback, mapper)` +### `subscribe(getter, callback)` -Call `mapped` to transform the keys and values of your state. The `mapper` function will be called for each key-value pair in the atom's state, and the new keys and atoms will be stored in a new atom. +The `subscribe` function is identical to `listen()`, but the callback does not run initially. The callback only runs when the value returned by the getter function changes. ```luau -local todosAtom: Atom<{ Todo }> = atom({}) -local todosById = mapped(todosAtom, function(todo, index) - return todo, todo.id +local getCounter, setCounter = signal(0) + +-- Does not output anything initially +subscribe(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) end) + +setCounter(1) -- Count is 1 (was 0) ``` -#### Parameters - -- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. - -- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: - - 1. Return a single value to map the table's original key to a new value. - 2. Return two values, the first being the value and the second being the key, to update both keys and values. - 3. Return `nil` for the value to remove the key from the resulting table. - -#### Returns - -`mapped` returns a read-only atom. - --- -### `peek(value, ...)` +### `untracked(callback)` -Call `peek` to call a function without tracking it as the dependency of an effect or a selector function. +In case you want to opt-out of dependency tracking in an effect, you can use `untracked()` to call a function _outside_ the current scope, preventing signals and effects in the function from being tracked. ```luau -local nameAtom = atom("John") -local ageAtom = atom(25) +local getTracked, setTracked = signal(0) +local getUntracked, setUntracked = signal(0) effect(function() - local name = nameAtom() - local age = peek(ageAtom) -end) + print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`) +end) -- Tracked: 0, Untracked: 0 + +setTracked(1) -- Tracked: 1, Untracked: 0 +setUntracked(1) -- No output +setTracked(2) -- Tracked: 2, Untracked: 1 ``` -#### Parameters +Because `untracked()` executes the callback outside the current effect, nested effects created during the execution of the callback will not be tracked by the parent effect: -- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. +```luau +local stopEffect +local stopScope = effectScope(function() + untracked(function() + stopEffect = effect(function() + return function() + print("Cleaned up untracked effect") + end + end) + end) +end) -- **optional** `...args`: Additional arguments to pass to the value if it is a function. - -#### Returns - -`peek` returns the result of the given function. If the value is not a function, it will return the value as-is. +stopScope() -- No output, the scope did not track the effect +stopEffect() -- Cleaned up untracked effect +``` --- ### `batch(callback)` -Call `batch` to defer state changes until after the callback has run. This is useful when you need to make multiple changes to the state and only want listeners to be notified once. +Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. ```luau -local nameAtom = atom("John") -local ageAtom = atom(25) +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +effect(function() + print(`Full name: {getName()} {getSurname()}`) +end) + +-- Combines both writes into a single update. +-- Once the callback completes, outputs "Full name: Foo Bar" batch(function() - nameAtom("Jane") - ageAtom(26) + setName("Foo") + setSurname("Bar") end) ``` -#### Parameters - -- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. - -#### Returns - -`batch` does not return anything. - --- -## 📦 React +### `mapped(getter, transform)` -### Setup +The `mapped` function iterates over every key in a table and uses the transform function to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, `transform` is called for that key and its current value. -Install the React bindings for Charm using your package manager of choice. - -```sh -npm install @rbxts/react-charm -yarn add @rbxts/react-charm -pnpm add @rbxts/react-charm -``` - -```toml -[dependencies] -ReactCharm = "littensy/react-charm@VERSION" -``` - ---- - -### `useAtom(callback, dependencies?)` - -Call `useAtom` at the top-level of a React component to read from an atom or selector. The component will re-render when the value changes. +The first value returned by the transform function is used as the new value: ```luau -local todosAtom = require(script.Parent.todosAtom) +local getList, setList = signal({ "a", "b", "c" }) -local function Todos() - local todos = useAtom(todosAtom) - -- ... -end +local getUppercase = mapped(getList, function(value) + return string.upper(value) +end) + +print(getUppercase()) -- { "A", "B", "C" } ``` -If your selector depends on the component's state or props, remember to pass them in a dependency array. This prevents skipped updates when an untracked parameter of the selector changes. +If the transform function returns two values, the second value is used as the new key: ```luau -local todos = useAtom(function() - return searchTodos(props.filter) -end, { props.filter }) -``` +local getList, setList = signal({ "a", "b", "c" }) -#### Parameters +local getSwapped = mapped(getList, function(value, key) + return key, value +end) -- `callback`: An atom or selector function that depends on an atom. - -- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. - -#### Returns - -`useAtom` returns the current state of the atom. - ---- - -## 📦 Vide - -### Setup - -Install the Vide bindings for Charm using your package manager of choice. - -```sh -npm install @rbxts/vide-charm -yarn add @rbxts/vide-charm -pnpm add @rbxts/vide-charm -``` - -```toml -[dependencies] -VideCharm = "littensy/vide-charm@VERSION" +print(getSwapped()) -- { a = 1, b = 2, c = 3 } ``` --- -### `useAtom(callback)` +### `onCleanup(callback, failSilently?)` -Call `useAtom` in any scope to create a Vide source that returns the current state of an atom or selector. +The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect. + +Unless `failSilently` is set to `true`, this function will emit a warning if there is no active effect or scope. ```luau -local todosAtom = require(script.Parent.todosAtom) +local dispose = effectScope(function() + onCleanup(function() + print("Cleaned up") + end) +end) -local function Todos() - local todos = useAtom(todosAtom) - -- ... -end +dispose() -- Cleaned up ``` -#### Parameters - -- `callback`: An atom or selector function that depends on an atom. - -#### Returns - -`useAtom` returns a Vide source. - --- -## 📗 Charm Sync +### `atom(initialValue, equals?)` -### Setup +The `atom` function creates a new reactive signal and returns a single function that acts as both a getter and setter. -The Charm Sync package provides server-client synchronization for your Charm atoms. Install it using your package manager of choice. +If the atom is called with 0 arguments, the atom returns the current value and subscribes to the signal. Otherwise, when called with 1 or more arguments, the atom will update the signal's value. + +```luau +local counter = atom(0) + +print(counter()) -- 0 +counter(1) +counter(function(count) + return count + 1 +end) +``` + +You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: + +```luau +local max = atom(0, function(current, incoming) + return incoming <= current +end) + +max(1) -- 1 +max(-1) -- 1 +``` + +--- + +### `trigger(callback)` + +The `trigger()` function allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter: + +```luau +local array = signal({} :: { number }) +local length = computed(function() + return #array() +end) + +print(length()) -- 0 + +-- Direct mutation doesn't automatically trigger updates +table.insert(array(), 1) +print(length()) -- Still 0 + +-- Manually trigger updates +trigger(array) +print(length()) -- 1 +``` + +You can also trigger multiple signals at once: + +```luau +local src1 = signal({} :: { number }) +local src2 = signal({} :: { number }) +local total = computed(function() + return #src1() + #src2() +end) + +table.insert(src1(), 1) +table.insert(src2(), 2) + +trigger(function() + src1() + src2() +end) + +print(total()) -- 2 +``` + +--- + +### `flags` + +Charm exposes the following global flags to customize behavior: + +| Flag | Default | Description | +| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| strict | `true`\* | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | +| frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | + +The `strict` and `frozen` flags are automatically enabled in Roblox Studio and other development environments where the Luau optimization level is `O1` or lower. + +--- + +## Client-Server Sync + +### Installation ```sh npm install @rbxts/charm-sync @@ -441,273 +621,459 @@ pnpm add @rbxts/charm-sync CharmSync = "littensy/charm-sync@VERSION" ``` ---- +### Quick Start -### `server(options)` - -Call `server` to create a server sync object. This synchronizes every client's atoms with the server's state by sending partial patches that the client merges into its state. +Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: ```luau -local syncer = CharmSync.server({ - -- A dictionary of the atoms to sync, matching the client's - atoms = atomsToSync, - -- The minimum interval between state updates - interval = 0, - -- Whether to send a full history of changes made to the atoms (slower) - preserveHistory = false, - -- Whether to apply fixes for remote event limitations. Disable this option - -- when using a network library with custom ser/des, like ByteNet or Zap. - autoSerialize = true, -}) +-- nameStore +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local ageAtom = atom(20) --- Sends state updates to clients when a synced atom changes. --- Omitting sensitive information and data serialization can be done here. -syncer:connect(function(player, ...) - remotes.syncState:fire(player, ...) -end) - --- Sends the initial state to a player upon request. This should fire when a --- player joins the game. -remotes.requestState:connect(function(player) - syncer:hydrate(player) -end) +return { + getName = getName, + setName = setName, + getSurname = getSurname, + setSurname = setSurname, + ageAtom = ageAtom, +} ``` -#### Parameters +When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. -- `options`: An object to configure sync behavior. +Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to: - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. +```luau +playerReadyEvent.OnServerEvent:Connect(function(player) + -- Sync signal getters, computed signals, atoms, or reactive proxies + server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, + age = nameStore.ageAtom, + }) +end) - - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. +Players.PlayerRemoving:Connect(function(player) + server.removeClient(player) +end) - - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. - - - **optional** `autoSerialize`: Whether to apply validation and workarounds to certain [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#table-indexing). Defaults to `true`, but you should set it to `false` if you serialize remote arguments (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). +server.connect(function(player, updates) + -- Customize how you send state updates to clients + syncEvent:FireClient(player, updates) +end) +``` > [!NOTE] -> Charm sends table updates in the form of partial tables, so arrays will contain `nil` values, which has undefined behavior in remotes without serialization. -> -> Charm's default `autoSerialize` behavior fixes this, but it can interfere with custom serialization. Disable this option if you use a network library that serializes remote event arguments. +> On the server, make sure each key corresponds to the same data across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. -#### Returns +To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. -`server` returns an object with the following methods: - -- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. - -- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. - -#### Caveats - -- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. [Read more about argument limitations in remotes.](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) - -- **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. - -- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `autoSerialize` accordingly! - ---- - -### `client(options)` - -Call `client` to create a client sync object. This synchronizes the client's atoms with the server's state by merging partial patches sent by the server into each atom. +After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau -local syncer = CharmSync.client({ - atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's - ignoreUnhydrated = true, -- Whether to ignore state updates before the initial update +-- Add signal setters, atoms, or reactive proxies +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, + age = nameStore.ageAtom, }) --- Applies state updates from the server to the client's atoms. --- Data deserialization can be done here. -remotes.syncState:connect(function(...) - syncer:sync(...) +-- Update client signals when receiving updates from the server +syncEvent.OnClientEvent:Connect(function(updates) + client.patch(updates) end) - --- Requests the initial state from the server when the client joins the game. --- Before this runs, the client uses the atoms' default values. -remotes.requestState:fire() ``` -#### Parameters - -- `options`: An object to configure sync behavior. - - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. - - - **optional** `ignoreUnhydrated`: Whether to ignore state updates before setting the initial state. Defaults to `true`. - -#### Returns - -`client` returns an object with the following methods: - -- `syncer:sync(...payloads)` applies a state update from the server. - -#### Caveats - -- **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above. - --- -### `isNone(value)` +### `config` -Call `isNone` to check if a value is `None`. Charm's partial state patches omit values that did not change between sync events, so to mark keys for deletion, Charm uses the `None` marker. +A configuration table that customizes the behavior of Charm Sync on the server. -This function can be used to check whether a value is about to be removed from an atom. - -```luau -local syncer = CharmSync.server({ atoms = atomsToSync }) - -syncer:connect(function(player, payload) - if - payload.type === "patch" - and payload.data.todosAtom - and CharmSync.isNone(payload.data.todosAtom.eggs) - then - -- 'eggs' will be removed from the client's todo list - end - remotes.syncState.fire(player, payload) -end) -``` - -#### Parameters - -- `value`: Any value. If the value is `None`, `isNone` will return `true`. - -#### Returns - -`isNone` returns a boolean. +| Option | Default | Description | +| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. | +| preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. | +| fixArrays | `true` | When `true`, Charm will attempt to work around remote event limitations that cause data loss in array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | +| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will emit a warning. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | --- -## 🚀 Examples +### `server.addSignalsToClient(client, getters)` -### Counter atom +The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. + +You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. ```luau -local counterAtom = atom(0) - --- Create a selector that returns double the counter value -local function doubleCounter() - return counterAtom() * 2 -end - --- Runs after counterAtom is updated and prints double the new value -subscribe(doubleCounter, function(value) - print(value) +playerReadyEvent.OnServerEvent:Connect(function(player) + server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, + age = nameStore.ageAtom, + }) end) - -counterAtom(1) --> 2 -counterAtom(function(count) - return count + 1 -end) --> 4 ``` -### React component +You're also allowed to create new signals to sync to specific players, as long as the key is unique to that data: ```luau -local counter = require(script.Parent.counter) -local counterAtom = counter.counterAtom -local incrementCounter = counter.incrementCounter +playerReadyEvent.OnServerEvent:Connect(function(player) + server.addSignalsToClient(player, { + -- 🟢 Good: Player-specific data is synced with a unique key + [`data-{player.UserId}`] = computed(function() + return getPlayerData(player.UserId) + end), + + -- 🔴 Bad: Syncing different data with the same keys does not work + playerData = computed(function() + return getPlayerData(player.UserId) + end), + }) +end) +``` + +--- + +### `server.removeSignalsFromClient(client, ...keys)` + +The `removeSignalsFromClient` function unsubscribes the client from a list of keys that were previously subscribed to via `addSignalsToClient`. + +```luau +server.addSignalsToClient(player, { + name = nameStore.getName, + surname = nameStore.getSurname, +}) + +server.removeSignalsFromClient(player, "surname") +``` + +--- + +### `server.removeClient(client)` + +The `removeClient` function unsubscribes a client from receiving all state updates from the server. You should call this function when a player leaves the game. + +```luau +playerReadyEvent.OnServerEvent:Connect(function(player) + server.addSignalsToClient(player, signals) +end) + +Players.PlayerRemoving:Connect(function(player) + server.removeClient(player) +end) +``` + +--- + +### `server.connect(onSync)` + +Binds a callback to run when sending state updates a client. Scheduled updates will be sent periodically at the interval specified in [`config.interval`](#config). + +When a synced signal updates, the `onSync` function is scheduled to run for each client subscribed to that signal on the next sync event. + +```luau +server.connect(function(player, updates) + syncEvent:FireServer(player, updates) +end) +``` + +--- + +### `server.disconnect()` + +Stops syncing state updates to clients at the automatic interval. This doesn't unbind the last callback, so you can still manually trigger sync events after calling this function by calling `server.flush()`. + +```luau +server.connect(function(player, updates) + syncEvent:FireServer(player, updates) +end) + +-- Stops calling the function at the automatic interval +server.disconnect() + +-- Flushing still sends pending updates +server.flush() +``` + +--- + +### `client.addSignals(setters)` + +Subscribes the given writable signals to the states with the corresponding keys on the server. When the server sends updates, the functions associated with each key in the state will be called with the patched values. + +You can pass either writable signals, atoms, or [reactive objects](#reactiveinitialvalue) to this function: + +```luau +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, + age = nameStore.ageAtom, +}) +``` + +--- + +### `client.removeSignals(...keys)` + +Unsubscribes from each signal with the corresponding keys. The signals will retain their current values, but will no longer receive updates from the server. + +```luau +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, +}) + +client.removeSignals("name") +``` + +--- + +### `client.patch(updates)` + +The `patch` function patches the client's state with the updates sent from the server. The initial update sent by the server will be the full state, and later updates will only include the values that changed. + +You should call `patch` when receiving updates from the server from a remote event: + +```luau +client.addSignals({ + name = nameStore.setName, + surname = nameStore.setSurname, +}) + +syncEvent.OnClientEvent:Connect(function(updates) + client.patch(updates) +end) +``` + +--- + +### `signalToAtom(getter, setter)` + +If you have a lot of signals to sync between the server and clients, it might become difficult to keep track of many getters and setters. The `signalToAtom()` function unifies a signal's `get()` and `set()` functions, allowing you to reuse the same values for `client.addSignals` and `server.addSignalsToClient`. + +```luau +local sharedState = { + name = signalToAtom(nameStore.getName, nameStore.setName), + surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), +} + +client.addSignals(sharedState) +server.addSignalsToClient(client, sharedState) +``` + +--- + +### Sync Caveats + +Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. + +But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`. + +This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. + +--- + +## Deep Reactivity + +Charm's reactivity system is _shallow_ by default: only the top-level value is reactive, so table properties are not checked when determining whether a signal has updated. As a result, tables in Charm should be immutable (copied before writing) in order to signal that a table's properties have changed. + +Deep reactivity, on the other hand, uses proxy tables to perform dependency tracking on properties. You can subscribe to a property by indexing the proxy table, and setting a property will notify its subscribers. This approach to reactivity lets you work with mutable data, making state management more intuitive at the cost of added overhead. + +You can opt-in to deep reactivity with the Deep Charm library: + +### Installation + +> [!WARNING] +> Deep Charm is an experimental library that is not fully documented, and the API is subject to breaking changes. + +```sh +npm install @rbxts/deep-charm +yarn add @rbxts/deep-charm +pnpm add @rbxts/deep-charm +``` + +```toml +[dependencies] +DeepCharm = "littensy/deep-charm@VERSION" +``` + +--- + +### `reactive(initialValue)` + +The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and nested tables will also be wrapped in a reactive proxy. + +```luau +local users, updateUsers = reactive({ + { name = "John", surname = "Doe" }, +}) + +effect(function() + print(`{users[1].name} {users[1].surname}`) +end) -- Output: John Doe + +updateUsers(function(raw) + raw[1].name = "Jane" + raw[1].surname = "Smith" + table.insert(raw, { name = "Steve", surname = "Doe" }) +end) -- Output: Jane Smith + +-- You can also mutate the reactive proxy directly: +users[1].name = "John" -- John Smith +``` + +### Mutation vs. update function + +Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value, which you can access through the update function returned by `reactive()`. + +This update function (`updateUsers()` in the example above) passes the raw table for you to mutate. Once your callback is done executing, the updater will manually notify subscriptions to the reactive proxy and its nested properties. This process does not use metatables, so you should use this for table operations or batching updates. + +--- + +### `toRaw(value)` + +If you need to access the raw table through the reactive proxy, use the `toRaw()` function: + +```luau +local raw = {} +local proxy = reactive(raw) + +print(toRaw(proxy) == raw) -- true +``` + +Calling `toRaw()` on a proxy also subscribes to its changes. This allows you to use proxies with `observe()` and other Charm APIs that accept a getter function: + +```luau +local proxy, updateProxy = reactive({}) + +observe(function() + return toRaw(proxy) +end, function(value, key) + print(`Value {value} added at key {key}`) +end) + +updateProxy(function(state) + table.insert(state, "foo") +end) -- Value foo added at key 1 +``` + +--- + +### `isReactive(value)` + +The `isReactive()` function returns `true` if the given value is a reactive proxy by checking its metatable. + +```lua +local raw = {} +local proxy = reactive(raw) + +print(isReactive(proxy)) -- true +print(isReactive(raw)) -- false +``` + +--- + +## Migration + +Charm v0.11 introduces several breaking changes, so this section should help you migrate from an older version. + +For reference, a signal is a state container with a separated getter and setter function. Atoms are a signal with a single function for getting and setting the state. + +**What to look out for:** + +1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: + - `peek()` was changed to `untracked()` for parity with other state managers + - The second arguments of `atom()` changed from an `options` table to an equality function + - Removed the second argument of `computed()` (you can do your own equality checks since the computed callback now receives the previous value) + - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`) + +2. If you use Charm Sync, you'll have to rewrite a lot of your sync code. Fortunately, most of the changes should make your code _less_ complicated: + - You can now sync signals per-client, including computed signals. You shouldn't have to modify sync payloads to filter data anymore. + - Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`. + - [Read the updated docs for syncing state →](#client-server-sync) + +3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors. The flags have the following behaviors: + - `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error + - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations + +4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent. + - This feature applies to the observer function in `observe()`, which also automatically cleans up inner effects to prevent memory leaks. However, this does not apply to `subscribe()` or `listen()`. + - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). + - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can temporarily disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`. + +5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! + - [`signal()`](#signalinitialvalue-equals): make atom reads and writes more explicit + - [`listen()`](#listengetter-callback): create a subscription that also runs once immediately + - [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once + - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect or scope + - [`trigger()`](#triggercallback): trigger updates for table mutations + +--- + +## Examples + +- https://github.com/littensy/fishing-minigame: Fisch clone using Charm for server and client state + +### React Counter + +```luau +local Charm = require("@packages/charm") +local ReactCharm = require("@packages/react-charm") +local React = require("@packages/react") + +local getCounter, setCounter = Charm.signal(0) local function Counter() - local count = useAtom(counterAtom) + local count = ReactCharm.useSignalState(getCounter) return React.createElement("TextButton", { - [React.Event.Activated] = incrementCounter, + [React.Event.Activated] = function() + setCounter(count + 1) + end, Text = `Count: {count}`, - Size = UDim2.new(0, 100, 0, 50), + Size = UDim2.fromOffset(100, 50), }) end ``` -### Vide component +### Vide Counter ```luau -local counter = require(script.Parent.counter) -local counterAtom = counter.counterAtom -local incrementCounter = counter.incrementCounter +local Charm = require("@packages/charm") +local VideCharm = require("@packages/vide-charm") +local Vide = require("@packages/vide") + +local create = Vide.create + +local getCounter, setCounter = Charm.signal(0) local function Counter() - local count = useAtom(counterAtom) + local count = VideCharm.useSignalState(getCounter) return create "TextButton" { - Activated = incrementCounter, + Activated = function() + setCounter(function(count) + return count + 1 + end) + end, Text = function() return `Count: {count()}` end, - Size = UDim2.new(0, 100, 0, 50), + Size = UDim2.fromOffset(100, 50), } end ``` -### Server-client sync - -Charm is designed for both client and server use, but there are often cases where the client needs to reference state that lives on the server. The CharmSync package provides a way to synchronize atoms between the server and its clients using remote events. - -Start by creating a set of atoms to sync between the server and clients. Export these atoms from a module to be shared between the server and client: - -```luau --- atoms.luau -local counter = require(script.Parent.counter) -local todos = require(script.Parent.todos) - -return { - counterAtom = counter.counterAtom, - todosAtom = todos.todosAtom, -} -``` - -Then, on the server, create a server sync object and pass in the atoms to sync. Use remote events to broadcast state updates and send initial state to clients upon request. - -> [!NOTE] -> If `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a variadic `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter. - -```luau --- sync.server.luau -local atoms = require(script.Parent.atoms) - -local syncer = CharmSync.server({ atoms = atoms }) - --- Sends state updates to clients when a synced atom changes. --- Omitting sensitive information and data serialization can be done here. -syncer:connect(function(player, payload) - remotes.syncState.fire(player, payload) -end) - --- Sends the initial state to a player upon request. This should fire when a --- player joins the game. -remotes.requestState:connect(function(player) - syncer:hydrate(player) -end) -``` - -Finally, on the client, create a client sync object and use it to apply incoming state changes. - -```luau --- sync.client.luau -local atoms = require(script.Parent.atoms) - -local syncer = CharmSync.client({ atoms = atoms }) - --- Applies state updates from the server to the client's atoms. -remotes.syncState:connect(function(payload) - syncer:sync(payload) -end) - --- Requests the initial state from the server when the client joins the game. --- Before this runs, the client uses the atoms' default values. -remotes.requestState:fire() -``` - ---

-Charm is released under the MIT License. +Charm is released under the MIT License.

-[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md) +[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE)
diff --git a/images/logo.png b/assets/logo.png similarity index 100% rename from images/logo.png rename to assets/logo.png diff --git a/benches/bench.luau b/benches/bench.luau new file mode 100644 index 0000000..ffb839d --- /dev/null +++ b/benches/bench.luau @@ -0,0 +1,48 @@ +local profiles: { [string]: number } = {} +local times: { [string]: number } = {} +local stack: { string } = {} + +local function run(name: string, test: () -> ()) + local start = os.clock() + local iter = 0 + + table.clear(profiles) + table.clear(times) + table.clear(stack) + + while os.clock() - start < 2 do + iter += 1 + test() + end + + local elapsed = os.clock() - start + local results = {} + + table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) + + for name, duration in times do + table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`) + end + + print(table.concat(results, " | ")) +end + +local function profilebegin(name: string) + profiles[name] = os.clock() + table.insert(stack, name) +end + +local function profileend() + local name = table.remove(stack) + + if name then + times[name] = (times[name] or 0) + os.clock() - profiles[name] + profiles[name] = nil + end +end + +return { + run = run, + profilebegin = profilebegin, + profileend = profileend, +} diff --git a/benches/mapped.luau b/benches/mapped.luau new file mode 100644 index 0000000..695b7f5 --- /dev/null +++ b/benches/mapped.luau @@ -0,0 +1,84 @@ +local Charm = require("../packages/charm/src") +local bench = require("./bench") + +local computed = Charm.computed +local mapped = Charm.mapped +local signal = Charm.signal + +type Key = number +type Value = number + +local count = 512 +local initialState: { [Key]: Value } = table.create(count, 0) + +local function expensiveOperation() + for _ = 1, 100 do + continue + end +end + +local function simpleMapped( + source: () -> { [Key]: Value }, + mapper: (Value, Key) -> (Value, Key?) +): () -> { [Key]: Value } + return computed(function() + local state = source() + local nextState = {} + + for keyIn, valueIn in state do + local valueOut, keyOut = mapper(valueIn, keyIn) + nextState[if keyOut == nil then keyIn else keyOut] = valueOut + end + + return nextState + end) +end + +local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value } + return function(state) + local nextState = table.clone(state) + for key in state do + if math.random() < updateChance then + nextState[key] += 1 + end + end + return nextState + end +end + +for _, updateChance in { 0.1, 0.5, 1 } do + local function mapper(value: Value, key: Key): (Value, Key?) + expensiveOperation() + return value + 1, key + 1 + end + + bench.run(`mapped {updateChance}`, function() + local src, setSrc = signal(initialState) + local out = mapped(src, mapper) + + bench.profilebegin("init") + out() + bench.profileend() + + setSrc(updateRandomly(updateChance)) + + bench.profilebegin("update") + out() + bench.profileend() + end) + + bench.run(`control {updateChance}`, function() + local src, setSrc = signal(initialState) + local out = simpleMapped(src, mapper) + + bench.profilebegin("init") + out() + bench.profileend() + + setSrc(updateRandomly(updateChance)) + + bench.profilebegin("update") + out() + bench.profileend() + end) +end diff --git a/benches/multi.luau b/benches/multi.luau new file mode 100644 index 0000000..6c8235c --- /dev/null +++ b/benches/multi.luau @@ -0,0 +1,188 @@ +local Charm = require("../packages/charm/src") +local bench = require("./bench") + +type Atom = Charm.Atom + +local signal = Charm.atom +local computed = Charm.computed +local effect = Charm.effect + +local function busy(): number + local a = 0 + for i = 1, 100 do + a += 1 + end + return a +end + +do + local head = signal(0) + local computed1 = computed(function() + return head() + end) + local computed2 = computed(function() + computed1() + return 0 + end) + local computed3 = computed(function() + busy() -- heavy computation + return computed2() + 1 + end) + local computed4 = computed(function() + return computed3() + 2 + end) + local computed5 = computed(function() + return computed4() + 3 + end) + effect(function() + computed5() + busy() -- heavy side effect + end) + + bench.run("avoidablePropagation", function() + head(1) + assert(computed5() == 6) + for i = 0, 999 do + head(i) + end + assert(computed5() == 6) + end) +end + +do + local head = signal(0) + local double = computed(function() + return head() * 2 + end) + local inverse = computed(function() + return -head() + end) + local current = computed(function() + local result = 0 + for i = 0, 19 do + result += if head() % 2 == 1 then double() else inverse() + end + return result + end) + + local callCounter = 0 + effect(function() + current() + callCounter += 1 + end) + + bench.run("unstable", function() + head(1) + assert(current() == 40) + callCounter = 0 + for i = 0, 99 do + head(i) + end + assert(callCounter == 100) + end) +end + +do + local head = signal(0) + local double = computed(function() + return head() * 2 + end) + local inverse = computed(function() + return -head() + end) + local current = computed(function() + local result = 0 + for i = 0, 19 do + result += if head() % 2 == 1 and i % 2 == 1 then double() else inverse() + end + return result + end) + + local callCounter = 0 + effect(function() + current() + callCounter += 1 + end) + + bench.run("unstable 50%", function() + head(1) + assert(current() == 10) + callCounter = 0 + for i = 0, 99 do + head(i) + end + assert(callCounter == 100) + end) +end + +do + local head = signal(0) + local last: () -> number = head + local callCounter = 0 + + for i = 0, 49 do + local current = computed(function() + return head() + i + end) + local current2 = computed(function() + return current() + 1 + end) + effect(function() + current2() + callCounter += 1 + end) + last = current2 + end + + bench.run("broadPropagation", function() + head(1) + callCounter = 0 + for i = 0, 49 do + head(i) + assert(last() == i + 50) + end + assert(callCounter == 50 * 50) + end) +end + +do + local heads = {} + for index = 0, 99 do + heads[index] = signal(0) + end + + local mux = computed(function() + local result = {} + for index = 0, 99 do + result[index] = heads[index]() + end + return result + end) + + local splited = {} + for index = 0, 99 do + local x = computed(function() + return mux()[index] + end) + splited[index] = computed(function() + return x() + 1 + end) + end + + for _, x in splited do + effect(function() + x() + end) + end + + bench.run("mux", function() + for i = 0, 9 do + heads[i](i) + assert(splited[i]() == i + 1) + end + for i = 0, 9 do + heads[i](i * 2) + assert(splited[i]() == i * 2 + 1) + end + end) +end diff --git a/benches/propagate.luau b/benches/propagate.luau new file mode 100644 index 0000000..463ee2e --- /dev/null +++ b/benches/propagate.luau @@ -0,0 +1,47 @@ +local Charm = require("../packages/charm/src") +local bench = require("./bench") + +local computed = Charm.computed +local effect = Charm.effect +local listen = Charm.listen +local signal = Charm.signal + +for _, w in { 10, 100 } do + for _, h in { 10, 100 } do + bench.run(`propagate {w}*{h}`, function() + local src, setSrc = 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 + setSrc(src() + 1) + end) + end +end + +for _, w in { 10, 100 } do + for _, h in { 10, 100 } do + bench.run(`propagate (listen) {w}*{h}`, function() + local src, setSrc = signal(1) + for _ = 1, w do + local last = src + for _ = 1, h do + local prev = last + last = computed(function() + return prev() + 1 + end) + end + listen(last, function() end) + end + setSrc(src() + 1) + end) + end +end diff --git a/benches/reactive.luau b/benches/reactive.luau new file mode 100644 index 0000000..c272651 --- /dev/null +++ b/benches/reactive.luau @@ -0,0 +1,88 @@ +local Charm = require("../packages/charm/src") +local DeepCharm = require("../packages/deep-charm/src") +local bench = require("./bench") + +local effect = Charm.effect +local reactive = DeepCharm.reactive + +do + local value = reactive({ a = 1 }) + bench.run("track reactive property", function() + effect(function() + local _ = value.a + end) + end) +end + +do + local value = reactive({ a = 1 }) + bench.run("write reactive property", function() + value.a += 1 + end) +end + +for _, amount in { 10, 100, 1000 } do + print("---") + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + bench.run(`track for loop, {amount} elements`, function() + local sum = 0 + effect(function() + for i = 1, #array do + sum += array[i] + end + end) + end) + end + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + bench.run(`track iteration, {amount} elements`, function() + local sum = 0 + effect(function() + for _, x in array do + sum += x + end + end) + end) + end + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + effect(function() + local sum = 0 + for _, x in array do + sum += x + end + end) + + bench.run(`track index mutation (1st only), {amount} elements`, function() + array[1] += 1 + end) + end + + do + local rawArray = table.create(amount, 1) + local array = reactive(rawArray) + + effect(function() + local sum = 0 + for _, x in array do + sum += x + end + end) + + bench.run(`track index mutation (all), {amount} elements`, function() + for i = 1, #array do + array[i] += 1 + end + end) + end +end diff --git a/bin/build.sh b/bin/build.sh deleted file mode 100644 index 547e4e7..0000000 --- a/bin/build.sh +++ /dev/null @@ -1,5 +0,0 @@ -mkdir build -rojo build packages/charm -o build/charm.rbxm -rojo build packages/charm-sync -o build/charm-sync.rbxm -rojo build packages/react -o build/react-charm.rbxm -rojo build packages/vide -o build/vide-charm.rbxm diff --git a/bin/check.sh b/bin/check.sh deleted file mode 100644 index 0e948f8..0000000 --- a/bin/check.sh +++ /dev/null @@ -1,29 +0,0 @@ -curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua - -rojo sourcemap -o sourcemap.json - -check() { - echo "Checking $1" - - luau-lsp analyze \ - --defs=bin/roblox.d.luau \ - --defs=bin/testez.d.luau \ - --flag:LuauFixIndexerSubtypingOrdering=true \ - --flag:LuauInstantiateInSubtyping=true \ - --sourcemap=sourcemap.json \ - --ignore="**/_Index/**" \ - $1 - - selene $1 - stylua --check $1 -} - -eslint packages - -for i in packages/*/src; do - check $i -done - -check tests - -rm bin/roblox.d.luau diff --git a/bin/install.sh b/bin/install.sh deleted file mode 100644 index 53ccca2..0000000 --- a/bin/install.sh +++ /dev/null @@ -1,4 +0,0 @@ -cd modules -wally install -cd .. -rojo sourcemap --output sourcemap.json 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/build.project.json b/build.project.json new file mode 100644 index 0000000..53d32b4 --- /dev/null +++ b/build.project.json @@ -0,0 +1,11 @@ +{ + "name": "Packages", + "tree": { + "$className": "Folder", + "Charm": { "$path": "packages/charm/src" }, + "CharmSync": { "$path": "packages/charm-sync/src" }, + "DeepCharm": { "$path": "packages/deep-charm/src" }, + "ReactCharm": { "$path": "packages/react-charm/src" }, + "VideCharm": { "$path": "packages/vide-charm/src" } + } +} diff --git a/default.project.json b/default.project.json index a43197e..85d8f55 100644 --- a/default.project.json +++ b/default.project.json @@ -1,28 +1,59 @@ { "name": "charm-test", + "globIgnorePaths": [ + "**/default.project.json", + "node_modules/charm", + "node_modules/charm-sync", + "node_modules/react-charm", + "node_modules/vide-charm" + ], + "emitLegacyScripts": false, "tree": { "$className": "DataModel", "ReplicatedStorage": { "$className": "ReplicatedStorage", - "DevPackages": { - "$path": "modules/DevPackages", - "Charm": { - "$path": "packages/charm" + "packages": { + "$className": "Folder", + "vide": { + "$path": "node_modules/@rbxts/vide" }, - "CharmSync": { - "$path": "packages/charm-sync" + "charm": { + "$className": "Folder", + "src": { "$path": "packages/charm/src" }, + "test": { "$path": "packages/charm/test" } }, - "ReactCharm": { - "$path": "packages/react" + "deep-charm": { + "$className": "Folder", + "src": { "$path": "packages/deep-charm/src" }, + "test": { "$path": "packages/deep-charm/test" }, + "Charm": { "$path": "packages/deep-charm/Charm.luau" } }, - "VideCharm": { - "$path": "packages/vide" + "charm-sync": { + "$className": "Folder", + "src": { "$path": "packages/charm-sync/src" }, + "test": { "$path": "packages/charm-sync/test" }, + "Charm": { "$path": "packages/charm-sync/Charm.luau" } + }, + "react-charm": { + "$className": "Folder", + "src": { "$path": "packages/react-charm/src" }, + "test": { "$path": "packages/react-charm/test" }, + "React": { "$path": "packages/react-charm/React.luau" }, + "ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" }, + "Charm": { "$path": "packages/react-charm/Charm.luau" } + }, + "vide-charm": { + "$className": "Folder", + "src": { "$path": "packages/vide-charm/src" }, + "test": { "$path": "packages/vide-charm/test" }, + "Vide": { "$path": "packages/vide-charm/Vide.luau" }, + "Charm": { "$path": "packages/vide-charm/Charm.luau" } } - } - }, - "ServerScriptService": { - "$className": "ServerScriptService", - "Tests": { + }, + "@rbxts-js": { + "$path": "node_modules/@rbxts-js" + }, + "tests": { "$path": "tests" } } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..bfccfb4 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,20 @@ +const eslint = require("@eslint/js"); +const { defineConfig, globalIgnores } = require("eslint/config"); +const prettierRecommended = require("eslint-plugin-prettier/recommended"); +const roblox = require("eslint-plugin-roblox-ts"); +const tseslint = require("typescript-eslint"); + +module.exports = defineConfig( + eslint.configs.recommended, + tseslint.configs.recommended, + roblox.configs.tsRecommendedCompat, + roblox.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + "roblox-ts/no-any": "off", + }, + }, + globalIgnores(["out/**", "eslint.config.js"]), + prettierRecommended, +); diff --git a/modules/wally.lock b/modules/wally.lock deleted file mode 100644 index 7294d77..0000000 --- a/modules/wally.lock +++ /dev/null @@ -1,103 +0,0 @@ -# This file is automatically @generated by Wally. -# It is not intended for manual editing. -registry = "test" - -[[package]] -name = "centau/vide" -version = "0.3.1" -dependencies = [] - -[[package]] -name = "jsdotlua/boolean" -version = "1.2.7" -dependencies = [["number", "jsdotlua/number@1.2.7"]] - -[[package]] -name = "jsdotlua/collections" -version = "1.2.7" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"]] - -[[package]] -name = "jsdotlua/console" -version = "1.2.7" -dependencies = [["collections", "jsdotlua/collections@1.2.7"]] - -[[package]] -name = "jsdotlua/es7-types" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/instance-of" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/luau-polyfill" -version = "1.2.7" -dependencies = [["boolean", "jsdotlua/boolean@1.2.7"], ["collections", "jsdotlua/collections@1.2.7"], ["console", "jsdotlua/console@1.2.7"], ["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"], ["math", "jsdotlua/math@1.2.7"], ["number", "jsdotlua/number@1.2.7"], ["string", "jsdotlua/string@1.2.7"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.7"]] - -[[package]] -name = "jsdotlua/math" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/number" -version = "1.2.7" -dependencies = [] - -[[package]] -name = "jsdotlua/promise" -version = "3.5.2" -dependencies = [] - -[[package]] -name = "jsdotlua/react" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/react-reconciler" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/react-roblox" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["react", "jsdotlua/react@17.2.1"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/scheduler" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]] - -[[package]] -name = "jsdotlua/shared" -version = "17.2.1" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"]] - -[[package]] -name = "jsdotlua/string" -version = "1.2.7" -dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["number", "jsdotlua/number@1.2.7"]] - -[[package]] -name = "jsdotlua/symbol-luau" -version = "1.0.1" -dependencies = [] - -[[package]] -name = "jsdotlua/timers" -version = "1.2.7" -dependencies = [["collections", "jsdotlua/collections@1.2.7"]] - -[[package]] -name = "littensy/charm" -version = "0.1.0" -dependencies = [["React", "jsdotlua/react@17.2.1"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.1"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]] - -[[package]] -name = "roblox/testez" -version = "0.4.1" -dependencies = [] diff --git a/modules/wally.toml b/modules/wally.toml deleted file mode 100644 index 096ab3f..0000000 --- a/modules/wally.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "littensy/charm" -version = "0.1.0" -registry = "https://github.com/UpliftGames/wally-index" -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..d5f8c09 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,29 @@ { "private": true, "scripts": { - "build": "sh bin/build.sh", - "check": "sh bin/check.sh", - "wally": "sh bin/install.sh", + "test": "lute run test", + "build": "lute run build", + "check": "lute run check", "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, + "packageManager": "pnpm@10.33.3", "devDependencies": { + "@eslint/js": "^9.39.4", "@rbxts/compiler-types": "3.0.0-types.0", - "@rbxts/types": "^1.0.811", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.2.1", - "eslint-plugin-roblox-ts": "^0.0.36", - "prettier": "^3.3.3", + "@rbxts/react": "17.3.7-ts.1", + "@rbxts/react-roblox": "17.3.7-ts.1", + "@rbxts/types": "^1.0.913", + "@rbxts/vide": "^0.6.1", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-roblox-ts": "^1.4.0", + "prettier": "^3.8.1", "roblox-ts": "^3.0.0", - "typescript": "~5.5.4" - }, - "packageManager": "pnpm@9.15.4" + "typescript": "^5.9.3", + "typescript-eslint": "^8.58.0" + } } diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau new file mode 100644 index 0000000..480f2d5 --- /dev/null +++ b/packages/charm-sync/Charm.luau @@ -0,0 +1,9 @@ +if not script then + return require("../charm/src") +end + +local Charm = require(script.Parent.Parent.charm.src) + +export type Atom = Charm.Atom + +return Charm diff --git a/packages/charm-sync/default.project.json b/packages/charm-sync/default.project.json index 5115ff0..9c44c7c 100644 --- a/packages/charm-sync/default.project.json +++ b/packages/charm-sync/default.project.json @@ -1,5 +1,5 @@ { - "name": "CharmSync", + "name": "charm-sync", "tree": { "$path": "src" } diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 5256c2d..3efa745 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,16 +1,27 @@ { "name": "@rbxts/charm-sync", - "version": "0.3.0", - "description": "Sync server state with clients using Charm", - "repository": "littensy/charm", + "version": "0.4.0-rc.7", + "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "prepare": "cp ../../LICENSE . && cp ../../README.md .", "publish:wally": "wally publish" }, - "peerDependencies": { - "@rbxts/charm": "workspace:*" + "dependencies": { + "@rbxts/charm": "workspace:^" } } diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 8f3cfb1..a514b75 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -1,83 +1,108 @@ -local Charm = require(script.Parent.Parent.Charm) -local patch = require(script.Parent.patch) -local types = require(script.Parent.types) -type AtomMap = types.AtomMap +local Charm = require("../Charm") +local patch = require("./patch") +local types = require("./types") + type SyncPayload = types.SyncPayload +type ReactiveProxy = types.ReactiveProxy -type ClientOptions = { - --[=[ - The atoms to synchronize with the server. - ]=] - atoms: AtomMap, - --[=[ - Whether to ignore patches sent before the client has been hydrated. - Default is `true`. - ]=] - ignoreUnhydrated: boolean?, -} +local trackedSignals: { [string]: (any) -> any } = {} +local trackedReactives: { [string]: ReactiveProxy } = {} -type ClientSyncer = { - --[=[ - Applies a patch or initializes the state of the atoms with the given - payload from the server. - - @param ...payloads The patches or hydration payloads to apply. - ]=] - sync: (self: ClientSyncer, ...SyncPayload) -> (), -} - ---[=[ - Creates a `ClientSyncer` object that receives patches from the server and - applies them to the local state. - - @param options The atoms to synchronize with the server. - @return A `ClientSyncer` object. -]=] -local function client(options: ClientOptions): ClientSyncer - local atoms = options.atoms - local ignoreUnhydrated = options.ignoreUnhydrated ~= false - - local self = {} :: ClientSyncer - local hydrated = false - - local function hydrate(state: { [string | number]: any }) - hydrated = true - - for key, value in next, state do - local atom = atoms[key] - atom(value) - end +local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any }) + local target = reactive.__target + for key, value in source do + target[key] = value end - - local function apply(data: { [string | number]: any }) - local target = {} - - for key, atom in next, atoms do - target[key] = atom() - end - - target = patch.apply(target, data) - - for key, atom in next, atoms do - atom(target[key]) - end - end - - function self:sync(...) - for index = 1, select("#", ...) do - local payload: SyncPayload = select(index, ...) - - Charm.batch(function() - if payload.type == "init" then - hydrate(payload.data) - elseif not ignoreUnhydrated or hydrated then - apply(payload.data) - end - end) - end - end - - return self + Charm.trigger(reactive.__track) end -return client +--[[ + Registers a map of setters to the state with the corresponding keys on the + server. When the server sends updates, these setters will be called with + the new values. + + Atoms, signals converted to atoms, and signal setter functions are allowed. + Note that `server.addSignalsToClient` still requires some way to get the + state. + + @param signals A map of setter functions to sync with the server. +]] +local function addSignals(signals: { [string]: ((any) -> any) | {} }) + for key, signal in signals do + if type(signal) == "function" then + trackedSignals[key] = signal + else + trackedReactives[key] = (signal :: any) :: ReactiveProxy + end + end +end + +--[[ + Unregisters from server state updates for the given keys. The signals + will retain their current values, but will no longer receive updates + from the server. +]] +local function removeSignals(...: string) + for index = 1, select("#", ...) do + local key = select(index, ...) + trackedSignals[key] = nil + trackedReactives[key] = nil + end +end + +--[[ + Unsubscribes from all server state updates. +]] +local function removeAllSignals() + table.clear(trackedSignals) + table.clear(trackedReactives) +end + +local function applyInitPayload(data: { [string]: any }) + for key, value in data do + if trackedSignals[key] then + trackedSignals[key](value) + elseif trackedReactives[key] then + shallowMergeReactive(trackedReactives[key], value) + end + end +end + +local function applyPatchPayload(data: { [string]: any }) + for key, diffs in data do + local trackedSignal = trackedSignals[key] + local trackedReactive = trackedReactives[key] + + if trackedSignal then + trackedSignal(function(value) + return patch.apply(value, diffs) + end) + elseif trackedReactive then + patch.applyMutable(trackedReactive.__target, diffs) + Charm.trigger(trackedReactive.__track) + end + end +end + +--[[ + Merges the server's state patches into the client's state, either + initializing it with the full state or merging a partial patch. + + @param payloads The state updates received from the server. +]] +local function applyPatches(payloads: { SyncPayload }) + for _, payload in payloads do + if payload.type == "init" then + Charm.batch(applyInitPayload, payload.data) + elseif payload.type == "patch" then + Charm.batch(applyPatchPayload, payload.data) + end + end +end + +return { + addSignals = addSignals, + removeSignals = removeSignals, + removeAllSignals = removeAllSignals, + patch = applyPatches, +} diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau new file mode 100644 index 0000000..c3ce27c --- /dev/null +++ b/packages/charm-sync/src/config.luau @@ -0,0 +1,31 @@ +return { + --[[ + The interval at which to send patches to the client, in seconds. + Defaults to `0` (patches are sent up to once per frame). Set to a + negative value to disable automatic syncing. + ]] + interval = 0, + --[[ + Whether the history of state changes since the client's last update + should be preserved. This is useful for values that change multiple + times per frame, where each individual change is important. Defaults + to `false` for performance reasons. + ]] + preserveHistory = false, + --[[ + When `true`, Charm will modify sparse arrays to prevent data loss from + remote event limitations. When an unsafe array is detected, numeric + indices will be converted to string keys so that the array can be sent + safely and then converted back to an array on the receiving end. + + This option should be disabled if your network library uses a custom + serialization method (i.e. Zap, ByteNet) to prevent interference. + ]] + fixArrays = true, + --[[ + When `true`, synced state containing unsafe sparse arrays or mixed + tables will emit a warning. Only checked in strict mode and if + `fixArrays` is enabled. Defaults to `true`. + ]] + validatePatches = true, +} diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau deleted file mode 100644 index 9c8d012..0000000 --- a/packages/charm-sync/src/flatten.luau +++ /dev/null @@ -1,28 +0,0 @@ -local types = require(script.Parent.types) -type AtomMap = types.AtomMap - -type NestedAtomMap = { - [string]: NestedAtomMap | () -> (), -} - -local function flatten(atoms: NestedAtomMap): AtomMap - local result: AtomMap = {} - - local function visit(node: NestedAtomMap, path: string) - for key, value in node do - local location = if path == "" then key else path .. "/" .. key - - if type(value) == "table" then - visit(value, location) - else - result[location] = value - end - end - end - - visit(atoms, "") - - return result -end - -return flatten diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 52ff658..bb40eba 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -1,214 +1,292 @@ -import { Atom, Selector } from "@rbxts/charm"; - -export = CharmSync; -export as namespace CharmSync; - -type Cleanup = () => void; +import { Atom, Setter } from "@rbxts/charm"; type Key = string | number | symbol; -declare namespace CharmSync { - type AtomMap = Record>; +/** + * Represents the removal of a value from the state. + */ +export interface None { + readonly __none: "__none"; +} - type SelectorMap = Record>; +export type MaybeNone = undefined extends T ? None : never; +type ReactiveObject = { [key: Key]: any }; + +type ServerSignalMap = Record any) | ReactiveObject>; + +type ClientSignalMap = Record | ReactiveObject>; + +/** + * Infers the type of the return values produced by a map of functions. + */ +export type StateOfMap = { + readonly [P in keyof T]: T[P] extends () => infer State ? State : never; +}; + +type DataTypes = { + [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; +}; + +type DataType = DataTypes[keyof DataTypes]; + +/** + * A partial patch that can be applied to the state to update it. Represents + * the difference between the current state and the next state. + * + * If a value was removed, it is replaced with `None`. This can be checked + * using the `isNone` function. + */ +// TODO: Optimize type, this looks overly complex +export type SyncPatch = MaybeNone | State extends + | ReadonlyMap + | Map + ? ReadonlyMap | None> + : State extends Set | ReadonlySet + ? ReadonlyMap + : State extends readonly (infer T)[] + ? FixArrays extends true + ? ReadonlyMap | None> + : readonly (SyncPatch | None | undefined)[] + : State extends DataType + ? State + : State extends object + ? { readonly [P in keyof State]?: SyncPatch } + : State; + +/** + * A payload that can be sent from the server to the client to synchronize + * state between the two. + */ +export type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch, FixArrays> }; + +/** + * Global configuration options that affect the behavior of Charm Sync. + */ +export const config: { /** - * @deprecated Use `SelectorMap` instead. - */ - type MoleculeMap = SelectorMap; - - /** - * Infers the type of the return values produced by a map of functions. - */ - type StateOfMap = { - readonly [P in keyof T]: T[P] extends Selector ? State : never; - }; - - /** - * Represents the removal of a value from the state. - */ - interface None { - readonly __none: "__none"; - } - - type MaybeNone = undefined extends T ? None : never; - - /** - * Creates a `ClientSyncer` object that receives patches from the server and - * applies them to the local state. - * - * @client - * @param options The atoms to synchronize with the server. - * @returns A `ClientSyncer` object. - */ - function client(options: ClientOptions): ClientSyncer; - - /** - * Creates a `ServerSyncer` object that sends patches to the client and - * hydrates the client's state. + * The interval at which to send patches to the client, in seconds. + * Defaults to `0` (patches are sent up to once per frame). Set to a + * negative value to disable automatic syncing. * * @server - * @param options The atoms to synchronize with the client. - * @returns A `ServerSyncer` object. */ - function server( - options: ServerOptions, - ): ServerSyncer; - + interval: number; /** - * Checks whether a value is `None`. If `true`, the value is scheduled to be - * removed from the state when the patch is applied. + * Whether the history of state changes since the client's last update + * should be preserved. This is useful for values that change multiple + * times per frame, where each individual change is important. Defaults + * to `false` for performance reasons. * - * @param value The value to check. - * @returns `true` if the value is `None`, otherwise `false`. + * @server */ - function isNone(value: unknown): value is None; - - interface NestedAtomMap { - readonly [key: string]: NestedAtomMap | Atom; - } - - type AppendPath = undefined extends Path - ? Name - : Name extends string - ? Path extends string - ? `${Path}/${Name}` - : never - : never; - - type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; - - type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; - - type FlattenNestedAtoms = IntersectValues<{ - readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom - ? { readonly [K in AppendPath]: Atoms[Name] } - : Atoms[Name] extends NestedAtomMap - ? FlattenNestedAtoms> - : never; - }>; - + preserveHistory: boolean; /** - * Flattens a nested atom map into a single object with slash-separated - * keys. Useful for recursively collecting atoms returned by modules. + * When `true`, Charm will modify sparse arrays to prevent data loss from + * remote event limitations. When an unsafe array is detected, numeric + * indices will be converted to string keys so that the array can be sent + * safely and then converted back to an array on the receiving end. * - * @param atoms The nested atom map to flatten. - * @return A flattened atom map. - */ - function flatten(atoms: Atoms): FlattenNestedAtoms; - - type DataTypes = { - [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; - }; - - /** - * A type that should not be made partial in patches. - */ - type DataType = DataTypes[keyof DataTypes]; - - /** - * A partial patch that can be applied to the state to update it. Represents - * the difference between the current state and the next state. + * This option should be disabled if your network library uses a custom + * serialization method (i.e. Zap, ByteNet) to prevent interference. * - * If a value was removed, it is replaced with `None`. This can be checked - * using the `sync.isNone` function. + * @server */ - type SyncPatch = MaybeNone | State extends - | ReadonlyMap - | Map - ? ReadonlyMap | None> - : State extends Set | ReadonlySet - ? ReadonlyMap - : State extends readonly (infer T)[] - ? Serialize extends true - ? ReadonlyMap | None> - : readonly (SyncPatch | None | undefined)[] - : State extends DataType - ? State - : State extends object - ? { readonly [P in keyof State]?: SyncPatch } - : State; + fixArrays: boolean; + /** + * When `true`, synced state containing unsafe sparse arrays or mixed + * tables will emit a warning. Only checked in strict mode and if + * `fixArrays` is enabled. Defaults to `true`. + * + * @server + */ + validatePatches: boolean; +}; + +/** + * @client + */ +export namespace client { + /** + * Registers a map of setters to the state with the corresponding keys on the + * server. When the server sends updates, these setters will be called with + * the new values. + * + * Atoms, signals converted to atoms, and signal setter functions are allowed. + * Note that `server.addSignalsToClient` still requires some way to get the + * state. + * + * @param signals A map of setter functions to sync with the server. + */ + export function addSignals(signals: Signals): void; /** - * A payload that can be sent from the server to the client to synchronize - * state between the two. + * Unregisters from server state updates for the given keys. The signals + * will retain their current values, but will no longer receive updates + * from the server. */ - type SyncPayload = - | { readonly type: "init"; readonly data: StateOfMap } - | { readonly type: "patch"; readonly data: SyncPatch, Serialize> }; + export function removeSignals( + ...keys: (keyof Signals)[] + ): void; - interface ClientOptions { - /** - * The atoms to synchronize with the server. - */ - atoms: Atoms; - /** - * Whether to ignore patches sent before the client has been hydrated. - * Defaults to `true`. - */ - ignoreUnhydrated?: boolean; - } + /** + * Unsubscribes from all server state updates. + */ + export function removeAllSignals(): void; - interface ServerOptions { - /** - * The atoms to synchronize with the client. - */ - atoms: Selectors; - /** - * The interval at which to send patches to the client, in seconds. - * Defaults to `0` (patches are sent up to once per frame). Set to a - * negative value to disable automatic syncing. - */ - interval?: number; - /** - * Whether the history of state changes since the client's last update - * should be preserved. This is useful for values that change multiple times - * per frame, where each individual change is important. Defaults to `false`. - * - * If `true`, the broadcaster will send a list of payloads to the client - * instead of a single payload. The client will apply each payload in order - * to reconstruct the state's changes over time. - */ - preserveHistory?: boolean; - /** - * When `true`, Charm will apply validation and serialize unsafe arrays - * to address remote event argument limitations. Defaults to `true`. - * - * This option should be disabled if your network library uses a custom - * serialization method (i.e. Zap, ByteNet) to prevent interference. - */ - autoSerialize?: Serialize; - } - - interface ClientSyncer { - /** - * Applies a patch or initializes the state of the atoms with the given - * payload from the server. - * - * @param payloads The patches or hydration payloads to apply. - */ - sync(...payloads: SyncPayload[]): void; - } - - interface ServerSyncer { - /** - * Sets up a subscription to each atom that schedules a patch to be sent to - * the client whenever the state changes. When a change occurs, the `callback` - * is called with the player and the payloads to send. - * - * Note that a `payload` object should not be mutated. If you need to modify - * a payload, apply the changes to a copy of the object. - * - * @param callback The function to call when the state changes. - * @returns A cleanup function that unsubscribes all listeners. - */ - connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; - /** - * Hydrates the client's state with the server's state. This should be - * called when a player joins the game and requires the server's state. - * - * @param player The player to hydrate. - */ - hydrate(player: Player): void; - } + /** + * Merges the server's state patches into the client's state, either + * initializing it with the full state or merging a partial patch. + * + * @param payloads The state updates received from the server. + */ + export function patch( + payloads: SyncPayload[], + ): void; } + +/** + * @server + */ +export namespace server { + /** + * Subscribes a client to the given signals. When an update occurs, the client + * will receive a partial state patch to merge into their local state. May be + * called multiple times to subscribe to additional keys. + * + * Atoms, computed signals, and signal getter functions are allowed. Note + * that `client.addSignals` still requires some way to set the state. + * + * @param client The client receiving state updates. + * @param signals A map of getter functions to sync with the client. + */ + export function addSignalsToClient( + client: Player, + signals: Signals, + ): void; + + /** + * Unsubscribes a client from receiving all state updates. To only unsubscribe + * from specific keys, use `removeSignalsFromClient` instead. + * + * @param client The client receiving state updates. + */ + export function removeClient(client: Player): void; + + /** + * Unsubscribes a client from receiving updates for the given keys. + * + * @param client The client receiving state updates. + * @param keys The keys of the state to unsubscribe from. + */ + export function removeSignalsFromClient( + client: Player, + ...keys: (keyof Signals)[] + ): void; + + /** + * Uses the callback to send state updates to clients that are subscribed + * to state changes. Starts automatically sending updates at the interval + * specified in `config.interval`. + * + * @param onSync Called when sending patches to a client. + */ + export function connect( + onSync: (client: Player, payloads: SyncPayload[]) => void, + ): void; + + /** + * Stops syncing state updates to clients at the automatic interval. + * Flushing can still be performed manually via `flush`. + */ + export function disconnect(): void; + + /** + * Immediately sends pending updates to all clients. Normally, updates are + * sent at the interval specified in `config.interval`, but this function + * can be used to force an immediate sync. + */ + export function flush(): void; + + /** + * Unsubscribes all clients, stops tracking all state, and disconnects + * from the automatic sync interval. + */ + export function reset(): void; +} + +export namespace patch { + /** + * Converts `nil` values to the special `None` symbol. This is useful when + * preparing data for serialization, since `nil` can represent both the + * absence of a value and the removal of a value in patches. + */ + export function nilToNone(value: T | undefined): T | None; + + /** + * Compares two states and returns a patch that can be applied to the + * current state to produce the next state. + * + * @param currentState The current state. + * @param nextState The next state. + * @returns A patch that can be applied to the current state. + */ + export function diff( + currentState: State, + nextState: State, + ): SyncPatch; + + /** + * Applies a patch to a state and returns the resulting value. + * + * @param currentState The current state. + * @param statePatch The patches to apply. + * @returns The new state with the patch applied. + */ + export function applyImmutable( + currentState: State, + statePatch: SyncPatch, + ): State; +} + +type DeepFlattenImpl = { + [K in keyof T]: T[K] extends DataType | Callback + ? { [P in `${Prefix}${Exclude}`]: T[K] } + : T[K] extends object + ? DeepFlattenImpl}/`> + : { [P in `${Prefix}${Exclude}`]: T[K] }; +}[keyof T]; + +type DeepFlatten = Reconstruct>>; + +/** + * Flattens a nested table into a single table with slash-separated keys. + * Useful for recursively collecting atoms returned by modules. + * + * @param input A nested table. + * @return A flattened table with slash-separated keys. + */ +export function flatten(input: T): DeepFlatten; + +/** + * Checks whether a value is `None`. If `true`, the value is scheduled to be + * removed from the state when the patch is applied. + * + * @param value The value to check. + * @returns `true` if the value is `None`, otherwise `false`. + */ +export function isNone(value: unknown): value is None; + +/** + * Converts a signal getter and setter into an atom. This is useful for + * syncing client signals with the server, as the sync system requires both + * the getter and setter to update state. + * + * @param getter A function that returns the current value of the signal. + * @param setter A function that sets the value of the signal. + * @returns An atom that can either get or set the signal's value. + * @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter + */ +export function signalToAtom(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom; diff --git a/packages/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau index 5fc9171..53a6353 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -1,18 +1,19 @@ -local client = require(script.client) -local flatten = require(script.flatten) -local patch = require(script.patch) -local server = require(script.server) -local types = require(script.types) +local client = require("@self/client") +local config = require("@self/config") +local patch = require("@self/patch") +local server = require("@self/server") +local types = require("@self/types") +local utils = require("@self/utils") +export type None = types.None export type SyncPayload = types.SyncPayload ---[=[ - Synchronizes state between the client and server. The server sends patches - to the client, which applies them to its local state. -]=] return { + config = config, client = client, server = server, - flatten = flatten, + patch = patch, isNone = patch.isNone, + flatten = utils.flatten, + signalToAtom = utils.signalToAtom, } diff --git a/packages/charm-sync/src/interval.luau b/packages/charm-sync/src/interval.luau deleted file mode 100644 index 31df067..0000000 --- a/packages/charm-sync/src/interval.luau +++ /dev/null @@ -1,30 +0,0 @@ -local RunService = game:GetService("RunService") - ---[=[ - Schedules a function to be called at a fixed interval of `seconds`. - - @param callback The function to call. - @param seconds The interval in seconds. - @return A function that cancels the interval. -]=] -local function interval(callback: () -> (), seconds: number): () -> () - if seconds < 0 then - return function() end - end - - local clock = 0 - - local connection = RunService.Heartbeat:Connect(function(delta) - clock += delta - if clock >= seconds then - clock = 0 - callback() - end - end) - - return function() - connection:Disconnect() - end -end - -return interval diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 3d021b5..d6b940f 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -1,28 +1,45 @@ -local validate = require(script.Parent.validate) +local Charm = require("../Charm") +local config = require("./config") +local types = require("./types") +local validate = require("./validate") -local __DEV__ = _G.__DEV__ +local flags = Charm.flags ---[=[ - A special symbol that denotes the absence of a value. Used to represent - deleted values in patches. -]=] -local None = { __none = "__none" } +export type None = types.None +local None: None = { __none = "__none" } + +--[[ + Checks whether a value is `None`. If `true`, the value is scheduled to be + removed from the state when the patch is applied. + + @param value The value to check. + @return `true` if the value is `None`, otherwise `false`. +]] local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" end ---[=[ +--[[ + Converts `nil` values to the special `None` symbol. This is useful when + preparing data for serialization, since `nil` can represent both the absence + of a value and the removal of a value in patches. +]] +local function nilToNone(value: T?): T | None + return if value == nil then None else value +end + +--[[ JSON serialization can drop the last values of a sparse array. For example, `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the serialization of sparse arrays can be resolved by converting them to dictionaries with string keys. - The `apply` function will convert these dictionaries back into arrays as + The `patch` functions will convert these dictionaries back into arrays as long as the target array is not sparse. This function ensures that the diff function produces a patch that can be correctly applied. -]=] -local function stringifySparseArray(object: { any }): { [string | number]: any } +]] +local function stringifySparseArray(object: { [any]: any }): { [string | number]: any } local maxn = table.maxn(object) if maxn == 0 or maxn == #object then @@ -32,86 +49,91 @@ local function stringifySparseArray(object: { any }): { [string | number]: any } local dictionary = {} - for index, value in next, object do + for index, value in object do dictionary[tostring(index)] = value end return dictionary end ---[=[ - Compares two states and returns a patch that can be applied to the previous - state to produce the next state. +local function diff(oldState: any, newState: any): any + if oldState == newState then + return nil + elseif newState == nil then + return None + elseif type(oldState) ~= "table" or type(newState) ~= "table" then + return newState + end - @param prevState The previous state. - @param nextState The next state. - @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`. - @returns A patch that can be applied to the previous state. -]=] -local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?) - serialize = serialize ~= false + local diffs = table.clone(newState) - local patches = table.clone(nextState) + for key, oldValue in oldState do + diffs[key] = diff(oldValue, newState[key]) + end - for key, previous in next, prevState do - local current = nextState[key] + if next(diffs) == nil then + return nil + end - if previous == current then - patches[key] = nil - elseif current == nil then - patches[key] = None - elseif type(previous) == "table" and type(current) == "table" then - patches[key] = diff(previous, current, serialize) + if config.fixArrays then + -- Convert sparse array patches that could be misserialized + if oldState[1] ~= nil or newState[1] ~= nil then + diffs = stringifySparseArray(diffs) + end + if flags.strict and config.validatePatches then + validate(diffs) end end - -- Coerce sparse array patches into dictionaries - if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then - patches = stringifySparseArray(patches) - end - - if serialize and __DEV__ then - for key, value in next, patches do - validate(value, key) - end - end - - return patches + return diffs end ---[=[ - Applies a patch to a state and returns the next state. - - @param state The current state. - @param patches The patches to apply. - @returns The next state. -]=] -local function apply(state: any, patches: any): any - if type(patches) == "table" and patches.__none == "__none" then +local function apply(oldState: any, diffs: any): any + if isNone(diffs) then return nil - elseif type(state) ~= "table" or type(patches) ~= "table" then - return patches + elseif type(oldState) ~= "table" or type(diffs) ~= "table" then + return diffs end - local nextState = table.clone(state) - local stateIsArray = state[1] ~= nil + local newState = table.clone(oldState) + local stateIsArray = oldState[1] ~= nil - for key, patch in next, patches do + for key, value in diffs do -- Diff-checking an array produces a sparse array, which will not be -- preserved when converted to JSON. To prevent this, we turn string -- keys back into numeric keys. if stateIsArray and type(key) == "string" then key = tonumber(key) or key end - - nextState[key] = apply(nextState[key], patch) + newState[key] = apply(newState[key], value) end - return nextState + return newState +end + +local function applyMutable(targetState: any, diffs: any): any + if isNone(diffs) then + return nil + elseif type(targetState) ~= "table" or type(diffs) ~= "table" then + return diffs + end + + local targetIsArray = targetState[1] ~= nil + + for key, value in diffs do + if targetIsArray and type(key) == "string" then + key = tonumber(key) or key + end + targetState[key] = applyMutable(targetState[key], value) + end + + return targetState end return { isNone = isNone, + nilToNone = nilToNone, diff = diff, apply = apply, + applyMutable = applyMutable, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index f2c336e..ca04ba7 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -1,267 +1,416 @@ -local Players = game:GetService("Players") +local Charm = require("../Charm") +local config = require("./config") +local patch = require("./patch") +local types = require("./types") +local utils = require("./utils") -local Charm = require(script.Parent.Parent.Charm) -local interval = require(script.Parent.interval) -local patch = require(script.Parent.patch) -local types = require(script.Parent.types) -type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload -type ServerOptions = { - --[=[ - The atoms to synchronize with the client. - ]=] - atoms: AtomMap, - --[=[ - The interval at which to send patches to the client, in seconds. - Defaults to `0` (patches are sent up to once per frame). Set to a - negative value to disable automatic syncing. - ]=] - interval: number?, - --[=[ - Whether the history of state changes since the client's last update - should be preserved. This is useful for values that change multiple times - per frame, where each individual change is important. Defaults to `false`. - ]=] - preserveHistory: boolean?, - --[=[ - When `true`, Charm will apply validation and serialize unsafe arrays - to address remote event argument limitations. Defaults to `true`. +type SyncStatus = number - This option should be disabled if your network library uses a custom - serialization method (i.e. Zap, ByteNet) to prevent interference. - ]=] - autoSerialize: boolean?, +type ClientKeys = { [string]: SyncStatus } + +type TrackedState = { + identifier: unknown, + currentValue: T, + lastSyncedValue: T, + isReactive: boolean, + dispose: () -> (), } -type ServerSyncer = { - --[=[ - Sets up a subscription to each atom that schedules a patch to be sent to - the client whenever the state changes. When a change occurs, the `callback` - is called with the player and the payload to send. - - Note that the `payload` object should not be mutated. If you need to - modify the payload, apply the changes to a copy of the object. - - @param callback The function to call when the state changes. - @return A cleanup function that unsubscribes all listeners. - ]=] - connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (), - --[=[ - Hydrates the client's state with the server's state. This should be - called when a player joins the game and requires the server's state. - - @param player The player to hydrate. - ]=] - hydrate: (self: ServerSyncer, player: Player) -> (), - --[=[ - @deprecated For internal use only. - ]=] - _sendPatch: (self: ServerSyncer, player: Player) -> (), +type StateUpdate = { + key: string, + newValue: any, + oldValue: any, } ---[=[ - Creates a `ServerSyncer` object that sends patches to the client and - hydrates the client's state. - - @param options The atoms to synchronize with the client. - @return A `ServerSyncer` object. -]=] -local function server(options: ServerOptions): ServerSyncer - local atoms = options.atoms - local autoSerialize = options.autoSerialize ~= false - local preserveHistory = options.preserveHistory - local syncRate = options.interval or 0 +local PENDING_INITIAL_STATE: SyncStatus = 0 +local LISTENING_FOR_CHANGES: SyncStatus = 1 - local self = {} :: ServerSyncer - local sync: (player: Player, payload: SyncPayload) -> () +local trackedStates: { [string]: TrackedState } = {} +local globalUpdates: { StateUpdate } = {} +local clients: { [any]: ClientKeys } = {} - local function createSnapshot() - local snapshot = {} +local connection: RBXScriptConnection? +local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? - for key, atom in next, atoms do - snapshot[key] = atom() +--[[ + Manually converts a reactive proxy to its raw representation to potentially + support custom implementations of reactive proxies. +]] +local function toRaw(value: T): T + value = value :: types.ReactiveProxy + if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then + value.__track() + return toRaw(value.__target) + end + return value +end + +local function trackSignal(key: string, getter: () -> T, identifier: unknown) + if trackedStates[key] then + -- Warn against name collisions with conflicting signals or proxies + if trackedStates[key].identifier ~= identifier then + warn("[Charm] Attempted to sync different states with the same key:", key) end - - return snapshot + return end - -- Send the initial state to a player when they join the server. - function self:hydrate(player) - assert(sync, "connect() must be called before hydrate()") + local trackedState: TrackedState + local initialValue = getter() + local rawInitialValue = toRaw(initialValue) + local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue - sync(player, { - type = "init", - data = createSnapshot(), - }) - end + local dispose = Charm.effect(function() + local newValue = getter() + local rawValue = toRaw(newValue) + local isReactive = newValue ~= rawValue - -- If the history of state changes should be preserved, keep a list of - -- previous snapshots and diff-check each one from the last. - if preserveHistory then - local snapshots = { createSnapshot() } - local changed = false - - function self:connect(callback) - local subscriptions = {} - - sync = callback - - local function pushSnapshot(key: string | number, current: any, previous: any) - local lastSnapshot = snapshots[#snapshots] - local previousSnapshot = snapshots[#snapshots - 1] - - -- Optimize snapshots by updating the most recent snapshot if the - -- previous and current values are the same, since this allows us - -- to group multiple changes into a single snapshot. - if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then - lastSnapshot[key] = current - else - local nextSnapshot = table.clone(lastSnapshot) - nextSnapshot[key] = current - table.insert(snapshots, nextSnapshot) - end - end - - -- Populate the snapshot with the initial state of each atom. - -- Subscribe to each atom and update the state when it changes. - for key, atom in next, atoms do - subscriptions[key] = Charm.subscribe(atom, function(current, previous) - pushSnapshot(key, current, previous) - changed = true - end) - end - - local disconnect = interval(function() - if not changed then - return - end - - local payloads: { SyncPayload } = {} - local lastSnapshot - - for index, snapshot in next, snapshots do - lastSnapshot = snapshot - - if index == 1 then - continue - end - - table.insert(payloads, { - type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, autoSerialize), - }) - end - - snapshots = { lastSnapshot } - changed = false - - for _, player in next, Players:GetPlayers() do - callback(player, unpack(payloads)) - end - end, syncRate) - - return function() - disconnect() - - for _, unsubscribe in next, subscriptions do - unsubscribe() - end - end - end - - function self:_sendPatch(player: Player) - assert(sync, "connect() must be called before _sendPatch()") - - if not changed then - return - end - - local payloads: { SyncPayload } = {} - - for index, snapshot in next, snapshots do - if index == 1 then - continue - end - - table.insert(payloads, { - type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, autoSerialize), - }) - end - - snapshots = { snapshots[#snapshots] } - changed = false - - sync(player, unpack(payloads)) - end - - return self - end - - local previousSnapshot = createSnapshot() - local currentSnapshot = table.clone(previousSnapshot) - local changed = false - - function self:connect(callback) - local subscriptions = {} - - sync = callback - - -- Subscribe to each atom and update the state when one changes. - for key, atom in next, atoms do - subscriptions[key] = Charm.subscribe(atom, function(state) - currentSnapshot[key] = state - changed = true - end) - end - - local disconnect = interval(function() - if not changed then - return - end - - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), - } - - previousSnapshot = table.clone(currentSnapshot) - changed = false - - for _, player in next, Players:GetPlayers() do - callback(player, payload) - end - end, syncRate) - - return function() - disconnect() - - for _, unsubscribe in next, subscriptions do - unsubscribe() - end - end - end - - function self:_sendPatch(player) - assert(sync, "connect() must be called before _sendPatch()") - - if not changed then + if not trackedState then return end - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), - } + -- When preserving history, we need to keep track of previous values + -- so we can create granular patches for each change. + if config.preserveHistory then + local prevSnapshot = snapshot + snapshot = if isReactive then utils.deepCopy(rawValue) else newValue + table.insert(globalUpdates, { + key = key, + newValue = snapshot, + oldValue = prevSnapshot, + }) + end - previousSnapshot = table.clone(currentSnapshot) - changed = false + trackedState.currentValue = rawValue + trackedState.isReactive = isReactive + end) - sync(player, payload) - end + trackedState = { + identifier = identifier, + currentValue = rawInitialValue, + lastSyncedValue = snapshot, + isReactive = initialValue ~= rawInitialValue, + dispose = dispose, + } - return self + trackedStates[key] = trackedState end -return server +local function stopTracking(key: string) + local trackedState = trackedStates[key] + if trackedState then + trackedState.dispose() + trackedStates[key] = nil + end +end + +local function purgeUntracked() + local active: { [string]: boolean } = {} + + for _, clientKeys in clients do + for key in clientKeys do + active[key] = true + end + end + + for key in trackedStates do + if not active[key] then + stopTracking(key) + end + end +end + +local function diffGlobalUpdateBuffer(): { any } + local patchCount = #globalUpdates + local patches = table.create(patchCount) + + for _, update in globalUpdates do + table.insert(patches, patch.diff(update.oldValue, update.newValue)) + end + + return patches +end + +local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }? + local clientKeys = clients[client] + + if not clientKeys then + return nil + end + + local payloads: { SyncPayload } = {} + + -- Iterate over all global updates and see if the client is interested in + -- any of them. If so, we create a patch payload for the update. + for index, update in globalUpdates do + local patchData = globalPatches[index] + + if not patchData then + continue + end + + local key = update.key + local status = clientKeys[key] + + if status == LISTENING_FOR_CHANGES then + local payload: SyncPayload = { + type = "patch", + data = { [key] = patchData }, + } + table.insert(payloads, payload) + elseif status == PENDING_INITIAL_STATE then + local payload: SyncPayload = { + type = "init", + data = { [key] = update.newValue }, + } + table.insert(payloads, payload) + clientKeys[key] = LISTENING_FOR_CHANGES + end + end + + -- If a pending signal was never updated, we still need to send its initial + -- state. This can happen if a signal was registered after its last change. + local initData = {} + + for key, status in clientKeys do + local trackedState = trackedStates[key] + + if status == PENDING_INITIAL_STATE and trackedState then + initData[key] = trackedState.currentValue + clientKeys[key] = LISTENING_FOR_CHANGES + end + end + + if next(initData) ~= nil then + table.insert(payloads, 1, { type = "init" :: "init", data = initData }) + end + + return payloads +end + +local function diffGlobalState(): { [string]: any }? + local currentGlobalState: { [string]: any } = {} + local previousGlobalState: { [string]: any } = {} + + for key, trackedState in trackedStates do + local currentValue = if trackedState.isReactive + then utils.deepCopy(trackedState.currentValue) + else trackedState.currentValue + + currentGlobalState[key] = currentValue + previousGlobalState[key] = trackedState.lastSyncedValue + trackedState.lastSyncedValue = currentValue + end + + return patch.diff(previousGlobalState, currentGlobalState) +end + +local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }? + local clientKeys = clients[client] + + if not clientKeys then + return nil + end + + local payloads: { SyncPayload } = {} + local initData: { [string]: any } = {} + local patchData: { [string]: any } = {} + + -- Create patches for initialized signals that changed, and add signals + -- that are still pending initial state to the init payload. + for key, status in clientKeys do + local trackedState = trackedStates[key] + + if status == LISTENING_FOR_CHANGES and globalPatch then + patchData[key] = globalPatch[key] + elseif status == PENDING_INITIAL_STATE then + initData[key] = trackedState.currentValue + clientKeys[key] = LISTENING_FOR_CHANGES + end + end + + if next(initData) ~= nil then + local payload: SyncPayload = { + type = "init", + data = initData, + } + table.insert(payloads, payload) + end + + if next(patchData) ~= nil then + local payload: SyncPayload = { + type = "patch", + data = patchData, + } + table.insert(payloads, payload) + end + + return payloads +end + +local function startInterval(callback: () -> ()) + if not game then + return + end + + if connection then + connection:Disconnect() + end + + local timestamp = os.clock() + + connection = game:GetService("RunService").Heartbeat:Connect(function() + if config.interval >= 0 and os.clock() >= timestamp + config.interval then + timestamp = os.clock() + debug.profilebegin("CharmSync") + callback() + debug.profileend() + end + end) +end + +--[[ + Subscribes a client to the given signals. When an update occurs, the client + will receive a partial state patch to merge into their local state. May be + called multiple times to subscribe to additional keys. + + Atoms, signals converted to atoms, and signal getter functions are allowed. + Note that `client.addSignals` still requires some way to set the state. + + @param client The client receiving state updates. + @param signals A map of getter functions to sync with the client. +]] +local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any }) + if not clients[client] then + clients[client] = {} + end + + local clientKeys = clients[client] + + for key, signal in signals do + if type(signal) == "function" then + trackSignal(key, signal, signal) + else + trackSignal(key, function() + return signal + end, signal) + end + + if not clientKeys[key] then + -- Mark the signal as pending initial state so we send its full + -- value the next time we sync. + clientKeys[key] = PENDING_INITIAL_STATE + end + end +end + +--[[ + Unsubscribes a client from receiving all state updates. To only unsubscribe + from specific keys, use `unsubscribeClientFrom` instead. + + @param client The client receiving state updates. +]] +local function removeClient(client: any) + clients[client] = nil + purgeUntracked() +end + +--[[ + Unsubscribes a client from receiving updates for the given keys. + + @param client The client receiving state updates. + @param keys The keys of the state to unsubscribe from. +]] +local function removeSignalsFromClient(client: any, ...: string) + local clientKeys = clients[client] + + if not clientKeys then + return + end + + for index = 1, select("#", ...) do + local key = select(index, ...) + clientKeys[key] = nil + end + + if next(clientKeys) == nil then + removeClient(client) + else + purgeUntracked() + end +end + +--[[ + Immediately sends pending updates to all clients. Normally, updates are + sent at the interval specified in `config.interval`, but this function + can be used to force an immediate sync. +]] +local function flush() + if not syncCallback then + return + end + + local granular = config.preserveHistory + local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState() + + for client in clients do + local payloads = if granular + then selectFromGlobalPatchList(client, globalPatch :: { any }) + else selectFromGlobalPatch(client, globalPatch) + + if payloads and payloads[1] then + if task then + task.spawn(syncCallback, client, payloads) + else + syncCallback(client, payloads) + end + end + end + + table.clear(globalUpdates) +end + +--[[ + Uses the callback to send state updates to clients that are subscribed + to state changes. Starts automatically sending updates at the interval + specified in `config.interval`. + + @param onSync Called when sending patches to a client. +]] +local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ()) + syncCallback = onSync + startInterval(flush) +end + +--[[ + Stops syncing state updates to clients at the automatic interval. + Flushing can still be performed manually via `flush`. +]] +local function disconnect() + if connection then + connection:Disconnect() + connection = nil + end +end + +--[[ + Unsubscribes all clients, stops tracking all state, and disconnects + from the automatic sync interval. +]] +local function reset() + table.clear(trackedStates) + table.clear(clients) + table.clear(globalUpdates) + disconnect() +end + +return { + addSignalsToClient = addSignalsToClient, + removeClient = removeClient, + removeSignalsFromClient = removeSignalsFromClient, + connect = connect, + disconnect = disconnect, + flush = flush, + reset = reset, +} diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 641f744..664ee61 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -1,20 +1,30 @@ -local Charm = require(script.Parent.Parent.Charm) -type Atom = Charm.Atom - ---[=[ +--[[ A payload that can be sent from the server to the client to synchronize - state between the two. -]=] -export type SyncPayload = { - type: "init", - data: { [string | number]: any }, -} | { - type: "patch", - data: { [string | number]: any }, -} + the states of the client's atoms with the server. +]] +export type SyncPayload = + | { + type: "init", + data: { [string]: any }, + } + | { + type: "patch", + data: { [string]: any }, + } -export type AtomMap = { - [string | number]: Atom, -} +--[[ + A special type that represents a removed value in a patch. This is used to + differentiate between a value that was set to `nil` and an unchanged value. +]] +export type None = { __none: "__none" } -return nil +export type ReactiveProxy = typeof(setmetatable( + {} :: { + __track: () -> (), + __target: T, + __signals: { [unknown]: () -> unknown }, + }, + {} :: { __index: T } +)) + +return {} diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau new file mode 100644 index 0000000..4a49871 --- /dev/null +++ b/packages/charm-sync/src/utils.luau @@ -0,0 +1,65 @@ +local Charm = require("../Charm") + +type Atom = Charm.Atom + +--[[ + Flattens a nested table into a single table with slash-separated keys. + Useful for recursively collecting atoms returned by modules. + + @param input A nested table. + @return A flattened table with slash-separated keys. +]] +local function flatten(input: { [any]: any }): Flattened + local result = {} + + local function visit(node: { [any]: any }, path: string) + for key, value in node do + local location = if path == "" then tostring(key) else path .. "/" .. tostring(key) + + if type(value) == "table" then + visit(value, location) + else + result[location] = value + end + end + end + + visit(input, "") + + return result :: any +end + +--[[ + Converts a signal getter and setter into an atom. This is useful for + syncing client signals with the server, as the sync system requires both + the getter and setter to update state. + + @param getter A function that returns the current value of the signal. + @param setter A function that sets the value of the signal. + @return An atom that can either get or set the signal's value. +]] +local function signalToAtom(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom + return function(...) + return if select("#", ...) == 0 then getter() else setter((...)) + end +end + +--[[ + Returns a deep copy of the given table. Skips tables with metatables to + prevent copying reactive proxies. +]] +local function deepCopy(object: T): T + local copy: any = table.clone(object :: any) + for key, value in copy do + if type(value) == "table" and getmetatable(value) == nil then + copy[key] = deepCopy(value) + end + end + return copy +end + +return { + flatten = flatten, + signalToAtom = signalToAtom, + deepCopy = deepCopy, +} diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index c832542..dfba716 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,67 +1,46 @@ -local SAFE_KEYS = { string = true, number = true } -local UNSAFE_VALUES = { ["function"] = true, thread = true } - local function isTableUnsafe(object: { [any]: any }) - local keyType = nil - local objectSize = 0 + local firstKey = next(object) - -- All keys must have the same type - for key in next, object do - local currentType = type(key) - - if not keyType and SAFE_KEYS[currentType] then - keyType = currentType - elseif keyType ~= currentType then - return true - end - - objectSize += 1 + if firstKey == nil then + return false end - -- If there are more keys than the length of the array, it's an array with - -- non-sequential keys. - if objectSize > #object and keyType == "number" then + local keyType = type(firstKey) + local maxKey = 0 + + -- Keys must all be the same type + for key in object do + if type(key) ~= keyType then + return true + elseif type(key) == "number" and key > maxKey then + maxKey = key + end + end + + -- Array keys must be sequential + if keyType == "number" and maxKey > #object then return true end return false end ---[=[ - Validates a value to ensure it can be synced over a remote event. +--[[ + Emits a warning if the value is a table that cannot be synced via + RemoteEvents or RemoteFunctions. This only aims to catch common mistakes + with remote argument limitations, such as sparse arrays or mixed tables. - @param value The value to validate. - @param key The key of the value in the table. - @error Throws an error if the value cannot be synced. -]=] -local function validate(value: any, key: any) - local typeOfKey = type(key) - local typeOfValue = type(value) - - if not SAFE_KEYS[typeOfKey] then - error(`Invalid key type '{typeOfKey}' at key '{key}'`) - elseif UNSAFE_VALUES[typeOfValue] then - error(`Invalid value type '{typeOfValue}' at key '{key}'`) - elseif typeOfValue == "table" then - if getmetatable(value) ~= nil then - error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) - elseif isTableUnsafe(value) then - error( - `Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n` - .. "This can be for the following reasons:\n" - .. "1. The object is an array with non-sequential keys\n" - .. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n" - .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations" - ) - end - end - - if typeOfKey == "number" then - if key == math.huge or key == -math.huge then - error("Cannot sync infinity as key") - elseif key ~= math.floor(key) then - error("Cannot sync non-integer number as key") - end + @param value The table value. +]] +local function validate(value: { [any]: any }) + if isTableUnsafe(value) then + warn( + "Cannot sync tables unsupported by remote events! This can be for the following reasons:\n" + .. "1. The table is a sparse array with non-sequential indices\n" + .. "2. The table is a mixed table (not all keys have the same type)\n\n" + .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n", + value + ) end end diff --git a/packages/charm-sync/test/client.test.luau b/packages/charm-sync/test/client.test.luau new file mode 100644 index 0000000..20c41a8 --- /dev/null +++ b/packages/charm-sync/test/client.test.luau @@ -0,0 +1,137 @@ +local Charm = require("../Charm") +local DeepCharm = require("../../deep-charm/src") +local client = require("../src/client") +local test = require("../../../tests/test") + +test.afterEach(function() + client.removeAllSignals() +end) + +test("should hydrate atom", function() + local a = Charm.atom(1) + + client.addSignals({ a = a }) + client.patch({ + { type = "init", data = { a = 2 } }, + }) + + assert(a() == 2, "should set a to the initial value") +end) + +test("should merge patch", function() + local a = Charm.atom({ b = 1, c = 2 }) + local previous = a() + + client.addSignals({ a = a }) + client.patch({ + { type = "init", data = { a = a() } }, + }) + + assert(a() == previous, "should set a to the initial value") + + client.patch({ + { type = "patch", data = { a = { b = 3 } } }, + }) + + assert(a() ~= previous, "should apply patch") + assert(a().b == 3, "should set b to the new value") + assert(a().c == 2, "should not change c") +end) + +test("should merge patch (reactive)", function() + local a = DeepCharm.reactive({ b = 1, c = 2 }) + local runs = 0 + + Charm.effect(function() + local _ = a.b + a.c + runs += 1 + end) + + client.addSignals({ a = a }) + client.patch({ + { type = "init", data = { a = { b = a.b, c = a.c } } }, + }) + + assert(runs == 1, "should not trigger effect for redundant init") + + client.patch({ + { type = "patch", data = { a = { b = 3 } } }, + }) + + assert(a.b == 3, "should set b to the new value") + assert(a.c == 2, "should not change c") + assert(runs == 2, "should trigger effect for patch") +end) + +test("should fix sparse array patches", function() + local a = Charm.atom({ 1 }) + + client.addSignals({ a = a }) + client.patch({ + { type = "init", data = { a = a() } }, + }) + + -- This patch would be the result of a diff between { 1 } and { 1, 2 } that + -- was JSON-serialized and deserialized. The client should be able to catch + -- this and convert the string key back to a number. + client.patch({ + { type = "patch", data = { a = { ["2"] = 2 } } }, + }) + + assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers") + assert(a()[2] == 2, "should convert serialized arrays to use numbers") +end) + +test("should merge multiple patches", function() + local a = Charm.atom(1) + local calls = {} + + client.addSignals({ a = a }) + + Charm.subscribe(a, function(value) + table.insert(calls, value) + end) + + client.patch({ + { type = "init", data = { a = a() } }, + }) + + client.patch({ + { type = "patch", data = { a = 2 } }, + { type = "patch", data = { a = 3 } }, + }) + + assert(a() == 3, "should apply the last patch") + assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`) +end) + +test("should batch patches", function() + local a = Charm.atom(0) + local b = Charm.atom(0) + local states = {} + + client.addSignals({ a = a, b = b }) + + Charm.subscribe(a, function() + states.b = b() + end) + + Charm.subscribe(b, function() + states.a = a() + end) + + client.patch({ + { type = "init", data = { a = a(), b = b() } }, + }) + + client.patch({ + { type = "patch", data = { a = 1, b = 2 } }, + }) + + assert(a() == 1, "should set a to the new value") + assert(b() == 2, "should set b to the new value") + assert(states.a == 1, "sub to a should run after b updates") + assert(states.b == 2, "sub to b should run after a updates") +end) + +return {} diff --git a/packages/charm-sync/test/e2e.test.luau b/packages/charm-sync/test/e2e.test.luau new file mode 100644 index 0000000..62ca4a7 --- /dev/null +++ b/packages/charm-sync/test/e2e.test.luau @@ -0,0 +1,97 @@ +local Charm = require("../Charm") +local DeepCharm = require("../../deep-charm/src") +local client = require("../src/client") +local config = require("../src/config") +local server = require("../src/server") +local test = require("../../../tests/test") + +test.afterEach(function() + server.reset() + client.removeAllSignals() + config.preserveHistory = false +end) + +test("should replicate server state to client", function() + local target = {} + local serverA = Charm.atom({ count = 0 }) + local serverB = DeepCharm.reactive({ count = 0 }) + local clientA = Charm.atom({ count = -1 }) + local clientB = DeepCharm.reactive({ count = -1 }) + + client.addSignals({ a = clientA, b = clientB }) + server.addSignalsToClient(target, { a = serverA, b = serverB }) + server.connect(function(syncTarget, payloads) + if syncTarget == target then + client.patch(payloads) + end + end) + + server.flush() + assert(clientA().count == 0, "Client atom A should be initialized to 0") + assert(clientB.count == 0, "Client reactive B should be initialized to 0") + + serverA({ count = 1 }) + serverB.count = 1 + server.flush() + assert(clientA().count == 1, "Client atom A should be updated to 1") + assert(clientB.count == 1, "Client reactive B should be updated to 1") + + local runs = -1 + Charm.effect(function() + local _ = clientB.count + runs += 1 + end) + + serverA({ count = 2 }) + server.flush() + assert(clientA().count == 2, "Client atom A should be updated to 2") + assert(runs == 0, "Client reactive B should not trigger update") +end) + +test("should replicate historical server state to client", function() + local target = {} + local serverA = Charm.atom({ count = 0 }) + local serverB = DeepCharm.reactive({ count = 0 }) + local clientA = Charm.atom({ count = -1 }) + local clientB = DeepCharm.reactive({ count = -1 }) + + config.preserveHistory = true + + client.addSignals({ a = clientA, b = clientB }) + server.addSignalsToClient(target, { a = serverA, b = serverB }) + server.connect(function(syncTarget, payloads) + if syncTarget == target then + client.patch(payloads) + end + end) + + local expectedA = { -1, 0, 1, 2, 3, 4 } + local expectedB = { -1, 0, 1, 2 } + + Charm.effect(function() + local expected = table.remove(expectedA, 1) + assert(expected == clientA().count, `Client reactive A should be {expected}`) + end) + + Charm.effect(function() + local expected = table.remove(expectedB, 1) + assert(expected == clientB.count, `Client reactive B should be {expected}`) + end) + + server.flush() + + serverA({ count = 1 }) + serverA({ count = 2 }) + serverB.count = 1 + serverB.count = 2 + server.flush() + assert(clientA().count == 2, "Client atom A should be updated to 2") + assert(clientB.count == 2, "Client reactive B should be updated to 2") + + serverA({ count = 3 }) + serverA({ count = 4 }) + server.flush() + assert(clientA().count == 4, "Client atom A should be updated to 4") +end) + +return {} diff --git a/packages/charm-sync/test/init.luau b/packages/charm-sync/test/init.luau new file mode 100644 index 0000000..111dc1d --- /dev/null +++ b/packages/charm-sync/test/init.luau @@ -0,0 +1,23 @@ +local test = require("../../tests/test") + +test.describe("client", function() + require("@self/client.test") +end) + +test.describe("e2e", function() + require("@self/e2e.test") +end) + +test.describe("utils", function() + require("@self/utils.test") +end) + +test.describe("patch", function() + require("@self/patch.test") +end) + +test.describe("server", function() + require("@self/server.test") +end) + +return {} diff --git a/packages/charm-sync/test/patch.test.luau b/packages/charm-sync/test/patch.test.luau new file mode 100644 index 0000000..076544e --- /dev/null +++ b/packages/charm-sync/test/patch.test.luau @@ -0,0 +1,133 @@ +local config = require("../src/config") +local patch = require("../src/patch") +local test = require("../../../tests/test") + +local function mockArraySerdes(object: { [any]: any }): { [any]: any } + if object[1] then + local copy = {} + for index = 1, #object do + copy[index] = object[index] + end + return copy + elseif table.maxn(object) ~= #object then + local copy = {} + for key, value in object do + copy[tostring(key)] = value + end + return copy + else + return object + end +end + +test.afterEach(function() + config.fixArrays = true + config.validatePatches = true +end) + +test("returns a patch", function() + local prevState = { foo = "bar", baz = "qux" } + local nextState = { foo = "bar", baz = "quux" } + local diffs = patch.diff(prevState, nextState) + + assert(diffs ~= nil, "should return a patch") + assert(diffs.baz == "quux", "should return the changed value") + assert(diffs.foo == nil, "should omit unchanged values") +end) + +test("applies a patch", function() + local prevState = { foo = "bar", baz = "qux" } + local diffs = { baz = "quux", qaz = "qux" } + local applied = patch.apply(prevState, diffs) + + assert(applied ~= nil, "should return a new state") + assert(applied.baz == "quux", "should apply the changed value") + assert(applied.foo == "bar", "should keep the unchanged value") + assert(applied.qaz == "qux", "should add the new value") +end) + +test("allows nested diffs", function() + local prevState = { foo = { bar = "baz" } } + local nextState = { foo = { bar = "qux" } } + local diffs = patch.diff(prevState, nextState) + local applied = patch.apply(prevState, diffs) + + assert(applied.foo.bar == "qux", "should apply the nested patch") +end) + +test("preserves array density when applying", function() + local prevState = { "a", "b", "c", "d" } + local nextState = { ["2"] = "B", ["4"] = "D" } + local applied = patch.apply(prevState, nextState) + + assert(applied[1] == "a", "should keep a") + assert(applied[2] == "B", "should apply B") + assert(applied[3] == "c", "should keep c") + assert(applied[4] == "D", "should apply D") + + assert(applied["2"] == nil, "should convert serialized arrays to use numbers") + assert(applied["4"] == nil, "should convert serialized arrays to use numbers") +end) + +test("preserves array density after serdes", function() + -- This particular change results in a patch that cannot be serialized + -- without losing information. Charm resolves this issue internally. + local prevState = { "a", "b", "c", "d", "e" } + local nextState = { "A", "b", "c", "D" } + + local diffs = mockArraySerdes(patch.diff(prevState, nextState)) + local applied = patch.apply(prevState, diffs) + + for index in prevState do + assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) + end + + for index in applied do + assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) + end +end) + +test("cannot preserve sparse arrays", function() + config.validatePatches = false + + local prevState = {} + local nextState = { [2] = "foo" } + + local diffs = mockArraySerdes(patch.diff(prevState, nextState)) + local applied = patch.apply(prevState, diffs) + + -- Not enough information is available to determine whether 'diffs' + -- should keep the string keys added during remote event serialization. + assert(applied["2"] ~= nil, "will fail to convert string keys to numbers") + assert(applied[2] == nil, "will fail to convert string keys to numbers") +end) + +test("optionally fixes sparse arrays", function() + config.fixArrays = true + + for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do + assert(type(index) == "string", `expected {type(index)} {index} to be string`) + end + + config.fixArrays = false + + for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do + assert(type(index) == "number", `expected {type(index)} {index} to be number`) + end +end) + +test("patches mutable state", function() + local targetState: { [string]: string } = { foo = "bar", baz = "qux" } + patch.applyMutable(targetState, { baz = "quux", qaz = "qux" }) + assert(targetState.baz == "quux", "should apply the changed value") + assert(targetState.foo == "bar", "should keep the unchanged value") + assert(targetState.qaz == "qux", "should add the new value") +end) + +test("removes table from mutable state", function() + local targetState = { foo = { bar = "baz" } } + patch.applyMutable(targetState, { foo = { __none = "__none" } }) + assert(targetState.foo == nil, "should remove the table") +end) + +return {} diff --git a/packages/charm-sync/test/server.test.luau b/packages/charm-sync/test/server.test.luau new file mode 100644 index 0000000..380fbb0 --- /dev/null +++ b/packages/charm-sync/test/server.test.luau @@ -0,0 +1,408 @@ +local Charm = require("../Charm") +local DeepCharm = require("../../deep-charm/src") +local config = require("../src/config") +local patch = require("../src/patch") +local server = require("../src/server") +local test = require("../../../tests/test") +local types = require("../src/types") + +test.afterEach(function() + server.reset() +end) + +test("should replicate state changes", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local c = Charm.atom(0) + local target: unknown, payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a, b = b, c = c }) + server.connect(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == a(), "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'") + + a(1) + b(2) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == a(), "Expected patch payload to contain atom 'a'") + assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'") +end) + +test("should replicate partial state patches", function() + local client = {} + local a = Charm.atom({ x = 0, y = 0 }) + local b = Charm.atom(0 :: number?) + local target: unknown, payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a, b = b }) + server.connect(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a.x == a().x, "Expected init payload to contain atom 'a.x'") + assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + + a({ x = 1, y = 0 }) + b(nil) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a.x == 1, "Expected patch payload to contain atom 'a.x'") + assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'") + assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None") +end) + +test("should replicate partial state patches (reactive)", function() + local client = {} + local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? }) + local target: unknown, payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a.x ~= nil, "Expected init payload to contain value 'a.x'") + assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'") + assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'") + + a.x.y = 1 + a.z = nil + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a.x.y == 1, "Expected patch payload to contain value 'a.x'") + assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None") + + a.z = 1 + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a.z == 1, "Expected patch payload to contain value 'a.z'") + assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'") +end) + +test("should handle nested proxies", function() + local client = {} + local src = DeepCharm.reactive({ (DeepCharm.reactive({})) }) + local payloads: {}? = nil + + server.addSignalsToClient(client, { src = src }) + server.connect(function(_, newPayloads) + payloads = newPayloads + end) + server.flush() + + payloads = nil + server.flush() + assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy") +end) + +test("should init new clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + + server.addSignalsToClient(clientA, { a = a }) + server.connect(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + a(1) + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") + + a(2) + server.addSignalsToClient(clientB, { a = a }) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B") +end) + +test("should init new atoms", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local target: unknown, payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(...) + target, payloads = ... + end) + + server.flush() + server.addSignalsToClient(client, { b = b }) + a(1) + b(1) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + assert(payloads[2].type == "patch", "Expected second payload to be patch") + assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'") +end) + +test("should only replicate to synced clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + + server.addSignalsToClient(clientA, { a = a }) + server.addSignalsToClient(clientB, { b = b }) + server.connect(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") + assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B") + assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B") + + payloadsA = {} + payloadsB = {} + a(1) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A") + assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A") + assert(#payloadsB == 0, "Expected no payloads for client B") + + payloadsA = {} + payloadsB = {} + b(1) + server.flush() + assert(#payloadsA == 0, "Expected no payloads for client A") + assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B") + assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B") + assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B") +end) + +test("should serialize state", function() + local client = {} + local a = Charm.atom({ 1, 2, 3, 4, 5 }) + local payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + server.flush() + + a({ 1, 2, 0, 4 }) + server.flush() + assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key") + assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key") +end) + +test("should not send unchanged state", function() + local client = {} + local a = Charm.atom(0) + local payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + payloads = {} + a(0) + server.flush() + assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") +end) + +test.describe("historical", function() + test.beforeEach(function() + config.preserveHistory = true + end) + + test.afterEach(function() + config.preserveHistory = false + end) + + test("should replicate granular state changes", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local c = Charm.atom(0) + local target: unknown, payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a, b = b, c = c }) + server.connect(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'") + + a(1) + a(2) + b(2) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'") + assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'") + end) + + test("should replicate granular state changes (reactive)", function() + local client = {} + local a = DeepCharm.reactive({ value = 0 }) + local b = DeepCharm.reactive({ value = 0 }) + local c = DeepCharm.reactive({ value = 0 }) + local target: unknown, payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a, b = b, c = c }) + server.connect(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'") + + a.value = 1 + a.value = 2 + b.value = 2 + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'") + assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'") + end) + + test("should init new clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + + server.addSignalsToClient(clientA, { a = a }) + server.connect(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + a(1) + a(2) + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A") + assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A") + assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A") + + server.addSignalsToClient(clientB, { a = a }) + a(3) + a(4) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A") + assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B") + assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B") + assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B") + end) + + test("should init new atoms", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, ...) + payloads = ... + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + server.addSignalsToClient(client, { b = b }) + a(1) + a(2) + b(1) + b(2) + server.flush() + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'") + assert(payloads[3].type == "init", "Expected third payload to be init") + assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'") + assert(payloads[4].type == "patch", "Expected fourth payload to be patch") + assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'") + end) + + test("should not send unchanged state", function() + local client = {} + local a = Charm.atom(0) + local payloads: { types.SyncPayload } + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + payloads = {} + a(0) + server.flush() + assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") + end) +end) + +return {} diff --git a/packages/charm-sync/test/utils.test.luau b/packages/charm-sync/test/utils.test.luau new file mode 100644 index 0000000..15dcabf --- /dev/null +++ b/packages/charm-sync/test/utils.test.luau @@ -0,0 +1,22 @@ +local Charm = require("../Charm") +local test = require("../../../tests/test") +local utils = require("../src/utils") + +test("should flatten functions", function() + local input = { + a = Charm.atom(0), + b = { c = Charm.atom(0) }, + d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, + } + + local result = utils.flatten<<{ [string]: Charm.Atom }>>(input) + + assert(result.a == input.a, "should flatten the first level") + assert(result["b/c"] == input.b.c, "should flatten the second level") + assert(result["d/e/f"] == input.d.e.f, "should flatten the third level") + + assert(result.b == nil, "should remove the original nested tables") + assert(result.d == nil, "should remove the original nested tables") +end) + +return {} diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index f1a78f0..28a6fc9 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,8 +1,15 @@ [package] name = "littensy/charm-sync" -version = "0.3.0" +version = "0.4.0-rc.7" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = [ + "node_modules", + "test", + "src/index.d.ts", + "package.json", + "Charm.luau", +] [dependencies] -charm = "littensy/charm@^0.10" +Charm = "littensy/charm@^0.11.0-rc.6" diff --git a/packages/charm/default.project.json b/packages/charm/default.project.json index 89d3ada..a485e4d 100644 --- a/packages/charm/default.project.json +++ b/packages/charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "Charm", + "name": "charm", "tree": { "$path": "src" } diff --git a/packages/charm/package.json b/packages/charm/package.json index ea40fa6..28f5c7c 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,13 +1,24 @@ { "name": "@rbxts/charm", - "version": "0.10.0", - "description": "An atomic state management library for Roblox", - "repository": "littensy/charm", + "version": "0.11.0-rc.6", + "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "prepare": "cp ../../LICENSE . && cp ../../README.md .", "publish:wally": "wally publish" } } diff --git a/packages/charm/src/atom.luau b/packages/charm/src/atom.luau deleted file mode 100644 index f10dbd6..0000000 --- a/packages/charm/src/atom.luau +++ /dev/null @@ -1,42 +0,0 @@ -local store = require(script.Parent.store) -local types = require(script.Parent.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 function atom(...) - if select("#", ...) == 0 then - local index = store.capturing.index - - if index > 0 then - store.capturing.stack[index][atom] = true - end - - return state - end - - local nextState = store.peek(..., state) - - if state ~= nextState and not (equals and equals(state, nextState)) then - state = nextState - store.notify(atom) - end - - return state - end - - store.listeners[atom] = setmetatable({}, { __mode = "v" }) - - return atom -end - -return atom diff --git a/packages/charm/src/computed.luau b/packages/charm/src/computed.luau deleted file mode 100644 index e786c45..0000000 --- a/packages/charm/src/computed.luau +++ /dev/null @@ -1,37 +0,0 @@ -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 - ---[=[ - Creates a read-only atom that derives its state from one or more atoms. - Used to avoid unnecessary recomputations if multiple listeners depend on - the same atoms. - - @param callback The function that produces the state. - @param options Optional configuration. - @return A new read-only atom. -]=] -local function computed(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 listener() - local computedAtom = computedRef.current - - if computedAtom then - store.disconnect(dependencies, listener) - dependencies, state = store.capture(callback) - store.connect(dependencies, listener, computedAtom) - computedAtom(state) - end - end - - store.connect(dependencies, listener, computedAtom) - - return computedAtom -end - -return computed diff --git a/packages/charm/src/effect.luau b/packages/charm/src/effect.luau deleted file mode 100644 index 23f3294..0000000 --- a/packages/charm/src/effect.luau +++ /dev/null @@ -1,53 +0,0 @@ -local store = require(script.Parent.store) - -type Cleanup = () -> () - ---[=[ - Runs the given callback immediately and whenever any atom it depends on - changes. Returns a cleanup function that unsubscribes the callback. - - @param callback The function to run. - @return A function that unsubscribes the callback. -]=] -local function effect(callback: (cleanup: Cleanup) -> Cleanup?): Cleanup - local dependencies = {} - local cleanup: Cleanup? - local disconnected = false - local disconnect - - local function listener() - if cleanup then - cleanup() - end - - store.disconnect(dependencies, listener) - dependencies, cleanup = store.capture(callback, disconnect) - - if not disconnected then - store.connect(dependencies, listener) - end - end - - function disconnect() - if disconnected then - return - end - - disconnected = true - store.disconnect(dependencies, listener) - - if cleanup then - cleanup() - end - end - - dependencies, cleanup = store.capture(callback, disconnect) - - if not disconnected then - store.connect(dependencies, listener) - end - - return disconnect -end - -return effect :: (callback: ((cleanup: Cleanup) -> ()) | ((cleanup: Cleanup) -> Cleanup?)) -> Cleanup diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 8336177..4e4ad8d 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1,187 +1,280 @@ -export = Charm; -export as namespace Charm; +type Key = string | number | symbol; type Cleanup = () => void; -declare namespace Charm { - /** - * A primitive state container that can be read from or written to. When - * the state changes, all subscribers are notified. - * - * @param state The next state or a function that produces the next state. - * @returns The current state. - */ - interface Atom extends Selector { - (nextState: State | ((prevState: State) => State)): State; - } - - /** - * A function that depends on one or more atoms and produces a value. Can be - * used to derive state from atoms. - * - * @returns The current state. - */ - type Selector = () => State; - - /** - * 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; - - interface AtomOptions { - /** - * A function that determines whether the state has changed. By default, - * a strict equality check (`===`) is used. - */ - equals?: (prev: State, next: State) => boolean; - } - - /** - * Creates a new atom with the given state. - * - * @param state The initial state. - * @param options Optional configuration. - * @returns A new atom. - */ - function atom(state: State, options?: AtomOptions): Atom; - - // Overload for no arguments - function atom(state?: State, options?: AtomOptions): Atom; - - /** - * Creates a read-only atom that derives its state from one or more atoms. - * Used to avoid unnecessary recomputations if multiple listeners depend on - * the same atoms. - * - * @param callback The function that produces the state. - * @param options Optional configuration. - * @returns A new read-only atom. - */ - function computed(callback: Selector, options?: AtomOptions): Selector; - - /** - * Subscribes to changes in the given atom or selector. The callback is - * called with the current state and the previous state immediately after a - * change occurs. - * - * @param callback The atom or selector to subscribe to. - * @param listener The function to call when the state changes. - * @returns A function that unsubscribes the callback. - */ - function subscribe(callback: Selector, listener: (state: State, prev: State) => void): Cleanup; - - /** - * Runs the given callback immediately and whenever any atom it depends on - * changes. Returns a cleanup function that unsubscribes the callback. - * - * @param callback The function to run. - * @returns A function that unsubscribes the callback. - */ - function effect(callback: (cleanup: Cleanup) => Cleanup | void): Cleanup; - - /** - * Returns the result of the function without subscribing to changes. If a - * non-function value is provided, it is returned as is. - * - * @param callback The atom or selector to get the state of. - * @param args Arguments to pass to the function. - * @returns The current state. - */ - function peek(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; - - /** - * Runs the given function and schedules listeners to be notified only once - * after the function has completed. Useful for batching multiple changes. - * - * @param callback The function to run. - */ - function batch(callback: () => void): void; - - /** - * Captures all atoms that are read during the function call and returns them - * along with the result of the function. Useful for tracking dependencies. - * - * @param callback The function to run. - * @returns A tuple containing the captured atoms and the result of the function. - */ - function capture(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; - - /** - * Creates an instance of `factory` for each item in the atom's state, and - * cleans up the instance when the item is removed. Returns a cleanup function - * that unsubscribes all instances. - * - * @param callback The atom or selector to observe. - * @param factory The function that tracks the lifecycle of each item. - * @returns A function that unsubscribes all instances. - */ - function observe( - callback: Selector, - factory: (item: Item, index: number) => Cleanup | void, - ): Cleanup; - - function observe( - callback: Selector | ReadonlyMap>, - factory: (item: Item, key: Key) => Cleanup | void, - ): Cleanup; - - function observe( - callback: Selector<{ readonly [P in Key]: Item }>, - factory: (item: Item, key: Key) => Cleanup | void, - ): Cleanup; - - /** - * Maps each entry in the atom's state to a new key-value pair. If the `mapper` - * function returns `undefined`, the entry is omitted from the resulting map. - * When the atom changes, the `mapper` is called for each entry in the state - * to compute the new state. - * - * @param callback The atom or selector to map. - * @param mapper The function that maps each entry. - * @returns A new atom with the mapped state. - */ - function mapped( - callback: Selector, - mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>, - ): Selector>; - - function mapped( - callback: Selector, - mapper: (value: V0, index: number) => V1, - ): Selector; - - function mapped( - callback: Selector | ReadonlyMap>, - mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, - ): Selector>; - - function mapped( - callback: Selector<{ readonly [P in K0]: V0 }>, - mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, - ): Selector>; +export enum ReactiveFlags { + None = 0b000000, + Mutable = 0b000001, + Watching = 0b000010, + RecursedCheck = 0b000100, + Recursed = 0b001000, + Dirty = 0b010000, + Pending = 0b100000, } + +export interface ReactiveNode { + deps?: Link; + depsTail?: Link; + subs?: Link; + subsTail?: Link; + flags: ReactiveFlags; +} + +export interface Link { + version: number; + dep: ReactiveNode; + sub: ReactiveNode; + prevSub?: Link; + nextSub?: Link; + prevDep?: Link; + nextDep?: Link; +} + +export interface Atom { + (update: Update): T; + (): T; +} + +export type Getter = () => T; + +export type Setter = (update: Update) => T; + +export type Update = ((currentValue: T) => T) | T; + +export type Equals = (current: T, incoming: T) => boolean; + +/** + * Global flags that modify the behavior of Charm's reactive system. + */ +export const flags: { + /** + * Enforces synchronous, non-yielding behavior in signals and effects. + * Also enables state validation in Charm Sync to catch sync errors early. + * Enabled if the optimization level is below 2, which is true in studio. + */ + strict: boolean; + /** + * Enforces immutability of tables by deep-freezing table values. Enabled + * if the optimization level is below 2, which is true in studio. + */ + frozen: boolean; + /** + * Whether parent effects should track inner effects and clean them up + * when the parent effect re-runs. This is usually desirable, but can be + * disabled to revert to the old behavior. Defaults to `true`. + */ + trackInnerEffects: boolean; +}; + +/** + * Creates a reactive signal that stores a value and notifies subscribers when + * the value changes. The signal provides both a getter and a setter. + * + * @param initialValue The initial value of the signal. + * @param equals A comparator function to determine if the signal's value has changed. + * @returns A tuple containing the getter and setter functions for the signal. + * @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals + */ +export function signal(initialValue: T, equals?: Equals): LuaTuple<[getter: Getter, setter: Setter]>; +export function signal(): LuaTuple<[getter: Getter, setter: Setter]>; + +/** + * Creates a reactive atom that acts as both a signal getter and setter. + * Calling the atom with an argument sets its value, while calling it with no + * arguments returns its current value. + * + * @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. + * @param equals A comparator function to determine if the atom's value has changed. + * @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals + */ +export function atom(initialValue: T, equals?: Equals): Atom; +export function atom(): Atom; + +/** + * Creates a new read-only signal that is computed based on the values of + * other signals. The computed signal's value is automatically updated when a + * change is detected in any of its dependencies. + * + * @param getter A function that produces the next value. + * @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter + */ +export function computed(getter: (previousValue?: T) => T): Getter; + +/** + * Creates an effect that runs a callback in response to signal state changes. + * An effect tracks which signals are accessed during its execution, and runs + * the callback when those signals change. + * + * The effect callback may return a cleanup function, which gets called once, + * either before the effect re-runs or when it is disposed. + * + * If the effect is called within another effect, it will be disposed when the + * parent effect re-runs. + * + * @param fn The function to run when dependencies change. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback + */ +export function effect(fn: () => Cleanup | void): Cleanup; + +/** + * Creates an effect scope that can capture reactive effects and computed + * signals created within it so that they can be disposed together. + * + * @param fn A function that may create reactive effects. + * @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. + * @returns A function for disposing all effects created within the scope. + * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached + */ +export function effectScope(fn: () => Cleanup | void, detached?: boolean): Cleanup; + +/** + * Allows you to manually trigger updates for downstream dependencies when + * you've directly mutated a signal's value without using the signal setter. + * + * @param fn The dependency or a function that calls multiple dependencies. + * @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback + */ +export function trigger(fn: () => void): void; + +/** + * Binds the cleanup function to the current active effect or effect scope. If + * there is no active effect, a warning is logged, unless `failSilently` is + * set to `true`. + * + * @param fn The cleanup function to register. + * @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. + * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently + */ +export function onCleanup(fn: Cleanup, failSilently?: boolean): void; + +/** + * Runs the function without subscribing to signal updates and prevents the + * current effect or scope from tracking inner effects created within the + * function. + * + * To avoid tracking signals while still allowing inner effects to be tracked + * by parent effects/scopes, use `effectScope()` instead. + * + * @param fn A function that may read signals or create effects. + * @param args Arguments to pass to the function. + * @returns The return value of the function. + * @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback + */ +export function untracked(fn: (...args: Args) => Result, ...args: Args): Result; + +/** + * Combines multiple updates into a single "commit" that runs effects and + * computed signals after the provided function finishes running. + * + * @param fn A function that performs multiple updates. + * @param args Arguments to pass to the function. + * @returns The return value of the function. + * @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback + */ +export function batch(fn: (...args: Args) => Result, ...args: Args): Result; + +/** + * Creates an effect that runs the callback one immediately, and then again + * when the value returned by the getter changes. The callback receives the new + * value and the previous value as arguments. + * + * @param getter A function that returns the value to watch. + * @param callback The function to run when the value changes. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback + */ +export function listen(getter: Getter, callback: (value: T, previousValue: T | undefined) => void): Cleanup; + +/** + * Creates an effect that only runs the callback when the value returned by the + * getter changes. The callback receives the new value and the previous value + * as arguments. + * + * @param getter A function that returns the value to watch. + * @param callback The function to run when the value changes. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback + */ +export function subscribe(getter: Getter, callback: (value: T, previousValue: T) => void): Cleanup; + +/** + * Creates a new read-only signal that is computed by mapping over the entries + * of the source table. If the key is omitted from the transform function's + * return value, the original key is preserved. + * + * This function is optimized to minimize unnecessary updates by only calling + * the transform function for entries that have changed since the last run. + * + * @param getter A signal or function that returns a table + * @param transform Function that maps each entry from the source to a new entry in the mapped table + * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform + */ +export function mapped( + getter: Getter | ReadonlyMap>, + transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): Getter>; +// Overload for arrays +export function mapped( + getter: Getter, + transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, +): Getter; +// Overload for objects +export function mapped( + getter: Getter<{ readonly [K in KI]: VI }>, + transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): Getter<{ readonly [K in KO]: VO }>; + +/** + * Calls the callback function for each unique key in the table returned by the + * getter. The callback is called once for each key, and again when a new key + * is added. + * + * The callback may return a cleanup function, which will be called when the + * key is removed or when the entire callback is disposed. + * + * Effects created within the callback are cleaned up when the item is removed + * from the table or when the callback is disposed. + * + * @param getter A function that returns the table to observe. + * @param callback A function that is called for each key in the table. + * @returns A function for disposing the observer. + * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback + */ +export function observe( + getter: Getter | ReadonlyMap | readonly V[]>, + callback: (value: V, key: K) => Cleanup | void, +): Cleanup; +// Overload for objects +export function observe( + getter: Getter<{ readonly [K in Key]: V }>, + callback: (value: V, key: K) => Cleanup | void, +): Cleanup; + +/** + * Returns the currently active subscriber (effect or computed node). + * + * @return Current active subscriber or `nil` + */ +export function getActiveSub(): ReactiveNode | undefined; + +/** + * Sets the currently active subscriber (effect or computed node) and returns + * the previous subscriber. + * + * @param sub New subscriber to set as active + * @return Previous active subscriber + */ +export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; + +/** + * Starts a batch operation, incrementing the batch depth counter. Effects + * will not run until the outermost batch ends. + */ +export function startBatch(): void; + +/** + * Ends a batch operation, decrementing the batch depth counter. If this is + * the outermost batch, queued effects are flushed and run. + */ +export function endBatch(): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 4bc1a8c..d47ede5 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,27 +1,1000 @@ -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) +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/system.ts +]] -export type Atom = types.Atom -export type Selector = types.Selector -export type Molecule = types.Selector -export type SyncPayload = types.SyncPayload +local system = require("@self/system") + +export type Link = system.Link +export type ReactiveFlags = system.ReactiveFlags +export type ReactiveNode = system.ReactiveNode + +local link = system.link +local unlink = system.unlink +local propagate = system.propagate +local checkDirty = system.checkDirty +local shallowPropagate = system.shallowPropagate +local createReactiveSystem = system.createReactiveSystem + +export type Getter = () -> T +export type Setter = (update: Update) -> T +export type Atom = (...Update) -> T +export type Update = ((current: T) -> T) | T +export type Equals = (current: T, incoming: T) -> boolean +export type Cleanup = () -> () + +type EffectScopeNode = ReactiveNode & { + cleanups: { Cleanup }, +} + +type EffectNode = ReactiveNode & { + fn: () -> ...Cleanup?, + cleanups: { Cleanup }, +} + +type ComputedNode = ReactiveNode & { + value: T?, + getter: (previousValue: T?) -> T, +} + +type SignalNode = ReactiveNode & { + currentValue: T, + pendingValue: T, +} + +-- Bitmask flags for tracking the state of reactive nodes. Flags are combined +-- and checked using bitwise operations for efficiency. +local NONE = 0b000000 -- Default state +local MUTABLE = 0b000001 -- Can be written to (signal or computed) +local WATCHING = 0b000010 -- Notified for changes in dependencies (effects) +local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies +local RECURSED = 0b001000 -- Has been visited in a recursion check +local DIRTY = 0b010000 -- Value is outdated and needs recomputation +local PENDING = 0b100000 -- Might be dirty, pending verification + +-- Global version counter for dependency link deduplication. Incremented at the +-- start of each effect/computed run to ensure links created in the same cycle +-- are deduplicated. +local cycle = 0 +-- Effects runs are queued to prevent redundant executions when multiple +-- signals update in the same batch. +local queued: { EffectNode } = {} +local queuedLength = 0 +local notifyIndex = 0 +-- Batch operations group multiple updates together, deferring effect runs +-- until the batch completes. Effects only run when the outermost batch ends. +local batchDepth = 0 +-- Tracks the currently active effect or computed signal during execution. +-- Dependencies accessed during this time will link to the active subscriber. +local activeSub: ReactiveNode? + +local function isO2(): boolean + return debug.info(1, "n") ~= "isO2" +end + +local flags = { + --[[ + Enforces synchronous, non-yielding behavior in signals and effects. + Also enables state validation in Charm Sync to catch sync errors early. + Enabled if the optimization level is below 2, which is true in studio. + ]] + strict = not isO2(), + --[[ + Enforces immutability of tables by deep-freezing table values. Enabled + if the optimization level is below 2, which is true in studio. + ]] + frozen = not isO2(), + --[[ + Whether parent effects should track inner effects and clean them up + when the parent effect re-runs. This is usually desirable, but can be + disabled to revert to the old behavior. Defaults to `true`. + ]] + trackInnerEffects = true, +} + +--[[ + Wraps a user-created function to prevent yielding when in strict mode. To + shorten error stack traces, only functions created outside of this module + are wrapped. +]] +local function wrapUserSpace(callback: (Args...) -> Results...): (Args...) -> Results... + if not flags.strict then + return callback + end + + -- If the sources match, the function was created in this module + if debug.info(1, "s") == debug.info(callback, "s") then + return callback + end + + local handler = function(thread: thread, ok: boolean, ...: Results...): Results... + if not ok then + local err = (...) + if type(err) == "string" then + error(debug.traceback(thread, err), 0) + else + error(tostring(err), 0) + end + elseif coroutine.status(thread) ~= "dead" then + error(debug.traceback(thread, "Attempted to yield in an effect or scope"), 0) + else + return ... + end + end + + return function(...: Args...): Results... + local thread = coroutine.create(callback) + return handler(thread, coroutine.resume(thread, ...)) + end +end + +--[[ + Recursively freezes a table and all nested tables to enforce immutability. + Tables with metatables are skipped, as they are assumed to be mutable. +]] +local function deepFreeze(value: any) + if type(value) == "table" and not table.isfrozen(value) and getmetatable(value) == nil then + table.freeze(value) + for _, entry in value do + deepFreeze(entry) + end + end +end + +--[[ + Returns the currently active subscriber (effect or computed node). +]] +local function getActiveSub(): ReactiveNode? + return activeSub +end + +--[[ + Sets the currently active subscriber (effect or computed node) and returns + the previous subscriber. +]] +local function setActiveSub(sub: ReactiveNode?) + local prevSub = activeSub + activeSub = sub + return prevSub +end + +--[[ + Removes dependencies from the subscriber's dependency list. When called + after tracking is complete, this ensures that only currently tracked + dependencies remain linked. +]] +local function purgeDeps(sub: ReactiveNode) + local depsTail = sub.depsTail + local dep = if depsTail then depsTail.nextDep else sub.deps + while dep do + dep = unlink(dep, sub) + end +end + +--[[ + Runs all cleanup functions registered with the effect node. Any errors + thrown during cleanup are caught and logged in a single error after all + cleanups are complete. +]] +local function runCleanups(cleanups: { Cleanup }) + local hasErrors = false + local errors: { string } = {} + + for index, cleanup in cleanups do + cleanups[index] = nil + + local prevSub = setActiveSub(nil) + local success, err = pcall(cleanup) + activeSub = prevSub + + if not success then + hasErrors = true + table.insert(errors, tostring(err)) + end + end + + if hasErrors then + error(`Errors occurred during effect cleanup:\n\n{table.concat(errors, "\n\n")}`, 0) + end +end + +--[[ + Checks if the effect node needs to run based on its flags and dependencies. + Effects run if they are marked dirty or pending with dirty dependencies. +]] +local function run(effect: EffectNode) + local effectFlags = effect.flags + + if + bit32.btest(effectFlags, DIRTY) + or (bit32.btest(effectFlags, PENDING) and checkDirty(effect.deps :: Link, effect)) + then + runCleanups(effect.cleanups) + + -- Cancel the run if the effect was disposed during cleanup + if effect.flags == NONE then + return + end + + cycle += 1 + effect.depsTail = nil + effect.flags = WATCHING + + local prevSub = setActiveSub(effect) + local success, result = pcall(effect.fn) + activeSub = prevSub + + purgeDeps(effect) + + if success and result then + table.insert(effect.cleanups, wrapUserSpace(result)) + elseif not success then + error(result, 0) + end + + -- If the effect was disposed during execution but before a cleanup was + -- was registered, run the cleanups that would have been missed + if effect.flags == NONE then + runCleanups(effect.cleanups) + end + else + effect.flags = WATCHING + end +end + +--[[ + Processes the queued effect runs. Effects are run in the order they were + queued, ensuring that all updates are processed. +]] +local function flush() + -- Bail out early if a flush is already in progress to allow effects to + -- complete before the next effect runs + if batchDepth ~= 0 or notifyIndex ~= 0 then + return + end + + local success, err = pcall(function() + while notifyIndex < queuedLength do + notifyIndex += 1 + local effect = queued[notifyIndex] :: EffectNode + queued[notifyIndex] = nil + run(effect) + end + end) + + -- Skip over any remaining queued effects that were not run due to an error + while notifyIndex < queuedLength do + notifyIndex += 1 + local effect = queued[notifyIndex] :: EffectNode + queued[notifyIndex] = nil + effect.flags = bit32.bor(effect.flags, bit32.bor(WATCHING, RECURSED)) + end + + notifyIndex = 0 + queuedLength = 0 + + if not success then + error(err, 0) + end +end + +--[[ + Starts a batch operation, incrementing the batch depth counter. Effects + will not run until the outermost batch ends. +]] +local function startBatch() + batchDepth += 1 +end + +--[[ + Ends a batch operation, decrementing the batch depth counter. If this is + the outermost batch, queued effects are flushed and run. +]] +local function endBatch() + batchDepth -= 1 + flush() +end + +--[[ + Updates a computed signal by re-evaluating its getter function. Returns + whether the computed value changed. +]] +local function updateComputed(computed: ComputedNode): boolean + cycle += 1 + computed.depsTail = nil + computed.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + + local prevSub = setActiveSub(computed) + local oldValue = computed.value + local success, newValue = pcall(computed.getter, oldValue) + activeSub = prevSub + + computed.flags = bit32.band(computed.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(computed) + + if success then + computed.value = newValue + return oldValue ~= newValue + else + error(newValue, 0) + end +end + +--[[ + Updates a signal by applying its pending value. Returns whether the + signal's value changed. +]] +local function updateSignal(signal: SignalNode): boolean + signal.flags = MUTABLE + local pendingValue = signal.pendingValue + if signal.currentValue ~= pendingValue then + signal.currentValue = pendingValue + return true + else + return false + end +end + +--[[ + Clears all dependencies and subscribers from the effect node. After calling + this, the node will no longer be subscribed to any dependencies and will be + inactive, with all cleanup functions run and resources released. +]] +local function stopEffect(effect: EffectScopeNode) + effect.depsTail = nil + effect.flags = NONE + + purgeDeps(effect) + + -- Unlink from the parent effect, if it exists + local sub = effect.subs + if sub then + unlink(sub, effect) + end + + runCleanups(effect.cleanups) +end + +--[[ + Updates a signal or computed value and returns whether the value changed. +]] +local function update(node: ReactiveNode): boolean + if node.depsTail then + return updateComputed(node :: ComputedNode) + else + return updateSignal(node :: SignalNode) + end +end + +--[[ + Queues an effect for execution, ensuring inner effects are notified in the + correct order. +]] +local function notify(effect: ReactiveNode) + local startIndex = queuedLength + 1 + + -- Collect inner effects in a chain + repeat + queuedLength += 1 + queued[queuedLength] = effect :: EffectNode + + -- Mark as not watching to prevent duplicates in the same flush + effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) + + -- Traverse to the parent effect, if it exists and is not queued, to + -- ensure it gets notified before the child effect + effect = (effect.subs and effect.subs.sub) :: EffectNode + + if not effect or bit32.band(effect.flags, WATCHING) == 0 then + break + end + until false + + local endIndex = queuedLength + + -- Reverse the collected effects since they were collected from child to + -- parent, but should be notified from parent to child + while startIndex < endIndex do + queued[startIndex], queued[endIndex] = queued[endIndex], queued[startIndex] + startIndex += 1 + endIndex -= 1 + end +end + +--[[ + Called when a node is not being watched by any subscribers. Cleans up the + node's dependencies and performs the necessary cleanup operations: + + - Computed signals: Unlink dependencies and mark as dirty for potential + future updates + - Effects/scopes: Perform full cleanup to free resources +]] +local function unwatched(node: ReactiveNode) + if bit32.band(node.flags, MUTABLE) == 0 then + stopEffect(node :: EffectScopeNode) + elseif node.depsTail then + node.depsTail = nil + node.flags = bit32.bor(MUTABLE, DIRTY) + purgeDeps(node) + end +end + +--[[ + Creates a reactive signal that stores a value and notifies subscribers when + the value changes. The signal provides both a getter and a setter. + + @param initialValue Initial value for the signal + @param equals Optional comparator function to check for value changes + @return Getter function to read the signal value + @return Setter function to update the signal value + @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals +]] +local function signal(initialValue: T, equals: Equals?): (Getter, Setter) + local node: SignalNode = { + currentValue = initialValue, + pendingValue = initialValue, + flags = MUTABLE, + } + + if flags.frozen then + deepFreeze(initialValue) + end + + local function signalGetter(): T + -- Check if the signal has a pending update that needs to be applied + if bit32.btest(node.flags, DIRTY) then + if updateSignal(node) then + local subs = node.subs + if subs then + shallowPropagate(subs) + end + end + end + + -- Register this signal as a dependency of the first subscriber in the + -- chain that is a computed signal or an effect + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(node, sub, cycle) + break + end + local subs = sub.subs + sub = if subs then subs.sub else nil + end + + return node.currentValue + end + + local function signalSetter(update: Update): T + local value: T + + -- Determine the new value based on whether the update is a function or + -- a direct value + if type(update) == "function" then + local prevSub = setActiveSub(nil) + local success, result = pcall(wrapUserSpace(update), node.pendingValue) + activeSub = prevSub + + if success then + value = result + else + error(result, 2) + end + else + value = update + end + + -- Check if the new value is different from the pending value + if if equals then not equals(node.pendingValue, value) else node.pendingValue ~= value then + if flags.frozen then + deepFreeze(value) + end + + node.pendingValue = value + node.flags = bit32.bor(MUTABLE, DIRTY) + + local subs = node.subs + if subs then + propagate(subs) + flush() + end + end + + return node.pendingValue + end + + return signalGetter, signalSetter +end + +--[[ + Creates a signal and returns a function that can act as both the getter and + setter. Calling the atom with one argument sets its value, while calling it + with no arguments returns its current value. + + @param initialValue Initial value for the atom + @param equals Optional comparator function to check for value changes + @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals +]] +local function atom(initialValue: T, equals: Equals?): Atom + local get, set = signal(initialValue, equals) + + local function atomOper(...) + return if select("#", ...) == 0 then get() else set((...)) + end + + return atomOper +end + +--[[ + Creates a new read-only signal that is computed based on the values of + other signals. The computed signal's value is automatically updated when a + change is detected in any of its dependencies. + + @param getter Function that calculates the next value + @return A function that returns the cached value + @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter +]] +local function computed(getter: (previousValue: T?) -> T): Getter + local node: ComputedNode = { + flags = NONE, + getter = wrapUserSpace(getter), + } + + local function computedOper(): T + local nodeFlags = node.flags + + if bit32.btest(nodeFlags, DIRTY) then + if updateComputed(node) then + local subs = node.subs + if subs then + shallowPropagate(subs) + end + end + elseif bit32.btest(nodeFlags, PENDING) then + if checkDirty(node.deps :: Link, node) then + if updateComputed(node) then + local subs = node.subs + if subs then + shallowPropagate(subs) + end + end + else + node.flags = bit32.band(nodeFlags, bit32.bnot(PENDING)) + end + elseif nodeFlags == NONE then + -- First access should initialize the computed value + node.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + + local prevSub = setActiveSub(node) + local success, result = pcall(node.getter) + activeSub = prevSub + + node.flags = bit32.band(node.flags, bit32.bnot(RECURSED_CHECK)) + + if success then + node.value = result + else + error(result, 2) + end + end + + if activeSub then + link(node, activeSub, cycle) + end + + return node.value + end + + return computedOper +end + +--[[ + Creates an effect that runs a callback in response to signal state changes. + An effect tracks which signals are accessed during its execution, and runs + the callback when those signals change. + + The effect callback may return a cleanup function, which gets called once, + either before the effect re-runs or when it is disposed. + + If the effect is called within another effect, it will be disposed when the + parent effect re-runs. + + @param fn Function to execute reactively + @return A cleanup function that stops the effect + @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback +]] +local function effect(fn: () -> ...Cleanup?): Cleanup + local node: EffectNode = { + fn = wrapUserSpace(fn), + flags = WATCHING, + cleanups = {}, + } + + -- Inner effects are tracked as a dependency of the parent effect, so that + -- they can be automatically cleaned up when the parent effect re-runs, and + -- to ensure the correct execution order when nested effects are notified + if activeSub and (flags.trackInnerEffects or activeSub.flags == NONE) then + link(node, activeSub, 0) + end + + -- Start a batch so that the effect can register a cleanup function before + -- it can receive notifications, ensuring the cleanup always runs + startBatch() + + local prevSub = setActiveSub(node) + local success, result = pcall(node.fn) + activeSub = prevSub + + if success and result then + table.insert(node.cleanups, wrapUserSpace(result)) + elseif not success then + endBatch() + error(result, 2) + end + + endBatch() + + return function() + stopEffect(node) + end +end + +--[[ + Creates an effect scope that can capture reactive effects and computed + signals created within it so that they can be disposed together. + + @param fn Function that creates effects within the scope + @param detached If true, the scope will not be tracked by the parent effect or scope + @return A cleanup function that stops all effects in the scope + @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached +]] +local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup + local node: EffectScopeNode = { + flags = NONE, + cleanups = {}, + } + + -- Inner effect scopes are tracked as a dependency of the parent effect + if activeSub and not detached then + link(node, activeSub, 0) + end + + local prevSub = setActiveSub(node) + local success, result = pcall(wrapUserSpace(fn)) + activeSub = prevSub + + if success and result then + table.insert(node.cleanups, wrapUserSpace(result)) + elseif not success then + error(result, 2) + end + + return function() + stopEffect(node) + end +end + +--[[ + Allows you to manually trigger updates for downstream dependencies when + you've directly mutated a signal's value without using the signal setter. + + @param fn A signal or function that accesses signals + @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback +]] +local function trigger(fn: () -> ...any) + local sub: ReactiveNode = { + flags = WATCHING, + } + + local prevSub = setActiveSub(sub) + local success, err = pcall(wrapUserSpace(fn)) + activeSub = prevSub + sub.flags = NONE + + local currentLink = sub.deps + while currentLink do + local dep = currentLink.dep + local subs = dep.subs + currentLink = unlink(currentLink, sub) + if subs then + propagate(subs) + shallowPropagate(subs) + end + end + + flush() + + if not success then + error(err, 2) + end +end + +--[[ + Binds the cleanup function to the current active effect or effect scope. If + there is no active effect, a warning is logged, unless `failSilently` is + set to `true`. + + @param fn Function to run during cleanup + @param failSilently If true, silences the warning when called outside an effect or scope + @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently +]] +local function onCleanup(fn: Cleanup, failSilently: boolean?) + if activeSub and (activeSub :: EffectNode).cleanups then + table.insert((activeSub :: EffectNode).cleanups, wrapUserSpace(fn)) + elseif not failSilently then + warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) + end +end + +--[[ + Runs the function without subscribing to signal updates and prevents the + current effect or scope from tracking inner effects created within the + function. + + To avoid tracking signals while still allowing inner effects to be tracked + by parent effects/scopes, use `effectScope()` instead. + + @param fn A signal or function that accesses signals + @param ...args Arguments to pass to the function + @return Values returned by the function + @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback +]] +local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... + if activeSub then + local prevSub = setActiveSub(nil) + local results: { any } = { pcall(wrapUserSpace(fn), ...) } + activeSub = prevSub + + if not results[1] then + error(results[2], 2) + end + + return unpack(results, 2) + else + return fn(...) + end +end + +--[[ + Combines multiple updates into a single "commit" that runs effects and + computed signals after the provided callback finishes running. + + @param fn Function that updates signals + @param ...args Arguments to pass to the function + @return Values returned by the function + @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback +]] +local function batch(fn: (Args...) -> Results..., ...: Args...): Results... + startBatch() + local results: { any } = { pcall(wrapUserSpace(fn), ...) } + endBatch() + + if not results[1] then + error(results[2], 2) + end + + return unpack(results, 2) +end + +--[[ + Returns true if the given function is a signal getter, computed signal, or + atom function. Used for determining whether a function should be wrapped in + a computed signal so that it can be memoized in `listen()` and `subscribe()`. +]] +local function isSignal(fn: Getter): boolean + local name = debug.info(fn, "n") + return name == "signalGetter" or name == "computedOper" or name == "atomOper" +end + +--[[ + Creates an effect that runs the callback once immediately, and then again + when the value returned by the getter changes. The callback receives the + new value and the previous value as arguments. + + @param getter A signal or function that accesses signals + @param callback Function to run when the value changes + @return A cleanup function that stops the effect + @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +]] +local function listen(getter: Getter, callback: (state: T, lastState: T?) -> ()): Cleanup + local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) + local current: T + + return effect(function() + local previous = current + current = tracked() + untracked(callback, current, previous) + end) +end + +--[[ + Creates an effect that only runs the callback when the value returned by + the getter changes. The callback receives the new value and the previous + value as arguments. + + @param getter A signal or function that accesses signals + @param callback Function to run when the value changes + @return A cleanup function that stops the effect + @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback +]] +local function subscribe(getter: Getter, callback: (state: T, lastState: T) -> ()): Cleanup + local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) + local current: T + local firstRun = true + + return effect(function() + local previous = current + current = tracked() + if firstRun then + firstRun = false + else + untracked(callback, current, previous) + end + end) +end + +--[[ + Calls the callback function for each unique key in the table returned by + the getter. The callback is called once for each key, and again when a new + key is added. + + The callback may return a cleanup function, which will be called when the + key is removed or the `observe()` effect is disposed. Effects created + during the callback will also be bound to the lifetime of the key. + + @param getter A signal or function that returns a table + @param callback Function that is called for each key added to the table + @return A cleanup function that disposes scopes for all keys + @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback +]] +local function observe(getter: Getter<{ [K]: V }>, callback: (item: V, key: K) -> ...Cleanup?): Cleanup + getter = wrapUserSpace(getter) + callback = wrapUserSpace(callback) + + local scopes: { [K]: Cleanup } = {} + local stopObserving = false + + local function updateScopes(data: { [K]: V }) + for key, dispose in scopes do + if data[key] == nil then + scopes[key] = nil + dispose() + if stopObserving then + return + end + end + end + + for key, value in data do + if scopes[key] == nil then + local dispose = effectScope(function() + return callback(value, key) + end, true) + if stopObserving then + dispose() + return + end + scopes[key] = dispose + end + end + end + + return effectScope(function() + effect(function() + -- Wrap in untracked so that calling fn inside updateScopes does + -- not register dependencies to this effect + untracked(updateScopes, getter()) + end) + + return function() + stopObserving = true + for key, dispose in scopes do + scopes[key] = nil + dispose() + end + end + end) +end + +--[[ + Creates a new read-only signal that is computed by mapping over the entries + of the source table. If the key is omitted from the transform function's + return value, the original key is preserved. + + This function is optimized to minimize unnecessary updates by only calling + the transform function for entries that have changed since the last run. + + @param getter A signal or function that returns a table + @param transform Function that maps each entry from the source to a new entry in the mapped table + @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform +]] +local function mapped(getter: Getter<{ [KI]: VI }>, transform: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> + getter = wrapUserSpace(getter) + transform = wrapUserSpace(transform) + + local nextInput: { [KI]: VI } = {} + local keyMap: { [KI]: KO } = {} + + return computed(function(lastOutput: { [KO]: VO }?): { [KO]: VO } + local nextOutput: { [KO]: VO } = lastOutput or {} + local prevInput = nextInput + nextInput = getter() + + -- For keys that were removed from the input, remove the corresponding + -- keys from the output. + for keyIn in prevInput do + if nextInput[keyIn] ~= nil then + continue + end + + local keyOut = keyMap[keyIn] + + if keyOut ~= nil then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) + end + nextOutput[keyOut] = nil + keyMap[keyIn] = nil + end + end + + -- For keys that are new or changed, pass it to the mapper and update + -- the output. If the mapper returns a new key that is different from + -- the previous call, the old mapped key should be removed from the + -- output. + for keyIn, nextValueIn in nextInput do + if nextValueIn == prevInput[keyIn] then + continue + end + + local nextValueOut, keyOutOrNil = transform(nextValueIn, keyIn) + local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn + local prevKeyOut = keyMap[keyIn] + + if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) + end + nextOutput[prevKeyOut] = nil + nextOutput[nextKeyOut] = nextValueOut + keyMap[keyIn] = nextKeyOut + elseif nextOutput[nextKeyOut] ~= nextValueOut then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) + end + nextOutput[nextKeyOut] = nextValueOut + keyMap[keyIn] = nextKeyOut + end + end + + return nextOutput + end) +end + +createReactiveSystem(update, notify, unwatched) return { + ReactiveFlags = system.ReactiveFlags, + flags = flags, atom = atom, + signal = signal, computed = computed, effect = effect, - mapped = mapped, - observe = observe, + effectScope = effectScope, + trigger = trigger, + untracked = untracked, + batch = batch, subscribe = subscribe, - batch = store.batch, - capture = store.capture, - isAtom = store.isAtom, - notify = store.notify, - peek = store.peek, + listen = listen, + observe = observe, + mapped = mapped :: (( + getter: Getter<{ [KI]: VI }>, + transform: (VI, KI) -> (VO, KO) + ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, transform: (VI, K) -> VO) -> Getter<{ [K]: VO }>), + onCleanup = onCleanup, + getActiveSub = getActiveSub, + setActiveSub = setActiveSub, + startBatch = startBatch, + endBatch = endBatch, } diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau deleted file mode 100644 index 555aa3f..0000000 --- a/packages/charm/src/mapped.luau +++ /dev/null @@ -1,71 +0,0 @@ -local atom = require(script.Parent.atom) -local store = require(script.Parent.store) -local subscribe = require(script.Parent.subscribe) -local types = require(script.Parent.types) -type Atom = types.Atom -type Selector = types.Selector - -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 }>) - ---[=[ - 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 - - local function listener(items: { [K0]: V0 }) - local mappedAtom = mappedAtomRef.current - - if not mappedAtom then - return unsubscribe() - end - - local mappedItems = table.clone(mappedAtom()) - local mappedKeys = {} - - -- TODO: Only call mapper if the item has changed. - for key, item in next, items do - local newItem, newKey = mapper(item, key) - if newKey == nil then - newKey = key :: any - end - if mappedItems[newKey :: K1] ~= newItem then - mappedItems[newKey :: K1] = newItem :: V1 - else - mappedKeys[newKey] = key - end - end - - for key in next, prevMappedItems do - if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then - mappedItems[key] = nil - end - end - - prevMappedItems = mappedItems - mappedAtom(mappedItems) - end - - unsubscribe = subscribe(callback, listener) - - store.peek(function(): () - listener(callback()) - end) - - return mappedAtom -end - -return mapped :: Map diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau deleted file mode 100644 index 120ae3b..0000000 --- a/packages/charm/src/observe.luau +++ /dev/null @@ -1,50 +0,0 @@ -local store = require(script.Parent.store) -local subscribe = require(script.Parent.subscribe) -local types = require(script.Parent.types) -type Selector = types.Selector - -local function noop() end - ---[=[ - Creates an instance of `factory` for each item in the atom's state, and - cleans up the instance when the item is removed. Returns a cleanup function - that unsubscribes all instances. - - @param callback The atom or selector to observe. - @param factory The function that tracks the lifecycle of each item. - @return A function that unsubscribes all instances. -]=] -local function observe(callback: Selector<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> () - local connections: { [K]: () -> () } = {} - - local function listener(state: { [K]: V }) - for key, disconnect in next, connections do - if state[key] == nil then - connections[key] = nil - disconnect() - end - end - - for key, value in next, state do - if not connections[key] then - connections[key] = factory(value, key) or noop - end - end - end - - local unsubscribe = subscribe(callback, listener) - - store.peek(function(): () - listener(callback()) - end) - - return function() - unsubscribe() - for _, disconnect in next, connections do - disconnect() - end - table.clear(connections) - end -end - -return observe 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 deleted file mode 100644 index 0286d68..0000000 --- a/packages/charm/src/subscribe.luau +++ /dev/null @@ -1,43 +0,0 @@ -local store = require(script.Parent.store) -local types = require(script.Parent.types) -type Selector = types.Selector - ---[=[ - 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 function handler() - local prevState = state - - store.disconnect(dependencies, handler) - dependencies, state = store.capture(callback) - - if not disconnected then - store.connect(dependencies, handler) - end - - if state ~= prevState then - listener(state, prevState) - end - end - - store.connect(dependencies, handler) - - return function() - if not disconnected then - disconnected = true - store.disconnect(dependencies, handler) - end - end -end - -return subscribe diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau new file mode 100644 index 0000000..a01aa5e --- /dev/null +++ b/packages/charm/src/system.luau @@ -0,0 +1,237 @@ +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/system.ts +]] + +export type ReactiveNode = { + deps: Link?, + depsTail: Link?, + subs: Link?, + subsTail: Link?, + flags: ReactiveFlags, +} + +export type Link = { + version: number, + dep: ReactiveNode, + sub: ReactiveNode, + prevSub: Link?, + nextSub: Link?, + prevDep: Link?, + nextDep: Link?, +} + +type Stack = { + value: T, + prev: Stack?, +} + +export type ReactiveFlags = number + +local NONE = 0b000000 +local MUTABLE = 0b000001 +local WATCHING = 0b000010 +local RECURSED_CHECK = 0b000100 +local RECURSED = 0b001000 +local DIRTY = 0b010000 +local PENDING = 0b100000 + +local ReactiveFlags = table.freeze({ + None = NONE, + Mutable = MUTABLE, + Watching = WATCHING, + RecursedCheck = RECURSED_CHECK, + Recursed = RECURSED, + Dirty = DIRTY, + Pending = PENDING, +}) + +local update: (sub: ReactiveNode) -> boolean +local notify: (sub: ReactiveNode) -> () +local unwatched: (sub: ReactiveNode) -> () + +local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean + local link = sub.depsTail + while link do + if link == checkLink then + return true + end + link = link.prevDep + end + return false +end + +local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) + local prevDep = sub.depsTail + if prevDep and prevDep.dep == dep then + return + end + local nextDep = if prevDep then prevDep.nextDep else sub.deps + if nextDep and nextDep.dep == dep then + nextDep.version = version + sub.depsTail = nextDep + return + end + local prevSub = dep.subsTail + if prevSub and prevSub.version == version and prevSub.sub == sub then + return + end + local newLink: Link = { + version = version, + dep = dep, + sub = sub, + prevDep = prevDep, + nextDep = nextDep, + prevSub = prevSub, + } + sub.depsTail = newLink + dep.subsTail = newLink + if nextDep then + nextDep.prevDep = newLink + end + if prevDep then + prevDep.nextDep = newLink + else + sub.deps = newLink + end + if prevSub then + prevSub.nextSub = newLink + else + dep.subs = newLink + end +end + +local function unlink(link: Link, sub: ReactiveNode?): Link? + sub = sub or link.sub + local dep = link.dep + local prevDep = link.prevDep + local nextDep = link.nextDep + local nextSub = link.nextSub + local prevSub = link.prevSub + if nextDep then + nextDep.prevDep = prevDep + else + sub.depsTail = prevDep + end + if prevDep then + prevDep.nextDep = nextDep + else + sub.deps = nextDep + end + if nextSub then + nextSub.prevSub = prevSub + else + dep.subsTail = prevSub + end + if prevSub then + prevSub.nextSub = nextSub + else + dep.subs = nextSub + if not nextSub then + unwatched(dep) + end + end + return nextDep +end + +local function propagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + + if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then + sub.flags = bit32.bor(flags, PENDING) + elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then + flags = NONE + elseif bit32.band(flags, RECURSED_CHECK) == 0 then + sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) + elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then + sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) + flags = bit32.band(flags, MUTABLE) + else + flags = NONE + end + + if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then + notify(sub) + end + + if bit32.btest(flags, MUTABLE) then + local subSubs = sub.subs + if subSubs then + propagate(subSubs) + end + end + + link = link.nextSub :: Link + until not link +end + +local function shallowPropagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then + sub.flags = bit32.bor(flags, DIRTY) + if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then + notify(sub) + end + end + link = link.nextSub :: Link + until not link +end + +local function checkDirty(link: Link, sub: ReactiveNode): boolean + repeat + local dep = link.dep + local depFlags = dep.flags + + if bit32.btest(sub.flags, DIRTY) then + return true + elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then + if update(dep) then + local subs = dep.subs :: Link + if subs.nextSub then + shallowPropagate(subs) + end + return true + end + elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then + if checkDirty(dep.deps :: Link, dep) then + if update(dep) then + local subs = dep.subs :: Link + if subs.nextSub then + shallowPropagate(subs) + end + return true + end + else + dep.flags = bit32.band(depFlags, bit32.bnot(PENDING)) + end + end + + link = link.nextDep :: Link + until not link + + return false +end + +local function createReactiveSystem( + updateFn: (sub: ReactiveNode) -> boolean, + notifyFn: (sub: ReactiveNode) -> (), + unwatchedFn: (sub: ReactiveNode) -> () +) + update = updateFn + notify = notifyFn + unwatched = unwatchedFn +end + +return { + ReactiveFlags = ReactiveFlags, + link = link, + unlink = unlink, + propagate = propagate, + checkDirty = checkDirty, + shallowPropagate = shallowPropagate, + createReactiveSystem = createReactiveSystem, +} diff --git a/packages/charm/src/types.luau b/packages/charm/src/types.luau deleted file mode 100644 index fd3ba6b..0000000 --- a/packages/charm/src/types.luau +++ /dev/null @@ -1,43 +0,0 @@ -export type Set = { [T]: true } - -export type WeakMap = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" })) - ---[=[ - A primitive state container that can be read from and written to. When the - state changes, all subscribers are notified. - - @param state The next state or a function that produces the next state. - @return The current state, if no arguments are provided. -]=] -export type Atom = (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, -} - ---[=[ - 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 diff --git a/packages/charm/test/computed.test.luau b/packages/charm/test/computed.test.luau new file mode 100644 index 0000000..c0095cf --- /dev/null +++ b/packages/charm/test/computed.test.luau @@ -0,0 +1,220 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local signal = Charm.signal + +test("should correctly propagate changes through computed core", function() + local src, setSrc = signal(0) + local c1 = computed(function() + return src() % 2 + end) + local c2 = computed(function() + return c1() + end) + local c3 = computed(function() + return c2() + end) + + c3() + setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty + c2() -- c1 -> none, c2 -> none + setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty + + assert(c3() == 1, "bad final value") +end) + +test("should propagate updated source value through chained computations", function() + local src, setSrc = signal(0) + local a = computed(function() + return src() + end) + local b = computed(function() + return a() % 2 + end) + local c = computed(function() + return src() + end) + local d = computed(function() + return b() + c() + end) + + assert(d() == 0, "bad initial value") + setSrc(2) + assert(d() == 2, "bad updated value") +end) + +test("should handle flags are indirectly updated during checkDirty", function() + local a, setA = signal(false) + local b = computed(function() + return a() + end) + local c = computed(function() + b() + return 0 + end) + local d = computed(function() + c() + return b() + end) + + assert(d() == false, "bad initial value") + setA(true) + assert(d() == true, "bad updated value") +end) + +test("should not update if the signal value is reverted", function() + local times = 0 + + local src, setSrc = signal(0) + local c1 = computed(function() + times += 1 + return src() + end) + c1() + assert(times == 1, "computed did not run") + setSrc(1) + setSrc(0) + c1() + assert(times == 1, "computed ran when it should not") +end) + +test("should receive previous value", function() + local calls = {} + local src, setSrc = signal(0) + local c1 = computed(function(current: number?): number + local incoming = src() + return if current == incoming or incoming % 2 == 1 then current or incoming else incoming + end) + + effect(function() + table.insert(calls, c1()) + end) + + setSrc(2) + setSrc(3) + assert(c1() == 2, "should skip odd value") + + setSrc(4) + setSrc(5) + assert(c1() == 4, "should skip odd value") + assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L875 +test("should chained computeds keep reactivity when computed effect happens", function() + local v = atom("Hello") + local c = computed(function() + v(v() .. " World") + return v() + end) + local d = computed(function() + return c() + end) + local e = computed(function() + return d() + end) + local res = computed(function() + return d() .. " | " .. e() + end) + + assert(res() == "Hello World | Hello World", res()) + + v(v() .. " World") + assert(res() == "Hello World World World World | Hello World World World World", res()) +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L580 +test("should not trigger effect scheduler by recursive computed effect", function() + local v = atom("Hello") + local c = computed(function() + v(v() .. " World") + return v() + end) + local comp = computed(function() + return c() + end) + + assert(comp() == "Hello World", comp()) + + v(v() .. " World") + assert(comp() == "Hello World World World World", comp()) +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528 +test("should not override queried MaybeDirty result", function() + local v1 = atom(nil :: any) + local v2 = atom(false) + local c1 = computed(function() + local c = v1() + if not v1() then + c = { v = atom(0) } + v1(c) + end + return c.v() + end) + local c2 = computed(function() + if not v2() then + return "no" + end + return if c1() then "yes" else "no" + end) + local c3 = computed(function() + return c2() + end) + + c3() + v2(true) + + c3() + v1().v(999) + + assert(c3() == "yes", "should return correct value after update") +end) + +-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L503 +test("should trigger effect even computed already dirty", function() + local calls: { string } = {} + local v = atom(0) + local c1 = computed(function() + if v() == 0 then + v(1) + end + return "foo" + end) + local c2 = computed(function() + return v() .. c1() + end) + + effect(function() + table.insert(calls, c2()) + end) + assert(table.concat(calls, ",") == "0foo", table.concat(calls, ",")) + assert(v() == 1, `expected 1 for v(), got {v()}`) + v(2) + assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ",")) + assert(v() == 2, `expected 2 for v(), got {v()}`) +end) + +test("should handle inner effect clearing outer computed's deps on rerun", function() + local src, setSrc = signal(false) + local stopEffect: () -> () + local c = computed(function() + src() + effect(function() + return function() + stopEffect() + end + end) + effect(function() end) + return 0 + end) + stopEffect = effect(function() + c() + end) + setSrc(true) +end) + +return {} diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau new file mode 100644 index 0000000..c94ec51 --- /dev/null +++ b/packages/charm/test/effect.test.luau @@ -0,0 +1,576 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local endBatch = Charm.endBatch +local onCleanup = Charm.onCleanup +local setActiveSub = Charm.setActiveSub +local signal = Charm.signal +local startBatch = Charm.startBatch + +test("should clear subscriptions when untracked by all subscribers", function() + local bRunTimes = 0 + + local a, setA = signal(1) + local b = computed(function() + bRunTimes += 1 + return a() * 2 + end) + local stopEffect = effect(function() + b() + end) + + assert(bRunTimes == 1, "should run initially") + setA(2) + assert(bRunTimes == 2, "should run after change") + stopEffect() + setA(3) + assert(bRunTimes == 2, "should not run after stop") +end) + +test("should not run untracked inner effect", function() + local a, setA = signal(3) + local b = computed(function() + return a() > 0 + end) + + effect(function() + if b() then + effect(function() + if a() == 0 then + error("bad") + end + end) + end + end) + + setA(2) + setA(1) + setA(0) +end) + +test("should run outer effect first", function() + local a, setA = signal(1) + local b, setB = signal(1) + + effect(function() + if a() > 0 then + effect(function() + b() + if a() == 0 then + error("bad") + end + end) + end + end) + + startBatch() + setB(0) + setA(0) + endBatch() +end) + +test("should not trigger inner effect when resolve maybe dirty", function() + local a, setA = signal(0) + local b = computed(function() + return a() % 2 + end) + + local innerTriggerTimes = 0 + + effect(function() + effect(function() + b() + innerTriggerTimes += 1 + if innerTriggerTimes >= 2 then + error("bad") + end + end) + end) + + setA(2) +end) + +test("should notify inner effects in the same order as non-inner effects", function() + local getA, setA = signal(0) + local getB, setB = signal(0) + local c = computed(function() + return getA() - getB() + end) + local order1 = {} + local order2 = {} + local order3 = {} + + effect(function() + table.insert(order1, "effect1") + getA() + end) + effect(function() + table.insert(order1, "effect2") + getA() + getB() + end) + + effect(function() + c() + effect(function() + table.insert(order2, "effect1") + getA() + end) + effect(function() + table.insert(order2, "effect2") + getA() + getB() + end) + end) + + effectScope(function() + effect(function() + table.insert(order3, "effect1") + getA() + end) + effect(function() + table.insert(order3, "effect2") + getA() + getB() + end) + end) + + table.clear(order1) + table.clear(order2) + table.clear(order3) + + startBatch() + setB(1) + setA(1) + endBatch() + + assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1") + assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2") + assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3") +end) + +test("should custom effect support batch", function() + local function batchEffect(fn: () -> ()) + return effect(function() + startBatch() + local success, result: unknown = pcall(fn) + endBatch() + if not success then + error(result) + end + end) + end + + local logs: { string } = {} + local a = signal(0) + local b, setB = signal(0) + + local aa = computed(function() + table.insert(logs, "aa-0") + if a() == 0 then + setB(1) + end + table.insert(logs, "aa-1") + return nil + end) + + local bb = computed(function() + table.insert(logs, "bb") + return b() + end) + + batchEffect(function() + bb() + end) + batchEffect(function() + aa() + end) + + assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`) +end) + +test("should duplicate subscribers do not affect the notify order", function() + local src1, setSrc1 = signal(0) + local src2, setSrc2 = signal(0) + local order: { string } = {} + + effect(function() + table.insert(order, "a") + local currentSub = setActiveSub(nil) + local isOne = src2() == 1 + setActiveSub(currentSub) + if isOne then + src1() + end + src2() + src1() + end) + effect(function() + table.insert(order, "b") + src1() + end) + setSrc2(1) -- src1.subs: a -> b -> a + + table.clear(order) + setSrc1(src1() + 1) + + assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) +end) + +test("should handle side effect with inner effects", function() + local a, setA = signal(0) + local b, setB = signal(0) + local order: { string } = {} + + effect(function() + effect(function() + a() + table.insert(order, "a") + end) + effect(function() + b() + table.insert(order, "b") + end) + assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) + + table.clear(order) + setB(1) + setA(1) + end) + + -- DEVIATION: This assertion was moved outside of the effect so that it + -- runs after the batch updates have been processed + assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) +end) + +test("should handle flags are indirectly updated during checkDirty", function() + local a, setA = signal(false) + local b = computed(function() + return a() + end) + local c = computed(function() + b() + return 0 + end) + local d = computed(function() + c() + return b() + end) + + local triggers = 0 + + effect(function() + d() + triggers += 1 + end) + assert(triggers == 1, "did not run initially") + setA(true) + assert(triggers == 2, "did not update indirectly") +end) + +test("should not execute skipped effects from previous failed flush when updating unrelated signal", function() + local a, setA = signal(0) + local b, setB = signal(0) + local c, setC = signal(0) + local d = computed(function() + c() + return 0 + end) + + local effect3Executed = false + + effect(function() + a() + end) + effect(function() + if a() == 2 then + error("Error in effect 2") + end + end) + effect(function() + a() + d() + effect3Executed = true + end) + effect(function() + b() + end) + + setA(1) + + effect3Executed = false + local success, err = pcall(setA, 2) + assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`) + + assert(not effect3Executed, "effect 3 should not have executed after failed flush") + setB(1) + assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b") + setC(1) + assert(effect3Executed, "effect 3 should have executed after updating signal c") +end) + +test("should allow finite recursion", function() + local src, setSrc = signal(0) + local order = {} + + effect(function() + table.insert(order, src()) + setSrc(math.min(src() + 1, 5)) + end) + assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`) +end) + +test("should run cleanups in sequence", function() + local order = {} + + local a, setA = signal(false) + local stopEffect = effect(function() + a() + onCleanup(function() + table.insert(order, "a") + end) + onCleanup(function() + table.insert(order, "b") + end) + end) + + assert(table.concat(order) == "", "should not cleanup immediately") + + setA(true) + assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`) + + table.clear(order) + stopEffect() + assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`) + + table.clear(order) + setA(false) + assert(table.concat(order) == "", "should not cleanup after update") +end) + +test("should run inner effect cleanup", function() + local cleanups = 0 + + local src, setSrc = signal(false) + local stopEffect = effect(function() + src() + effect(function() + onCleanup(function() + cleanups += 1 + end) + end) + effect(function() + onCleanup(function() + cleanups += 1 + end) + end) + end) + + assert(cleanups == 0, "should not cleanup immediately") + setSrc(true) + assert(cleanups == 2, "should cleanup after outer update") + stopEffect() + assert(cleanups == 4, "should cleanup after outer dispose") + setSrc(false) + assert(cleanups == 4, "should not cleanup after update") +end) + +test("should run cleanups outside any evaluation context", function() + local calls = 0 + local a, setA = signal(0) + local b, setB = signal(0) + local c = computed(function() + if a() == 0 then + effect(function() + onCleanup(function() + b() + end) + end) + end + return a() + end) + + effect(function() + calls += 1 + c() + end) + assert(calls == 1, "should run once") + + calls = 0 + setA(1) + assert(calls == 1, "should run once after a update") + + calls = 0 + setB(1) + assert(calls == 0, "should not run after b update") +end) + +test("should not retrigger the effect if the cleanup modifies one of the dependencies", function() + local a, setA = signal(0) + local calls = {} + + effect(function() + table.insert(calls, a()) + return function() + setA(2) + end + end) + assert(#calls == 1, "should have run once initially") + table.clear(calls) + + setA(1) + assert(#calls == 1, "should run once after an update") + assert(calls[1] == 2, `bad call: {calls[1]}`) +end) + +test("should run the cleanup if the effect disposes itself", function() + local a, setA = signal(0) + local calls = 0 + local dispose: () -> () + + dispose = effect(function() + if a() > 0 then + dispose() + return function() + calls += 1 + end + end + end) + assert(calls == 0, "should not have called cleanup initially") + setA(1) + assert(calls == 1, "should have called cleanup once after first update") + setA(2) + assert(calls == 1, "should not call cleanup again after second update") +end) + +test("should not run the effect if the cleanup function disposes it", function() + local a, setA = signal(0) + local calls = 0 + local dispose: () -> () + + dispose = effect(function() + a() + calls += 1 + return function() + dispose() + end + end) + assert(calls == 1, "should have run effect once initially") + setA(1) + assert(calls == 1, "should not run effect again after update") +end) + +test("should reset the cleanup if the effect throws", function() + local a, setA = signal(0) + local calls = 0 + + effect(function() + if a() == 0 then + return function() + calls += 1 + end + else + error("hello") + end + end) + assert(calls == 0, "should not have called cleanup initially") + local success, err = pcall(setA, 1) + assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`) + assert(calls == 1, "should have called cleanup once after throwing") + setA(0) + assert(calls == 1, "should not call cleanup again after resetting value") +end) + +test("recursive effects should run and cleanup in order", function() + local a, setA = signal(0) + local order: { string } = {} + + effect(function() + local value = a() + table.insert(order, `+{value}`) + if value < 3 then + setA(value + 1) + end + return function() + table.insert(order, `-{value}`) + end + end) + assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`) + table.clear(order) + setA(100) + assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`) +end) + +test("should effects dispose in order", function() + local order: { number } = {} + + local src, setSrc = signal(false) + local stopEffect = effect(function() + src() + for i = 1, 3 do + effect(function() + return function() + table.insert(order, i) + end + end) + end + end) + + assert(#order == 0, "should not cleanup immediately") + setSrc(true) + assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`) + table.clear(order) + stopEffect() + assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`) + table.clear(order) + setSrc(false) + assert(#order == 0, "should not cleanup after dispose") +end) + +test("should handle inner effect clearing outer effect's deps on rerun", function() + local src, setSrc = signal(false) + local stopEffect: () -> () + stopEffect = effect(function() + src() + effect(function() + return function() + stopEffect() + end + end) + effect(function() end) + end) + setSrc(true) +end) + +test.skip("should handle inner effect cleanup disposing outer effect", function() + local order: { number } = {} + + local src, setSrc = signal(false) + local stopEffect: () -> () + stopEffect = effect(function() + src() + table.insert(order, 0) + for i = 1, 4 do + effect(function() + return function() + table.insert(order, i) + stopEffect() + end + end) + end + end) + assert(table.concat(order) == "0", "should not cleanup immediately") + + table.clear(order) + setSrc(true) + assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`) + + table.clear(order) + setSrc(false) + assert(#order == 0, "should not cleanup after dispose") +end) + +return {} diff --git a/packages/charm/test/effectScope.test.luau b/packages/charm/test/effectScope.test.luau new file mode 100644 index 0000000..964b7d5 --- /dev/null +++ b/packages/charm/test/effectScope.test.luau @@ -0,0 +1,95 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local effect = Charm.effect +local effectScope = Charm.effectScope +local signal = Charm.signal +local onCleanup = Charm.onCleanup + +test("should not trigger after stop", function() + local count, setCount = signal(1) + + local triggers = 0 + + local stopScope = effectScope(function() + effect(function() + triggers += 1 + count() + end) + assert(triggers == 1, "did not run initially") + + setCount(2) + assert(triggers == 2, "did not run after update") + end) + + setCount(3) + assert(triggers == 3, "did not run after update outside scope") + stopScope() + setCount(4) + assert(triggers == 3, "ran after stop") +end) + +test("should run indirect scope cleanup", function() + local cleanups = 0 + local cleanup: () -> () + + local stopScope = effectScope(function() + cleanup = effectScope(function() + onCleanup(function() + cleanups += 1 + end) + end) + end) + + assert(cleanups == 0, "should not cleanup immediately") + stopScope() + assert(cleanups == 1, "should cleanup from parent dispose") + cleanup() + assert(cleanups == 1, "should skip redundant dispose") +end) + +test("should run indirect effect cleanup", function() + local cleanups = 0 + local a, setA = signal(false) + + local stopScope = effectScope(function() + effect(function() + a() + onCleanup(function() + cleanups += 1 + end) + end) + end) + + assert(cleanups == 0, "should not cleanup immediately") + setA(true) + assert(cleanups == 1, "should cleanup once") + stopScope() + assert(cleanups == 2, "should cleanup after outer dispose") + setA(false) + assert(cleanups == 2, "should not cleanup after update") +end) + +test("should dispose inner effects if created in an effect", function() + local source, setSource = signal(1) + local triggers = 0 + local dispose: () -> () + + effect(function() + dispose = effectScope(function() + effect(function() + source() + triggers += 1 + end) + end) + assert(triggers == 1, "did not run initially") + end) + + setSource(2) + assert(triggers == 2, "did not run after update") + dispose() + setSource(3) + assert(triggers == 2, "ran after dispose") +end) + +return {} diff --git a/packages/charm/test/errors.test.luau b/packages/charm/test/errors.test.luau new file mode 100644 index 0000000..151a0d5 --- /dev/null +++ b/packages/charm/test/errors.test.luau @@ -0,0 +1,200 @@ +--!optimize 1 + +local Charm = require("../src") +local test = require("../../../tests/test") + +test("should disallow yielding", function() + local success = pcall(function() + Charm.effectScope(function() + coroutine.yield() + end) + end) + assert(not success, "should error on yield") +end) + +test.describe("traceback", function() + local DEFAULT_ERROR = "Test error" + + local function fail(message: string?) + error(message or DEFAULT_ERROR, 0) + end + + local function indirectError(message: string?) + fail(message) + end + + local function assertStackTrace(err: unknown, expectation: string?) + expectation = expectation or DEFAULT_ERROR + assert(type(err) == "string", "Expected error to be a string") + assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`) + assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`) + assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`) + end + + test("signal update", function() + local _, err = pcall(function() + local _, setSrc = Charm.signal(0) + setSrc(function() + indirectError() + return 1 + end) + end) + assertStackTrace(err) + end) + + test("atom update", function() + local _, err = pcall(function() + local src = Charm.atom(0) + src(function() + indirectError() + return 1 + end) + end) + assertStackTrace(err) + end) + + test("computed", function() + local _, err = pcall(function() + local src = Charm.computed(function() + indirectError() + end) + src() + end) + assertStackTrace(err) + end) + + test("effect init", function() + local _, err = pcall(function() + Charm.effect(function() + indirectError() + end) + end) + assertStackTrace(err) + end) + + test("effect run", function() + local _, err = pcall(function() + local src, setSrc = Charm.signal(0) + Charm.effect(function() + if src() == 1 then + indirectError() + end + end) + setSrc(1) + end) + assertStackTrace(err) + end) + + test("effect cleanups", function() + local _, err = pcall(function() + local dispose = Charm.effect(function() + Charm.onCleanup(function() + indirectError("onCleanup") + end) + return function() + indirectError("returned cleanup") + end + end) + dispose() + end) + assertStackTrace(err, "onCleanup") + assertStackTrace(err, "returned cleanup") + end) + + test("trigger", function() + local _, err = pcall(function() + Charm.trigger(function() + indirectError() + end) + end) + assertStackTrace(err) + end) + + test("untracked", function() + local _, err = pcall(function() + Charm.effectScope(function() + Charm.untracked(function() + indirectError() + end) + end) + end) + assertStackTrace(err) + end) + + test("batch", function() + local _, err = pcall(function() + Charm.batch(function() + indirectError() + end) + end) + assertStackTrace(err) + end) + + test("subscribe getter", function() + local _, err = pcall(function() + Charm.subscribe(function() + indirectError() + end, function() end) + end) + assertStackTrace(err) + end) + + test("subscribe callback", function() + local _, err = pcall(function() + local src, setSrc = Charm.signal(0) + Charm.subscribe(src, function() + indirectError() + end) + setSrc(1) + end) + assertStackTrace(err) + end) + + test("listen getter", function() + local _, err = pcall(function() + Charm.listen(function() + indirectError() + end, function() end) + end) + assertStackTrace(err) + end) + + test("listen callback", function() + local _, err = pcall(function() + local src, setSrc = Charm.signal(0) + Charm.listen(src, function() + indirectError() + end) + setSrc(1) + end) + assertStackTrace(err) + end) + + test("mapped getter", function() + local _, err = pcall(function() + local src = Charm.mapped(function() + indirectError() + return { 1 } + end, function() + return 1 + end) + src() + end) + assertStackTrace(err) + end) + + test("mapped transform", function() + local _, err = pcall(function() + local src = Charm.mapped(function() + return { 1 } + end, function() + indirectError() + return 1 + end) + src() + end) + assertStackTrace(err) + end) +end) + +return {} diff --git a/packages/charm/test/gc.test.luau b/packages/charm/test/gc.test.luau new file mode 100644 index 0000000..aed0af1 --- /dev/null +++ b/packages/charm/test/gc.test.luau @@ -0,0 +1,65 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope + +local function weakRef(value: T): { value: T? } + return setmetatable({ value = value }, { __mode = "v" }) :: any +end + +local function gc() + for _ = 1, 2000 do + local _ = table.create(100, 0) + end +end + +--[[ + In alien-signals, a computed value unlinks from dependencies when its last + parent scope is disposed. However, calling a computed value outside of a + scope still links it to dependencies, which act as strong references. + Computed values called in this way won't GC until unlinked from a scope. + + This function releases a computed value by linking it to a scope and then + immediately closing it, causing alien-signals to unlink the computed value + from its dependencies and allow it to be garbage collected. +]] +local function unlink(getter: () -> unknown) + effectScope(function() + getter() + end)() +end + +test("should release computed cache", function() + local srcRef = weakRef({}) + local src, setSrc = signal(srcRef.value) + local c = computed(function() + return src() + end) + + c() -- cache src value + setSrc(nil) -- release value + unlink(c) -- release computed + + gc() + assert(srcRef.value == nil, "should release computed cache") +end) + +test("should not release effect for atom", function() + local effectCalls = 0 + local src, setSrc = signal(0) + + effect(function() + effectCalls += 1 + src() + end) + assert(effectCalls == 1, "should call effect once") + + gc() + setSrc(src() + 1) + assert(effectCalls == 2, "should call effect again") +end) + +return {} diff --git a/packages/charm/test/init.luau b/packages/charm/test/init.luau new file mode 100644 index 0000000..52da435 --- /dev/null +++ b/packages/charm/test/init.luau @@ -0,0 +1,55 @@ +local test = require("../../tests/test") + +test.describe("computed", function() + require("@self/computed.test") +end) + +test.describe("effect", function() + require("@self/effect.test") +end) + +test.describe("effectScope", function() + require("@self/effectScope.test") +end) + +test.describe("error", function() + require("@self/errors.test") +end) + +test.describe("gc", function() + require("@self/gc.test") +end) + +test.describe("listen", function() + require("@self/listen.test") +end) + +test.describe("mapped", function() + require("@self/mapped.test") +end) + +test.describe("observe", function() + require("@self/observe.test") +end) + +test.describe("signal", function() + require("@self/signal.test") +end) + +test.describe("subscribe", function() + require("@self/subscribe.test") +end) + +test.describe("topology", function() + require("@self/topology.test") +end) + +test.describe("trigger", function() + require("@self/trigger.test") +end) + +test.describe("untracked", function() + require("@self/untracked.test") +end) + +return {} diff --git a/packages/charm/test/listen.test.luau b/packages/charm/test/listen.test.luau new file mode 100644 index 0000000..5d9c335 --- /dev/null +++ b/packages/charm/test/listen.test.luau @@ -0,0 +1,77 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local listen = Charm.listen + +test("should listen to selector", function() + local source, setSource = signal(0) + local current, previous + + local unsub = listen(function() + return source() + 1 + end, function(a, b) + current = a + previous = b + end) + + assert(current == 1 and previous == nil, "should run initially") + setSource(0) + assert(current == 1 and previous == nil, "should skip redundant update") + setSource(1) + assert(current == 2 and previous == 1, "should run with new value") + unsub() + setSource(2) + assert(current == 2 and previous == 1, "should not run after unsub") +end) + +test("should run in untracked", function() + local runs = 0 + local a, setA = signal(0) + local b, setB = signal(0) + + listen(a, function() + runs += 1 + b() + end) + + assert(runs == 1, "should run once on init") + setA(1) + assert(runs == 2, "should run once on change") + setB(1) + assert(runs == 2, "should not run on b change") + setA(2) + assert(runs == 3, "should run on a change") +end) + +test("should run if first value is nil", function() + local runs = 0 + local source, setSource = signal(nil :: number?) + + listen(source, function() + runs += 1 + end) + + assert(runs == 1, "should run once on init") + setSource(1) + assert(runs == 2, "should run once on change") +end) + +test("should skip redundant updates", function() + local order = {} + local a, setA = signal(0) + + listen(function(): "even" | "odd" + return if a() % 2 == 0 then "even" else "odd" + end, function(value) + table.insert(order, value) + end) + + setA(1) + setA(3) + setA(4) + setA(6) + assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`) +end) + +return {} diff --git a/packages/charm/test/mapped.test.luau b/packages/charm/test/mapped.test.luau new file mode 100644 index 0000000..33620d4 --- /dev/null +++ b/packages/charm/test/mapped.test.luau @@ -0,0 +1,128 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local mapped = Charm.mapped + +test("should map state", function() + local src, setSrc = signal({ 1, 2, 3 }) + local map = mapped(src, function(value: number) + return value + 1 + end) + + assert(table.concat(map()) == "234", table.concat(map())) + setSrc({ 4, 5, 6 }) + assert(table.concat(map()) == "567", table.concat(map())) +end) + +test("should not map unchanged values", function() + local calls = 0 + + local src, setSrc = signal({ 1, 2, 3 }) + local map = mapped(src, function(value: number) + calls += 1 + return value + 1 + end) + + assert(table.concat(map()) == "234", table.concat(map())) + assert(calls == 3, "should map 3 values") + + setSrc({ 1, 3, 3 }) + assert(table.concat(map()) == "244", table.concat(map())) + assert(calls == 4, "should map 1 change") + + setSrc({}) + assert(#map() == 0, "should be empty") + assert(calls == 4, "should not map for removed values") +end) + +test("should not update if no values change", function() + local calls = 0 + + local src, setSrc = signal({ 1, 2, 3 }) + local map = mapped(src, function(value: number) + calls += 1 + return value + 1 + end) + + local prev = map() + assert(calls == 3, "should map 3 values") + assert(table.concat(map()) == "234", table.concat(map())) + + setSrc({ 4, 5, 6, 7 }) + setSrc({ 1, 2, 3 }) + map() + assert(calls == 3, "should not map unchanged values") + assert(table.concat(map()) == "234", table.concat(map())) + assert(prev == map(), "should not create new table") +end) + +test("should filter values", function() + local calls = 0 + + local src, setSrc = signal({ a = 1, b = 2, c = 3 }) + local map = mapped(src, function(value: number) + calls += 1 + return if value < 0 then nil else value + end) + + local state = map() + assert(calls == 3, "should map 3 values") + assert(state.a and state.b and state.c, "should map all values") + + calls = 0 + setSrc({ a = -1, b = 2, c = 3, d = 4 }) + state = map() + assert(calls == 2, "should map 2 values") + assert(state.a == nil, "should remove a") + assert(state.b and state.c, "should keep b and c") + assert(state.d == 4, "should add d") +end) + +test("should map keys", function() + local calls = 0 + + local src, setSrc = signal({ a = 1, b = 2, c = 3 }) + local map = mapped(src, function(value: number, key: string) + calls += 1 + return if value < 0 then nil else value, string.upper(key) + end) + + local state = map() + assert(calls == 3, "should map 3 values") + assert(not (state.a and state.b and state.c), "should discard old keys") + assert(state.A == 1, "should map a") + assert(state.B == 2, "should map b") + assert(state.C == 3, "should map c") + + calls = 0 + setSrc({ a = -1, b = 2, c = 3, d = 4 }) + state = map() + assert(calls == 2, "should map 2 values") + assert(state.A == nil and state.a == nil, "should remove a") + assert(state.B and state.C, "should keep b and c") + assert(state.D == 4, "should add d") +end) + +test("should remove old key if mapper returns a new key", function() + local src, setSrc = signal({ 1 }) + local map = mapped(src, function(value: number, index: number) + return true, `{index}={value}` + end) + + local state = map() + assert(state["1=1"], "should have mapped first element to key 1=1") + + setSrc({ 2 }) + state = map() + assert(state["1=1"] == nil, "should have removed old key 1=1") + assert(state["1=2"], "should have remapped element to key 1=2") + + setSrc({ 3, 1 }) + state = map() + assert(state["1=2"] == nil, "should have removed old key 1=2") + assert(state["1=3"], "should have remapped element to key 1=3") + assert(state["2=1"], "should have mapped second element to key 2=1") +end) + +return {} diff --git a/packages/charm/test/observe.test.luau b/packages/charm/test/observe.test.luau new file mode 100644 index 0000000..64bbd7d --- /dev/null +++ b/packages/charm/test/observe.test.luau @@ -0,0 +1,198 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local observe = Charm.observe + +test("should observe items", function() + local items, setItems = signal({ 0, 0, 0 }) + local updates = {} + local cleanups = {} + + observe(items, function(_, key: number) + local get = computed(function() + return items()[key] + end) + + effect(function() + table.insert(updates, get()) + end) + + return function() + table.insert(cleanups, key) + end + end) + + assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`) + + table.clear(updates) + setItems({ 1, 2, 3 }) + assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`) + + table.clear(updates) + setItems({ 1, 2, nil :: any, 4 }) + assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`) + assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`) +end) + +test("should dispose scopes", function() + local items, setItems = signal({ 1, 2, 3 }) + local a, setA = signal(0) + local runs = {} + local disposes = {} + + local dispose = observe(items, function(item) + effect(function() + table.insert(runs, item) + a() + end) + effectScope(function() + return function() + table.insert(disposes, item) + end + end) + end) + + table.clear(runs) + setItems({ 1, 2 }) + setA(1) + assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`) + assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`) + + table.clear(runs) + table.clear(disposes) + dispose() + setA(2) + assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`) + assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`) +end) + +test("should queue addition and removal", function() + local items, setItems = signal({ 1 }) + local edits = {} + + observe(items, function(item) + if item == 1 then + setItems({ 1, 2, 3, 4 }) + elseif item == 4 then + setItems({ 1 }) + end + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`) +end) + +test("should handle recursive additions and removals", function() + local items, setItems = signal({ 1 }) + local edits: { string } = {} + + observe(items, function(item) + if item < 5 then + setItems(function(state: { number }) + state = table.clone(state) + table.insert(state, item + 1) + return state + end) + else + setItems({}) + end + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`) +end) + +test("should handle self removal", function() + local items, setItems = signal({ 1, 2, 3, 4 }) + local edits: { string } = {} + + observe(items, function(item, index) + setItems(function(state: { number }) + state = table.clone(state) + state[index] = nil + return state + end) + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) +end) + +test("should allow disposal during addition", function() + local items, setItems = signal({}) + local edits = {} + local dispose: () -> () + + dispose = observe(items, function(item) + if item == 1 then + dispose() + end + table.insert(edits, `+{item}`) + return function() + table.insert(edits, `-{item}`) + end + end) + + setItems({ 1, 2, 3, 4 }) + assert(table.concat(edits) == "+1-1", `bad edits: {table.concat(edits)}`) +end) + +test("should allow disposal during removal", function() + local items, setItems = signal({ 1, 2, 3, 4 }) + local edits = {} + local dispose: () -> () + + dispose = observe(items, function(item) + table.insert(edits, `+{item}`) + return function() + dispose() + table.insert(edits, `-{item}`) + end + end) + + setItems({ 1, 2, 3, nil :: any, 5 }) + assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) +end) + +test("should not hang on error after recursive update", function() + local items, setItems = signal({}) + + observe(items, function() + setItems({ {} }) + error("test error") + end) + + local success, err = pcall(function() + setItems({ {} }) + end) + assert(not success, "should error") + assert(string.find(tostring(err), "test error"), `bad error: {err}`) +end) + +test("should not hang on error after recursive init", function() + local items, setItems = signal({ {} }) + + local success, err = pcall(function() + observe(items, function() + setItems({ {} }) + error("test error") + end) + end) + assert(not success, "should error") + assert(string.find(tostring(err), "test error"), `bad error: {err}`) +end) + +return {} diff --git a/packages/charm/test/signal.test.luau b/packages/charm/test/signal.test.luau new file mode 100644 index 0000000..21c1746 --- /dev/null +++ b/packages/charm/test/signal.test.luau @@ -0,0 +1,67 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local effect = Charm.effect + +test("should accept equality function", function() + local calls = {} + local src, setSrc = signal(0, function(current: number, incoming: number) + return current == incoming or incoming % 2 == 1 + end) + + effect(function() + table.insert(calls, src()) + end) + + setSrc(2) + setSrc(3) + assert(src() == 2, "should skip odd value") + + setSrc(4) + setSrc(5) + assert(src() == 4, "should skip odd value") + assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) +end) + +test("should enforce immutability", function() + local src, setSrc = signal({ nested = {} }) + assert(not pcall(function() + src().nested.a = 1 + end), "should not allow mutation of initial value") + + setSrc({ nested = { a = 1 } }) + assert(not pcall(function() + src().nested.a = 2 + end), "should not allow mutation of new value") +end) + +test("should not freeze values with metatables", function() + local src = signal({ object = setmetatable({}, {}) }) + assert( + pcall(function() + src().object.a = 2 + end), + "should allow mutation of values with metatables" + ) +end) + +test("should setter return same value as getter even if equal", function() + local src, setSrc = signal(0, function() + return true + end) + assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`) +end) + +test("should setter return latest value", function() + local src, setSrc = signal(0) + effect(function() + if src() == 1 then + setSrc(2) + end + end) + assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`) + assert(src() == 2, `src() expected 2, got {src()}`) +end) + +return {} diff --git a/packages/charm/test/subscribe.test.luau b/packages/charm/test/subscribe.test.luau new file mode 100644 index 0000000..10ab932 --- /dev/null +++ b/packages/charm/test/subscribe.test.luau @@ -0,0 +1,91 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local computed = Charm.computed +local subscribe = Charm.subscribe + +test("should subscribe to selector", function() + local source, setSource = signal(0) + local current, previous + + local unsub = subscribe(function() + return source() + 1 + end, function(a, b) + current = a + previous = b + end) + + assert(not current and not previous, "should not run initially") + setSource(0) + assert(not current and not previous, "should skip redundant update") + setSource(1) + assert(current == 2 and previous == 1, "should run with new value") + unsub() + setSource(2) + assert(current == 2 and previous == 1, "should not run after unsub") +end) + +test("should run in untracked", function() + local runs = 0 + local a, setA = signal(0) + local b, setB = signal(0) + + subscribe(a, function() + runs += 1 + b() + end) + + assert(runs == 0, "should not run once on init") + setA(1) + assert(runs == 1, "should run once on change") + setB(1) + assert(runs == 1, "should not run on b change") + setA(2) + assert(runs == 2, "should run on a change") +end) + +test("should ensure correct execution order", function() + local order: { number } = {} + local n1, setN1 = signal(0) + local n2, setN2 = signal(0) + local sum = computed(function() + return n1() + n2() + end) + + subscribe(n1, function() + table.insert(order, 1) + setN2(n2() + 1) + end) + + subscribe(sum, function() + table.insert(order, 2) + end) + + subscribe(n1, function() + table.insert(order, 3) + end) + + setN1(n1() + 1) + + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) +end) + +test("should skip redundant updates", function() + local order: { "even" | "odd" } = {} + local a, setA = signal(0) + + subscribe(function(): "even" | "odd" + return if a() % 2 == 0 then "even" else "odd" + end, function(value) + table.insert(order, value) + end) + + setA(1) + setA(3) + setA(4) + setA(6) + assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`) +end) + +return {} diff --git a/packages/charm/test/topology.test.luau b/packages/charm/test/topology.test.luau new file mode 100644 index 0000000..3675b3d --- /dev/null +++ b/packages/charm/test/topology.test.luau @@ -0,0 +1,484 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect + +--[[ + Tests adopted with thanks from preact-core implementation at + https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx + + The MIT License (MIT) + + Copyright (c) 2022-present Preact Team + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE +]] + +test.describe("graph updates", function() + test("should drop A->B->A updates", function() + -- A + -- / | + -- B | <- Looks like a flag doesn't it? :D + -- \ | + -- C + -- | + -- D + local a = atom(2) + + local b = computed(function() + return a() - 1 + end) + local c = computed(function() + return a() + b() + end) + + local computeCalls = 0 + local compute = function() + computeCalls += 1 + return "d: " .. c() + end + local d = computed(compute) + + -- Trigger read + assert(d() == "d: 3", "d should be 3") + assert(computeCalls == 1, "compute should be called once") + computeCalls = 0 + + a(4) + d() + assert(computeCalls == 1, "compute should drop update") + end) + + test("should only update every signal once (diamond graph)", function() + -- In this scenario "D" should only update once when "A" receives + -- an update. This is sometimes referred to as the "diamond" scenario. + -- A + -- / \ + -- B C + -- \ / + -- D + local a = atom("a") + local b = computed(function() + return a() + end) + local c = computed(function() + return a() + end) + + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return b() .. " " .. c() + end + local d = computed(spy) + + assert(d() == "a a", "d should be 'a a'") + assert(spyCalls == 1, "compute should be called once") + + a("aa") + assert(d() == "aa aa", "d should be 'aa aa'") + assert(spyCalls == 2, "compute should be called only twice") + end) + + test("should only update every signal once (diamond graph + tail)", function() + -- "E" will be likely updated twice if our mark+sweep logic is buggy. + -- A + -- / \ + -- B C + -- \ / + -- D + -- | + -- E + local a = atom("a") + local b = computed(function() + return a() + end) + local c = computed(function() + return a() + end) + + local d = computed(function() + return b() .. " " .. c() + end) + + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return d() + end + local e = computed(spy) + + assert(e() == "a a", "e should be 'a a'") + assert(spyCalls == 1, "compute should be called once") + + a("aa") + assert(e() == "aa aa", "e should be 'aa aa'") + assert(spyCalls == 2, "compute should be called only twice") + end) + + test("should bail out if result is the same", function() + -- Bail out if value of "B" never changes + -- A->B->C + local a = atom("a") + local b = computed(function() + a() + return "foo" + end) + + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return b() + end + local c = computed(spy) + + assert(c() == "foo", "c should be 'foo'") + assert(spyCalls == 1, "compute should be called once") + + a("aa") + assert(c() == "foo", "c should be 'foo'") + assert(spyCalls == 1, "compute should bail out") + end) + + test("should only update every signal once (jagged diamond graph + tails)", function() + -- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy. + -- A + -- / \ + -- B C + -- | | + -- | D + -- \ / + -- E + -- / \ + -- F G + local a = atom("a") + + local b = computed(function() + return a() + end) + local c = computed(function() + return a() + end) + + local d = computed(function() + return c() + end) + + local eSpyCalls = 0 + local eSpyTime = 0 + local eSpy = function() + eSpyCalls += 1 + eSpyTime = os.clock() + return b() .. " " .. d() + end + local e = computed(eSpy) + + local fSpyCalls = 0 + local fSpyTime = 0 + local fSpy = function() + fSpyCalls += 1 + fSpyTime = os.clock() + return e() + end + local f = computed(fSpy) + local gSpyCalls = 0 + local gSpyTime = 0 + local gSpy = function() + gSpyCalls += 1 + gSpyTime = os.clock() + return e() + end + local g = computed(gSpy) + + assert(f() == "a a", "f should be 'a a'") + assert(fSpyCalls == 1, "f should be called once") + + assert(g() == "a a", "g should be 'a a'") + assert(gSpyCalls == 1, "g should be called once") + + eSpyCalls, eSpyTime = 0, 0 + fSpyCalls, fSpyTime = 0, 0 + gSpyCalls, gSpyTime = 0, 0 + + a("b") + + assert(e() == "b b", "e should be 'b b'") + assert(eSpyCalls == 1, "e should run once after update") + + assert(f() == "b b", "f should be 'b b'") + assert(fSpyCalls == 1, "f should run once after update") + + assert(g() == "b b", "g should be 'b b'") + assert(gSpyCalls == 1, "g should run once after update") + + eSpyCalls, eSpyTime = 0, 0 + fSpyCalls, fSpyTime = 0, 0 + gSpyCalls, gSpyTime = 0, 0 + + a("c") + + assert(e() == "c c", "e should be 'c c'") + assert(eSpyCalls == 1, "e should run once after update") + + assert(f() == "c c", "f should be 'c c'") + assert(fSpyCalls == 1, "f should run once after update") + + assert(g() == "c c", "g should be 'c c'") + assert(gSpyCalls == 1, "g should run once after update") + + -- top to bottom + assert(eSpyTime < fSpyTime, "e should be called before f") + -- left to right + assert(eSpyTime < gSpyTime, "e should be called before g") + end) + + test("should only subscribe to core listened to", function() + -- *A + -- / \ + -- *B C <- we don't listen to C + local a = atom("a") + + local b = computed(function() + return a() + end) + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return a() + end + computed(spy) + + assert(b() == "a", "b should be 'a'") + assert(spyCalls == 0, "compute should not run") + + a("aa") + assert(b() == "aa", "b should be 'aa'") + assert(spyCalls == 0, "compute should not run") + end) + + test("should only subscribe to core listened to II", function() + -- Here both "B" and "C" are active in the beginning, but + -- "B" becomes inactive later. At that point it should + -- not receive any updates anymore. + -- *A + -- / \ + -- *B D <- we don't listen to C + -- | + -- *C + local a = atom("a") + local spyBCalls = 0 + local spyB = function() + spyBCalls += 1 + return a() + end + local b = computed(spyB) + + local spyCCalls = 0 + local spyC = function() + spyCCalls += 1 + return b() + end + local c = computed(spyC) + + local d = computed(function() + return a() + end) + + local result = "" + local unsub = effect(function() + result = c() + end) + + assert(result == "a", "result should be 'a'") + assert(d() == "a", "d should be 'a'") + + spyBCalls = 0 + spyCCalls = 0 + unsub() + + a("aa") + + assert(spyBCalls == 0, "b should not run") + assert(spyCCalls == 0, "c should not run") + assert(d() == "aa", "d should be 'aa'") + end) + + test("should ensure subs update even if one dep unmarks it", function() + -- In this scenario "C" always returns the same value. When "A" + -- changes, "B" will update, then "C" at which point its update + -- to "D" will be unmarked. But "D" must still update because + -- "B" marked it. If "D" isn't updated, then we have a bug. + -- A + -- / \ + -- B *C <- returns same value every time + -- \ / + -- D + local a = atom("a") + local b = computed(function() + return a() + end) + local c = computed(function() + a() + return "c" + end) + local spyResult: string? + local spy = function() + spyResult = b() .. " " .. c() + return spyResult + end + local d = computed(spy) + + assert(d() == "a c", "d should be 'a c'") + spyResult = nil + + a("aa") + d() + assert(spyResult == "aa c", "d should have returned 'aa c'") + end) + + test("should ensure subs update even if two deps unmark it", function() + -- In this scenario both "C" and "D" always return the same + -- value. But "E" must still update because "A" marked it. + -- If "E" isn't updated, then we have a bug. + -- A + -- / | \ + -- B *C *D + -- \ | / + -- E + local a = atom("a") + local b = computed(function() + return a() + end) + local c = computed(function() + a() + return "c" + end) + local d = computed(function() + a() + return "d" + end) + local spyResult: string? + local spy = function() + spyResult = b() .. " " .. c() .. " " .. d() + return spyResult + end + local e = computed(spy) + + assert(e() == "a c d", "e should be 'a c d'") + spyResult = nil + + a("aa") + e() + assert(spyResult == "aa c d", "e should have returned 'aa c d'") + end) + + test("should support lazy branches", function() + local a = atom(0) + local b = computed(function() + return a() + end) + local c = computed(function() + return if a() > 0 then a() else b() + end) + + assert(c() == 0, "c should be 0") + a(1) + assert(c() == 1, "c should be 1") + + a(0) + assert(c() == 0, "c should be 0 again") + end) + + test("should not update a sub if all deps unmark it", function() + -- In this scenario "B" and "C" always return the same value. When "A" + -- changes, "D" should not update. + -- A + -- / \ + -- *B *C + -- \ / + -- D + local a = atom("a") + local b = computed(function() + a() + return "b" + end) + local c = computed(function() + a() + return "c" + end) + local spyCalls = 0 + local spy = function() + spyCalls += 1 + return b() .. " " .. c() + end + local d = computed(spy) + + assert(d() == "b c", "d should be 'b c'") + spyCalls = 0 + + a("aa") + assert(spyCalls == 0, "d should not run") + end) +end) + +test.describe("error handling", function() + test("should keep graph consistent on errors during activation", function() + local a = atom(0) + local b = computed(function(): nil + error("fail") + end) + local c = computed(function() + return a() + end) + + local success, result = pcall(b) + assert(not success, "should throw") + assert(string.match(tostring(result), "fail"), "should throw fail") + + a(1) + assert(c() == 1, "c should recover and return 1") + end) + + test("should keep graph consistent on errors in computeds", function() + local a = atom(0) + local b = computed(function(): number + if a() == 1 then + error("fail") + end + return a() + end) + local c = computed(function() + return b() + end) + + assert(c() == 0, "c should be 0") + + a(1) + local success, result = pcall(b) + assert(not success, "should throw") + assert(string.match(tostring(result), "fail"), "should throw fail") + + a(2) + assert(c() == 2, "c should recover and return 2") + end) +end) + +return {} diff --git a/packages/charm/test/trigger.test.luau b/packages/charm/test/trigger.test.luau new file mode 100644 index 0000000..20ebf32 --- /dev/null +++ b/packages/charm/test/trigger.test.luau @@ -0,0 +1,111 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local computed = Charm.computed +local effect = Charm.effect +local listen = Charm.listen +local signal = Charm.signal +local subscribe = Charm.subscribe +local trigger = Charm.trigger + +test.beforeEach(function() + Charm.flags.frozen = false +end) + +test.afterEach(function() + Charm.flags.frozen = true +end) + +test("should not throw when triggering with no dependencies", function() + trigger(function() end) +end) + +test("should trigger updates for dependent computed signals", function() + local arr = signal({} :: { number }) + local length = computed(function() + return #arr() + end) + + assert(length() == 0, "initial length should be 0") + table.insert(arr(), 1) + trigger(arr) + assert(length() == 1, "length should be 1") +end) + +test("should trigger updates for the second source signal", function() + local src1 = signal({} :: { number }) + local src2 = signal({} :: { number }) + local length = computed(function() + return #src2() + end) + + assert(length() == 0, "initial length should be 0") + table.insert(src2(), 1) + trigger(function() + src1() + src2() + end) + assert(length() == 1, "length should be 1") +end) + +test("should trigger effect once", function() + local src1 = signal({} :: { number }) + local src2 = signal({} :: { number }) + local triggers = 0 + + effect(function() + triggers += 1 + src1() + src2() + end) + assert(triggers == 1, "effect should run once initially") + + trigger(function() + src1() + src2() + end) + assert(triggers == 2, "effect should run twice") +end) + +test("should not notify the trigger function sub", function() + local src1 = signal({} :: { number }) + local src2 = computed(function() + return src1() + end) + + effect(function() + src1() + src2() + end) + trigger(function() + src1() + src2() + end) +end) + +test("should trigger updates for subscribe", function() + local src = signal({} :: { number }) + local triggers = 0 + + subscribe(src, function() + triggers += 1 + end) + + trigger(src) + assert(triggers == 1, "subscribe callback should run after trigger") +end) + +test("should trigger updates for listen", function() + local src = signal({} :: { number }) + local triggers = 0 + + listen(src, function() + triggers += 1 + end) + assert(triggers == 1, "listen callback should run once initially") + + trigger(src) + assert(triggers == 2, "listen callback should run after trigger") +end) + +return {} diff --git a/packages/charm/test/untracked.test.luau b/packages/charm/test/untracked.test.luau new file mode 100644 index 0000000..3c7fea4 --- /dev/null +++ b/packages/charm/test/untracked.test.luau @@ -0,0 +1,176 @@ +local Charm = require("../src") +local test = require("../../../tests/test") + +local signal = Charm.signal +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local untracked = Charm.untracked + +test("should run detached from effect", function() + local a, setA = signal(1) + local effects = 0 + local innerEffects = {} + + local stopEffect = effect(function() + a() + untracked(function() + local stopInnerEffect = effect(function() + effects += 1 + return function() + effects -= 1 + end + end) + table.insert(innerEffects, stopInnerEffect) + end) + end) + + assert(effects == 1, "should have one effect") + setA(2) + assert(effects == 2, "should have two effects") + stopEffect() + assert(effects == 2, "should still have two effects") + for _, stopInnerEffect in innerEffects do + stopInnerEffect() + end + assert(effects == 0, "should have zero effects") +end) + +test("should run detached from effect scope", function() + local effects = 0 + local stopEffect: () -> () + + local stopScope = effectScope(function() + untracked(function() + stopEffect = effect(function() + effects += 1 + return function() + effects -= 1 + end + end) + end) + end) + + assert(effects == 1, "should have one effect") + stopScope() + assert(effects == 1, "should still have one effect") + stopEffect() + assert(effects == 0, "should have zero effects") +end) + +test("should block tracking inside effects", function() + local a, setA = signal(1) + local b, setB = signal(2) + local spyCalls = 0 + + effect(function() + untracked(function() + spyCalls += 1 + return a() + b() + end) + end) + assert(spyCalls == 1, "should be called once") + + setA(10) + setB(20) + assert(spyCalls == 1, "should be called once") +end) + +test("should block tracking even when run inside effect run inside untracked", function() + local s, setS = signal(1) + local spyCalls = 0 + + untracked(function() + effect(function() + untracked(function() + spyCalls += 1 + return s() + end) + end) + end) + assert(spyCalls == 1, "should be called once") + + setS(2) + assert(spyCalls == 1, "should be called once") +end) + +test("should not cause signal assignments to throw", function() + local a, setA = signal(1) + local aChangedTime, setAChangedTime = signal(0) + + local dispose = effect(function() + a() + untracked(function() + setAChangedTime(aChangedTime() + 1) + end) + end) + + setA(2) + assert(aChangedTime() == 2, "aChangedTime should be 2") + setA(3) + assert(aChangedTime() == 3, "aChangedTime should be 3") + + dispose() +end) + +test("should block tracking inside computed signals", function() + local a, setA = signal(1) + local b, setB = signal(2) + local spyCalls = 0 + local c = computed(function() + return untracked(function() + spyCalls += 1 + return a() + b() + end) + end) + + assert(spyCalls == 0, "should not be called yet") + assert(c() == 3, "c should be 3") + setA(10) + c() + setB(20) + c() + assert(spyCalls == 1, "should be called once") + assert(c() == 3, "c should be 3") +end) + +test("should track outside untracked", function() + local a, setA = signal(1) + local b, setB = signal(2) + local c, setC = signal(3) + local spyCalls = 0 + + effect(function() + a() + untracked(c) + b() + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + setA(10) + assert(spyCalls == 2, "should be called twice") + setB(20) + assert(spyCalls == 3, "should be called three times") + setC(30) + assert(spyCalls == 3, "should not run on c change") +end) + +test("should stack untracked", function() + local a, setA = signal(1) + local spyCalls = 0 + + effect(function() + untracked(function() + untracked(a) + return a() + end) + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + setA(2) + assert(spyCalls == 1, "should not be called twice") +end) + +return {} diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 4497d3a..6298b39 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,7 +1,8 @@ [package] name = "littensy/charm" -version = "0.10.0" +version = "0.11.0-rc.6" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] [dependencies] diff --git a/packages/deep-charm/Charm.luau b/packages/deep-charm/Charm.luau new file mode 100644 index 0000000..619a57a --- /dev/null +++ b/packages/deep-charm/Charm.luau @@ -0,0 +1,5 @@ +if not script then + return require("../charm/src") +end + +return require(script.Parent.Parent.charm.src) diff --git a/packages/react/default.project.json b/packages/deep-charm/default.project.json similarity index 60% rename from packages/react/default.project.json rename to packages/deep-charm/default.project.json index c1ee26a..1937acb 100644 --- a/packages/react/default.project.json +++ b/packages/deep-charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "ReactCharm", + "name": "deep-charm", "tree": { "$path": "src" } diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json new file mode 100644 index 0000000..96d06af --- /dev/null +++ b/packages/deep-charm/package.json @@ -0,0 +1,27 @@ +{ + "name": "@rbxts/deep-charm", + "version": "0.1.0-rc.1", + "description": "Use Charm with plain Luau tables", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:^" + } +} diff --git a/packages/deep-charm/src/index.d.ts b/packages/deep-charm/src/index.d.ts new file mode 100644 index 0000000..dc1aa58 --- /dev/null +++ b/packages/deep-charm/src/index.d.ts @@ -0,0 +1,53 @@ +export type ReactiveProxy = T & { + __track: () => void; + __target: T; + __signals: Map unknown>; +}; + +/** + * Creates a deep reactive value that tracks properties when they're accessed. + * Returns a proxy object that subscribes to changes on any nested property + * you read, and a function for mutating the original table. + * + * @example + * ```luau + * local users, updateUsers = reactive({ + * user = { name = "John", surname = "Doe" }, + * }) + * + * effect(function() + * print(users.user and `{users.user.name} {users.user.surname}`) + * end) + * + * updateUsers(function(state) + * state.user.name = "Jane" + * state.user.surname = "Smith" + * end) + * ``` + * + * @param initialValue The value to make deeply reactive. + * @returns A proxy table and a function to mutate the original table. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function reactive( + initialValue: T, +): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>; + +/** + * Determines if the provided value is a reactive proxy created by `reactive()`. + * + * @param value A value to test. + * @returns `true` if the value is a reactive proxy, `false` otherwise. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function isReactive(value: unknown): boolean; + +/** + * Unwraps a reactive proxy to get the original table. If the provided value + * is not a proxy, it is returned as-is. + * + * @param value A reactive proxy or a non-proxy value. + * @returns The unwrapped target value. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function toRaw(value: T): T; diff --git a/packages/deep-charm/src/init.luau b/packages/deep-charm/src/init.luau new file mode 100644 index 0000000..9eb9943 --- /dev/null +++ b/packages/deep-charm/src/init.luau @@ -0,0 +1,165 @@ +local Charm = require("./Charm") + +local computed = Charm.computed +local trigger = Charm.trigger +local getActiveSub = Charm.getActiveSub + +export type ReactiveProxy = typeof(setmetatable( + {} :: { + __track: () -> (), + __target: T, + __signals: { [unknown]: () -> unknown }, + }, + {} :: { __index: T } +)) + +local ReactiveProxy = { + proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), +} + +--[[ + Determines if the provided value is a reactive proxy created by `reactive()`. + + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function isReactive(value: any): boolean + return type(value) == "table" and getmetatable(value) == ReactiveProxy +end + +--[[ + Unwraps a reactive proxy to get the original table. If the provided value + is not a proxy, it is returned as-is. + + @param value A reactive proxy or a non-proxy value + @return The source table if `value` is a reactive proxy + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function toRaw(value: T): T + if isReactive(value) then + value = value :: ReactiveProxy + value.__track() + return toRaw(value.__target) + end + return value +end + +--[[ + Creates a deep reactive value that tracks properties when they're accessed. + Returns a proxy object that subscribes to changes on any nested property + you read, and a function for mutating the original table. + + @example + ```luau + local users, updateUsers = reactive({ + { name = "John", surname = "Doe" }, + }) + + effect(function() + print(`{users[1].name} {users[1].surname}`) + end) + + updateUsers(function(state) + state[1].name = "Jane" + state[1].surname = "Smith" + end) + ``` + + @param initialValue Source table to make deeply reactive + @return A proxy table that tracks reads to nested properties + @return A function for batching mutations to the source table + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) + initialValue = toRaw(initialValue) + + local proxy = ReactiveProxy.proxyMap[initialValue] + + if not proxy then + local track = computed(function(version: number?) + -- Reference the proxy to avoid GCing while an effect is actively + -- tracking this reactive object. + local _ref = proxy + -- Allow updates from the parent to propagate down when the + -- target is mutated directly through the setter. + if trackParent then + trackParent() + end + return (version or 0) + 1 + end) + + proxy = setmetatable({ + __target = initialValue, + __signals = setmetatable({}, { __mode = "v" }) :: any, + __track = track, + }, ReactiveProxy) + + ReactiveProxy.proxyMap[initialValue] = proxy + end + + local function setValue(update: ((T) -> ())?) + if update then + update(proxy.__target) + end + trigger(proxy.__track) + end + + return proxy :: any, setValue +end + +--[[ + Wraps a value in `reactive()` if it is a table; otherwise, returns the + value as-is. + + @param value Source value to make deeply reactive + @param trackParent Optional tracking function to propagate deep updates + @return A reactive proxy if the value is a table +]] +local function toReactive(value: T, trackParent: (() -> ())?): T + return if type(value) == "table" then reactive(value, trackParent) else value +end + +function ReactiveProxy.__index(self: ReactiveProxy, key: any): any + local track = self.__track + local signals = self.__signals + local target = self.__target + + if getActiveSub() then + if not signals[key] then + signals[key] = computed(function() + track() + return target[key] + end) + end + signals[key]() + end + + return toReactive(target[key], track) +end + +function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any) + value = toRaw(value) + if self.__target[key] ~= value then + self.__target[key] = value + trigger(self.__track) + end +end + +function ReactiveProxy.__iter(self: ReactiveProxy) + self.__track() + local function nextReactive(target: any, prevKey: any) + local nextKey, nextValue = next(target, prevKey) + return nextKey, toReactive(nextValue, self.__track) + end + return nextReactive, self.__target +end + +function ReactiveProxy.__len(self: ReactiveProxy) + self.__track() + return #self.__target +end + +return { + reactive = reactive, + isReactive = isReactive, + toRaw = toRaw, +} diff --git a/packages/deep-charm/test/gc.test.luau b/packages/deep-charm/test/gc.test.luau new file mode 100644 index 0000000..4fe9f3d --- /dev/null +++ b/packages/deep-charm/test/gc.test.luau @@ -0,0 +1,79 @@ +local Charm = require("../Charm") +local DeepCharm = require("../src") +local test = require("../../../tests/test") + +type ReactiveProxy = DeepCharm.ReactiveProxy + +local computed = Charm.computed +local effect = Charm.effect +local reactive = DeepCharm.reactive + +local function weakRef(value: T): { value: T? } + return setmetatable({ value = value }, { __mode = "v" }) :: any +end + +local function gc() + for _ = 1, 2000 do + local _ = table.create(100, 0) + end +end + +test("should release inner reactive proxy", function() + local valueRef = weakRef({}) + local src: any = reactive({ value = valueRef.value }) + + local dispose = effect(function() + local _ = src.value -- create proxy + end) + src.value = nil :: any -- release proxy + dispose() + + gc() + assert(src.__signals.value == nil, "should release signals") + assert(valueRef.value == nil, "should release inner proxy") +end) + +test("should release reactive property deps", function() + local src: any = reactive({ foo = 1 }) + + local c = computed(function() + return src.foo + end) + + c() + assert(src.__signals.foo ~= nil, "should have deps for foo") + + c = nil :: any + gc() + assert(src.__signals.foo == nil, "should release deps for foo") +end) + +test("should not release effect for reactive", function() + local src = reactive({ foo = 1 }) + local calls = 0 + + effect(function() + calls += 1 + local _ = src.foo + end) + assert(calls == 1, "should call effect once") + + gc() + src.foo += 1 + assert(calls == 2, "should call effect again") +end) + +test("should not release tracked reactive", function() + local src = reactive({ users = {} :: { [string]: {}? } }) + local user + + effect(function() + user = src.users.user + end) + + gc() + src.users.user = {} + assert(user ~= nil, "src.users.user should not be released") +end) + +return {} diff --git a/packages/deep-charm/test/init.luau b/packages/deep-charm/test/init.luau new file mode 100644 index 0000000..b7123d6 --- /dev/null +++ b/packages/deep-charm/test/init.luau @@ -0,0 +1,11 @@ +local test = require("../../tests/test") + +test.describe("reactive", function() + require(`@self/reactive.test`) +end) + +test.describe("gc", function() + require(`@self/gc.test`) +end) + +return {} diff --git a/packages/deep-charm/test/reactive.test.luau b/packages/deep-charm/test/reactive.test.luau new file mode 100644 index 0000000..41654b1 --- /dev/null +++ b/packages/deep-charm/test/reactive.test.luau @@ -0,0 +1,232 @@ +local Charm = require("../Charm") +local DeepCharm = require("../src") +local test = require("../../../tests/test") + +local batch = Charm.batch +local computed = Charm.computed +local effect = Charm.effect +local observe = Charm.observe +local reactive = DeepCharm.reactive +local toRaw = DeepCharm.toRaw + +test("should track nested properties", function() + local src, setSrc = reactive({ a = { b = { c = 0 } } }) + local calls = {} + + effect(function() + table.insert(calls, `{src.a.b.c}`) + end) + + setSrc(function(state) + state.a.b.c = 1 + end) + src.a.b.c = 2 + src.a.b.c = 2 + setSrc() + assert(table.concat(calls) == "012", table.concat(calls)) +end) + +test("should track object type changes", function() + local src = reactive({ a = { b = 0 } } :: { a: any }) + local calls = {} + + effect(function() + table.insert(calls, tostring(if type(src.a) == "table" then src.a.b else src.a)) + end) + + src.a = { b = 0 } + src.a = 1 + src.a = { b = 2 } + src.a = nil + src.a = 3 + assert(table.concat(calls) == "0012nil3", table.concat(calls)) +end) + +test("should unwrap proxy assignments", function() + local src = reactive({ a = { x = 0 }, b = { x = 0 } }) + local runs = 0 + + effect(function() + local _, _ = src.a.x, src.b.x + runs += 1 + end) + + src.a, src.b = src.a, src.b + assert(runs == 1, `bad runs: {runs}`) + + src.a, src.b = src.b, src.a + assert(runs == 3, `bad runs: {runs}`) +end) + +test("should track iteration", function() + local src, setSrc = reactive({} :: { [string]: number }) + local calls = {} + + effect(function() + for key, value in src do + table.insert(calls, `{key}={value};`) + end + end) + + src.a = 1 + assert(table.concat(calls) == "a=1;", table.concat(calls)) + + table.clear(calls) + src.b = 2 + assert(table.concat(calls) == "a=1;b=2;", table.concat(calls)) + + table.clear(calls) + src.a = 2 + assert(table.concat(calls) == "a=2;b=2;", table.concat(calls)) + + table.clear(calls) + setSrc() + assert(table.concat(calls) == "a=2;b=2;", table.concat(calls)) +end) + +test("should track iteration for nested tables", function() + local src = reactive({} :: { [string]: { value: number } }) + local calls = {} + + effect(function() + for key, obj in src do + table.insert(calls, `{key}={obj.value};`) + end + end) + + src.a = { value = 1 } + + table.clear(calls) + src.b = { value = 1 } + assert(table.concat(calls) == "a=1;b=1;", table.concat(calls)) + + table.clear(calls) + src.a.value = 2 + assert(table.concat(calls) == "a=2;b=1;", table.concat(calls)) +end) + +test("should track array length", function() + local src = reactive({ array = {} :: { number } }) + local calls = {} + + effect(function() + table.insert(calls, #src.array) + end) + + src.array[#src.array + 1] = 1 + src.array[#src.array + 1] = 2 + src.array[#src.array + 1] = 3 + src.array = { 1, 2, 3, 4 } + assert(table.concat(calls) == "01234", table.concat(calls)) +end) + +test("should not track unread properties", function() + local src = reactive({ value = { a = 1, b = 2 } }) + local calls = 0 + + effect(function() + local _ = src.value.a + calls += 1 + end) + assert(calls == 1, `bad calls: {calls}`) + + src.value.b = 3 + assert(calls == 1, `bad calls: {calls}`) +end) + +test("should overwrite with table passed to setter", function() + local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number? }) + local calls = {} + + effect(function() + table.insert(calls, `{src.a}{src.b}{src.c}`) + end) + + setSrc(function(src) + src.a = 2 + src.b = nil + src.c = 4 + end) + assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ",")) +end) + +test("should unwrap proxy", function() + local raw = {} + local src = reactive(raw) + local runs = 0 + + effect(function() + local _ = toRaw(src) + runs += 1 + end) + + assert(toRaw(src) == raw, "unwrap did not return the original table") + assert(toRaw(raw) == raw, "unwrap modified a non-proxy value") + assert(runs == 1, `bad runs: {runs}`) + + src.x = 1 + assert(runs == 2, `bad runs: {runs}`) +end) + +test("should unwrap nested proxy", function() + local raw = {} + local inner = reactive(raw) + local src = reactive(inner) + local runs = 0 + + effect(function() + local _ = toRaw(src) + runs += 1 + end) + + assert(toRaw(src) == raw, "unwrap did not return the original table") + assert(runs == 1, `bad runs: {runs}`) + + inner.x = 1 + assert(runs == 2, `bad runs: {runs}`) + + src.y = 2 + assert(runs == 3, `bad runs: {runs}`) +end) + +test("should observe items", function() + local items, updateItems = reactive({ 0, 0, 0 }) + local updates = {} + local cleanups = {} + + observe(function() + return toRaw(items) + end, function(_, key: number) + local get = computed(function() + return items[key] + end) + + effect(function() + table.insert(updates, get()) + end) + + return function() + table.insert(cleanups, key) + end + end) + + assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`) + + table.clear(updates) + updateItems(function(state) + state[1] = 1 + state[2] = 2 + state[3] = 3 + end) + assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`) + + table.clear(updates) + batch(function() + items[3] = nil + items[4] = 4 + end) + assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`) + assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`) +end) + +return {} diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml new file mode 100644 index 0000000..f1a2b11 --- /dev/null +++ b/packages/deep-charm/wally.toml @@ -0,0 +1,9 @@ +[package] +name = "littensy/deep-charm" +version = "0.1.0-rc.1" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] + +[dependencies] +Charm = "littensy/charm@^0.11.0-rc.6" diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau new file mode 100644 index 0000000..49baa5a --- /dev/null +++ b/packages/react-charm/Charm.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.charm.src) diff --git a/packages/react-charm/React.luau b/packages/react-charm/React.luau new file mode 100644 index 0000000..1c7cfd8 --- /dev/null +++ b/packages/react-charm/React.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React) diff --git a/packages/react-charm/ReactRoblox.luau b/packages/react-charm/ReactRoblox.luau new file mode 100644 index 0000000..0350e6b --- /dev/null +++ b/packages/react-charm/ReactRoblox.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").ReactRoblox) diff --git a/packages/react-charm/default.project.json b/packages/react-charm/default.project.json new file mode 100644 index 0000000..baf768c --- /dev/null +++ b/packages/react-charm/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "react-charm", + "tree": { + "$path": "src" + } +} diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json new file mode 100644 index 0000000..d58d8c7 --- /dev/null +++ b/packages/react-charm/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rbxts/react-charm", + "version": "0.4.0-rc.4", + "description": "React bindings for Charm state management", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:^" + }, + "peerDependencies": { + "@rbxts/react": ">=17.0.0" + } +} diff --git a/packages/react-charm/src/index.d.ts b/packages/react-charm/src/index.d.ts new file mode 100644 index 0000000..db8ac24 --- /dev/null +++ b/packages/react-charm/src/index.d.ts @@ -0,0 +1,31 @@ +import { Binding } from "@rbxts/react"; + +/** + * Returns a React state that updates when the getter function updates. + * Re-renders the component when the value changes. + * + * If the `dependencies` array is provided, a new effect is created for the + * current `getter` when the dependencies change. Otherwise, the effect is + * created only once, when the component is mounted. + * + * @param getter The signal or selector to watch. + * @param dependencies An array of values used to memoize the getter. + * @return The current state. + */ +export function useSignalState(getter: () => T, dependencies?: any[]): T; + +/** + * Returns a React binding that updates when the getter function updates. + * + * If the `dependencies` array is provided, a new effect is created for the + * current `getter` when the dependencies change. Otherwise, the effect is + * created only once, when the component is mounted. + * + * @param getter The atom or selector to watch. + * @param dependencies An array of values used to memoize the getter. + * @return A binding that stores the current state. + */ +export function useSignalBinding(getter: () => T, dependencies?: any[]): Binding; + +// Aliases +export { useSignalState as useAtom }; diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau new file mode 100644 index 0000000..19a95e8 --- /dev/null +++ b/packages/react-charm/src/init.luau @@ -0,0 +1,52 @@ +local Charm = require("./Charm") +local React = require("./React") + +--[[ + Returns a React state that updates when the getter function updates. + Re-renders the component when the value changes. + + If the `dependencies` array is provided, a new effect is created for the + current `getter` when the dependencies change. Otherwise, the effect is + created only once, when the component is mounted. + + @param getter The signal or selector to watch. + @param dependencies An array of values used to memoize the getter. + @return The current state. +]] +local function useSignalState(getter: () -> T, dependencies: {}?): T + local value, setValue = React.useState(getter) + + React.useLayoutEffect(function(): () -> () + return Charm.listen(getter, setValue :: (T, T?) -> ()) + end, dependencies :: { any } or {}) + + return value +end + +--[[ + Returns a React binding that updates when the getter function updates. + + If the `dependencies` array is provided, a new effect is created for the + current `getter` when the dependencies change. Otherwise, the effect is + created only once, when the component is mounted. + + @param getter The atom or selector to watch. + @param dependencies An array of values used to memoize the getter. + @return A binding that stores the current state. +]] +local function useSignalBinding(getter: () -> T, dependencies: {}?) + local initialValue = React.useMemo(getter, {}) + local binding, setBinding = React.useBinding(initialValue) + + React.useLayoutEffect(function(): () -> () + return Charm.listen(getter, setBinding :: (T, T?) -> ()) + end, dependencies :: { any } or {}) + + return binding +end + +return table.freeze({ + useSignalState = useSignalState, + useSignalBinding = useSignalBinding, + useAtom = useSignalState, +}) diff --git a/tests/utils/renderHook.luau b/packages/react-charm/test/helpers/renderHook.luau similarity index 62% rename from tests/utils/renderHook.luau rename to packages/react-charm/test/helpers/renderHook.luau index 0d2d19f..661d2e0 100644 --- a/tests/utils/renderHook.luau +++ b/packages/react-charm/test/helpers/renderHook.luau @@ -1,9 +1,5 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -_G.__ROACT_17_MOCK_SCHEDULER__ = true - -local React = require(ReplicatedStorage.DevPackages.React) -local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox) +local React = require("../../React") +local ReactRoblox = require("../../ReactRoblox") type RenderedHook = { result: Result, @@ -15,14 +11,15 @@ local function renderHook(callback: (Props) -> Result, props: Pro local root = ReactRoblox.createLegacyRoot(Instance.new("Folder")) local hook = {} - local function Component(props) + local function Component(props: Props) hook.result = callback(props) + return nil end - function hook.rerender(newProps) + function hook.rerender(newProps: Props?) props = newProps or props ReactRoblox.act(function() - root:render(React.createElement(Component, props)) + root:render(React.createElement(Component, props) :: any) end) end diff --git a/packages/react-charm/test/init.luau b/packages/react-charm/test/init.luau new file mode 100644 index 0000000..25679e5 --- /dev/null +++ b/packages/react-charm/test/init.luau @@ -0,0 +1,13 @@ +local test = require("../../tests/test") + +_G.__ROACT_17_MOCK_SCHEDULER__ = true + +test.describe("useSignalState", function() + require("@self/useSignalState.test") +end) + +test.describe("useSignalBinding", function() + require("@self/useSignalBinding.test") +end) + +return {} diff --git a/packages/react-charm/test/useSignalBinding.test.luau b/packages/react-charm/test/useSignalBinding.test.luau new file mode 100644 index 0000000..8b721ed --- /dev/null +++ b/packages/react-charm/test/useSignalBinding.test.luau @@ -0,0 +1,44 @@ +local Charm = require("../Charm") +local ReactCharm = require("../src") +local renderHook = require("./helpers/renderHook") +local test = require("../../../tests/test") + +test("tracks signal state", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalBinding(getState) + end) + assert(hook.result:getValue() == 0, "should return 0") + setState(1) + assert(hook.result:getValue() == 1, "should return 1") +end) + +test("accepts a selector", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalBinding(function() + return 2 * getState() + end) + end) + assert(hook.result:getValue() == 0, "should return 0") + setState(1) + assert(hook.result:getValue() == 2, "should return 2") +end) + +test("tracks dependencies", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalBinding(getState, { getState }) + end) + assert(hook.result:getValue() == 0, "should return 0") + setState(1) + assert(hook.result:getValue() == 1, "should return 1") + + getState, setState = Charm.signal(0) + hook.rerender() + assert(hook.result:getValue() == 0, "should return 0") + setState(1) + assert(hook.result:getValue() == 1, "should return 1") +end) + +return {} diff --git a/packages/react-charm/test/useSignalState.test.luau b/packages/react-charm/test/useSignalState.test.luau new file mode 100644 index 0000000..9618577 --- /dev/null +++ b/packages/react-charm/test/useSignalState.test.luau @@ -0,0 +1,44 @@ +local Charm = require("../Charm") +local ReactCharm = require("../src") +local renderHook = require("./helpers/renderHook") +local test = require("../../../tests/test") + +test("tracks signal state", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalState(getState) + end) + assert(hook.result == 0, "should return 0") + setState(1) + assert(hook.result == 1, "should return 1") +end) + +test("accepts a selector", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalState(function() + return 2 * getState() + end) + end) + assert(hook.result == 0, "should return 0") + setState(1) + assert(hook.result == 2, "should return 2") +end) + +test("tracks dependencies", function() + local getState, setState = Charm.signal(0) + local hook = renderHook(function() + return ReactCharm.useSignalState(getState, { getState }) + end) + assert(hook.result == 0, "should return 0") + setState(1) + assert(hook.result == 1, "should return 1") + + getState, setState = Charm.signal(0) + hook.rerender() + assert(hook.result == 0, "should return 0") + setState(1) + assert(hook.result == 1, "should return 1") +end) + +return {} diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml new file mode 100644 index 0000000..a1de6d4 --- /dev/null +++ b/packages/react-charm/wally.toml @@ -0,0 +1,17 @@ +[package] +name = "littensy/react-charm" +version = "0.4.0-rc.4" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +exclude = [ + "node_modules", + "test", + "src/index.d.ts", + "package.json", + "React.luau", + "Charm.luau", +] + +[dependencies] +Charm = "littensy/charm@^0.11.0-rc.6" +React = "jsdotlua/react@^17" diff --git a/packages/react/package.json b/packages/react/package.json deleted file mode 100644 index df178aa..0000000 --- a/packages/react/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@rbxts/react-charm", - "version": "0.3.0", - "description": "React bindings for Charm", - "repository": "littensy/charm", - "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", - "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", - "publish:wally": "wally publish" - }, - "peerDependencies": { - "@rbxts/charm": "workspace:*", - "@rbxts/react": "*" - } -} diff --git a/packages/react/src/index.d.ts b/packages/react/src/index.d.ts deleted file mode 100644 index e3eb917..0000000 --- a/packages/react/src/index.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Selector } from "@rbxts/charm"; - -/** - * A hook that subscribes to changes in the given atom or selector. The - * component is re-rendered whenever the state changes. - * - * If the `dependencies` array is provided, the subscription to the atom or - * selector is re-created whenever the dependencies change. Otherwise, the - * subscription is created once when the component is mounted. - * - * @param callback The atom or selector to subscribe to. - * @param dependencies An array of values that the subscription depends on. - * @returns The current state. - */ -export function useAtom(callback: Selector, dependencies?: unknown[]): State; diff --git a/packages/react/src/init.luau b/packages/react/src/init.luau deleted file mode 100644 index e200551..0000000 --- a/packages/react/src/init.luau +++ /dev/null @@ -1,31 +0,0 @@ -local Charm = require(script.Parent.Charm) -local React = if script.Parent:FindFirstChild("react") - then require(script.Parent.react) :: never - else require(script.Parent.React) - ---[=[ - A hook that subscribes to changes in the given atom or selector. The - component is re-rendered whenever the state changes. - - If the `dependencies` array is provided, the subscription to the atom or - selector is re-created whenever the dependencies change. Otherwise, the - subscription is created once when the component is mounted. - - @param callback The atom or selector to subscribe to. - @param dependencies An array of values that the subscription depends on. - @return The current state. -]=] -local function useAtom(callback: Charm.Selector, dependencies: { any }?): State - local state, setState = React.useState(callback) - - React.useEffect(function() - setState(callback()) - return Charm.subscribe(callback, setState) - end, dependencies or {}) - - return state -end - -return { - useAtom = useAtom, -} diff --git a/packages/react/wally.toml b/packages/react/wally.toml deleted file mode 100644 index 91073ad..0000000 --- a/packages/react/wally.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "littensy/react-charm" -version = "0.3.0" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" - -[dependencies] -charm = "littensy/charm@^0.10" -React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau new file mode 100644 index 0000000..49baa5a --- /dev/null +++ b/packages/vide-charm/Charm.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.charm.src) diff --git a/packages/vide-charm/Vide.luau b/packages/vide-charm/Vide.luau new file mode 100644 index 0000000..0c1ac14 --- /dev/null +++ b/packages/vide-charm/Vide.luau @@ -0,0 +1 @@ +return require(script.Parent.Parent.vide.src) diff --git a/packages/vide/default.project.json b/packages/vide-charm/default.project.json similarity index 60% rename from packages/vide/default.project.json rename to packages/vide-charm/default.project.json index 3a447c6..e2a35ad 100644 --- a/packages/vide/default.project.json +++ b/packages/vide-charm/default.project.json @@ -1,5 +1,5 @@ { - "name": "VideCharm", + "name": "vide-charm", "tree": { "$path": "src" } diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json new file mode 100644 index 0000000..2b4517a --- /dev/null +++ b/packages/vide-charm/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rbxts/vide-charm", + "version": "0.4.0-rc.4", + "description": "Vide bindings for Charm state management", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "files": [ + "src", + "*.luau", + "!wally.toml", + "!wally.lock", + "!default.project.json", + "!test" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/littensy/charm.git" + }, + "scripts": { + "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:^" + }, + "peerDependencies": { + "@rbxts/vide": "*" + } +} diff --git a/packages/vide-charm/src/index.d.ts b/packages/vide-charm/src/index.d.ts new file mode 100644 index 0000000..eb12134 --- /dev/null +++ b/packages/vide-charm/src/index.d.ts @@ -0,0 +1,11 @@ +/** + * Returns a reactive source that subscribes to the current value of the given + * signal or getter function. + * + * @param getter The getter function to subscribe to. + * @return The reactive source. + */ +export function useSignalState(getter: () => T, dependencies?: any[]): () => T; + +// Aliases +export { useSignalState as useAtom }; diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau new file mode 100644 index 0000000..5a27f54 --- /dev/null +++ b/packages/vide-charm/src/init.luau @@ -0,0 +1,22 @@ +local Charm = require("./Charm") +local Vide = require("./Vide") + +--[[ + Returns a reactive source that subscribes to the current value of the given + signal or getter function. + + @param getter The getter function to subscribe to. + @return The reactive source. +]] +local function useSignalState(getter: () -> State): () -> State + local state = Vide.source(getter()) + + Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ())) + + return state +end + +return { + useSignalState = useSignalState, + useAtom = useSignalState, +} diff --git a/packages/vide-charm/test/init.luau b/packages/vide-charm/test/init.luau new file mode 100644 index 0000000..7b99c24 --- /dev/null +++ b/packages/vide-charm/test/init.luau @@ -0,0 +1,7 @@ +local test = require("../../tests/test") + +test.describe("useSignalState", function() + require("@self/useSignalState.test") +end) + +return {} diff --git a/packages/vide-charm/test/useSignalState.test.luau b/packages/vide-charm/test/useSignalState.test.luau new file mode 100644 index 0000000..9661501 --- /dev/null +++ b/packages/vide-charm/test/useSignalState.test.luau @@ -0,0 +1,59 @@ +local Charm = require("../Charm") +local Vide = require("../Vide") +local VideCharm = require("../src") +local test = require("../../../tests/test") + +test("tracks signal state", function() + local getState, setState = Charm.signal(0) + local source: () -> number + local cleanup = Vide.root(function() + source = VideCharm.useSignalState(getState) + end) + assert(source() == 0, "should return 0") + setState(1) + assert(source() == 1, "should return 1") + cleanup() +end) + +test("accepts a selector", function() + local getState, setState = Charm.signal(0) + local source: () -> number + local cleanup = Vide.root(function() + source = VideCharm.useSignalState(function() + return 2 * getState() + end) + end) + assert(source() == 0, "should return 0") + setState(1) + assert(source() == 2, "should return 2") + cleanup() +end) + +test("updates source in untracked", function() + local getOuter, setOuter = Charm.signal(nil :: any) + local getInner, setInner = Charm.signal(0) + local spyCalls = 0 + local cleanup = Vide.root(function() + return Vide.show(VideCharm.useSignalState(getOuter), function() + -- Charm effects created in Vide scopes triggered by useSignalState might + -- incorrectly get treated as an inner effect in useSignalState. This test + -- ensures that doesn't happen. + Vide.cleanup(Charm.effect(function() + getInner() + spyCalls += 1 + end)) + return nil + end) + end) + assert(spyCalls == 0, "should not call spy initially") + setOuter(true) + assert(spyCalls == 1, "should call spy when visible") + setInner(1) + assert(spyCalls == 2, "spy should run when counter updates") + setOuter({}) -- set to a truthy value to trigger an outer effect re-run + setInner(2) + assert(spyCalls == 3, "spy should still run when counter updates") + cleanup() +end) + +return {} diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml new file mode 100644 index 0000000..5c59947 --- /dev/null +++ b/packages/vide-charm/wally.toml @@ -0,0 +1,17 @@ +[package] +name = "littensy/vide-charm" +version = "0.4.0-rc.5" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +exclude = [ + "node_modules", + "test", + "src/index.d.ts", + "package.json", + "Vide.luau", + "Charm.luau", +] + +[dependencies] +Charm = "littensy/charm@^0.11.0-rc.6" +Vide = "centau/vide@^0.4" diff --git a/packages/vide/package.json b/packages/vide/package.json deleted file mode 100644 index adfcef4..0000000 --- a/packages/vide/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@rbxts/vide-charm", - "version": "0.3.0", - "description": "Vide bindings for Charm", - "repository": "littensy/charm", - "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", - "scripts": { - "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", - "publish:wally": "wally publish" - }, - "peerDependencies": { - "@rbxts/charm": "workspace:*", - "@rbxts/vide": "*" - } -} diff --git a/packages/vide/src/index.d.ts b/packages/vide/src/index.d.ts deleted file mode 100644 index 21ace65..0000000 --- a/packages/vide/src/index.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Selector } from "@rbxts/charm"; - -/** - * Subscribes to the state of an atom and returns a Vide source. - * - * @param callback The atom or selector to subscribe to. - * @returns The reactive source. - */ -export function useAtom(callback: Selector): () => T; diff --git a/packages/vide/src/init.luau b/packages/vide/src/init.luau deleted file mode 100644 index df962b2..0000000 --- a/packages/vide/src/init.luau +++ /dev/null @@ -1,25 +0,0 @@ -local Charm = require(script.Parent.Charm) -local Vide = if script.Parent:FindFirstChild("vide") - then require(script.Parent.vide.src) :: never - else require(script.Parent.Vide) - ---[=[ - Subscribes to the state of an atom and returns a Vide source. - - @param callback The atom or selector to subscribe to. - @return The reactive source. -]=] -local function useAtom(callback: () -> State): () -> State - local state = Vide.source(callback()) - local unsubscribe = Charm.subscribe(callback, function(value) - task.spawn(state :: (State) -> State, value) - end) - - Vide.cleanup(unsubscribe) - - return state -end - -return { - useAtom = useAtom, -} diff --git a/packages/vide/wally.toml b/packages/vide/wally.toml deleted file mode 100644 index de0f253..0000000 --- a/packages/vide/wally.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "littensy/vide-charm" -version = "0.3.0" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" - -[dependencies] -charm = "littensy/charm@^0.10" -Vide = "centau/vide@^0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90e0e73..3c0486d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,257 +8,366 @@ importers: .: devDependencies: + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 + '@rbxts/react': + specifier: 17.3.7-ts.1 + version: 17.3.7-ts.1 + '@rbxts/react-roblox': + specifier: 17.3.7-ts.1 + version: 17.3.7-ts.1(@rbxts/react@17.3.7-ts.1) '@rbxts/types': - specifier: ^1.0.811 - version: 1.0.811 + specifier: ^1.0.913 + version: 1.0.913 + '@rbxts/vide': + specifier: ^0.6.1 + version: 0.6.1 '@typescript-eslint/eslint-plugin': - specifier: ^7.18.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) + specifier: ^8.58.0 + version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^7.18.0 - version: 7.18.0(eslint@8.57.1)(typescript@5.5.4) + specifier: ^8.58.0 + version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) eslint: - specifier: ^8.57.1 - version: 8.57.1 + specifier: ^9.39.4 + version: 9.39.4 eslint-config-prettier: - specifier: ^9.1.0 - version: 9.1.0(eslint@8.57.1) + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.4) eslint-plugin-prettier: - specifier: ^5.2.1 - version: 5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3) + specifier: ^5.5.5 + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1) eslint-plugin-roblox-ts: - specifier: ^0.0.36 - version: 0.0.36(eslint@8.57.1) + specifier: ^1.4.0 + version: 1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) prettier: - specifier: ^3.3.3 - version: 3.3.3 + specifier: ^3.8.1 + version: 3.8.1 roblox-ts: specifier: ^3.0.0 version: 3.0.0 typescript: - specifier: ~5.5.4 - version: 5.5.4 + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.58.0 + version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) packages/charm: {} packages/charm-sync: dependencies: '@rbxts/charm': - specifier: workspace:* + specifier: workspace:^ version: link:../charm - packages/react: + packages/deep-charm: dependencies: '@rbxts/charm': - specifier: workspace:* + specifier: workspace:^ + version: link:../charm + + packages/react-charm: + dependencies: + '@rbxts/charm': + specifier: workspace:^ version: link:../charm '@rbxts/react': - specifier: '*' + specifier: '>=17.0.0' version: 17.2.3 - packages/vide: + packages/vide-charm: dependencies: '@rbxts/charm': - specifier: workspace:* + specifier: workspace:^ version: link:../charm '@rbxts/vide': specifier: '*' - version: 0.5.3 + version: 0.6.0 packages: - '@eslint-community/eslint-utils@4.4.0': - resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.11.1': - resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@pkgr/core@0.1.1': - resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@rbxts-js/boolean@1.2.3-ts.1': + resolution: {integrity: sha512-IlYFG9uatXQKXOLR1mdptVeEcxudwBCierO+Vu6VZRRaI+/+JDxUClm+QFZyce/kitwnDTmtNvhqVAUtFvCU0Q==} + + '@rbxts-js/collections@1.2.3-ts.1': + resolution: {integrity: sha512-RYT73QyEdsV+DCQMtyzT2bZ1P12j2b+JSHVdb1x6xrDq/KdVEkk/QAzW+o22vPrLXbHa6/jA5bmVlgVcOOrIjA==} + + '@rbxts-js/console@1.2.3-ts.1': + resolution: {integrity: sha512-qacyJE7bYf4n9448F7mn9e8Y5qljZb7QkSmsmnbQT2HA9Mecvg3gqc9RwCIdVkBV6o1AGVAHbatNQ3a39zPjaw==} + + '@rbxts-js/es7-types@1.2.3-ts.1': + resolution: {integrity: sha512-Z7NitBQp6tyzsr5eKlSX54rmopKXpzGu33lYikjF37llrnGFBpnRcZgCJwHKYciloRmjUtnR/UohUNwbWyG9Zg==} + + '@rbxts-js/instance-of@1.2.3-ts.1': + resolution: {integrity: sha512-3fKvDPvxHuEZALNBP2XdbXfzjEfbgsXAheCm1Jf5kC2r+HgszW+yqg4zY6TjW0pcsJM2JjH+fM52oomSNu3LLQ==} + + '@rbxts-js/luau-polyfill@1.2.3-ts.1': + resolution: {integrity: sha512-2asgvWrOhf311rNgEM/fxMO3+xOVI63Cz1K1PobyZVtDdd87ZJ6RGiZSFl1EAeU77SZc8280toJDVs9YSwxWnA==} + + '@rbxts-js/math@1.2.3-ts.1': + resolution: {integrity: sha512-mF7IMJJyxYE02cRNWJ1wsq4pPaAEXKX+CbGlmI0G3QBgWVHj+Oi02IR6pssyUyE6Y2BKvgvP3dcJuTO51OX25A==} + + '@rbxts-js/number@1.2.3-ts.1': + resolution: {integrity: sha512-tPdt2s7fEBSxImVFBUvuSMtBKIvaZUCtlOjBl58Y0+GKB15VPXFIRW/5biVruXPnv9d4sDE3mh986ZxxN4UQ5w==} + + '@rbxts-js/react-globals@17.3.7-ts.1': + resolution: {integrity: sha512-vwMHsc3Oy2MDAw2M212nQJ8A7nEG5O/lyXDyMGzYJp4832awhrf34YCkU4dxBE4sdMDcoWykhmuPjmzwrcS9Eg==} + + '@rbxts-js/react-reconciler@17.3.7-ts.1': + resolution: {integrity: sha512-MxKU48i1gv4djcgHTq3geMvl6MzXPW85FO+itLg0YCvqT3WgE3jsMAc0nneOFi5Mwd5tqybQY6UFF1SbQtBopA==} + + '@rbxts-js/react-roblox@17.3.7-ts.1': + resolution: {integrity: sha512-xiJQXmJAho/mStcWYC7AXFqgUqW78whUMacaNeODMimQXgoVqaIfo0sEhLtLtS/N3qZRs1KR64nsNG/0n1r/NQ==} + + '@rbxts-js/react@17.3.7-ts.1': + resolution: {integrity: sha512-nRHYoBzk9DSbOvk281jmN5EM3TlmKv2jOzLgSIGtmNMuwZKi9yEjjxdluS1wHmjSvus1YiPHhBUwrKnbu01Obg==} + + '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': + resolution: {integrity: sha512-H8fxKVJkRnM60yTZ/riO4X43GZD/MViFm+ftCzzAALkAhE4vSaBDNv9E7IUjZZoWi21bG6vZ8mGIiYfavaljvw==} + + '@rbxts-js/safe-flags@17.3.7-ts.1': + resolution: {integrity: sha512-aQ/5aQ/wQO5npifAbTEpR3jQRMeyGsVc91AQEyhBW461lPv6ZEg+Ad2HJFSzg4eeK2mb8Z9WyIMBrb01Jt+GnQ==} + + '@rbxts-js/scheduler@17.3.7-ts.1': + resolution: {integrity: sha512-WCgGQqH9/+A5oJd6HpxMj7Vp1VGqhycnJK0CZTsJl/Ceyf/VH/vif8fo1FrBWkxYEgz/7y0q3LOeRj8ArhyDWQ==} + + '@rbxts-js/shared@17.3.7-ts.1': + resolution: {integrity: sha512-B/OCkRmbR6ho1yuf0M0hQIb78coDY//2XdmNBaX2XCN5pKa7dquiX6ufk4dizJ/OMBoy9br/cnxqcI0PLvla3w==} + + '@rbxts-js/string@1.2.3-ts.1': + resolution: {integrity: sha512-Je/WKgZbXF/sAHEsQPuns5n4rWzux327zjr0KRf9Gy62ieUzl2FRZZHjVYce3xdMtHif2JstIf/J3p513TUtRg==} + + '@rbxts-js/symbol-luau@1.0.0-ts.1': + resolution: {integrity: sha512-TQS/ImDCK5KPGvbmyScH5EZS0ELSCDvZEFR3W+ItPlsLl+bDRSI/0uaKtJ7rbwXgdGwBDKM1QXFPjqe+KOphIA==} + + '@rbxts-js/timers@1.2.3-ts.1': + resolution: {integrity: sha512-HKP4ymFX3QLWbR/2Gy4LXwvkhjC9OzbwZ5bNunxu+M8Ocbi1cjRduiZK/y5ogLr9cNIfSoxs6e1DQ3soc8/hwA==} + '@rbxts/compiler-types@3.0.0-types.0': resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} + '@rbxts/react-roblox@17.3.7-ts.1': + resolution: {integrity: sha512-lsH3lP+X0JUS/6Fg5MJfP8OjMn7bjxapbg3r1ajrpvwZGPGU1WOFcFDS/xMMk/2RK/GNYRmkduNM8pQ9vHw54Q==} + peerDependencies: + '@rbxts/react': ^17.3.7-ts.1 + '@rbxts/react-vendor@17.2.3': resolution: {integrity: sha512-dWCiVelvTK6h920geaFH/OQ5zoWHyhyqyfH51hKx2/iVZ6OiiyNVJ5UqEIkJMG4HsJ8JnkTORizV3+MtQ41BtA==} '@rbxts/react@17.2.3': resolution: {integrity: sha512-IoOB8e2W8MxT0MKWFf0Ls0vT61ImYL6ctHQgz5+oazma6z8bqFtJjgPxOuy6jmMSuyfBe7rYp/muou0kIjZT+Q==} - '@rbxts/types@1.0.811': - resolution: {integrity: sha512-MPhs5lxzxvDqie34Y9FzJ1R+k+33jjrkj03agX2y2WzBKyGmO0rD/kPuUaKXQgEiACXuk36C5FxUTYgkKJPbvA==} + '@rbxts/react@17.3.7-ts.1': + resolution: {integrity: sha512-/jqZ2TvyLSAPwC30h95loUmy4XN2PwPhv6fBARJKtZfyozFx2RuxC9FiQSjpEZ2uOOPzr60It0MBUf94D+tsMg==} - '@rbxts/vide@0.5.3': - resolution: {integrity: sha512-evaZbBRZzaqU8ibddTMaomb30pmW5OhWTT7ba1PLSA/iGBCid0eOF6BX1o0ArKPyMFnNj6pMM2yMTQzL5b9cIA==} + '@rbxts/types@1.0.913': + resolution: {integrity: sha512-AweuNYRVJb6y4PNajx2mnpqv+hm0+8GWtH9Dj4EMUuOVN5qcfcOMNSqjvQ4dmF5u0yQ89HKGeXa+4sLnaTIEng==} + + '@rbxts/vide@0.6.0': + resolution: {integrity: sha512-OLfs4kqV2IP79xS+gZAprMujuYEc+n0JwMhJF+puNVoxBXSzOv2kKlgeLJhscCy7Jdhr9TbqsaRNLJP5akbfog==} + + '@rbxts/vide@0.6.1': + resolution: {integrity: sha512-syFqx+HYXy0TauDCGLhc68K2cAlfgYookIJSRtJZ01W7mUI+EKyj80x2L91dQxos3BwAAGJqBD/ovWU4M//+Lg==} '@roblox-ts/luau-ast@2.0.0': resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} + '@roblox-ts/luau-ast@2.0.1': + resolution: {integrity: sha512-jej2mzRGwhR9wu4woAtgTwqsKADlegZYlQ+M5qVHRojvjoyJVFkLrSbhBKRr8HR+fVW5nCz0qX7kNa0tSpAY+A==} + '@roblox-ts/path-translator@1.1.0': resolution: {integrity: sha512-D0akTmnNYqBw+ZIek5JxocT3BjmbgGOuOy0x1nIIxHBPNLGCpzseToY8jyYs/0mlvnN2xnSP/k8Tv+jvGOQSwQ==} '@roblox-ts/rojo-resolver@1.1.0': resolution: {integrity: sha512-QmvVryu1EeME+3QUoG5j/gHGJoJUaffCgZ92mhlG7cJSd1uyhgpY4CNWriZAwZJYkTlzd5Htkpn+18yDFbOFXA==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@20.16.11': - resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==} - - '@types/semver@7.5.8': - resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} - - '@typescript-eslint/eslint-plugin@7.18.0': - resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^7.0.0 - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/parser': ^8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/experimental-utils@5.62.0': - resolution: {integrity: sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@7.18.0': - resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@5.62.0': - resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@typescript-eslint/scope-manager@7.18.0': - resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} - engines: {node: ^18.18.0 || >=20.0.0} - - '@typescript-eslint/type-utils@7.18.0': - resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@5.62.0': - resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/scope-manager@8.56.0': + resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@7.18.0': - resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@5.62.0': - resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@7.18.0': - resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@5.62.0': - resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@7.18.0': - resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@5.62.0': - resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@7.18.0': - resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.2.0': - resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.12.1: - resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -275,22 +384,26 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -322,12 +435,12 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -338,14 +451,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -357,19 +462,19 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-prettier@9.1.0: - resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' - eslint-plugin-prettier@5.2.1: - resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==} + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' eslint: '>=8.0.0' - eslint-config-prettier: '*' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' prettier: '>=3.0.0' peerDependenciesMeta: '@types/eslint': @@ -377,44 +482,52 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-roblox-ts@0.0.36: - resolution: {integrity: sha512-vlbgfHY1heWSyDunVX3gIOamCy28KqrJlDkmuDOyYVBo8uLqdvbFkbWkdGxn59Vky1m6pFcvZGU6TObfmwm5Pg==} - engines: {node: '>=0.10.0'} + eslint-plugin-roblox-ts@1.4.0: + resolution: {integrity: sha512-OWj+JfQlvGSiAJ5YOmiQc+M29ZJFc7VFYP0U1J3orZNT9GP5CWfpRNqPHuDhsvz+ni0BwoI4aqGv2bIPDueGrQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=22.5.1} + peerDependencies: + '@typescript-eslint/parser': '>=6.0.0' + eslint: '>=8.0.0' + typescript: '>=5.2.0' - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -429,25 +542,27 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-glob@3.3.2: - resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.0.2: - resolution: {integrity: sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fastq@1.17.1: - resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} @@ -457,20 +572,17 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} - flatted@3.3.1: - resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - fs-extra@11.2.0: - resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -491,24 +603,13 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -521,27 +622,24 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-core-module@2.15.1: - resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} is-extglob@2.1.1: @@ -560,15 +658,11 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true json-buffer@3.0.1: @@ -583,8 +677,8 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -604,19 +698,15 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} ms@2.1.3: @@ -629,9 +719,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -652,10 +739,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -663,24 +746,24 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.0: - resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} - prettier@3.3.3: - resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -688,9 +771,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -707,28 +787,17 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve@1.22.8: - resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} - hasBin: true - - reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} hasBin: true roblox-ts@3.0.0: resolution: {integrity: sha512-hwAC2frIFlLJOtHd6F+5opMEhBgfAMK9z5l1mP+bykLBbMO5cn1q5lIwhhXbeh9Pq07rlhF8uGHlmeRLPd/3AA==} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -740,10 +809,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -764,56 +829,45 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.9.2: - resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-api-utils@1.3.0: - resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} - engines: {node: '>=16'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} peerDependencies: - typescript: '>=4.2.0' - - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - - tslib@2.7.0: - resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} - - tsutils@3.21.0: - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + typescript: '>=4.8.4' type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' typescript@5.5.3: resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} engines: {node: '>=14.17'} hasBin: true - typescript@5.5.4: - resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -834,9 +888,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -855,236 +906,368 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: - eslint: 8.57.1 + eslint: 9.39.4 eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.11.1': {} + '@eslint-community/regexpp@4.12.2': {} - '@eslint/eslintrc@2.1.4': + '@eslint/config-array@0.21.2': dependencies: - ajv: 6.12.6 - debug: 4.3.7 - espree: 9.6.1 - globals: 13.24.0 + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 ignore: 5.3.2 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@8.57.1': {} + '@eslint/js@9.39.4': {} - '@humanwhocodes/config-array@0.13.0': + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.7 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/object-schema@2.0.3': {} + '@humanwhocodes/retry@0.4.3': {} - '@nodelib/fs.scandir@2.1.5': + '@pkgr/core@0.2.9': {} + + '@rbxts-js/boolean@1.2.3-ts.1': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 + '@rbxts-js/collections': 1.2.3-ts.1 - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': + '@rbxts-js/collections@1.2.3-ts.1': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.17.1 + '@rbxts-js/es7-types': 1.2.3-ts.1 + '@rbxts-js/instance-of': 1.2.3-ts.1 - '@pkgr/core@0.1.1': {} + '@rbxts-js/console@1.2.3-ts.1': + dependencies: + '@rbxts-js/collections': 1.2.3-ts.1 + + '@rbxts-js/es7-types@1.2.3-ts.1': {} + + '@rbxts-js/instance-of@1.2.3-ts.1': {} + + '@rbxts-js/luau-polyfill@1.2.3-ts.1': + dependencies: + '@rbxts-js/boolean': 1.2.3-ts.1 + '@rbxts-js/collections': 1.2.3-ts.1 + '@rbxts-js/console': 1.2.3-ts.1 + '@rbxts-js/es7-types': 1.2.3-ts.1 + '@rbxts-js/instance-of': 1.2.3-ts.1 + '@rbxts-js/math': 1.2.3-ts.1 + '@rbxts-js/number': 1.2.3-ts.1 + '@rbxts-js/string': 1.2.3-ts.1 + '@rbxts-js/symbol-luau': 1.0.0-ts.1 + '@rbxts-js/timers': 1.2.3-ts.1 + + '@rbxts-js/math@1.2.3-ts.1': {} + + '@rbxts-js/number@1.2.3-ts.1': {} + + '@rbxts-js/react-globals@17.3.7-ts.1': + dependencies: + '@rbxts-js/safe-flags': 17.3.7-ts.1 + + '@rbxts-js/react-reconciler@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react': 17.3.7-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/roblox-lua-promise': 3.5.0-ts.2 + '@rbxts-js/safe-flags': 17.3.7-ts.1 + '@rbxts-js/scheduler': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 + + '@rbxts-js/react-roblox@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react': 17.3.7-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/react-reconciler': 17.3.7-ts.1 + '@rbxts-js/scheduler': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 + + '@rbxts-js/react@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 + + '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': {} + + '@rbxts-js/safe-flags@17.3.7-ts.1': {} + + '@rbxts-js/scheduler@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/safe-flags': 17.3.7-ts.1 + '@rbxts-js/shared': 17.3.7-ts.1 + + '@rbxts-js/shared@17.3.7-ts.1': + dependencies: + '@rbxts-js/luau-polyfill': 1.2.3-ts.1 + '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/safe-flags': 17.3.7-ts.1 + + '@rbxts-js/string@1.2.3-ts.1': + dependencies: + '@rbxts-js/es7-types': 1.2.3-ts.1 + '@rbxts-js/number': 1.2.3-ts.1 + + '@rbxts-js/symbol-luau@1.0.0-ts.1': {} + + '@rbxts-js/timers@1.2.3-ts.1': + dependencies: + '@rbxts-js/collections': 1.2.3-ts.1 '@rbxts/compiler-types@3.0.0-types.0': {} + '@rbxts/react-roblox@17.3.7-ts.1(@rbxts/react@17.3.7-ts.1)': + dependencies: + '@rbxts-js/react-roblox': 17.3.7-ts.1 + '@rbxts/react': 17.3.7-ts.1 + '@rbxts/react-vendor@17.2.3': {} '@rbxts/react@17.2.3': dependencies: '@rbxts/react-vendor': 17.2.3 - '@rbxts/types@1.0.811': {} + '@rbxts/react@17.3.7-ts.1': + dependencies: + '@rbxts-js/react': 17.3.7-ts.1 - '@rbxts/vide@0.5.3': {} + '@rbxts/types@1.0.913': {} + + '@rbxts/vide@0.6.0': {} + + '@rbxts/vide@0.6.1': {} '@roblox-ts/luau-ast@2.0.0': {} + '@roblox-ts/luau-ast@2.0.1': {} + '@roblox-ts/path-translator@1.1.0': dependencies: - ajv: 8.17.1 - fs-extra: 11.2.0 + ajv: 8.18.0 + fs-extra: 11.3.4 '@roblox-ts/rojo-resolver@1.1.0': dependencies: - ajv: 8.17.1 - fs-extra: 11.2.0 + ajv: 8.18.0 + fs-extra: 11.3.4 + + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} - '@types/node@20.16.11': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: - undici-types: 6.19.8 - - '@types/semver@7.5.8': {} - - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@eslint-community/regexpp': 4.11.1 - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 9.39.4 + ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': + '@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) - eslint: 8.57.1 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7 - eslint: 8.57.1 - optionalDependencies: - typescript: 5.5.4 + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@5.62.0': + '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - - '@typescript-eslint/scope-manager@7.18.0': - dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - - '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': - dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) - debug: 4.3.7 - eslint: 8.57.1 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + debug: 4.4.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@5.62.0': {} - - '@typescript-eslint/types@7.18.0': {} - - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.5.4)': + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.7 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.6.3 - tsutils: 3.21.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.5.4)': + '@typescript-eslint/scope-manager@8.56.0': dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.5.8 - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.4) - eslint: 8.57.1 - eslint-scope: 5.1.1 - semver: 7.6.3 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - - typescript - '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': + '@typescript-eslint/types@8.56.0': {} + + '@typescript-eslint/types@8.58.0': {} + + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - eslint: 8.57.1 + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + minimatch: 9.0.9 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - - typescript - '@typescript-eslint/visitor-keys@5.62.0': + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 5.62.0 - eslint-visitor-keys: 3.4.3 + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - '@typescript-eslint/visitor-keys@7.18.0': + '@typescript-eslint/utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 7.18.0 - eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - '@ungap/structured-clone@1.2.0': {} - - acorn-jsx@5.3.2(acorn@8.12.1): + '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - acorn: 8.12.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - acorn@8.12.1: {} + '@typescript-eslint/visitor-keys@8.56.0': + dependencies: + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.1 - ajv@6.12.6: + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.0.2 + fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -1097,25 +1280,29 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 argparse@2.0.1: {} - array-union@2.1.0: {} - balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + binary-extensions@2.3.0: {} - brace-expansion@1.1.11: + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.1: + brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1153,116 +1340,105 @@ snapshots: concat-map@0.0.1: {} - cross-spawn@7.0.3: + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - debug@4.3.7: + debug@4.4.3: dependencies: ms: 2.1.3 deep-is@0.1.4: {} - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - emoji-regex@8.0.0: {} escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@9.1.0(eslint@8.57.1): + eslint-config-prettier@10.1.8(eslint@9.39.4): dependencies: - eslint: 8.57.1 + eslint: 9.39.4 - eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1): dependencies: - eslint: 8.57.1 - prettier: 3.3.3 - prettier-linter-helpers: 1.0.0 - synckit: 0.9.2 + eslint: 9.39.4 + prettier: 3.8.1 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 9.1.0(eslint@8.57.1) + eslint-config-prettier: 10.1.8(eslint@9.39.4) - eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1): + eslint-plugin-roblox-ts@1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3): dependencies: - '@types/node': 20.16.11 - '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) - typescript: 5.5.4 + '@roblox-ts/luau-ast': 2.0.1 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 transitivePeerDependencies: - - eslint - supports-color - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - - eslint-scope@7.2.2: + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint@8.57.1: + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@eslint-community/regexpp': 4.11.1 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.2.0 - ajv: 6.12.6 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 chalk: 4.1.2 - cross-spawn: 7.0.3 - debug: 4.3.7 - doctrine: 3.0.0 + cross-spawn: 7.0.6 + debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 - esquery: 1.6.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 + file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.0 json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 transitivePeerDependencies: - supports-color - espree@9.6.1: + espree@10.4.0: dependencies: - acorn: 8.12.1 - acorn-jsx: 5.3.2(acorn@8.12.1) - eslint-visitor-keys: 3.4.3 + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -1270,8 +1446,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} esutils@2.0.3: {} @@ -1280,27 +1454,19 @@ snapshots: fast-diff@1.3.0: {} - fast-glob@3.3.2: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fast-uri@3.0.2: {} + fast-uri@3.1.0: {} - fastq@1.17.1: - dependencies: - reusify: 1.0.4 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 - file-entry-cache@6.0.1: + file-entry-cache@8.0.0: dependencies: - flat-cache: 3.2.0 + flat-cache: 4.0.1 fill-range@7.1.1: dependencies: @@ -1311,22 +1477,19 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@3.2.0: + flat-cache@4.0.1: dependencies: - flatted: 3.3.1 + flatted: 3.4.2 keyv: 4.5.4 - rimraf: 3.0.2 - flatted@3.3.1: {} + flatted@3.4.2: {} - fs-extra@11.2.0: + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.1.0 + jsonfile: 6.2.0 universalify: 2.0.1 - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true @@ -1342,32 +1505,10 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - globals@13.24.0: - dependencies: - type-fest: 0.20.2 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.2 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 + globals@14.0.0: {} graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - has-flag@4.0.0: {} hasown@2.0.2: @@ -1376,25 +1517,20 @@ snapshots: ignore@5.3.2: {} - import-fresh@3.3.0: + ignore@7.0.5: {} + + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 imurmurhash@0.1.4: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - is-core-module@2.15.1: + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -1408,11 +1544,9 @@ snapshots: is-number@7.0.0: {} - is-path-inside@3.0.3: {} - isexe@2.0.0: {} - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -1424,7 +1558,7 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - jsonfile@6.1.0: + jsonfile@6.2.0: dependencies: universalify: 2.0.1 optionalDependencies: @@ -1447,20 +1581,17 @@ snapshots: lodash.merge@4.6.2: {} - merge2@1.4.1: {} - - micromatch@4.0.8: + minimatch@10.2.5: dependencies: - braces: 3.0.3 - picomatch: 2.3.1 + brace-expansion: 5.0.5 - minimatch@3.1.2: + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.13 - minimatch@9.0.5: + minimatch@9.0.9: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.0.3 ms@2.1.3: {} @@ -1468,10 +1599,6 @@ snapshots: normalize-path@3.0.0: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1495,31 +1622,27 @@ snapshots: path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-parse@1.0.7: {} - path-type@4.0.0: {} + picomatch@2.3.2: {} - picomatch@2.3.1: {} + picomatch@4.0.4: {} prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.0: + prettier-linter-helpers@1.0.1: dependencies: fast-diff: 1.3.0 - prettier@3.3.3: {} + prettier@3.8.1: {} punycode@2.3.1: {} - queue-microtask@1.2.3: {} - readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 require-directory@2.1.1: {} @@ -1527,35 +1650,25 @@ snapshots: resolve-from@4.0.0: {} - resolve@1.22.8: + resolve@1.22.11: dependencies: - is-core-module: 2.15.1 + is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - reusify@1.0.4: {} - - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - roblox-ts@3.0.0: dependencies: '@roblox-ts/luau-ast': 2.0.0 '@roblox-ts/path-translator': 1.1.0 '@roblox-ts/rojo-resolver': 1.1.0 chokidar: 3.6.0 - fs-extra: 11.2.0 + fs-extra: 11.3.4 kleur: 4.1.5 - resolve: 1.22.8 + resolve: 1.22.11 typescript: 5.5.3 yargs: 17.7.2 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - semver@7.6.3: {} + semver@7.7.4: {} shebang-command@2.0.0: dependencies: @@ -1563,8 +1676,6 @@ snapshots: shebang-regex@3.0.0: {} - slash@3.0.0: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -1583,41 +1694,41 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.9.2: + synckit@0.11.12: dependencies: - '@pkgr/core': 0.1.1 - tslib: 2.7.0 + '@pkgr/core': 0.2.9 - text-table@0.2.0: {} + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ts-api-utils@1.3.0(typescript@5.5.4): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: - typescript: 5.5.4 - - tslib@1.14.1: {} - - tslib@2.7.0: {} - - tsutils@3.21.0(typescript@5.5.4): - dependencies: - tslib: 1.14.1 - typescript: 5.5.4 + typescript: 5.9.3 type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - type-fest@0.20.2: {} + typescript-eslint@8.58.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color typescript@5.5.3: {} - typescript@5.5.4: {} - - undici-types@6.19.8: {} + typescript@5.9.3: {} universalify@2.0.1: {} @@ -1637,8 +1748,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrappy@1.0.2: {} - y18n@5.0.8: {} yargs-parser@21.1.1: {} diff --git a/rokit.toml b/rokit.toml index 02a00e8..9348712 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,12 +1,6 @@ -# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. -# For more information, see https://github.com/rojo-rbx/rokit - -# New tools can be added by running `rokit add ` in a terminal. - [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.4.4" -wally-package-types = "JohnnyMorganz/wally-package-types@1.4.2" -stylua = "johnnymorganz/stylua@2.0.2" -selene = "Kampfkarren/selene@0.28.0" -luau-lsp = "johnnymorganz/luau-lsp@1.38.1" +rojo = "rojo-rbx/rojo@7.6.1" +stylua = "johnnymorganz/stylua@2.5.2" +luau-lsp = "johnnymorganz/luau-lsp@1.68.1" +lute = "luau-lang/lute@1.0.0" diff --git a/selene.toml b/selene.toml deleted file mode 100644 index 56479a7..0000000 --- a/selene.toml +++ /dev/null @@ -1,5 +0,0 @@ -std = "roblox+testez" - -[lints] -global_usage = "allow" -shadowing = "allow" 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/atom.bench.luau deleted file mode 100644 index 3bae3ca..0000000 --- a/tests/benchmarks/atom.bench.luau +++ /dev/null @@ -1,30 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local computed = Charm.computed -local atom = Charm.atom - -return { - ParameterGenerator = function() end, - Functions = { - atom = function() - local state = atom(0) - - for _ = 1, 500 do - state(math.random(1, 2)) - end - end, - - computed = function() - local state = atom(0) - - local _double = computed(function() - return 2 * state() - end) - - for _ = 1, 500 do - state(math.random(1, 2)) - end - end, - }, -} diff --git a/tests/benchmarks/patch.bench.luau b/tests/benchmarks/patch.bench.luau deleted file mode 100644 index 6ea53f6..0000000 --- a/tests/benchmarks/patch.bench.luau +++ /dev/null @@ -1,48 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) - -local USERS = 1000 - -local function generate() - local users = {} - - for user = 1, USERS do - users[user] = { - coins = math.random(1, 2), - gems = math.random(1, 2), - } - end - - return { users = users } -end - -local function update(state) - state = table.clone(state) - state.users = table.clone(state.users) - - for user = 1, USERS do - if math.random() > 0.5 then - continue - end - - state.users[user] = { - coins = math.random(1, 2), - gems = math.random(1, 2), - } - end - - return state -end - -return { - ParameterGenerator = function() - local state = generate() - return state, update(state) - end, - Functions = { - diff = function(_, a, b) - patch.diff(a, b) - 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/useAtom.spec.luau b/tests/react/useAtom.spec.luau deleted file mode 100644 index 5476f28..0000000 --- a/tests/react/useAtom.spec.luau +++ /dev/null @@ -1,46 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -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) - -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) - - 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) - end) - - it("tracks dependencies", function() - local state = atom(0) - local hook = renderHook(function() - return useAtom(state, { 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 diff --git a/tests/run.server.luau b/tests/run.server.luau new file mode 100644 index 0000000..44015cb --- /dev/null +++ b/tests/run.server.luau @@ -0,0 +1,33 @@ +local Charm = require("../packages/charm/src") +local test = require("./test") + +Charm.flags.strict = true +Charm.flags.frozen = true + +local describeInRoblox = test.describeIf(function() + return not not game +end) + +test.describe("charm", function() + require("../packages/charm/test") +end) + +test.describe("charm-sync", function() + require("../packages/charm-sync/test") +end) + +test.describe("deep-charm", function() + require("../packages/deep-charm/test") +end) + +describeInRoblox("react-charm", function() + require("../packages/react-charm/test") +end) + +describeInRoblox("vide-charm", function() + require("../packages/vide-charm/test") +end) + +test.finish() + +return {} diff --git a/tests/test b/tests/test new file mode 160000 index 0000000..7ef2b88 --- /dev/null +++ b/tests/test @@ -0,0 +1 @@ +Subproject commit 7ef2b88191af884d543e7392d50c7e2adc484e32 diff --git a/tests/utils/collect.luau b/tests/utils/collect.luau deleted file mode 100644 index 0f498aa..0000000 --- a/tests/utils/collect.luau +++ /dev/null @@ -1,12 +0,0 @@ ---[=[ - Trigger a garbage collection cycle by allocating a large amount of junk memory - and then waiting for the next frame. -]=] -local function collect() - for _ = 1, 1e4 do - local _ = table.create(1e3) - end - task.wait() -end - -return collect diff --git a/tests/utils/count.luau b/tests/utils/count.luau deleted file mode 100644 index 251776a..0000000 --- a/tests/utils/count.luau +++ /dev/null @@ -1,15 +0,0 @@ ---[=[ - Counts the number of elements in a table. - - @param object The table to count. - @return The number of elements in the table. -]=] -local function count(object: any): number - local count = 0 - for _ in next, object do - count += 1 - end - return count -end - -return count diff --git a/tests/vide/useAtom.spec.luau b/tests/vide/useAtom.spec.luau deleted file mode 100644 index cc4dd40..0000000 --- a/tests/vide/useAtom.spec.luau +++ /dev/null @@ -1,36 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.DevPackages.Charm) -local atom = Charm.atom -local Vide = require(ReplicatedStorage.DevPackages.Vide) -local root = Vide.root -local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm) -local useAtom = VideCharm.useAtom - -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() - end) - - it("accepts a selector", function() - local state = atom(0) - local source - local cleanup = root(function() - source = useAtom(function() - return 2 * state() - end) - end) - expect(source()).to.equal(0) - state(1) - expect(source()).to.equal(2) - cleanup() - end) -end diff --git a/tsconfig.json b/tsconfig.json index b255ab8..bf99b80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,13 +15,6 @@ "moduleDetection": "force", "strict": true, "target": "ESNext", - "typeRoots": ["node_modules/@rbxts"], - - // configurable - "rootDir": "src", - "outDir": "out", - "incremental": true, - "tsBuildInfoFile": "out/tsconfig.tsbuildinfo", - "declaration": true + "typeRoots": ["node_modules/@rbxts"] } } From d09fb5536b8d64b84e12b48bf1d2d14327534807 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:07:50 -0700 Subject: [PATCH 150/160] fix: set strict flags if `RunService.IsStudio` is true --- README.md | 2 +- packages/charm/src/index.d.ts | 4 ++-- packages/charm/src/init.luau | 12 ++++++------ rokit.toml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 35895ac..c630eae 100644 --- a/README.md +++ b/README.md @@ -602,7 +602,7 @@ Charm exposes the following global flags to customize behavior: | frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. | | trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | -The `strict` and `frozen` flags are automatically enabled in Roblox Studio and other development environments where the Luau optimization level is `O1` or lower. +The `strict` and `frozen` flags are automatically enabled in Roblox Studio. --- diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 4e4ad8d..e4689e5 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -50,12 +50,12 @@ export const flags: { /** * Enforces synchronous, non-yielding behavior in signals and effects. * Also enables state validation in Charm Sync to catch sync errors early. - * Enabled if the optimization level is below 2, which is true in studio. + * Enabled by default in Roblox Studio. */ strict: boolean; /** * Enforces immutability of tables by deep-freezing table values. Enabled - * if the optimization level is below 2, which is true in studio. + * by default in Roblox Studio. */ frozen: boolean; /** diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index d47ede5..ff87785 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -68,22 +68,22 @@ local batchDepth = 0 -- Dependencies accessed during this time will link to the active subscriber. local activeSub: ReactiveNode? -local function isO2(): boolean - return debug.info(1, "n") ~= "isO2" +local function isStudio(): boolean + return game ~= nil and game:GetService("RunService"):IsStudio() end local flags = { --[[ Enforces synchronous, non-yielding behavior in signals and effects. Also enables state validation in Charm Sync to catch sync errors early. - Enabled if the optimization level is below 2, which is true in studio. + Enabled by default in Roblox Studio. ]] - strict = not isO2(), + strict = isStudio(), --[[ Enforces immutability of tables by deep-freezing table values. Enabled - if the optimization level is below 2, which is true in studio. + by default in Roblox Studio. ]] - frozen = not isO2(), + frozen = isStudio(), --[[ Whether parent effects should track inner effects and clean them up when the parent effect re-runs. This is usually desirable, but can be diff --git a/rokit.toml b/rokit.toml index 9348712..c99bf5d 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,6 @@ [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.6.1" +rojo = "rojo-rbx/rojo@7.7.0-rc.1" stylua = "johnnymorganz/stylua@2.5.2" luau-lsp = "johnnymorganz/luau-lsp@1.68.1" lute = "luau-lang/lute@1.0.0" From 29be66fa94523e4032aae44089aa8670cccec851 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:16:14 -0700 Subject: [PATCH 151/160] test: add skipped inner effect dispose order test --- packages/charm/test/effect.test.luau | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau index c94ec51..17cd0a0 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -573,4 +573,21 @@ test.skip("should handle inner effect cleanup disposing outer effect", function( assert(#order == 0, "should not cleanup after dispose") end) +test.skip("should old inner effect dispose before outer effect re-runs", function() + local order: { string } = {} + local src, setSrc = signal(false) + effect(function() + src() + table.insert(order, "outer") + effect(function() + return function() + table.insert(order, "inner") + end + end) + end) + table.clear(order) + setSrc(true) + assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`) +end) + return {} From ba870a8c2cbd1a96a7d2703a5bf7440aa06f95eb Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:34:35 -0700 Subject: [PATCH 152/160] release: charm v0.11.0 --- .npmrc | 1 - package.json | 2 +- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 4 ++-- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/deep-charm/package.json | 2 +- packages/deep-charm/wally.toml | 4 ++-- packages/react-charm/package.json | 2 +- packages/react-charm/wally.toml | 4 ++-- packages/vide-charm/package.json | 2 +- packages/vide-charm/wally.toml | 4 ++-- pnpm-workspace.yaml | 2 ++ 13 files changed, 17 insertions(+), 16 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index b644580..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -public-hoist-pattern[]=*@rbxts* diff --git a/package.json b/package.json index d5f8c09..ce5bb44 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@10.33.3", + "packageManager": "pnpm@11.8.0", "devDependencies": { "@eslint/js": "^9.39.4", "@rbxts/compiler-types": "3.0.0-types.0", diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 3efa745..6727a4a 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0-rc.7", + "version": "0.4.0", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 28a6fc9..b24a5e4 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0-rc.7" +version = "0.4.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.6" +Charm = "littensy/charm@^0.11.0" diff --git a/packages/charm/package.json b/packages/charm/package.json index 28f5c7c..fa04726 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0-rc.6", + "version": "0.11.0", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 6298b39..345d2b8 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0-rc.6" +version = "0.11.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json index 96d06af..0fa10b8 100644 --- a/packages/deep-charm/package.json +++ b/packages/deep-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/deep-charm", - "version": "0.1.0-rc.1", + "version": "0.1.0", "description": "Use Charm with plain Luau tables", "license": "MIT", "main": "src/init.luau", diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml index f1a2b11..f2ef6e9 100644 --- a/packages/deep-charm/wally.toml +++ b/packages/deep-charm/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/deep-charm" -version = "0.1.0-rc.1" +version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.6" +Charm = "littensy/charm@^0.11.0" diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index d58d8c7..64bf4a5 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.0-rc.4", + "version": "0.4.0", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index a1de6d4..71c822d 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.4.0-rc.4" +version = "0.4.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.6" +Charm = "littensy/charm@^0.11.0" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 2b4517a..19ced68 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.0-rc.4", + "version": "0.4.0", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 5c59947..4b473c3 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.4.0-rc.5" +version = "0.4.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.6" +Charm = "littensy/charm@^0.11.0" Vide = "centau/vide@^0.4" diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 924b55f..d8424bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - packages/* +publicHoistPattern: + - "*@rbxts*" From fdb506dc23994e00b3639a7fada044e47a17b5cf Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:40:03 -0700 Subject: [PATCH 153/160] docs: update readme with new charm-sync details --- README.md | 125 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 81 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index c630eae..43a7c64 100644 --- a/README.md +++ b/README.md @@ -623,34 +623,43 @@ CharmSync = "littensy/charm-sync@VERSION" ### Quick Start -Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: +Start by specifying the signals that the server should sync to clients. For this example, we'll replicate a round status and timer signal, and each player's inventory: ```luau --- nameStore -local getName, setName = signal("John") -local getSurname, setSurname = signal("Doe") -local ageAtom = atom(20) +-- store.luau + +local getStatus, setStatus = signal("intermission") +local timerAtom = atom(20) +local getInventories, setInventories = signal({} :: { [Username]: Inventory }) return { - getName = getName, - setName = setName, - getSurname = getSurname, - setSurname = setSurname, - ageAtom = ageAtom, + getStatus = getStatus, + setStatus = setStatus, + timerAtom = timerAtom, + getInventories = getInventories, + setInventories = setInventories, } ``` +#### Server setup + When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to: ```luau +-- sync.server.luau + playerReadyEvent.OnServerEvent:Connect(function(player) -- Sync signal getters, computed signals, atoms, or reactive proxies server.addSignalsToClient(player, { - name = nameStore.getName, - surname = nameStore.getSurname, - age = nameStore.ageAtom, + status = store.getStatus, + timer = store.timerAtom, + -- Signals unique to a player should also have a key unique to + -- that player, to avoid overwriting other synced signals + [`inventory-{player.UserId}`] = computed(function() + return store.getInventories()[player.Name] + end), }) end) @@ -659,30 +668,51 @@ Players.PlayerRemoving:Connect(function(player) end) server.connect(function(player, updates) - -- Customize how you send state updates to clients + -- Customize how you replicate state updates to clients syncEvent:FireClient(player, updates) end) + +-- Changes now get replicated to clients +store.setStatus("started") +store.timerAtom(30) ``` -> [!NOTE] -> On the server, make sure each key corresponds to the same data across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. +> [!WARNING] +> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player. +> +> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning. + +#### Client setup To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau +-- sync.client.luau + +-- The server is replicating a signal containing the client's +-- inventory, so this signal will receive those updates +local getInventory, setInventory = signal({} :: Inventory) + -- Add signal setters, atoms, or reactive proxies client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, - age = nameStore.ageAtom, + status = store.setStatus, + timer = store.timerAtom, + -- Signals unique to a player should also have a key unique to + -- that player, to avoid overwriting other synced signals + [`inventory-{Players.LocalPlayer.UserId}`] = setInventory, }) -- Update client signals when receiving updates from the server syncEvent.OnClientEvent:Connect(function(updates) client.patch(updates) end) + +-- The signals will now update to match the server's state +subscribe(getInventory, function(inventory) + print("My inventory is", table.concat(inventory)) +end) ``` --- @@ -702,38 +732,42 @@ A configuration table that customizes the behavior of Charm Sync on the server. ### `server.addSignalsToClient(client, getters)` -The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. +The `addSignalsToClient` function replicates changes in the given signals to a specific client. When an update occurs, the client will receive a delta-compressed state patch containing only the values that changed. -You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. +You can pass signal getter functions, computed signals, atoms, and [reactive proxies](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. ```luau playerReadyEvent.OnServerEvent:Connect(function(player) server.addSignalsToClient(player, { - name = nameStore.getName, - surname = nameStore.getSurname, - age = nameStore.ageAtom, + status = store.getStatus, + timer = store.timerAtom, }) end) ``` -You're also allowed to create new signals to sync to specific players, as long as the key is unique to that data: +You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal: ```luau playerReadyEvent.OnServerEvent:Connect(function(player) server.addSignalsToClient(player, { - -- 🟢 Good: Player-specific data is synced with a unique key - [`data-{player.UserId}`] = computed(function() - return getPlayerData(player.UserId) + -- 🟢 Good: Player-specific signals are synced with a unique key + [`playerData-{player.UserId}`] = computed(function() + return clientStore.getPlayerData(player.UserId) end), - -- 🔴 Bad: Syncing different data with the same keys does not work + -- 🔴 Bad: Syncing different signals with the same keys does not work playerData = computed(function() - return getPlayerData(player.UserId) + return clientStore.getPlayerData(player.UserId) end), }) end) ``` +> [!NOTE] +> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player. +> +> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning. + --- ### `server.removeSignalsFromClient(client, ...keys)` @@ -742,11 +776,11 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke ```luau server.addSignalsToClient(player, { - name = nameStore.getName, - surname = nameStore.getSurname, + status = store.getStatus, + timer = store.timerAtom, }) -server.removeSignalsFromClient(player, "surname") +server.removeSignalsFromClient(player, "status") ``` --- @@ -807,9 +841,11 @@ You can pass either writable signals, atoms, or [reactive objects](#reactiveinit ```luau client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, - age = nameStore.ageAtom, + status = store.setStatus, + timer = store.timerAtom, + -- Signals unique to a player should also have a key unique to + -- that player, to avoid overwriting other synced signals + [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, }) ``` @@ -821,11 +857,11 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta ```luau client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, + status = store.setStatus, + timer = store.timerAtom, }) -client.removeSignals("name") +client.removeSignals("status") ``` --- @@ -838,8 +874,9 @@ You should call `patch` when receiving updates from the server from a remote eve ```luau client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, + status = store.setStatus, + timer = store.timerAtom, + [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, }) syncEvent.OnClientEvent:Connect(function(updates) @@ -855,8 +892,8 @@ If you have a lot of signals to sync between the server and clients, it might be ```luau local sharedState = { - name = signalToAtom(nameStore.getName, nameStore.setName), - surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), + status = signalToAtom(store.getStatus, store.setStatus), + inventories = signalToAtom(store.getInventories, store.setInventories), } client.addSignals(sharedState) @@ -869,7 +906,7 @@ server.addSignalsToClient(client, sharedState) Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. -But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`. +But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. Charm addresses this by representing deleted values with a special `None` value denoted by `{ __none = "__none" }`. This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. From b05f3a9de6fc5b784ad95681908c52d1ee418cc0 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:54:46 -0700 Subject: [PATCH 154/160] docs: add missing `playerReadyEvent` call --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 43a7c64..07d9641 100644 --- a/README.md +++ b/README.md @@ -700,7 +700,7 @@ client.addSignals({ status = store.setStatus, timer = store.timerAtom, -- Signals unique to a player should also have a key unique to - -- that player, to avoid overwriting other synced signals + -- that player, to avoid name collisions [`inventory-{Players.LocalPlayer.UserId}`] = setInventory, }) @@ -709,6 +709,9 @@ syncEvent.OnClientEvent:Connect(function(updates) client.patch(updates) end) +-- Notify the server that the client is ready to receive updates +playerReadyEvent:FireServer() + -- The signals will now update to match the server's state subscribe(getInventory, function(inventory) print("My inventory is", table.concat(inventory)) @@ -844,7 +847,7 @@ client.addSignals({ status = store.setStatus, timer = store.timerAtom, -- Signals unique to a player should also have a key unique to - -- that player, to avoid overwriting other synced signals + -- that player, to avoid name collisions [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, }) ``` From 73fbfb2685f293cc98e5c0706668897ede2ea69a Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:52:58 -0700 Subject: [PATCH 155/160] fix: resolve type errors for new luau version --- packages/charm-sync/src/server.luau | 8 ++++---- packages/charm-sync/test/utils.test.luau | 2 +- packages/charm/src/init.luau | 2 +- packages/deep-charm/src/init.luau | 8 ++++---- packages/react-charm/src/init.luau | 2 +- rokit.toml | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index ca04ba7..4e07941 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -39,10 +39,10 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? support custom implementations of reactive proxies. ]] local function toRaw(value: T): T - value = value :: types.ReactiveProxy - if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then - value.__track() - return toRaw(value.__target) + local proxy = value :: types.ReactiveProxy + if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then + proxy.__track() + return toRaw(proxy.__target) end return value end diff --git a/packages/charm-sync/test/utils.test.luau b/packages/charm-sync/test/utils.test.luau index 15dcabf..1d50f55 100644 --- a/packages/charm-sync/test/utils.test.luau +++ b/packages/charm-sync/test/utils.test.luau @@ -6,7 +6,7 @@ test("should flatten functions", function() local input = { a = Charm.atom(0), b = { c = Charm.atom(0) }, - d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, + d = { e = { f = utils.signalToAtom<>(Charm.signal(0)) } }, } local result = utils.flatten<<{ [string]: Charm.Atom }>>(input) diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index ff87785..0ba77d7 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -486,7 +486,7 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette end -- Check if the new value is different from the pending value - if if equals then not equals(node.pendingValue, value) else node.pendingValue ~= value then + if if equals then not equals(node.pendingValue, value :: T) else node.pendingValue ~= value then if flags.frozen then deepFreeze(value) end diff --git a/packages/deep-charm/src/init.luau b/packages/deep-charm/src/init.luau index 9eb9943..9e16cb1 100644 --- a/packages/deep-charm/src/init.luau +++ b/packages/deep-charm/src/init.luau @@ -36,9 +36,9 @@ end ]] local function toRaw(value: T): T if isReactive(value) then - value = value :: ReactiveProxy - value.__track() - return toRaw(value.__target) + local proxy = value :: ReactiveProxy + proxy.__track() + return toRaw(proxy.__target) end return value end @@ -115,7 +115,7 @@ end @return A reactive proxy if the value is a table ]] local function toReactive(value: T, trackParent: (() -> ())?): T - return if type(value) == "table" then reactive(value, trackParent) else value + return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T end function ReactiveProxy.__index(self: ReactiveProxy, key: any): any diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 19a95e8..08b5342 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -14,7 +14,7 @@ local React = require("./React") @return The current state. ]] local function useSignalState(getter: () -> T, dependencies: {}?): T - local value, setValue = React.useState(getter) + local value: T, setValue = React.useState(getter) React.useLayoutEffect(function(): () -> () return Charm.listen(getter, setValue :: (T, T?) -> ()) diff --git a/rokit.toml b/rokit.toml index c99bf5d..ca169e2 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,6 @@ [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.7.0-rc.1" +rojo = "rojo-rbx/rojo@7.7.0" stylua = "johnnymorganz/stylua@2.5.2" -luau-lsp = "johnnymorganz/luau-lsp@1.68.1" +luau-lsp = "johnnymorganz/luau-lsp@1.70.1" lute = "luau-lang/lute@1.0.0" From c31e5c5a3465036596f2dc8d4d7d1609b56b3771 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:06:38 -0700 Subject: [PATCH 156/160] fix(charm-sync): run addSignalsToClient outside tracking scope --- packages/charm-sync/src/server.luau | 2 +- packages/charm-sync/test/server.test.luau | 27 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 4e07941..0494804 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -61,7 +61,7 @@ local function trackSignal(key: string, getter: () -> T, identifier: unknown) local rawInitialValue = toRaw(initialValue) local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue - local dispose = Charm.effect(function() + local dispose = Charm.untracked(Charm.effect, function() local newValue = getter() local rawValue = toRaw(newValue) local isReactive = newValue ~= rawValue diff --git a/packages/charm-sync/test/server.test.luau b/packages/charm-sync/test/server.test.luau index 380fbb0..d43e7b8 100644 --- a/packages/charm-sync/test/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -405,4 +405,31 @@ test.describe("historical", function() end) end) +test("addSignalsToClient should ignore tracking context", function() + local counter = Charm.atom(0) + local payloads: { any }? + + server.connect(function(_client, _payloads) + payloads = _payloads + end) + + local dispose = Charm.effectScope(function() + server.addSignalsToClient("PLAYER_ID", { counter = counter }) + end) + + server.flush() + assert(payloads, "Expected payloads to be sent") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'") + + dispose() + + payloads = nil + counter(1) + server.flush() + assert(payloads, "Expected payloads to be sent") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'") +end) + return {} From 2fc3be34d0256fd0d6d57895930ce25175d9fe67 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:20:51 -0700 Subject: [PATCH 157/160] fix(charm-sync): preserve global patch list order for nil patches Resolves #67 --- packages/charm-sync/src/server.luau | 6 +++--- packages/charm-sync/test/server.test.luau | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 0494804..6485e18 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -125,8 +125,8 @@ local function diffGlobalUpdateBuffer(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) - for _, update in globalUpdates do - table.insert(patches, patch.diff(update.oldValue, update.newValue)) + for index, update in globalUpdates do + patches[index] = patch.diff(update.oldValue, update.newValue) end return patches @@ -146,7 +146,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { for index, update in globalUpdates do local patchData = globalPatches[index] - if not patchData then + if patchData == nil then continue end diff --git a/packages/charm-sync/test/server.test.luau b/packages/charm-sync/test/server.test.luau index d43e7b8..d24970c 100644 --- a/packages/charm-sync/test/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -403,6 +403,27 @@ test.describe("historical", function() server.flush() assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") end) + + test("should maintain patch list order for stale update", function() + local a = Charm.atom({ x = 1 }) + local b = Charm.atom({ y = 1 }) + local payloads + + server.connect(function(_client, _payloads) + payloads = _payloads + end) + + server.addSignalsToClient("client", { a = a, b = b }) + server.flush() + + payloads = nil + a({ x = 1 }) -- new table, same contents: diff is nil + b({ y = 2 }) + server.flush() + assert(payloads, "Expected payloads to be sent") + assert(payloads[1].type == "patch", "Expected payload to be patch") + assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'") + end) end) test("addSignalsToClient should ignore tracking context", function() From aa84fa1e3d99876c15fab9fbed5620d15a891e77 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:25:45 -0700 Subject: [PATCH 158/160] fix(charm-sync): warn if attempting to sync non-registered signal on the client --- packages/charm-sync/src/client.luau | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index a514b75..973a983 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -64,6 +64,8 @@ local function applyInitPayload(data: { [string]: any }) trackedSignals[key](value) elseif trackedReactives[key] then shallowMergeReactive(trackedReactives[key], value) + else + warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`) end end end @@ -80,6 +82,8 @@ local function applyPatchPayload(data: { [string]: any }) elseif trackedReactive then patch.applyMutable(trackedReactive.__target, diffs) Charm.trigger(trackedReactive.__track) + else + warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`) end end end From 1b856b065ee518b1c6c57a22d9b51b5cdca62107 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:30:33 -0700 Subject: [PATCH 159/160] release: charm v0.11.1 --- packages/charm-sync/package.json | 2 +- packages/charm-sync/wally.toml | 2 +- packages/charm/package.json | 2 +- packages/charm/wally.toml | 2 +- packages/deep-charm/package.json | 2 +- packages/deep-charm/wally.toml | 2 +- packages/react-charm/package.json | 2 +- packages/react-charm/wally.toml | 2 +- packages/vide-charm/package.json | 2 +- packages/vide-charm/wally.toml | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 6727a4a..f2c84a3 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0", + "version": "0.4.1", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index b24a5e4..589e7ed 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.0" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ diff --git a/packages/charm/package.json b/packages/charm/package.json index fa04726..7ef2e29 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0", + "version": "0.11.1", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 345d2b8..25eb0a5 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0" +version = "0.11.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json index 0fa10b8..081b711 100644 --- a/packages/deep-charm/package.json +++ b/packages/deep-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/deep-charm", - "version": "0.1.0", + "version": "0.1.1", "description": "Use Charm with plain Luau tables", "license": "MIT", "main": "src/init.luau", diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml index f2ef6e9..3f2702f 100644 --- a/packages/deep-charm/wally.toml +++ b/packages/deep-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/deep-charm" -version = "0.1.0" +version = "0.1.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index 64bf4a5..ca08eca 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.0", + "version": "0.4.1", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 71c822d..51ee9ab 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.4.0" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 19ced68..9b64809 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.0", + "version": "0.4.1", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 4b473c3..4aa1f71 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.4.0" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ From af4eb5262d640cf89c56217ab7feb403e939cbd2 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:31:07 -0700 Subject: [PATCH 160/160] chore: bump pnpm --- package.json | 2 +- pnpm-lock.yaml | 183 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ce5bb44..d0bf03d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@11.8.0", + "packageManager": "pnpm@12.8.1", "devDependencies": { "@eslint/js": "^9.39.4", "@rbxts/compiler-types": "3.0.0-types.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c0486d..e5c8ddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,186 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + '@pnpm/exe': + specifier: 12.8.1 + version: 12.8.1 + pnpm: + specifier: 12.8.1 + version: 12.8.1 + +packages: + + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + cpu: [x64] + os: [win32] + + '@pnpm/exe@12.8.1': + resolution: {integrity: sha512-ju5MeefhEEXI7NO3u6oj54uGBOkN0pVhI+tcmuZDkTTqVdRZvEVEo+zhjwAoe8vEXguQCvsa6cTDhmFPxpOXeQ==} + engines: {node: '>=18.*'} + hasBin: true + + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.8.1': + optional: true + + '@pnpm/exe.android-x64@12.8.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.8.1': + optional: true + + '@pnpm/exe.darwin-x64@12.8.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64@12.8.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.8.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.8.1': + optional: true + + '@pnpm/exe.linux-s390x@12.8.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-x64@12.8.1': + optional: true + + '@pnpm/exe.win32-arm64@12.8.1': + optional: true + + '@pnpm/exe.win32-x64@12.8.1': + optional: true + + '@pnpm/exe@12.8.1': + optionalDependencies: + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 + + pnpm@12.8.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 + +--- lockfileVersion: '9.0' settings: