From 9dfa91a8d40387ba6079a34663bd6d872f047874 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 19 Aug 2026 12:46:22 +0200 Subject: [PATCH 01/16] refactor(machine): drop the write-only version counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version field was bumped on every notify but never read anywhere — its comment claimed computed memoizes through it, while computed.ts actually tracks per-field deps via proxies and value snapshots. Leftover from the pre-proxy memoization design. Co-Authored-By: Claude Haiku 4.5 --- packages/core/src/machine.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index d3863bc..b2af0f9 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -38,8 +38,6 @@ 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>() @@ -115,7 +113,6 @@ class MachineClass< } 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) { From c1357b36402de1ab10354b9a355d98667b191ea8 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 19 Aug 2026 16:45:34 +0200 Subject: [PATCH 02/16] fix(machine): guard nested bump() from resurrecting removed listeners A nested bump() rebuilds busSnapshot and clears busDirty, so the outer pass lost its mid-pass-churn signal and could call a listener that was unsubscribed during the pass. Detect the swap by snapshot identity too. Co-Authored-By: Claude Fable 5 --- packages/core/src/machine.ts | 8 ++++-- packages/core/tests/subscribe.test.ts | 35 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index b2af0f9..c193df2 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -114,12 +114,16 @@ class MachineClass< private bump(): void { // 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. + // Skip the has() guard in the steady state. A nested bump() clears busDirty, so also treat + // a swapped busSnapshot (rebuilds always allocate anew) as mid-pass churn. if (this.busDirty) { this.busSnapshot = [...this.bus] this.busDirty = false } - for (const l of this.busSnapshot) if (!this.busDirty || this.bus.has(l)) l() + const snapshot = this.busSnapshot + for (const l of snapshot) { + if ((!this.busDirty && snapshot === this.busSnapshot) || this.bus.has(l)) l() + } } get state(): State { diff --git a/packages/core/tests/subscribe.test.ts b/packages/core/tests/subscribe.test.ts index 84ed28b..e51e41a 100644 --- a/packages/core/tests/subscribe.test.ts +++ b/packages/core/tests/subscribe.test.ts @@ -357,4 +357,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']) + }) }) From 4e45d79fcbd320042dc3ee37126ee24c3e96b1a9 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 19 Aug 2026 16:53:44 +0200 Subject: [PATCH 03/16] fix(machine): finish the cleanup pass when a cleanup throws stopEffects() bailed on the first throwing cleanup: the remaining cleanups leaked (timers, subscriptions) and the list stayed populated, so the next stop re-ran the whole pass. Run every cleanup, clear the list, rethrow the first error after the pass. Co-Authored-By: Claude Fable 5 --- packages/core/src/machine.ts | 19 +++++++++++++++++-- packages/core/tests/effects.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index c193df2..eb9cdf0 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -281,8 +281,23 @@ 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 { 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' }>({ From 9c82056fbd3b0611aa17377362d35043ed3e9e85 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 19 Aug 2026 16:57:12 +0200 Subject: [PATCH 04/16] perf(machine): cache the select facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get select() rebuilt the facade (function + 3 method closures) on every access. Build once, reuse — the facade is stateless, and the stable identity is now guaranteed (safe for dependency arrays). Co-Authored-By: Claude Fable 5 --- packages/core/src/machine.ts | 5 ++++- packages/core/tests/subscribe.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index eb9cdf0..26bce9c 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -382,7 +382,10 @@ class MachineClass< }, } } + // 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, @@ -392,7 +395,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/tests/subscribe.test.ts b/packages/core/tests/subscribe.test.ts index e51e41a..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) From 6553ff97f64c7849a93238fbc2fbe6c6503c0cf6 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 19 Aug 2026 20:02:54 +0200 Subject: [PATCH 05/16] docs: add the changeset for the machine hardening pass Co-Authored-By: Claude Fable 5 --- .../machine-notify-and-cleanup-hardening.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/machine-notify-and-cleanup-hardening.md diff --git a/.changeset/machine-notify-and-cleanup-hardening.md b/.changeset/machine-notify-and-cleanup-hardening.md new file mode 100644 index 0000000..5629f57 --- /dev/null +++ b/.changeset/machine-notify-and-cleanup-hardening.md @@ -0,0 +1,17 @@ +--- +'@dunky.dev/state-machine': patch +--- + +Harden the machine's notify and teardown paths, and trim dead weight: + +- 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. +- 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. +- `machine.select` is built once and reused instead of allocating a fresh + facade object on every property access. +- Dropped the internal write-only `version` counter — bumped on every notify, + read by nothing. From 080a523c3ec93077fd57e96073535c96731c08f5 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 01:15:17 +0200 Subject: [PATCH 06/16] perf(machine): bind a watcher's field source once at start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readField() probed `key in ctx` on every watcher notify to pick ctx-vs-computed, but the answer is static. Bind the source object per key in startWatchers instead. Membership is probed against computed — its keys are frozen at construction — because an optional context field can be patched in after start. Co-Authored-By: Claude Fable 5 --- packages/core/src/machine.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 26bce9c..5be2b5a 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -300,20 +300,21 @@ class MachineClass< 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) + // 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 + > + let prev = source[key] const listener = () => { - const next = this.readField(key) + const next = source[key] if (Object.is(prev, next)) return prev = next // Defer: this fires inside bump() (mid-transition). Running actions immediately From e9952191b35e28f689bf85bc1cfdab47cdaa10d2 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 01:19:04 +0200 Subject: [PATCH 07/16] refactor(machine): watchers reuse makeSelection for value dedupe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startWatchers re-implemented the Selection dedupe (prev seed, Object.is compare, update) inline. A watcher is now a makeSelection(...).subscribe(...) over its bound source — one home for the dedupe semantics. Co-Authored-By: Claude Fable 5 --- packages/core/src/machine.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 5be2b5a..497da14 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -312,19 +312,14 @@ class MachineClass< string, unknown > - let prev = source[key] - const listener = () => { - const next = source[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(). + // Defer: the selection fires inside bump() (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 { From fb337bcd28e409c843b9f1b1c10b2edcfcd32dfa Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 01:20:05 +0200 Subject: [PATCH 08/16] refactor(machine): rename bump() to notify() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no version counter to bump since 9dfa91a — the method only notifies the bus. The old name also collided with the "bump" event type used across tests. Co-Authored-By: Claude Fable 5 --- packages/core/src/machine.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 497da14..c020a82 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -98,7 +98,7 @@ class MachineClass< } if (!changed) return Object.assign(this.ctx, patch) // in place — this.ctx identity never changes - this.bump() + this.notify() } this.send = event => this.doSend(event) } @@ -112,9 +112,9 @@ class MachineClass< this.busDirty = true } - private bump(): void { + private notify(): void { // Iterate a stable snapshot so mid-pass (un)subscribes take effect after the current pass. - // Skip the has() guard in the steady state. A nested bump() clears busDirty, so also treat + // Skip the has() guard in the steady state. A nested notify() clears busDirty, so also treat // a swapped busSnapshot (rebuilds always allocate anew) as mid-pass churn. if (this.busDirty) { this.busSnapshot = [...this.bus] @@ -142,7 +142,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. @@ -312,7 +312,7 @@ class MachineClass< string, unknown > - // Defer: the selection fires inside bump() (mid-transition). Running actions immediately + // 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(() => { From aad029a08f520b95a36992ce93e5e0133a7c8d31 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 01:23:33 +0200 Subject: [PATCH 09/16] fix(compose): manual unsubscribe detaches from the group registry sync()/combine().subscribe() disposers stayed registered after a manual unsubscribe: long-lived groups with subscribe/unsubscribe churn grew the array without bound, and stop() re-ran every stale disposer. The registry is now a Set and each disposer deletes itself; the duplicated dispose blocks collapsed into one register() helper. Co-Authored-By: Claude Fable 5 --- packages/core/src/compose.ts | 29 ++++++++++++++--------------- packages/core/tests/compose.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/core/src/compose.ts b/packages/core/src/compose.ts index cd39160..1cd7931 100644 --- a/packages/core/src/compose.ts +++ b/packages/core/src/compose.ts @@ -34,7 +34,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,17 +52,11 @@ 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 { @@ -67,12 +71,7 @@ export function compose>( 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 register(list.map(m => m.subscribe(onChange))) }, } }, 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 From d7798070278fe2d3f72d62e8e5a68efe6f220455 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 01:27:40 +0200 Subject: [PATCH 10/16] refactor(core): one home for selection dedupe semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit combine() re-implemented makeSelection's prev/equals/notify loop — the third copy after the machine's own and the watchers'. Extract makeSelection(selector, attach) to selection.ts; the machine attaches to its bus, compose attaches via the group registry. Co-Authored-By: Claude Fable 5 --- packages/core/src/compose.ts | 19 +++---------------- packages/core/src/machine.ts | 23 +++++------------------ packages/core/src/selection.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/selection.ts diff --git a/packages/core/src/compose.ts b/packages/core/src/compose.ts index 1cd7931..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 @@ -59,21 +60,7 @@ export function compose>( 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) - } - return register(list.map(m => m.subscribe(onChange))) - }, - } + return makeSelection(selector, onWake => register(list.map(m => m.subscribe(onWake)))) }, } } diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index c020a82..e3bfacc 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -2,6 +2,7 @@ import { type ActionHost, runActions } from './actions' import { installComputed } from './computed' import { isDev, MACHINE_INIT, MAX_DRAIN } from './constants' import { makeGuardParams } from './guards' +import { makeSelection } from './selection' import { lookupOn, resolve } from './transitions' import type { Actions, @@ -359,24 +360,10 @@ class MachineClass< } 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.busAdd(onWake) + return () => this.busDelete(onWake) + }) } // Built on first access, then reused — the facade is stateless, so one instance serves all reads. selectFacade: Select | null = null diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts new file mode 100644 index 0000000..df9ea9d --- /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 bus, 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) + }) + }, + } +} From 7f607b9ab9bc7cffbb4117bd0ba706e86f34cf28 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 01:35:37 +0200 Subject: [PATCH 11/16] fix(core): extract the Broadcast primitive; connector wake stops allocating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connector's wake() copied [...listeners] on every notify AND had the mid-pass resurrect bug the machine's bus already solved (a listener unsubscribed during a pass still fired from the stale copy). Extract that bus as makeBroadcast() in broadcast.ts — allocation-free steady-state notify, membership-checked mode under mid-pass churn — and use it in both the machine and the connector. Direct contract tests added for the primitive, incl. mid-pass add deferral and clear(). Co-Authored-By: Claude Fable 5 --- packages/core/src/broadcast.ts | 43 +++++++++++++ packages/core/src/connector.ts | 10 +-- packages/core/src/machine.ts | 38 ++---------- packages/core/src/selection.ts | 2 +- packages/core/tests/broadcast.test.ts | 87 +++++++++++++++++++++++++++ packages/core/tests/connector.test.ts | 13 ++++ 6 files changed, 154 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/broadcast.ts create mode 100644 packages/core/tests/broadcast.test.ts 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/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 e3bfacc..d6ad18f 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -1,4 +1,5 @@ import { type ActionHost, runActions } from './actions' +import { makeBroadcast } from './broadcast' import { installComputed } from './computed' import { isDev, MACHINE_INIT, MAX_DRAIN } from './constants' import { makeGuardParams } from './guards' @@ -39,11 +40,7 @@ class MachineClass< ctx: Context stateValue: State tagsOf: Record> - // 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)> = [] @@ -104,27 +101,8 @@ class MachineClass< 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 notify(): void { - // Iterate a stable snapshot so mid-pass (un)subscribes take effect after the current pass. - // Skip the has() guard in the steady state. A nested notify() clears busDirty, so also treat - // a swapped busSnapshot (rebuilds always allocate anew) as mid-pass churn. - if (this.busDirty) { - this.busSnapshot = [...this.bus] - this.busDirty = false - } - const snapshot = this.busSnapshot - for (const l of snapshot) { - if ((!this.busDirty && snapshot === this.busSnapshot) || this.bus.has(l)) l() - } + this.broadcast.notify() } get state(): State { @@ -354,16 +332,10 @@ 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 { - return makeSelection(selector, onWake => { - this.busAdd(onWake) - return () => this.busDelete(onWake) - }) + 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 diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index df9ea9d..75ad4e3 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -3,7 +3,7 @@ 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 bus, a + * supplied equality). `attach` supplies the wake source — the machine broadcast, a * composition's members — and returns the detach. */ export function makeSelection( 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/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() From 479ac47daaab91987c1c99423e3407b2477574d0 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 01:39:40 +0200 Subject: [PATCH 12/16] refactor(store): share the changed-probe and the Broadcast primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit store.set() duplicated setContext's Object.is changed-loop and copied [...listeners] on every write. Extract shouldPatch() (patch.ts) for both, move the listeners onto makeBroadcast() — no copy per notify, and mid-pass unsubscribes are final here too. The fresh state identity per set() stays on purpose: get() serves as a useSyncExternalStore snapshot, so the identity change is the re-render signal (now pinned by a test). Co-Authored-By: Claude Fable 5 --- packages/core/src/machine.ts | 10 ++-------- packages/core/src/patch.ts | 7 +++++++ packages/core/src/store.ts | 21 +++++++++------------ packages/core/tests/store.test.ts | 20 ++++++++++++++++++++ 4 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/patch.ts diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index d6ad18f..5696017 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -3,6 +3,7 @@ import { makeBroadcast } from './broadcast' import { installComputed } 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 { @@ -87,14 +88,7 @@ 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.notify() } 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/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/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 }) From 8f7cd8f13ed992246a77482220e2ba659012ec92 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 15:14:03 +0200 Subject: [PATCH 13/16] perf(computed): allocation-free recompute, deps validated in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recompute allocated 7 objects (2 tracking Sets, the def params, 2 dep-array spreads, 2 snapshot objects) and then re-read every dep it had just computed to snapshot it. Dep keys/values now live in parallel buffers reused across recomputes and are captured at read time inside the tracking proxies — a recompute allocates nothing but the def's own return value. Also renames installComputed -> defineComputed (it defines getters, nothing gets installed). Benchmark (same machine, before -> after): recompute 3.4M -> 5.0M ops/s, 4-deep chain 929K -> 1.5M, cached read 35M -> 43M. Co-Authored-By: Claude Fable 5 --- packages/core/src/computed.ts | 111 ++++++++++++++++++++-------------- packages/core/src/machine.ts | 4 +- 2 files changed, 67 insertions(+), 48 deletions(-) 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/machine.ts b/packages/core/src/machine.ts index 5696017..ac7b1c6 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -1,6 +1,6 @@ import { type ActionHost, runActions } from './actions' import { makeBroadcast } from './broadcast' -import { installComputed } from './computed' +import { defineComputed } from './computed' import { isDev, MACHINE_INIT, MAX_DRAIN } from './constants' import { makeGuardParams } from './guards' import { shouldPatch } from './patch' @@ -71,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, From 88c327474ffaf58dab348303872b105b96bb8778 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 15:14:37 +0200 Subject: [PATCH 14/16] docs: extend the changeset to cover the full hardening + perf pass Co-Authored-By: Claude Fable 5 --- .../machine-notify-and-cleanup-hardening.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.changeset/machine-notify-and-cleanup-hardening.md b/.changeset/machine-notify-and-cleanup-hardening.md index 5629f57..3edddb4 100644 --- a/.changeset/machine-notify-and-cleanup-hardening.md +++ b/.changeset/machine-notify-and-cleanup-hardening.md @@ -2,16 +2,28 @@ '@dunky.dev/state-machine': patch --- -Harden the machine's notify and teardown paths, and trim dead weight: +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. + 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. + 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. From 7767afbb94683b917e3cc1744186664f3cc09d3e Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 15:32:57 +0200 Subject: [PATCH 15/16] docs(benchmark): refresh all result tables from a current run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New numbers from one clean run on this branch (M-series, Node 24) — the old tables predated the hardening/perf pass and came from different hardware. Ratio claims rechecked: throughput ~8x -> ~7x, propagation at 5000 ~10x -> ~11x, memory vs Zag ~33x -> ~31x, construction gap ~1.2x -> ~1.1x. The memory rows now show the small broadcast-closure debit honestly (3.9/4.4 KB vs XState's 3.6/4.1). Co-Authored-By: Claude Fable 5 --- README.md | 12 ++-- benchmark/README.md | 96 +++++++++++++------------- packages/core/README.md | 2 +- website/src/content/docs/benchmark.mdx | 38 +++++----- 4 files changed, 74 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 19362cc..3edc5b3 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.4 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..3114e42 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -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 | 690.1 K | 455.3 K | n/a ᵃ | +| 1000 | 59.7 K | 52.6 K | n/a ᵃ | +| 5000 | **15.0 K** | 1.4 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.3 M** | 909.4 K | n/a ᵃ | +| 5000 | **3.3 M** | 734.4 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.4 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.1 M | 11.4 M | n/a ᶠ | n/a ᶠ | +| 10 | 10.2 M | 10.8 M | n/a ᶠ | n/a ᶠ | +| 50 | 9.8 M | 9.0 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 **~9.0–11.4 M ops/sec** band even at 50 +synced members — the O(M) re-eval pass costs ~12–20% 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.7 M** | n/a ᶠ | n/a ᵃ | +| Fine-grain (change unread, re-read) | 11.3 M | n/a ᶠ | n/a ᵃ | +| Recompute (change read field) | 5.1 M | n/a ᶠ | n/a ᵃ | +| 4-deep chain (change root, read tip) | 1.6 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.7 M/sec** (near-free memo hit), and changing a field the +computed _doesn't_ read stays a memo hit at ~11.3 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.6 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 32 candidates | 3.3 M | n/a ᶠ | n/a ᵃ | +| State churn — exit+entry every event | 9.3 M | n/a ᶠ | n/a ᵃ | +| Effect churn — boot+cleanup each trans | 8.6 M | n/a ᶠ | n/a ᵃ | +| Sub churn — stable set | 11.7 M | n/a ᶠ | n/a ᵃ | +| Sub churn — churning set (rebuild) | 8.2 M | n/a ᶠ | n/a ᵃ | + +→ Even the heavy paths hold **~3–12 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.44 | 1.34 | 4.78 | → 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. +~1.1× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; +it's still ~3.3× 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.5 | **2.4** | +| Dunky/selector | 2 | 5.0 | 3.7 | +| xstate/selector | 2 | 3.8 | 4.2 | +| zag/instance | 2 | 3.7 | n/a ᵃ | +| naive (anti-pattern) | **980** | 4.4 | 36.8 | → 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× +~15× the wall time). Among the surgical strategies Dunky re-renders **~1.7× 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/website/src/content/docs/benchmark.mdx b/website/src/content/docs/benchmark.mdx index 9c3bab7..8a08100 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.4 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.2 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.4 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.3 M** | 909.4 K | +| 5 000 | **3.3 M** | 734.4 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 | 690.1 K | 455.3 K | +| 1 000 | 59.7 K | 52.6 K | +| 5 000 | **15.0 K** | 1.4 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.44 | **1.34** | 4.78 | -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.1× faster; Zag is ~3.3× slower than both. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). ## Where this matters From 02b856cb8342800a5a0215e51a6250b0f1ffc8d1 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 20 Aug 2026 17:31:57 +0200 Subject: [PATCH 16/16] docs(benchmark): re-baseline tables on a 4-run average MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous refresh came from a single run taken under machine load — the irrelevant-write@5000 row understated Dunky by ~25% and sync@50 by ~12%. All tables now carry the average of four clean runs (the header says so), ratio claims rechecked. Also fixes the benchmark skill: adds the root README's table as step D, corrects the website page path, and drops a nonexistent core-README memory claim from the checklist. Co-Authored-By: Claude Fable 5 --- .claude/skills/benchmark/SKILL.md | 24 +++++---- README.md | 2 +- benchmark/README.md | 70 +++++++++++++------------- website/src/content/docs/benchmark.mdx | 20 ++++---- 4 files changed, 61 insertions(+), 55 deletions(-) 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 3edc5b3..f71fa52 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ engine is built for it: | At scale (thousands of machines) | Dunky | XState | Zag | | -------------------------------- | ---------: | -----: | ------: | -| Event throughput (ops/s) | **11.4 M** | 1.6 M | n/a ᵃ | +| 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** | diff --git a/benchmark/README.md b/benchmark/README.md index 3114e42..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,9 +95,9 @@ XState's coarse `actor.subscribe`. | Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------- | --------------: | ---------------: | ----: | -| 100 | 690.1 K | 455.3 K | n/a ᵃ | -| 1000 | 59.7 K | 52.6 K | n/a ᵃ | -| 5000 | **15.0 K** | 1.4 K | 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 **~11× faster at 5000 observers** — XState's coarse subscribe degrades much faster as the observer set grows. @@ -109,8 +109,8 @@ 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 | **5.3 M** | 909.4 K | n/a ᵃ | -| 5000 | **3.3 M** | 734.4 K | n/a ᵃ | +| 1000 | **5.4 M** | 987.9 K | n/a ᵃ | +| 5000 | **4.4 M** | 844.1 K | n/a ᵃ | → Dunky is **~6× faster** at shrugging off a write nobody is watching (1000 cells); the value-deduping bus skips waking observers entirely. @@ -120,7 +120,7 @@ selection scaling — the raw `send` price. | Single machine, one event | ops/sec | | ------------------------- | ---------: | -| Dunky | **11.4 M** | +| Dunky | **11.6 M** | | XState (raw) | 1.6 M | | XState (diffed) | 1.6 M | | Zag | n/a ᵃ | @@ -142,12 +142,12 @@ change (the O(members) path by design). | Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | | ------- | ----------------------: | -------------------: | ------ | ----- | -| 2 | 11.1 M | 11.4 M | n/a ᶠ | n/a ᶠ | -| 10 | 10.2 M | 10.8 M | n/a ᶠ | n/a ᶠ | -| 50 | 9.8 M | 9.0 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 **~9.0–11.4 M ops/sec** band even at 50 -synced members — the O(M) re-eval pass costs ~12–20% 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) | **44.7 M** | n/a ᶠ | n/a ᵃ | -| Fine-grain (change unread, re-read) | 11.3 M | n/a ᶠ | n/a ᵃ | -| Recompute (change read field) | 5.1 M | n/a ᶠ | n/a ᵃ | -| 4-deep chain (change root, read tip) | 1.6 M | 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 **~44.7 M/sec** (near-free memo hit), and changing a field the -computed _doesn't_ read stays a memo hit at ~11.3 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`) @@ -182,14 +182,14 @@ Zag's `send` is async (**n/a ᵃ**). | Scenario | Dunky (ops/sec) | XState | Zag | | -------------------------------------- | --------------: | ------ | ----- | | Guard fallthrough — 2 candidates | 5.2 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 8 candidates | 4.6 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 32 candidates | 3.3 M | n/a ᶠ | n/a ᵃ | -| State churn — exit+entry every event | 9.3 M | n/a ᶠ | n/a ᵃ | -| Effect churn — boot+cleanup each trans | 8.6 M | n/a ᶠ | n/a ᵃ | -| Sub churn — stable set | 11.7 M | n/a ᶠ | n/a ᵃ | -| Sub churn — churning set (rebuild) | 8.2 M | n/a ᶠ | n/a ᵃ | - -→ Even the heavy paths hold **~3–12 M ops/sec**: a 32-candidate guard walk, full +| 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 | 1.44 | 1.34 | 4.78 | +| 10 000 | 1.58 | 1.35 | 5.06 | → Construction is the one axis where Dunky **doesn't** win — XState spins up -~1.1× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; -it's still ~3.3× faster than Zag's per-field reactive cells. +~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; +it's still ~3.2× faster than Zag's per-field reactive cells. ## 6. Memory per machine (`tests/memory.ts`) @@ -250,15 +250,15 @@ List of 1000 rows: | Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | | -------------------- | ----------------: | ---------: | ------------------: | -| Dunky/instance | **2** | 3.5 | **2.4** | -| Dunky/selector | 2 | 5.0 | 3.7 | -| xstate/selector | 2 | 3.8 | 4.2 | -| zag/instance | 2 | 3.7 | n/a ᵃ | -| naive (anti-pattern) | **980** | 4.4 | 36.8 | +| 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 -~15× 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/website/src/content/docs/benchmark.mdx b/website/src/content/docs/benchmark.mdx index 8a08100..325c2e3 100644 --- a/website/src/content/docs/benchmark.mdx +++ b/website/src/content/docs/benchmark.mdx @@ -42,10 +42,10 @@ These were run on a **MacBook Pro (M1, 32 GB)**. Your absolute numbers will diff | | Dunky | XState | Zag | | ------------------------- | ---------------: | -----: | -----: | -| Event throughput | **11.4 M ops/s** | 1.6 M | n/a ᵃ | +| 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.2 ms | n/a ᵃ | +| 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. @@ -55,7 +55,7 @@ A single machine, one event, tight loop: | | Dunky | XState | Zag | | ------- | ---------: | -----: | ----: | -| ops/sec | **11.4 M** | 1.6 M | n/a ᵃ | +| 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 | **5.3 M** | 909.4 K | -| 5 000 | **3.3 M** | 734.4 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 | 690.1 K | 455.3 K | -| 1 000 | 59.7 K | 52.6 K | -| 5 000 | **15.0 K** | 1.4 K | +| 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. @@ -100,9 +100,9 @@ Spin-up cost per machine: | | Dunky | XState | Zag | | ------------ | ----: | -------: | ---: | -| µs / machine | 1.44 | **1.34** | 4.78 | +| µs / machine | 1.58 | **1.35** | 5.06 | -XState cold-starts ~1.1× faster; Zag is ~3.3× 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