Cut allocations from event binding and prop application - #39
Merged
Conversation
Binding an event and applying a prop are what every row of every list does, and both were paying for a general shape on every call while using it almost never. `addEvent` kept an element's listeners in a `Map` keyed by a `"type:name"` string built per bind, and held each pair's handlers in a `Set`. Both are the right shape for fan-out — several handlers behind one dispatcher — and the wrong shape for the case the module spends its time in: one element, one pair, one handler. It now keeps a short array of pairs and stores a lone handler directly, promoting to a `Set` only when a pair gains a second one. Fan-out is unchanged: handlers stay isolated, dispatch still walks a copy when there is more than one, and the engine still sees one dispatcher per pair. Detach now matches its registration by identity instead of looking it up again by name, so a stale dispose cannot reach a fresh registration that replaced it. `applyProp` built a closure per prop so `bind` could decide static-or-reactive from it. The appliers are now shared module-level functions taking the element and name as arguments, so a static prop allocates nothing to be applied and only a getter pays for a closure. A `show` getter is now read once per update rather than twice. Against a host that does nothing, binding and releasing 100,000 listeners went from 145 ms to 62 ms and applying 200,000 static attributes from 18.9 ms to 2.8 ms. Through scripts/bench-reconciler.ts, where about half the time is the fake engine, creating 10,000 rows went from 260 ms to 185 ms and clearing 1,000 from 5.6 ms to 4.6 ms, with engine-call counts unchanged in every case. The core entry grows 166 gzipped bytes; core-size-budget.test.ts records the move and the measurements behind it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
amritk
force-pushed
the
claude/performance-deep-dive-6wyv71
branch
from
August 21, 2026 06:00
f46aa00 to
03b0f61
Compare
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.
Summary
Binding an event and applying a prop are what every row of every list does, and both were paying for a general shape on every call while using it almost never. Profiling
scripts/bench-reconciler.tsputadd-event.tsat ~15.7% self time — well ahead of the reconciler — with the worklet registry and prop parse behind it.Changes
add-event.tsMapkeyed by a"type:name"string built on every bind and every detach. The strings compared are the interned onesapply-prop.tsalready caches, so the walk is usually a pointer compare over one or two entries.Setonly when a pair genuinely gains a second handler. Fan-out behaviour is unchanged: handlers stay isolated, dispatch still walks a copy when there is more than one, and the engine still sees exactly one dispatcher per pair.apply-prop.tsbindneeded with shared module-level appliers taking the element and name as arguments. A static prop — most props, on most elements — now allocates nothing to be applied; only a getter pays for the one closure its effect needs.showgetter is now read once per update instead of twice.core-size-budget.test.tsTesting
Isolated, against a host that does nothing — the changed code itself:
End to end through
scripts/bench-reconciler.ts, where roughly half the time is the fake engine's own bookkeeping (medians of three fifteen-sample runs, engine-call counts identical in every case):Swap, remove and select being unchanged is the check that the story is the right one: they reconcile without building a row, so they bind no listener and apply no prop.
One rejected change worth recording: replacing the list reconciler's per-pass
Setwith a pass-stamp on each entry (three hash lookups per row down to one) measured as a large regression — creating 10,000 rows went 181 ms → 453 ms, because the stamp turns one compact write into N scattered heap writes. Not included here.bun run testpassesbun run checkpassesbun run check:reactivitypassesbun run types:checkpassesbun run buildpassesbunx changeset) if this affects a published packageBehaviour is covered by the existing suites — 627
mini-lynxtests, including the 103 that pin event binding, fan-out, dispatch isolation and detach lifecycle.@amritk/miniis untouched.Related issues