core: drop the dead version counter + hardening/perf pass over notify, teardown, and computed - #65
Merged
Merged
Conversation
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 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…cating 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Started as removing
MachineClass.version(write-only, bumped every notify, read by nothing). The code-review pass that confirmed it was dead surfaced a set of real defects and hot-path waste next to it — this PR lands the whole sweep overmachine.ts,compose.ts,connector.ts,store.ts, andcomputed.ts.Fixes
Unsubscribing is final, even mid-notify. A nested notify (a
sendfrom inside a subscriber) rebuilt the iteration snapshot and resurrected listeners removed earlier in the pass. The machine, connector, and store all had a variant of this; the hardened loop now lives in one primitive (broadcast.ts) they all share:A throwing effect cleanup no longer leaks the rest. Every cleanup runs, the list clears, the first error rethrows after the pass — the next stop can't double-run them.
Manual
sync()/combine().subscribe()disposers detach from the group registry. Long-lived compositions with subscribe/unsubscribe churn no longer grow it without bound;stop()no longer re-runs hand-run disposers.Perf
machine.selectis built once (stable identity, safe for dependency arrays); watcher field sources bind once at start;store.set/setContextshare oneshouldPatchprobe.Benchmark on the same machine — main measured once in a worktree, branch is the average of 4 clean runs:
The memory row is the one debit (+0.25 KB/machine from the per-instance broadcast closures). The documented tables (benchmark README, core README, website, root README) were re-baselined in this PR on the same 4-run average.
Naming
bump()→notify()(nothing left to bump),installComputed→defineComputed(it defines getters).Checks
patchfor@dunky.dev/state-machinecovering the consumer-visible fixes.🤖 Generated with Claude Code