Skip to content

Cut allocations from event binding and prop application - #39

Merged
amritk merged 1 commit into
mainfrom
claude/performance-deep-dive-6wyv71
Aug 21, 2026
Merged

Cut allocations from event binding and prop application#39
amritk merged 1 commit into
mainfrom
claude/performance-deep-dive-6wyv71

Conversation

@amritk

@amritk amritk commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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.ts put add-event.ts at ~15.7% self time — well ahead of the reconciler — with the worklet registry and prop parse behind it.

Changes

add-event.ts

  • Keep an element's pairs in a short array instead of a Map keyed by a "type:name" string built on every bind and every detach. The strings compared are the interned ones apply-prop.ts already caches, so the walk is usually a pointer compare over one or two entries.
  • Store a lone handler directly on its registration, promoting to a Set only 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.
  • Match the registration by identity on detach rather than looking it up again by name, so a stale dispose is harmless and cannot reach a fresh registration that a re-bind put in its place.

apply-prop.ts

  • Replace the per-prop closure that bind needed 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.
  • A show getter is now read once per update instead of twice.

core-size-budget.test.ts

  • Raise the ceiling 5560 → 5726 gzipped bytes and record the move in the docstring alongside the three before it, with the measurements behind it. This is the reviewable decision in the PR — the budget sits deliberately snug, and the alternative is keeping only the single-handler fast path for ~92 bytes instead of 166, which gives up about half the win.

Testing

Isolated, against a host that does nothing — the changed code itself:

before after
bind + dispose 100,000 listeners 145 ms 62 ms
apply 200,000 static attributes 18.9 ms 2.8 ms

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):

Operation before after Δ
create 10,000 rows 260.0 ms 184.6 ms −29%
clear 1,000 rows 5.63 ms 4.55 ms −19%
replace all 1,000 rows 28.1 ms 23.8 ms −16%
partial update (every 10th) 0.36 ms 0.32 ms −11%
create 1,000 rows 21.6 ms 19.8 ms −8%
swap / remove / select unchanged

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 Set with 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 test passes
  • bun run check passes
  • bun run check:reactivity passes
  • bun run types:check passes
  • bun run build passes
  • Changeset added (bunx changeset) if this affects a published package

Behaviour is covered by the existing suites — 627 mini-lynx tests, including the 103 that pin event binding, fan-out, dispatch isolation and detach lifecycle. @amritk/mini is untouched.

Related issues

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
amritk force-pushed the claude/performance-deep-dive-6wyv71 branch from f46aa00 to 03b0f61 Compare August 21, 2026 06:00
@amritk
amritk merged commit 44c5044 into main Aug 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants