Skip to content

Perf: Close per-FIELD gap in as-mode {#each}#191

Merged
jlukic merged 33 commits intomainfrom
perf/fgr-as
May 9, 2026
Merged

Perf: Close per-FIELD gap in as-mode {#each}#191
jlukic merged 33 commits intomainfrom
perf/fgr-as

Conversation

@jlukic
Copy link
Copy Markdown
Member

@jlukic jlukic commented May 8, 2026

This implements fine grained reactivity for each so that expressions that reference values that are not modified do not rerun.

Risk

7/10

Core rendering engine block affects a lot of code, but changes are well understood.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
semantic-next Ready Ready Preview, Comment May 9, 2026 9:47pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
mcp Ignored Ignored Preview May 9, 2026 9:47pm

Request Review

@semantic-performance-bot
Copy link
Copy Markdown

semantic-performance-bot Bot commented May 8, 2026

🟡 Mixed (mostly faster) for 7c617be on Benchmark Suite 📊

Base: main · Action: #25611264604 · Raw: bench-report.json

Perf: Close per-FIELD gap in as-mode {#each}

Warning

This PR improves ✅ 15 tests while regressing on ❌ 11 tests.

✅ 15 faster · ❌ 11 slower · 🔍 14 unsure · ⚪ 9 no change · 🏆 1 new peak · 📜 12 reopened


✅ Faster (15)

Metrics where this PR confidently improved performance compared to main.

metric Improvement
krausest:update-10th-50 -84% (127ms) 🏆
todo:toggle-all-200 -36% (673ms) 🌟
todo:rename-500 -33% (90ms) ⭐
todo:toggle-last-100 -23% (13ms) ⭐
todo:remove-50-middle -18% (3ms) ⭐
todo:toggle-100 -18% (9ms) ⭐
todo:toggle-first-100 -16% (10ms) ⭐
hydrate:helper-100-mount -15% (7ms) ⭐
todo:toggle-middle-100 -15% (7ms)
todo:remove-first-100 -13% (8ms)
todo:remove-last-100 -11% (7ms)
todo:remove-50-back -11% (2ms)
template:active-indicator-nested-200 -6% (1ms)
krausest:swap-rows-20 -5% (5ms)
hydrate:helper-100-state-change-1k -4% (0ms)

❌ Slower (11)

Metrics where this PR confidently regressed performance compared to main.

metric Regression
krausest:remove-row-middle-20 +27% (3ms) ❗
template:snippet-args-per-key-100x500 +14% (4ms)
template:active-indicator-200 +12% (4ms)
template:snippet-in-subtemplate-100x1k +11% (3ms)
template:stable-ref-mutate-500 +10% (1ms)
template:each-mount-1000 +9% (4ms)
template:subtemplate-shorthand-props-100x500 +7% (3ms)
hydrate:each-100 +7% (7ms)
todo:remove-50-front +7% (1ms)
renderer-micros:expr-simple-100k +3% (1ms)
krausest:select-40 +3% (22ms)

🏆 New peaks (1)

These metrics hit a new best on this PR. The most recent candidate is usually the cause.

metric improvement prior peak likely candidates
renderer-micros:expr-lisp-50k 2% 02f6a0c ce2ddcc, 0d19eab, 8d9e74b (+1 more)

📜 Regressions from peak (12)

These metrics were faster on an earlier push to this PR. The most recent candidate is usually where to look.

metric regression prior peak likely candidates
template:snippet-args-per-key-100x500 14% ce2ddcc
template:snippet-in-subtemplate-100x1k 13% 1291bd2 ce2ddcc, 0d19eab, 8d9e74b (+3 more)
template:subtemplate-shorthand-props-100x500 9% ce2ddcc
template:active-indicator-200 8% 02f6a0c ce2ddcc, 0d19eab, 8d9e74b (+1 more)
template:each-mount-1000 7% 1291bd2 ce2ddcc, 0d19eab, 8d9e74b (+3 more)
todo:remove-middle-100 7% 1eeb210 ce2ddcc, 0d19eab, 8d9e74b (+4 more)
todo:remove-last-100 6% 1eeb210 ce2ddcc, 0d19eab, 8d9e74b (+4 more)
template:subtemplate-helpers-light-100x500 5% 02f6a0c ce2ddcc, 0d19eab, 8d9e74b (+1 more)
template:subtemplate-helpers-heavy-100x500 4% ce2ddcc
renderer-micros:expr-simple-100k 4% 02f6a0c ce2ddcc, 0d19eab, 8d9e74b (+1 more)
todo:toggle-100 3% 8d9e74b ce2ddcc, 0d19eab
hydrate:each-100 3% 1291bd2 ce2ddcc, 0d19eab, 8d9e74b (+3 more)
⚪ No Change (9)

Metrics where this PR measured within ±2% of main — no meaningful performance change detected.

metric Change
todo:add-20 -2.0% – -0.8%
renderer-micros:build-html-string-10k -1.7% – +0.2%
todo:clear-completed-250 -1.9% – -0.1%
krausest:create-10k +0.8% – +1.6%
renderer-micros:dom-walker-1000x15 -0.7% – +1.7%
hydrate:each-100-mount -1.4% – +0.5%
renderer-micros:expr-js-10k -1.4% – +0.9%
krausest:replace-1k -1.8% – +0.7%
template:subtemplate-data-blob-100 -0.2% – +0.6%
🔍 Unsure (14)

Too Fast to Measure Precisely (14)

On benches this short, OS jitter, GC, and JIT pauses drown out anything under 4%. Bigger changes than that still show up.

metric Change Test Time Expected Noise
krausest:append-1k +0.6% – +8.4% ~150ms ±9%
todo:bulk-add-500 +0.9% – +2.1% ~273ms ±2%
krausest:clear-10k -10.5% – +1.4% ~95ms ±11%
krausest:create-1k +1.0% – +3.8% ~132ms ±3%
todo:edit-cycle-5 -2.7% – -0.4% ~133ms ±3%
todo:edit-start-10 +1.1% – +3.0% ~129ms ±2%
renderer-micros:expr-lisp-50k -3.6% – -1.6% ~40ms ±6%
todo:filter-cycle-20 -3.4% – -1.1% ~313ms ±3%
todo:remove-middle-100 -1.5% – +4.8% ~60ms ±5%
krausest:remove-row-back-100 -2.8% – +0.5% ~40ms ±4%
krausest:remove-row-front-20 -17.3% – +1.2% ~10ms ±26%
template:subtemplate-helpers-heavy-100x500 +1.6% – +3.8% ~42ms ±5%
template:subtemplate-helpers-light-100x500 +1.8% – +3.9% ~72ms ±4%
template:subtemplate-reactive-data-100x500 -2.0% – +0.8% ~47ms ±5%
📖 Bench glossary (49 metrics)
metric what it tests
hydrate:each-100 Reassigns the items of a hydrated 1000-item list to a fresh array with the same keys and data.
hydrate:each-100-mount Hydrates a server-rendered 1000-item list and waits for it to become interactive without re-rendering.
hydrate:helper-100-mount Hydrates a 1000-item list where each item calls a helper that reads state shared across the list.
hydrate:helper-100-state-change-1k Walks the shared activeID across every item in a hydrated 1000-item list so two items repaint per cycle.
krausest:append-1k Appends 1000 new rows onto an existing 1000-row table.
krausest:clear-10k Clears a 10000-row table back to empty in a single operation.
krausest:create-10k Renders a fresh 10000-row table into an empty parent at ten times the create-1k scale.
krausest:create-1k Renders a fresh 1000-row table into an empty parent.
krausest:remove-row-back-100 Removes the last row 100 times from a 1000-row table, with no other rows needing to move.
krausest:remove-row-front-20 Removes the first row 20 times from a 1000-row table, with all remaining rows sliding up each time.
krausest:remove-row-middle-20 Removes the middle row 20 times from a 1000-row table, with the rows below it sliding up each time.
krausest:replace-1k Replaces 1000 rows with a fresh 1000-row set, diffing the keyed list against a populated table.
krausest:select-40 Highlights one row at a time across 40 rows so only the previous and newly highlighted rows update.
krausest:swap-rows-20 Swaps the second and second-to-last rows in a 1000-row table, repeated 20 times.
krausest:update-10th-50 Updates the label on every tenth row of a 1000-row table, looped 50 times to lift the work above noise.
renderer-micros:build-html-string-10k Builds the HTML string for a realistic card AST 10000 times. Raw assembly throughput.
renderer-micros:dom-walker-1000x15 Runs bindMarkers across a 1000-node card fragment 15 times. TreeWalker pass and binding dispatch.
renderer-micros:expr-js-10k Evaluates one arithmetic expression and one ternary 10000 times each. JS-eval hot path.
renderer-micros:expr-lisp-50k Evaluates one Lisp-style helper call 50000 times. Parse-cache lookup and helper dispatch.
renderer-micros:expr-simple-100k Evaluates one simple identifier and one dotted path 100000 times each. Property-lookup hot path.
template:active-indicator-200 Cycles selectedId across 200 list items. Only the previously and newly active items update their class.
template:active-indicator-nested-200 Cycles currentUrl through 50 leaf urls in a 5×10×4 nav. Only the previously and newly active leaves should update their…
template:each-mount-1000 Mounts a fresh 1000-item each block with five-field items so per-record allocation cost dominates the wall clock.
template:snippet-args-per-key-100x500 Mutates one snippet arg's source across 100 invocations, 500 cycles. Adjacent no-signal expressions stay quiet.
template:snippet-in-subtemplate-100x1k Mutates one subtemplate prop's source across 25 cards each invoking 4 inner snippets, 1000 cycles. Snippet bodies shoul…
template:stable-ref-mutate-500 Replaces one item by index in a 500-item list across 100 cycles. Only that item's expressions re-render.
template:subtemplate-data-blob-100 Mutates one field inside data=expression on 100 children. Every child re-renders by design.
template:subtemplate-helpers-heavy-100x500 100 subtemplates, 4 inner bindings where three call helpers shaped like userland reality — Intl.NumberFormat, Array.fin…
template:subtemplate-helpers-light-100x500 100 subtemplates, 4 inner bindings each calling formatDate / classIf / capitalize, 500 cycles. Mutates one source signa…
template:subtemplate-reactive-data-100x500 Mutates one verbose reactiveData field across 100 child subtemplates, 500 cycles. Only the changed field re-evaluates.
template:subtemplate-shorthand-props-100x500 Mutates one shorthand prop's source across 100 child subtemplates, 500 cycles. Only that prop re-evaluates.
todo:add-20 Appends 20 todo items one at a time, like a user typing entries in a row.
todo:bulk-add-500 Renders 500 todo items added at once from a single data load.
todo:clear-completed-250 Clears 250 completed items from a 500-item list in one action, like clicking clear completed.
todo:edit-cycle-5 Runs 5 full edit-then-save cycles on different items, like editing a row and saving it.
todo:edit-start-10 Enters edit mode on 10 different items in a row, like double-clicking each one.
todo:filter-cycle-20 Cycles through active, completed, and all filters 20 times on a 100-item list.
todo:remove-50-back Deletes 50 items from the end of a 100-item list, one click at a time.
todo:remove-50-front Deletes 50 items from the front of a 100-item list, one click at a time.
todo:remove-50-middle Deletes 50 items from the middle of a 100-item list, one click at a time.
todo:remove-first-100 Deletes the first item 100 times from a 200-item list, with remaining items moving up each time.
todo:remove-last-100 Deletes the last item 100 times from a 200-item list, with no other items needing to move.
todo:remove-middle-100 Deletes the middle item 100 times from a 200-item list, walking halfway through to find each target.
todo:rename-500 Renames items in a 100-item list 500 times via single-field setProperty without editingId co-fires.
todo:toggle-100 Cycles through the first 10 items 10 times each, like a user toggling items repeatedly down a list.
todo:toggle-all-200 Toggles all 100 items completed and back across 200 cycles via the master checkbox.
todo:toggle-first-100 Toggles the first item in a 100-item list 100 times, alternating completed on and off.
todo:toggle-last-100 Toggles the last item in a 100-item list 100 times, alternating completed on and off.
todo:toggle-middle-100 Toggles a middle item in a 100-item list 100 times, alternating completed on and off.

Sample size: 70 floor / 280 max · Noise floor: ±2% · Timeout: 3min · Wall-clock: 13m25s

Records whose items are never mutated in-place (active-indicator, nav
lists) paid dep.depend() overhead on every item field read. Gate
trapItemGet's dep work on a fieldMutated flag, false until the first
notifyField call. First notifyField falls back to the per-key dep so all
bindings re-run and register per-FIELD deps; subsequent calls use the
per-FIELD path.
jlukic added 2 commits May 8, 2026 22:54
trapGet for the as-key registered a per-key Dependency on every
binding that read proxy.todo, even though reconcile's as-mode
object path fires only notifyField (each.js:381,:400) and never
notifyKey on the as-key. The subscription was pure overhead — each
Reaction cycle paid Set.add for subscribe + Set.delete for cleanUp
on a Dep that never invalidates. Worst on workloads that re-evaluate
every item per cycle (active-indicator, selection cycles).

Skip the registration when returning the itemProxy. Primitive items
keep the per-key path because reconcile's else-if-refChanged branch
fires it via setKey.

Drop the !record.fresh guard on the as-mode-ref-changed-object
branch. The guard was dead for in-reconcile-fresh records (refChanged
is always false for them by construction — createRecord runs with
items[newHead] and the record is placed at newRecords[newHead]) and
incorrectly blocked hydrated records (built by adoptServerItems with
fresh=true). Hydrated records arrive at Phase 3 with refChanged=true
on the first mutation after hydration; they need the snapshot diff +
notifyField fan-out to wake the bindings wired during hydration.
jlukic added 4 commits May 9, 2026 00:18
- Rewrite the trapGet/notifyField narrative to match what the code does
  now (per-key dep is skipped for object items; wakeups go through
  notifyField).
- Drop stale cross-file line citations, the createSettingsProxy rhyme,
  guestbook-entry tags, the previous-implementation delta in each.js,
  forward-looking speculation about future Signal modes, and 'today's
  hot path' temporal framing.
- Compress test-block comments that referred to 'the upcoming
  per-FIELD pass' / 'Open question 2 from the FGR plan'.
- Strip ai/workspace/ audit-trail references from three plan files;
  workspace is per-user gitignored scratch and the references decay
  the moment a tracked file points at them.
The arg was only at the signature for call-site readability; storage
is per-RDC-flat (one as-key per RDC, fixed at construction), so the
lookup never used it. Sibling notifyKey takes one arg already.
setKey allocated a Dependency for the as-key on every record's first
replace(), but trapGet returns the itemProxy for object items without
ever subscribing to that dep — the allocation never paid back. Skip it
when the as-key value is a non-null object. Other keys (indexAs, spread
fields) still eager-allocate; primitive as-key values still allocate
because trapGet's primitive branch reads through that dep. Lazy-allocate
the primitive-branch dep as a safety net for the rare object → primitive
transition where setKey would have skipped the alloc.
Test fails on current code: mutating an object-iteration entry's inner
field doesn't fire the per-FIELD dep registered via the item proxy.
Reconcile takes the catch-all refChanged branch for object iteration
(isArrayAsMode=false), which calls dataContext.replace but never
notifyField, so the binding stays stale.
jlukic added 6 commits May 9, 2026 00:26
…values

{#each entry in obj} where obj's values are objects routed through the
catch-all refChanged branch (isArrayAsMode is false for object
iteration), which only fires setKey's per-key dep — bindings reading
entry.X subscribe via the itemProxy and don't wake from that fire. Diff
the prior snapshot vs the unpacked inner value and fire per-FIELD
wakeups for changed fields. Snapshot the unpacked inner value at
createRecord time so the first post-mount diff is clean.
Fails on current code:
- Object.keys(itemProxy) returns RDC-internal slots (parent, asKey,
  values, …) instead of the user's item keys
- JSON.stringify(itemProxy) yields '{}' because the default ownKeys
  forwarding hits the RDC and the matching trapItemGet returns undefined
  for those internal slot names
- 'name' in itemProxy is false; 'parent' in itemProxy is true

Naive debugging is broken — passing an item to console.log shows
'Proxy(ReactiveDataContext) {parent, asKey, values, …}' instead of the
item's actual shape.
Previously, the item proxy's target was the ReactiveDataContext, with
only a 'get' trap. Devtools displayed 'Proxy(ReactiveDataContext)
{parent: …, asKey: …, values: …}' instead of the user's item; Object.keys,
JSON.stringify, and 'in' all leaked RDC internals.

Switch the proxy target to the user's item itself. The handler is now
a per-RDC closure that reaches fieldDeps via the RDC reference. Add
'has', 'ownKeys', and 'getOwnPropertyDescriptor' traps so iteration and
serialization see the item's shape. Re-allocate the proxy when the
as-key value's ref changes (itemProxyTarget tracks the wrapped ref so
reference-mode Signals get one allocation per record).

Naive debugging — {log fruit}, {stringify fruit} — now shows the
fruit, not the data context.

Also tightens the failing test to use a setProperty path that resolves
(needed an _id on the item).
Fails on current code: when an array as-mode item morphs from a
primitive to an object on the same key (object lacks _id so getItemID
falls back to indexOrKey, matching the primitive's key), reconcile's
path A is entered. refreshSnapshotAndDetect short-circuits because the
prior snapshot is the primitive, so changedKeys is null and no fire
reaches the binding. The per-key dep on the as-key (which the binding
subscribed to during the primitive period) is also not fired in this
branch, so the binding stays at 'prim:42' instead of advancing to
'obj:1'.
Path A's refreshSnapshotAndDetect short-circuits when the prior
snapshot is non-object (primitive), leaving record.snapshot stale
forever and no notifyField fired. When a record's item morphs from
primitive to object on the same key (object lacking _id falls back to
indexOrKey, matching the primitive's key), bindings subscribed via
trapGet's primitive branch never wake.

Re-snapshot from the new object and fire the per-key dep on the as-key
— that's the wakeup channel bindings used during the primitive period.
jlukic added 3 commits May 9, 2026 10:37
Both collectionType and node.as are loop-invariant — no need to
recompute the conjunction per record.
Spread mode and non-as-mode each blocks never reach the per-FIELD path
(trapItemGet only runs through the item proxy, which only exists when
asKey is set). The constructor was allocating an empty fieldDeps map
and a closure-captured item handler unconditionally. Skip both when
asKey is null.
For non-as-mode each blocks, asKey ends up null via the constructor
default — the explicit pass-through wrote a no-op property. Conditional
spread avoids the write at both createRecord and adoptServerItems
call sites.
The expression evaluator was importing unwrapItem from the native
engine — wrong direction; the evaluator is engine-agnostic and the
native engine is one of several possible consumers. Hoist the
protocol to the renderer's helpers.js (sibling to expression-evaluator).

Renamed ITEM_TARGET → UNWRAP and unwrapItem → unwrap, dropping the
proxy-specific connotation. The protocol is generic: any value that
wraps framework-internal state can opt in by responding to the
well-known Symbol from a get trap or as an own property.

Both expression-evaluator (the unwrap site) and native/reactive-context
(the proxy producer) now reference the same Symbol via Symbol.for,
with no cross-engine import.
JS-style template expressions ({foo(todo)}, {todo.x ? ... : ...}) route
through evaluator.jsProxy's with(ctx) get trap rather than the Lisp
helper-call spread site. Bare item references inside JS code crossed
into user functions still as the framework's tracking proxy. Apply
unwrap in jsProxy.get and the includeHelpers-false fallback handler so
JS expressions match the Lisp path's user-data contract.

Trade-off: dotted JS access ({todo.completed} inside a JS expression)
coalesces to per-key wakeup since the bare lookup unwraps before the
.X read can register a per-FIELD dep. JS expressions are <2% of all
expressions per the file's distribution comment, so the per-FIELD
precision loss is negligible relative to fixing the leak.
The unwrap helper added a function-call hop on the JS-expression
get-trap hot path — every property lookup inside a JS template
expression paid one call frame on top of the Symbol property miss.
Per-commit deltas on bench-template (active-indicator-200 +18%,
each-mount-1000 +14%, stable-ref-mutate-500 +12%) tracked the
addition of unwrap to jsProxy.get / jsNoHelpersHandler.get.

Three call sites inline the same null-and-typeof guard plus Symbol
lookup. The expression evaluator stays engine-neutral — the UNWRAP
sigil is the cross-engine protocol, no helper import needed.

Drops the unwrap export from helpers.js (no remaining importers).
The protocol is sigil-only.
The manual inline at the Lisp arg-spread loop regressed snippet/
subtemplate metrics 7-14% on chained within-session deltas — V8
was already auto-inlining the small unwrap helper across that hot
loop. The JS-eval get-trap inlining showed mixed effects on the
same proper deltas, only one metric cleanly improved.

Reverts 7c617be.
@jlukic jlukic merged commit 27d2864 into main May 9, 2026
18 of 19 checks passed
@jlukic jlukic deleted the perf/fgr-as branch May 9, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Docs Modifies documentation Tests Modifies tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant