diff --git a/.changeset/machine-notify-and-cleanup-hardening.md b/.changeset/machine-notify-and-cleanup-hardening.md new file mode 100644 index 0000000..3edddb4 --- /dev/null +++ b/.changeset/machine-notify-and-cleanup-hardening.md @@ -0,0 +1,29 @@ +--- +'@dunky.dev/state-machine': patch +--- + +Harden the notify and teardown paths across the core, and cut hot-path +allocations: + +- A listener removed during a notify pass no longer fires again when a nested + notify (a send from inside a subscriber) rebuilds the iteration snapshot + mid-pass — unsubscribing is now final even under re-entrancy. The same + guarantee now holds for connector and store subscribers, and the mechanism + lives in one shared primitive instead of three near-copies. +- A state cleanup that throws no longer skips the remaining cleanups or leaves + the pass populated: every cleanup runs (timers and subscriptions all + release), the first error is rethrown after the pass, and the next stop + cannot double-run them. +- A `sync()` rule or `combine().subscribe()` disposed by hand now detaches from + the composition's registry — long-lived groups with subscribe/unsubscribe + churn no longer grow it without bound, and `stop()` no longer re-runs + hand-run disposers. +- Computed recompute is allocation-free: dep keys/values live in reused + buffers and are captured at read time, so the old post-pass that re-read + every dep is gone. In the benchmark suite this lands recompute ~1.5× and + 4-deep computed chains ~1.6× faster. +- `machine.select` is built once and reused instead of allocating a fresh + facade object on every property access, so its identity is stable (safe for + dependency arrays). +- Dropped the internal write-only `version` counter — bumped on every notify, + read by nothing. diff --git a/.claude/skills/benchmark/SKILL.md b/.claude/skills/benchmark/SKILL.md index 428ab1c..88461dd 100644 --- a/.claude/skills/benchmark/SKILL.md +++ b/.claude/skills/benchmark/SKILL.md @@ -49,7 +49,7 @@ Do not proceed to Step 3 unless the user says yes. ## Step 3 — update the results (only on a yes) -Three files carry benchmark numbers. Update all three in one pass: +Four files carry benchmark numbers. Update all four in one pass: ### A. `benchmark/README.md` — full tables (source of truth) @@ -83,16 +83,15 @@ The core README carries short prose claims ("up to ~8× the event throughput", headline ratio clearly crossed a round number (e.g. throughput drops from ~8× to ~6×, or memory from ~33× to ~25×). Don't churn it for a rounding wobble. -Relevant lines to check: +Relevant line to check: -- The `### Performance` section prose claim ("up to ~8× the event throughput"). -- The memory comparison claim ("~33×" in the `## How it compares` diff table footnotes). +- The `### Performance` section prose claim ("up to ~N× the event throughput"). + (There is no memory-ratio claim in this file — verify with a grep for `×` + rather than assuming this list is complete.) -### C. `website/src/pages/benchmark.mdx` — headline table + section numbers +### C. `website/src/content/docs/benchmark.mdx` — headline table + section numbers -The website benchmark page at -`/Users/ivanbanov/dev/dunky-dev/.worktrees/website/website/src/pages/benchmark.mdx` -carries: +The website benchmark page (at that path from the repo root) carries: 1. **The headline table** — event throughput ops/s, memory 2-field and 64-field KB/machine. Update all three Dunky / XState / Zag cells. @@ -103,7 +102,14 @@ carries: Apply the same K/M notation rules as `benchmark/README.md`. -### After updating all three +### D. root `README.md` — the "Fast at scale" headline table + +The repo root README's performance section carries a small three-row +table (event throughput, memory 2-field, memory 64-field) plus a `→` prose line with ratio claims. Update +the table cells and recheck both ratios against the fresh figures — same K/M +notation rules. + +### After updating all four - Re-read each edited file's tables to confirm markdown pipes still line up. - Run `pnpm format` at the repo root so formatting matches the repo style. diff --git a/README.md b/README.md index 19362cc..f71fa52 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,13 @@ canvas board, a game HUD. There the cost of each transition and the memory per machine, multiplied by thousands, is what decides whether you hold the frame. The engine is built for it: -| At scale (thousands of machines) | Dunky | XState | Zag | -| -------------------------------- | --------: | -----: | ------: | -| Event throughput (ops/s) | **7.2 M** | 897 K | n/a ᵃ | -| Memory / machine, 2-field (KB) | **3.6** | 3.6 | 9.1 | -| Memory / machine, 64-field (KB) | **4.1** | 4.1 | **134** | +| At scale (thousands of machines) | Dunky | XState | Zag | +| -------------------------------- | ---------: | -----: | ------: | +| Event throughput (ops/s) | **11.6 M** | 1.6 M | n/a ᵃ | +| Memory / machine, 2-field (KB) | **3.9** | 3.6 | 8.9 | +| Memory / machine, 64-field (KB) | **4.4** | 4.1 | **134** | -→ **~8× XState's throughput**, on par with XState for memory but at least **3× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the +→ **~7× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the **[benchmark README](./benchmark/README.md)**. **▶ [Try the live benchmark demo](https://dunky.dev/state-machine/benchmark/demo)** — watch all three engines run in your browser. diff --git a/benchmark/README.md b/benchmark/README.md index 8957eff..f0fe616 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -4,7 +4,7 @@ A performance harness for `@dunky.dev/state-machine`. It measures the engine's hot paths in isolation and compares the runnable parts against [XState](https://stately.ai/docs) and [Zag](https://zagjs.com/). -Numbers below are from one clean run (Node 24, Apple Silicon). Absolute figures +Numbers below are averaged over four clean runs (Node 24, Apple Silicon). Absolute figures vary by machine, Node version, and thermal state — **run it yourself** — but the ranking and the scaling shape are what hold. @@ -95,11 +95,11 @@ XState's coarse `actor.subscribe`. | Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------- | --------------: | ---------------: | ----: | -| 100 | 325 K | 253 K | n/a ᵃ | -| 1000 | 10.7 K | 10.7 K | n/a ᵃ | -| 5000 | **7.9 K** | 741 | n/a ᵃ | +| 100 | 658.5 K | 437.8 K | n/a ᵃ | +| 1000 | 56.8 K | 49.2 K | n/a ᵃ | +| 5000 | **15.5 K** | 1.3 K | n/a ᵃ | -→ Roughly par at small N, but Dunky **~10× faster at 5000 observers** — XState's +→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's coarse subscribe degrades much faster as the observer set grows. **B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The @@ -109,23 +109,23 @@ model gets for free and a coarse bus has to work to ignore. | Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------------------- | --------------: | ---------------: | ----: | -| 1000 | **4.5 M** | 536 K | n/a ᵃ | -| 5000 | **1.9 M** | 453 K | n/a ᵃ | +| 1000 | **5.4 M** | 987.9 K | n/a ᵃ | +| 5000 | **4.4 M** | 844.1 K | n/a ᵃ | -→ Dunky is **~8× faster** at shrugging off a write nobody is watching (1000 +→ Dunky is **~6× faster** at shrugging off a write nobody is watching (1000 cells); the value-deduping bus skips waking observers entirely. **C. Throughput — single machine, one event.** Per-transition cost with no selection scaling — the raw `send` price. -| Single machine, one event | ops/sec | -| ------------------------- | --------: | -| Dunky | **7.2 M** | -| XState (raw) | 898 K | -| XState (diffed) | 897 K | -| Zag | n/a ᵃ | +| Single machine, one event | ops/sec | +| ------------------------- | ---------: | +| Dunky | **11.6 M** | +| XState (raw) | 1.6 M | +| XState (diffed) | 1.6 M | +| Zag | n/a ᵃ | -→ Dunky pushes **~8× the events/sec** of XState. Context is mutated in place, +→ Dunky pushes **~7× the events/sec** of XState. Context is mutated in place, so a transition allocates nothing; XState builds a fresh snapshot per event. ## 2. Compose / synced machines (`tests/compose.ts`) @@ -142,12 +142,12 @@ change (the O(members) path by design). | Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | | ------- | ----------------------: | -------------------: | ------ | ----- | -| 2 | 6.7 M | 7.1 M | n/a ᶠ | n/a ᶠ | -| 10 | 6.6 M | 6.5 M | n/a ᶠ | n/a ᶠ | -| 50 | 5.8 M | 6.2 M | n/a ᶠ | n/a ᶠ | +| 2 | 11.0 M | 11.7 M | n/a ᶠ | n/a ᶠ | +| 10 | 10.1 M | 10.5 M | n/a ᶠ | n/a ᶠ | +| 50 | 8.7 M | 10.2 M | n/a ᶠ | n/a ᶠ | -→ Cross-region coordination stays in the **~5.8–7.1 M ops/sec** band even at 50 -synced members — the O(M) re-eval pass costs ~13% going from 2 to 50. +→ Cross-region coordination stays in the **~8.7–11.7 M ops/sec** band even at 50 +synced members — the O(M) re-eval pass costs ~13–21% going from 2 to 50. > A third "chain" sub-test (a sync rule that `send()`s downstream every change) was > removed: under a tight loop it shows superlinear slowdown. That's a real @@ -163,13 +163,13 @@ profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Z | Scenario | Dunky (ops/sec) | XState | Zag | | ------------------------------------ | --------------: | ------ | ----- | -| Cached read (no change) | **16.6 M** | n/a ᶠ | n/a ᵃ | -| Fine-grain (change unread, re-read) | 6.2 M | n/a ᶠ | n/a ᵃ | -| Recompute (change read field) | 2.1 M | n/a ᶠ | n/a ᵃ | -| 4-deep chain (change root, read tip) | 567 K | n/a ᶠ | n/a ᵃ | +| Cached read (no change) | **44.0 M** | n/a ᶠ | n/a ᵃ | +| Fine-grain (change unread, re-read) | 10.7 M | n/a ᶠ | n/a ᵃ | +| Recompute (change read field) | 4.9 M | n/a ᶠ | n/a ᵃ | +| 4-deep chain (change root, read tip) | 1.5 M | n/a ᶠ | n/a ᵃ | -→ A cached read is **~16.6 M/sec** (near-free memo hit), and changing a field the -computed _doesn't_ read stays a memo hit at ~6.2 M/sec — read-key tracking means +→ A cached read is **~44 M/sec** (near-free memo hit), and changing a field the +computed _doesn't_ read stays a memo hit at ~10.7 M/sec — read-key tracking means you only pay the recompute when an input you actually read changes. ## 4. Engine hot paths (`tests/engine.ts`) @@ -181,15 +181,15 @@ Zag's `send` is async (**n/a ᵃ**). | Scenario | Dunky (ops/sec) | XState | Zag | | -------------------------------------- | --------------: | ------ | ----- | -| Guard fallthrough — 2 candidates | 3.4 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 8 candidates | 2.9 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 32 candidates | 2.0 M | n/a ᶠ | n/a ᵃ | -| State churn — exit+entry every event | 5.6 M | n/a ᶠ | n/a ᵃ | -| Effect churn — boot+cleanup each trans | 5.5 M | n/a ᶠ | n/a ᵃ | -| Sub churn — stable set | 7.0 M | n/a ᶠ | n/a ᵃ | -| Sub churn — churning set (rebuild) | 4.8 M | n/a ᶠ | n/a ᵃ | - -→ Even the heavy paths hold **~2–7 M ops/sec**: a 32-candidate guard walk, full +| Guard fallthrough — 2 candidates | 5.2 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 8 candidates | 4.5 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 32 candidates | 3.2 M | n/a ᶠ | n/a ᵃ | +| State churn — exit+entry every event | 8.8 M | n/a ᶠ | n/a ᵃ | +| Effect churn — boot+cleanup each trans | 8.2 M | n/a ᶠ | n/a ᵃ | +| Sub churn — stable set | 11.1 M | n/a ᶠ | n/a ᵃ | +| Sub churn — churning set (rebuild) | 8.0 M | n/a ᶠ | n/a ᵃ | + +→ Even the heavy paths hold **~3–11 M ops/sec**: a 32-candidate guard walk, full state transitions with entry/exit actions, and effect boot/cleanup every transition all stay in the same order of magnitude as a bare `send`. @@ -201,11 +201,11 @@ warmed first. | Build + start | Dunky (µs/machine) | XState | Zag | | ------------- | -----------------: | -----: | ---: | -| 10 000 | 2.42 | 1.95 | 8.16 | +| 10 000 | 1.58 | 1.35 | 5.06 | → Construction is the one axis where Dunky **doesn't** win — XState spins up ~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; -it's still ~3.4× faster than Zag's per-field reactive cells. +it's still ~3.2× faster than Zag's per-field reactive cells. ## 6. Memory per machine (`tests/memory.ts`) @@ -217,12 +217,12 @@ the footprint a churny app actually pays). | Context | Dunky (KB/machine) | XState | Zag | | -------- | -----------------: | -----: | ------: | -| 2-field | 3.60 | 3.62 | 9.06 | -| 64-field | 4.10 | 4.10 | **134** | +| 2-field | 3.85 | 3.61 | 8.92 | +| 64-field | 4.35 | 4.10 | **134** | → Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with the data you store, not with a per-field cell. **Zag is the contrast**: one reactive -cell per field balloons the 64-field context to ~134 KB/machine — **~33× more** than +cell per field balloons the 64-field context to ~134 KB/machine — **~31× more** than Dunky. **Idle vs written.** Dunky owns its context copy from construction and mutates @@ -231,8 +231,8 @@ lazy-copy scheme steps up once writes start: | 64-field, 5000 machines | Dunky | XState | Zag | | ----------------------- | ----: | -----: | --: | -| Idle (never written) | 4.10 | 3.55 | 130 | -| Written (1 event each) | 4.10 | 4.10 | 134 | +| Idle (never written) | 4.35 | 3.55 | 130 | +| Written (1 event each) | 4.35 | 4.10 | 134 | → Dunky idle ≡ written; XState's first `assign` allocates a per-actor context, so its written row grows. @@ -250,15 +250,15 @@ List of 1000 rows: | Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | | -------------------- | ----------------: | ---------: | ------------------: | -| Dunky/instance | **2** | 5.6 | **3.9** | -| Dunky/selector | 2 | 8.4 | 5.9 | -| xstate/selector | 2 | 5.7 | 6.8 | -| zag/instance | 2 | 6.2 | n/a ᵃ | -| naive (anti-pattern) | **980** | 7.1 | 56.2 | +| Dunky/instance | **2** | 3.6 | **2.4** | +| Dunky/selector | 2 | 4.9 | 3.8 | +| xstate/selector | 2 | 3.7 | 4.3 | +| zag/instance | 2 | 3.9 | n/a ᵃ | +| naive (anti-pattern) | **980** | 4.5 | 38.3 | → Every properly-set-up engine wakes only the **2** rows that changed (vs. the naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and -~14× the wall time). Among the surgical strategies Dunky re-renders **~1.7× +~16× the wall time). Among the surgical strategies Dunky re-renders **~1.8× faster than XState**. Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the diff --git a/packages/core/README.md b/packages/core/README.md index 5e8ea47..bca25cd 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -112,7 +112,7 @@ monitoring walls, game HUDs). Context is one plain object mutated in place behin a value-deduping bus, so a transition allocates nothing and an irrelevant write wakes no observers. -In practice that's **up to ~8× the event throughput** of the alternatives, flat +In practice that's **up to ~7× the event throughput** of the alternatives, flat memory as context grows wide, and surgical re-renders that wake only the rows that actually changed. diff --git a/packages/core/src/broadcast.ts b/packages/core/src/broadcast.ts new file mode 100644 index 0000000..bf97657 --- /dev/null +++ b/packages/core/src/broadcast.ts @@ -0,0 +1,43 @@ +/** Payload-less one-to-all notify: add listeners, wake them all, drop them all. */ +export interface Broadcast { + add: (listener: () => void) => () => void + notify: () => void + clear: () => void +} + +/** + * Steady-state notifies allocate nothing: iteration runs over a cached snapshot, + * re-derived only when membership changes. Mid-pass (un)subscribes still take + * effect within the pass — a dirty flag flips iteration to membership-checked + * mode, and since a nested notify() clears that flag, a swapped snapshot + * (rebuilds always allocate anew) counts as mid-pass churn too. + */ +export function makeBroadcast(): Broadcast { + const listeners = new Set<() => void>() + let snapshot: Array<() => void> = [] + let dirty = false + return { + add(listener) { + listeners.add(listener) + dirty = true + return () => { + listeners.delete(listener) + dirty = true + } + }, + notify() { + if (dirty) { + snapshot = [...listeners] + dirty = false + } + const snap = snapshot + for (const l of snap) { + if ((!dirty && snap === snapshot) || listeners.has(l)) l() + } + }, + clear() { + listeners.clear() + dirty = true + }, + } +} diff --git a/packages/core/src/compose.ts b/packages/core/src/compose.ts index cd39160..5fa8f32 100644 --- a/packages/core/src/compose.ts +++ b/packages/core/src/compose.ts @@ -1,4 +1,5 @@ -import type { EqualityFn, Machine, Selection } from './types' +import { makeSelection } from './selection' +import type { Machine, Selection } from './types' /** Any machine, regardless of its specific generics. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -34,7 +35,17 @@ export function compose>( members: Members, ): Composition { const list = Object.values(members) - const disposers: Array<() => void> = [] + // A Set so a hand-run disposer can remove itself — otherwise long-lived groups + // with subscribe/unsubscribe churn grow the registry without bound. + const disposers = new Set<() => void>() + const register = (offs: Array<() => void>): (() => void) => { + const dispose = () => { + disposers.delete(dispose) + for (const off of offs) off() + } + disposers.add(dispose) + return dispose + } return { members, @@ -42,39 +53,14 @@ export function compose>( for (const m of list) m.start() }, stop() { - for (const dispose of disposers) dispose() - disposers.length = 0 + for (const dispose of disposers) dispose() // self-deletes mid-iteration — safe on a Set for (let i = list.length - 1; i >= 0; i--) list[i]!.stop() }, sync(reaction) { - const offs = list.map(m => m.subscribe(reaction)) - const dispose = () => { - for (const off of offs) off() - } - disposers.push(dispose) - return dispose + return register(list.map(m => m.subscribe(reaction))) }, combine(selector: () => Value): Selection { - return { - get value() { - return selector() - }, - subscribe(listener: (value: Value) => void, equals: EqualityFn = Object.is) { - let prev = selector() - const onChange = () => { - const next = selector() - if (equals(prev, next)) return - prev = next - listener(next) - } - const offs = list.map(m => m.subscribe(onChange)) - const dispose = () => { - for (const off of offs) off() - } - disposers.push(dispose) - return dispose - }, - } + return makeSelection(selector, onWake => register(list.map(m => m.subscribe(onWake)))) }, } } diff --git a/packages/core/src/computed.ts b/packages/core/src/computed.ts index 0278db1..4da707b 100644 --- a/packages/core/src/computed.ts +++ b/packages/core/src/computed.ts @@ -7,56 +7,89 @@ export interface ComputedHost { } /** - * Install computed getters on `target` with read-key tracking: each def records which + * Define computed getters on `target` with read-key tracking: each def records which * context/computed keys it read and recomputes only when one of those inputs changed. - * Installs onto the SAME object the machine exposes as `this.computed` so computed→computed + * Defined onto the SAME object the machine exposes as `this.computed` so computed→computed * chains resolve in place. */ -export function installComputed( +export function defineComputed( target: Computed, defs: ComputedDefs, host: ComputedHost, ): void { + // Dep keys are runtime strings, so all dep reads are string-indexed — widen once here + // instead of casting at every read site. The proxy target is inert (traps never touch it). + const contextOf = host.context as () => Record + const computedOf = host.computed as () => Record + const proxyTarget: Record = {} + for (const key in defs) { const k = key as keyof Computed const def = defs[k] let computedOnce = false let cachedValue: Computed[keyof Computed] - let ctxDeps: string[] = [] - let computedDeps: string[] = [] - let ctxSnapshot: Record = {} - let computedSnapshot: Record = {} let readState = false let stateSnapshot: State | undefined - // Tracking proxies built once per computed; each get records the key into the current read-set. - let ctxRead: Set | null = null - let computedRead: Set | null = null - // True during recompute so reading `params.state` records a state dependency. + // Parallel dep-key/dep-value buffers, reused across recomputes — a recompute + // allocates nothing. Values are captured AT read time inside the tracking + // proxies, so no post-pass re-reads (and re-validates) what was just computed. + const ctxDeps: string[] = [] + const ctxVals: unknown[] = [] + const computedDeps: string[] = [] + const computedVals: unknown[] = [] + + // True during recompute so proxy reads record deps and `params.state` records + // a state dependency. Deps are few, so the includes() dedup beats a Set. let tracking = false - const trackedCtx = new Proxy({} as Record, { + const trackedCtx = new Proxy(proxyTarget, { get: (_t, p: string) => { - ctxRead?.add(p) - return (host.context() as Record)[p] + const value = contextOf()[p] + if (tracking && !ctxDeps.includes(p)) { + ctxDeps.push(p) + ctxVals.push(value) + } + return value }, }) as Context - const trackedComputed = new Proxy({} as Record, { + + const trackedComputed = new Proxy(proxyTarget, { get: (_t, p: string) => { - computedRead?.add(p) - return (host.computed() as Record)[p] + const value = computedOf()[p] + if (tracking && !computedDeps.includes(p)) { + computedDeps.push(p) + computedVals.push(value) + } + return value }, }) as Computed + // The def params never change shape — build them once, not per recompute. + const params = { + context: trackedCtx, + computed: trackedComputed, + get state() { + if (tracking) readState = true + return host.state() + }, + } + const stale = (): boolean => { if (readState && stateSnapshot !== host.state()) return true - for (const dk of ctxDeps) { - if (!Object.is(ctxSnapshot[dk], (host.context() as Record)[dk])) - return true + const ctx = contextOf() + + let i = 0 + while (i < ctxDeps.length) { + if (!Object.is(ctxVals[i], ctx[ctxDeps[i]!])) return true + i++ } + // Reading a computed dep resolves ITS staleness first — transitive changes surface here. - for (const dk of computedDeps) { - if (!Object.is(computedSnapshot[dk], (host.computed() as Record)[dk])) - return true + const computed = computedOf() + i = 0 + while (i < computedDeps.length) { + if (!Object.is(computedVals[i], computed[computedDeps[i]!])) return true + i++ } return false } @@ -65,36 +98,22 @@ export function installComputed { if (computedOnce && !stale()) return cachedValue - const cr = new Set() - const compr = new Set() - ctxRead = cr - computedRead = compr + ctxDeps.length = 0 + ctxVals.length = 0 + computedDeps.length = 0 + computedVals.length = 0 readState = false tracking = true + let completed = false try { - cachedValue = def({ - context: trackedCtx, - computed: trackedComputed, - get state() { - if (tracking) readState = true - return host.state() - }, - }) as Computed[keyof Computed] + cachedValue = def(params) as Computed[keyof Computed] + completed = true } finally { - ctxRead = null - computedRead = null tracking = false + // A throwing def leaves the buffers half-filled — force the next read to recompute. + computedOnce = completed } - ctxDeps = [...cr] - computedDeps = [...compr] stateSnapshot = readState ? host.state() : undefined - ctxSnapshot = {} - for (const dk of ctxDeps) ctxSnapshot[dk] = (host.context() as Record)[dk] - computedSnapshot = {} - for (const dk of computedDeps) { - computedSnapshot[dk] = (host.computed() as Record)[dk] - } - computedOnce = true return cachedValue }, }) diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts index 864ba28..8799ef8 100644 --- a/packages/core/src/connector.ts +++ b/packages/core/src/connector.ts @@ -1,3 +1,4 @@ +import { makeBroadcast } from './broadcast' import type { Connect, Connector, Machine } from './types' /** @@ -45,10 +46,10 @@ export function connector< return cached } - const listeners = new Set<() => void>() + const broadcast = makeBroadcast() const wake = () => { dirty = true - for (const l of [...listeners]) l() + broadcast.notify() } const offWake = service.subscribe(wake) @@ -71,8 +72,7 @@ export function connector< return snapshot() }, subscribe(listener) { - listeners.add(listener) - return () => listeners.delete(listener) + return broadcast.add(listener) }, select: service.select, setProps(next) { @@ -86,7 +86,7 @@ export function connector< offStop() for (const off of reactionOffs) off() reactionOffs = [] - listeners.clear() + broadcast.clear() }, } } diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index d3863bc..ac7b1c6 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -1,7 +1,10 @@ import { type ActionHost, runActions } from './actions' -import { installComputed } from './computed' +import { makeBroadcast } from './broadcast' +import { defineComputed } from './computed' import { isDev, MACHINE_INIT, MAX_DRAIN } from './constants' import { makeGuardParams } from './guards' +import { shouldPatch } from './patch' +import { makeSelection } from './selection' import { lookupOn, resolve } from './transitions' import type { Actions, @@ -38,13 +41,7 @@ class MachineClass< ctx: Context stateValue: State tagsOf: Record> - // Monotonic counter bumped on every notify — lets computed memoize without per-field tracking. - version = 0 - // Coarse notification bus. Mutated through busAdd/busDelete so the iteration snapshot - // (busSnapshot) is only re-derived when membership changes — steady-state notifies allocate nothing. - bus = new Set<() => void>() - busSnapshot: Array<() => void> = [] - busDirty = false + broadcast = makeBroadcast() // Run-to-completion queue. Events (objects) and deferred jobs (functions) both wait for // the in-flight transition to finish before running. queue: Array void)> = [] @@ -74,7 +71,7 @@ class MachineClass< this.computed = {} as Computed if (config.computed) { - installComputed(this.computed, config.computed, { + defineComputed(this.computed, config.computed, { context: () => this.ctx, computed: () => this.computed, state: () => this.stateValue, @@ -91,38 +88,15 @@ class MachineClass< } this.setContext = patch => { - let changed = false - for (const key in patch) { - if (!Object.is(this.ctx[key], patch[key])) { - changed = true - break - } - } - if (!changed) return + if (!shouldPatch(this.ctx, patch)) return Object.assign(this.ctx, patch) // in place — this.ctx identity never changes - this.bump() + this.notify() } this.send = event => this.doSend(event) } - private busAdd(listener: () => void): void { - this.bus.add(listener) - this.busDirty = true - } - private busDelete(listener: () => void): void { - this.bus.delete(listener) - this.busDirty = true - } - - private bump(): void { - this.version++ - // Iterate a stable snapshot so mid-pass (un)subscribes take effect after the current pass. - // Skip the has() guard in the steady state; flip to checked mode if membership changes mid-pass. - if (this.busDirty) { - this.busSnapshot = [...this.bus] - this.busDirty = false - } - for (const l of this.busSnapshot) if (!this.busDirty || this.bus.has(l)) l() + private notify(): void { + this.broadcast.notify() } get state(): State { @@ -141,7 +115,7 @@ class MachineClass< private setState(next: State): void { if (next === this.stateValue) return this.stateValue = next - this.bump() + this.notify() } // Guard params are built lazily — guardless transitions (the common case) never allocate them. @@ -280,34 +254,45 @@ class MachineClass< } } private stopEffects(): void { - for (const cleanup of this.stateCleanups) cleanup() - this.stateCleanups.length = 0 + // A throwing cleanup must not leak the others (timers, subscriptions) or leave the + // list populated for a double run on the next stop. Finish the pass, rethrow after. + const cleanups = this.stateCleanups + let thrown: unknown + let didThrow = false + for (const cleanup of cleanups) { + try { + cleanup() + } catch (error) { + if (!didThrow) { + didThrow = true + thrown = error + } + } + } + cleanups.length = 0 + if (didThrow) throw thrown } - private readField(key: string): unknown { - return key in this.ctx - ? (this.ctx as Record)[key] - : (this.computed as Record)[key] - } private startWatchers(): void { const watch = this.config.watch if (!watch) return for (const key in watch) { const actions = watch[key as keyof typeof watch] if (!actions) continue - let prev = this.readField(key) - const listener = () => { - const next = this.readField(key) - if (Object.is(prev, next)) return - prev = next - // Defer: this fires inside bump() (mid-transition). Running actions immediately - // would be re-entrant. The `running` check at job time drops pending runs on stop(). + // Bind the source once: computed keys are fixed at construction, while ctx keys + // may appear later (optional fields patched in), so membership is probed on computed. + const source = (key in (this.computed as object) ? this.computed : this.ctx) as Record< + string, + unknown + > + // Defer: the selection fires inside notify() (mid-transition). Running actions immediately + // would be re-entrant. The `running` check at job time drops pending runs on stop(). + const off = this.makeSelection(() => source[key]).subscribe(() => { this.enqueue(() => { if (this.running) this.runActions(actions, { type: MACHINE_INIT } as Event) }) - } - this.busAdd(listener) - this.watcherCleanups.push(() => this.busDelete(listener)) + }) + this.watcherCleanups.push(off) } } private stopWatchers(): void { @@ -341,32 +326,15 @@ class MachineClass< return () => this.stopListeners?.delete(fn) } - subscribe = (listener: () => void): (() => void) => { - this.busAdd(listener) - return () => this.busDelete(listener) - } + subscribe = (listener: () => void): (() => void) => this.broadcast.add(listener) private makeSelection(selector: () => Value): Selection { - const add = this.busAdd.bind(this) - const remove = this.busDelete.bind(this) - return { - get value() { - return selector() - }, - subscribe(listener, equals = Object.is) { - let prev = selector() - const l = () => { - const next = selector() - if (equals(prev, next)) return - prev = next - listener(next) - } - add(l) - return () => remove(l) - }, - } + return makeSelection(selector, onWake => this.broadcast.add(onWake)) } + // Built on first access, then reused — the facade is stateless, so one instance serves all reads. + selectFacade: Select | null = null get select(): Select { + if (this.selectFacade) return this.selectFacade const sel = ((selector: () => Value) => this.makeSelection(selector)) as Select< State, Context, @@ -376,7 +344,7 @@ class MachineClass< sel.computed = (key: K) => this.makeSelection(() => this.computed[key]) sel.state = () => this.makeSelection(() => this.stateValue) - return sel + return (this.selectFacade = sel) } } diff --git a/packages/core/src/patch.ts b/packages/core/src/patch.ts new file mode 100644 index 0000000..cca33f9 --- /dev/null +++ b/packages/core/src/patch.ts @@ -0,0 +1,7 @@ +/** True when applying `patch` would change at least one key (Object.is per key). */ +export function shouldPatch(target: T, patch: Partial): boolean { + for (const key in patch) { + if (!Object.is(target[key], patch[key])) return true + } + return false +} diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts new file mode 100644 index 0000000..75ad4e3 --- /dev/null +++ b/packages/core/src/selection.ts @@ -0,0 +1,27 @@ +import type { Selection } from './types' + +/** + * The one home for value-deduped selection semantics: seed prev at subscribe, + * re-select on every wake, notify only when the value changed (Object.is or a + * supplied equality). `attach` supplies the wake source — the machine broadcast, a + * composition's members — and returns the detach. + */ +export function makeSelection( + selector: () => Value, + attach: (onWake: () => void) => () => void, +): Selection { + return { + get value() { + return selector() + }, + subscribe(listener, equals = Object.is) { + let prev = selector() + return attach(() => { + const next = selector() + if (equals(prev, next)) return + prev = next + listener(next) + }) + }, + } +} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 6359026..4cf3860 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1,3 +1,6 @@ +import { makeBroadcast } from './broadcast' +import { shouldPatch } from './patch' + export type Listener = (state: T) => void export type SetStateAction = Partial | ((state: T) => Partial) @@ -15,25 +18,19 @@ export function createStore( build: (store: Store) => Methods = () => ({}) as Methods, ): Store & Methods { let state = initial - const listeners = new Set>() + const broadcast = makeBroadcast() const base: Store = { get: () => state, set(action) { const patch = typeof action === 'function' ? action(state) : action - let changed = false - for (const k in patch) { - if (!Object.is(state[k as keyof T], patch[k as keyof T])) { - changed = true - break - } - } - if (!changed) return + if (!shouldPatch(state, patch)) return + // Fresh identity on purpose — get() serves as a useSyncExternalStore + // snapshot, so the identity change IS the re-render signal. state = { ...state, ...patch } - for (const listener of [...listeners]) listener(state) + broadcast.notify() }, subscribe(listener) { - listeners.add(listener) - return () => listeners.delete(listener) + return broadcast.add(() => listener(state)) }, } return { ...base, ...build(base) } diff --git a/packages/core/tests/broadcast.test.ts b/packages/core/tests/broadcast.test.ts new file mode 100644 index 0000000..0d8d85a --- /dev/null +++ b/packages/core/tests/broadcast.test.ts @@ -0,0 +1,87 @@ +/** + * Broadcast — the payload-less one-to-all notify primitive under the machine's + * subscriptions and the connector's wake. Pins the membership contract under + * churn: what fires in the pass where the membership changed. + */ +import { makeBroadcast } from '../src/broadcast' +import { describe, expect, it } from 'vitest' + +describe('broadcast — membership under churn', () => { + it('notify wakes every listener; never on add', () => { + const b = makeBroadcast() + const calls: string[] = [] + b.add(() => calls.push('a')) + b.add(() => calls.push('b')) + expect(calls).toEqual([]) // add is silent + b.notify() + expect(calls).toEqual(['a', 'b']) + }) + + it('the remover detaches; removing twice is harmless', () => { + const b = makeBroadcast() + const calls: string[] = [] + const off = b.add(() => calls.push('a')) + off() + off() + b.notify() + expect(calls).toEqual([]) + }) + + it('a listener removed mid-pass does not fire in that pass', () => { + const b = makeBroadcast() + const calls: string[] = [] + let offB = () => {} + b.add(() => { + calls.push('a') + offB() + }) + offB = b.add(() => calls.push('b')) + b.notify() + expect(calls).toEqual(['a']) + }) + + it('a listener added mid-pass waits for the next notify', () => { + const b = makeBroadcast() + const calls: string[] = [] + let added = false + b.add(() => { + calls.push('a') + if (!added) { + added = true + b.add(() => calls.push('late')) + } + }) + b.notify() + expect(calls).toEqual(['a']) // not this pass + b.notify() + expect(calls).toEqual(['a', 'a', 'late']) // next pass includes it + }) + + it('a nested notify does not resurrect a listener removed in the outer pass', () => { + const b = makeBroadcast() + const calls: string[] = [] + let offB = () => {} + let nested = false + b.add(() => { + calls.push('a') + if (!nested) { + nested = true + offB() + b.notify() // rebuilds the snapshot and clears the dirty flag mid-pass + } + }) + offB = b.add(() => calls.push('b')) + b.notify() + expect(calls).toEqual(['a', 'a']) // b fired in neither pass + }) + + it('clear() drops everyone at once', () => { + const b = makeBroadcast() + const calls: string[] = [] + b.add(() => calls.push('a')) + b.add(() => calls.push('b')) + b.clear() + b.notify() + expect(calls).toEqual([]) + }) +}) diff --git a/packages/core/tests/compose.test.ts b/packages/core/tests/compose.test.ts index a65841b..659cd96 100644 --- a/packages/core/tests/compose.test.ts +++ b/packages/core/tests/compose.test.ts @@ -169,6 +169,31 @@ describe('compose — combine', () => { }) }) +describe('compose — manual dispose detaches from the group', () => { + it('stop() does not re-run a disposer already run by hand', () => { + // Real machines make the double-run invisible (removing a bus listener twice + // is a no-op), so spy on the member's unsubscribe directly. + let unsubs = 0 + const fakeMember = () => + ({ + start: () => {}, + stop: () => {}, + subscribe: () => () => { + unsubs++ + }, + }) as unknown as ReturnType> + const g = compose({ a: fakeMember(), b: fakeMember() }) + g.start() + const offSync = g.sync(() => {}) + const offCombine = g.combine(() => 0).subscribe(() => {}) + offSync() + offCombine() + expect(unsubs).toBe(4) // one per member per subscription + g.stop() + expect(unsubs).toBe(4) // hand-run disposers left the registry — stop must not re-run them + }) +}) + // Regression coverage for the cross-region feedback the benchmark suite found // (see benchmark/tests/compose.ts NOTE — a sync rule that send()s downstream). // `sync` subscribes to EVERY member, including any it writes to, so a reaction diff --git a/packages/core/tests/connector.test.ts b/packages/core/tests/connector.test.ts index 45b2a6d..b3a0049 100644 --- a/packages/core/tests/connector.test.ts +++ b/packages/core/tests/connector.test.ts @@ -87,6 +87,19 @@ describe('connector', () => { expect(fn).toHaveBeenCalledTimes(1) }) + it('a listener unsubscribed mid-notify does not fire in that pass', () => { + const { m, c } = setup() + const calls: string[] = [] + let offB = () => {} + c.subscribe(() => { + calls.push('a') + offB() // removes b while the wake pass is still iterating + }) + offB = c.subscribe(() => calls.push('b')) + m.send({ type: 'inc' }) + expect(calls).toEqual(['a']) // unsubscribing is final, even mid-pass + }) + it('props are reactive — setProps recomputes the snapshot and wakes subscribers', () => { const { c } = setup({ label: 'one' }) const fn = vi.fn() diff --git a/packages/core/tests/effects.test.ts b/packages/core/tests/effects.test.ts index 68cb16f..cd5ec65 100644 --- a/packages/core/tests/effects.test.ts +++ b/packages/core/tests/effects.test.ts @@ -147,6 +147,33 @@ describe('enter → cleanup on exit', () => { expect(() => m.send({ type: 'toB' })).toThrow(/no effect "missing"/) }) + it('a throwing cleanup still runs the remaining cleanups and clears the pass', () => { + const log: string[] = [] + const m = machine<'a' | 'b', object, { type: 'toB' | 'toA' }>({ + initial: 'a', + context: {}, + states: { + a: { on: { toB: { target: 'b' } } }, + b: { + effects: [ + () => () => { + log.push('c1') + throw new Error('boom') + }, + () => () => log.push('c2'), + ], + on: { toA: { target: 'a' } }, + }, + }, + }) + m.start() + m.send({ type: 'toB' }) + expect(() => m.send({ type: 'toA' })).toThrow('boom') + expect(log).toEqual(['c1', 'c2']) // c2 must not be skipped by c1's throw + m.stop() // the failed pass already ran its cleanups — stop must not re-run them + expect(log).toEqual(['c1', 'c2']) + }) + it('an effect can read context/event and queue events via send', () => { const seen: string[] = [] const m = machine<'a' | 'b', { label: string }, { type: 'toB' | 'mark' }>({ diff --git a/packages/core/tests/store.test.ts b/packages/core/tests/store.test.ts index 3d995eb..92eb0b7 100644 --- a/packages/core/tests/store.test.ts +++ b/packages/core/tests/store.test.ts @@ -42,6 +42,26 @@ describe('createStore', () => { expect(store.isOpen('y')).toBe(false) }) + it('set() produces a fresh state identity (a useSyncExternalStore snapshot signal)', () => { + const store = createStore({ count: 0 }) + const before = store.get() + store.set({ count: 1 }) + expect(store.get()).not.toBe(before) // identity change IS the re-render signal + }) + + it('a listener unsubscribed mid-notify does not fire in that pass', () => { + const store = createStore({ count: 0 }) + const calls: string[] = [] + let offB = () => {} + store.subscribe(() => { + calls.push('a') + offB() + }) + offB = store.subscribe(() => calls.push('b')) + store.set({ count: 1 }) + expect(calls).toEqual(['a']) + }) + it('no-op set (same shallow values) does NOT notify (Object.is dedup)', () => { // set shallow-equal-dedups: writing the same value is a no-op, no wake. const store = createStore({ n: 5 }) diff --git a/packages/core/tests/subscribe.test.ts b/packages/core/tests/subscribe.test.ts index 84ed28b..f9d53b5 100644 --- a/packages/core/tests/subscribe.test.ts +++ b/packages/core/tests/subscribe.test.ts @@ -119,6 +119,12 @@ const counter = () => }) describe('select(fn) — function form', () => { + it('the select facade is a stable identity across accesses', () => { + const m = counter() + // consumers may capture it, destructure it, or pass it to dependency arrays + expect(m.select).toBe(m.select) + }) + it('.value reads the current selected value', () => { const m = counter() const len = m.select(() => m.context.items.length) @@ -357,4 +363,39 @@ describe('reentrancy — subscribing/unsubscribing during a notify', () => { m.send({ type: 'inc' }) expect(calls).toEqual(['a']) }) + + it('a nested notify does not resurrect a listener removed in the outer pass', () => { + let set!: (patch: Partial<{ n: number }>) => void + const m = machine<'idle', { n: number }, { type: 'inc' }>({ + initial: 'idle', + context: { n: 0 }, + states: { + idle: { + effects: [ + ({ setContext }) => { + set = setContext + }, + ], + on: { inc: { actions: [({ context, setContext }) => setContext({ n: context.n + 1 })] } }, + }, + }, + }) + m.start() + const calls: string[] = [] + let offB = () => {} + let nested = false + m.subscribe(() => { + calls.push('a') + if (!nested) { + nested = true + offB() + set({ n: 99 }) // nested notify while the outer pass is still iterating + } + }) + offB = m.subscribe(() => calls.push('b')) + m.send({ type: 'inc' }) + // A fires in the outer pass and again in the nested one; B was removed + // before the nested notify and must not fire in either. + expect(calls).toEqual(['a', 'a']) + }) }) diff --git a/website/src/content/docs/benchmark.mdx b/website/src/content/docs/benchmark.mdx index 9c3bab7..325c2e3 100644 --- a/website/src/content/docs/benchmark.mdx +++ b/website/src/content/docs/benchmark.mdx @@ -36,16 +36,16 @@ These were run on a **MacBook Pro (M1, 32 GB)**. Your absolute numbers will diff - **`n/a ᵃ`**: _async._ Zag's `send` is microtask-batched, so it can't run in a synchronous ops/sec or `flushSync` loop, only where it runs synchronously (construction, memory, React rendering). - **`n/a ᶠ`**: _no equivalent feature._ The engine has no first-class primitive for that scenario (e.g. XState has no lazy/memoized `computed`), so there's nothing comparable to time. -**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 8×–33× gaps are the point. +**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 7×–31× gaps are the point. ## Overview -| | Dunky | XState | Zag | -| ------------------------- | --------------: | -----: | -----: | -| Event throughput | **7.2 M ops/s** | 897 K | n/a ᵃ | -| Memory, 2-field context | **3.6 KB** | 3.6 KB | 9.1 KB | -| Memory, 64-field context | **4.1 KB** | 4.1 KB | 134 KB | -| Re-render wall, 1000 rows | **3.9 ms** | 6.8 ms | n/a ᵃ | +| | Dunky | XState | Zag | +| ------------------------- | ---------------: | -----: | -----: | +| Event throughput | **11.6 M ops/s** | 1.6 M | n/a ᵃ | +| Memory, 2-field context | **3.9 KB** | 3.6 KB | 8.9 KB | +| Memory, 64-field context | **4.4 KB** | 4.1 KB | 134 KB | +| Re-render wall, 1000 rows | **2.4 ms** | 4.3 ms | n/a ᵃ | ᵃ Zag's `send` is microtask-batched; can't run in a synchronous ops/sec loop. @@ -53,9 +53,9 @@ These were run on a **MacBook Pro (M1, 32 GB)**. Your absolute numbers will diff A single machine, one event, tight loop: -| | Dunky | XState | Zag | -| ------- | --------: | -----: | ----: | -| ops/sec | **7.2 M** | 898 K | n/a ᵃ | +| | Dunky | XState | Zag | +| ------- | ---------: | -----: | ----: | +| ops/sec | **11.6 M** | 1.6 M | n/a ᵃ | XState allocates a new immutable snapshot on every transition. Dunky mutates context in place, so a transition allocates nothing. @@ -65,8 +65,8 @@ Change a field no observer has selected. The dedup layer re-evaluates and value- | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 1 000 | **4.5 M** | 536 K | -| 5 000 | **1.9 M** | 453 K | +| 1 000 | **5.4 M** | 987.9 K | +| 5 000 | **4.4 M** | 844.1 K | XState's `actor.subscribe` is coarse: it fires on every snapshot change. To match Dunky's behavior you'd add a differ in the listener, which is what the `xstate` column already does, for a fair comparison. @@ -77,9 +77,9 @@ affected observer" cycles complete per second (higher is better): | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 100 | 325 K | 253 K | -| 1 000 | 10.7 K | 10.7 K | -| 5 000 | **7.9 K** | 741 | +| 100 | 658.5 K | 437.8 K | +| 1 000 | 56.8 K | 49.2 K | +| 5 000 | **15.5 K** | 1.3 K | Roughly par at small N. The gap widens with N because a coarse subscribe re-runs every listener on each change, while a fine-grained selection wakes only the affected one. @@ -89,8 +89,8 @@ The whole point of the plain-object model: memory grows with your data, not with | Context width | Dunky | XState | Zag | | ------------- | -----: | -----: | -----: | -| 2 fields | 3.6 KB | 3.6 KB | 9.1 KB | -| 64 fields | 4.1 KB | 4.1 KB | 134 KB | +| 2 fields | 3.9 KB | 3.6 KB | 8.9 KB | +| 64 fields | 4.4 KB | 4.1 KB | 134 KB | Going 2 → 64 fields costs Dunky **~0.5 KB/machine**. Zag allocates one reactive cell per field, so a 64-field context grows to **134 KB/machine**. @@ -100,9 +100,9 @@ Spin-up cost per machine: | | Dunky | XState | Zag | | ------------ | ----: | -------: | ---: | -| µs / machine | 2.42 | **1.95** | 8.16 | +| µs / machine | 1.58 | **1.35** | 5.06 | -XState cold-starts ~1.2× faster; Zag is ~3.4× slower than both. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). +XState cold-starts ~1.2× faster; Zag is ~3.2× slower than both. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). ## Where this matters