Refactor: Swap buildArgsProxy for native getter records#189
Refactor: Swap buildArgsProxy for native getter records#189jlukic merged 9 commits intofeat/fine-grained-reactivityfrom
Conversation
Replace the lazy-getter Proxy that fronts every reactiveData subtemplate /
snippet invocation with a plain object whose declared keys are native ES
getter descriptors and whose non-declared keys fall through via the
prototype chain. Same per-key-isolation reactivity contract — source-signal
deps register on whichever Reaction is current at the read — without the
Proxy trap dispatch on every property access.
Adds `childContext(parent, extras)` to defineBlock as a shared helper for
blocks that render inner content with the parent context plus extras
(sample, async, SSR each path). Provided through the block bag for
registered blocks; exported for SSR use. Replaces the spread idiom
{ ...data, ...extras } which would snapshot any getters and lose
reactivity for parent reads inside the inner content.
`assignInPlace` gains an opt-in `preserveGetters` flag (default off) so
computed properties on the target — including the lazy-getter records
this refactor introduces — survive a sync that doesn't carry them in
source. Templates and the native renderer's updateData paths now opt
into this; behavior is unchanged for any other consumer of the standalone
utility.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🟡 Mixed (mostly faster) for
|
| metric | Improvement |
|---|---|
template:subtemplate-data-blob-100 |
-52% (197ms) 🌟 |
todo:filter-cycle-20 |
-31% (149ms) ⭐ |
todo:bulk-add-500 |
-29% (101ms) ⭐ |
todo:add-20 |
-12% (2ms) |
template:each-mount-1000 |
-7% (3ms) |
todo:edit-start-10 |
-5% (7ms) |
todo:toggle-middle-100 |
-5% (3ms) |
❌ Slower (5)
Metrics where this PR confidently regressed performance compared to main.
| metric | Regression |
|---|---|
template:subtemplate-helpers-heavy-100 |
+35% (2ms) ❗ |
template:active-indicator-200 |
+6% (2ms) |
template:subtemplate-helpers-light-100 |
+5% (0ms) |
template:snippet-in-subtemplate-100x1k |
+5% (1ms) |
todo:toggle-100 |
+5% (2ms) |
🏆 New peaks (7)
These metrics hit a new best on this PR. The most recent candidate is usually the cause.
| metric | improvement | prior peak | likely candidates |
|---|---|---|---|
todo:add-20 |
17% | 8d36a85 |
a95efc8 |
template:snippet-args-per-key-100 |
13% | bcf6a4d |
a95efc8, 8d36a85, cabf333 (+1 more) |
template:subtemplate-data-blob-100 |
13% | a95efc8 |
— |
template:each-mount-1000 |
4% | a95efc8 |
— |
todo:edit-start-10 |
4% | bcf6a4d |
a95efc8, 8d36a85, cabf333 (+1 more) |
todo:remove-first-100 |
4% | a95efc8 |
— |
todo:toggle-middle-100 |
4% | a95efc8 |
— |
📜 Regressions from peak (7)
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:subtemplate-helpers-heavy-100 |
64% | 66b25c8 |
a95efc8, 8d36a85, cabf333 |
template:active-indicator-nested-200 |
12% | 66b25c8 |
a95efc8, 8d36a85, cabf333 |
template:active-indicator-200 |
6% | bcf6a4d |
a95efc8, 8d36a85, cabf333 (+1 more) |
todo:toggle-100 |
5% | a95efc8 |
— |
template:snippet-in-subtemplate-100x1k |
4% | a95efc8 |
— |
template:subtemplate-helpers-light-100 |
4% | cabf333 |
a95efc8, 8d36a85 |
todo:toggle-last-100 |
4% | a95efc8 |
— |
⚪ No Change (33)
Metrics where this PR measured within ±2% of main — no meaningful performance change detected.
🔍 Unsure (21)
Inconclusive (1)
The CI crossed ±2% and is wider than this bench's duration usually produces. More samples may settle these.
| metric | Change | Expected Noise |
|---|---|---|
todo:remove-50-front |
-5.0% – +0.4% | ±2% |
Too Fast to Measure Precisely (20)
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 |
|---|---|---|---|
template:active-indicator-nested-200 |
+1.7% – +7.0% | ~21ms | ±8% |
krausest:append-1k |
-0.6% – +3.5% | ~152ms | ±5% |
krausest:clear-10k |
-12.5% – +7.2% | ~77ms | ±22% |
todo:clear-completed-250 |
-2.2% – -0.6% | ~42ms | ±2% |
signal:reaction-dep-diff-45k |
-1.2% – +2.1% | ~37ms | ±5% |
signal:reaction-flush-noop-5m |
-0.4% – +2.1% | ~56ms | ±4% |
signal:reactive-fanout-500x1200 |
-0.6% – +2.8% | ~91ms | ±6% |
todo:remove-50-middle |
-2.6% – +3.1% | ~19ms | ±7% |
todo:remove-first-100 |
-4.7% – -0.9% | ~64ms | ±5% |
krausest:remove-row-front-20 |
-2.8% – +13.3% | ~17ms | ±19% |
krausest:remove-row-middle-20 |
-6.0% – +3.7% | ~11ms | ±12% |
todo:rename-500 |
-3.4% – -1.7% | ~297ms | ±2% |
krausest:replace-1k |
-0.5% – +2.2% | ~113ms | ±3% |
signal:set-same-10m |
-1.5% – +3.4% | ~42ms | ±10% |
template:snippet-args-per-key-100 |
-5.9% – -0.4% | ~6ms | ±9% |
template:stable-ref-mutate-500 |
+0.8% – +7.5% | ~12ms | ±11% |
template:subtemplate-reactive-data-100 |
+0.3% – +4.9% | ~8ms | ±9% |
template:subtemplate-shorthand-props-100 |
-3.9% – +1.2% | ~6ms | ±9% |
todo:toggle-first-100 |
-1.1% – +2.4% | ~68ms | ±4% |
todo:toggle-last-100 |
+1.4% – +4.6% | ~64ms | ±4% |
📖 Bench glossary (66 metrics)
| metric | what it tests |
|---|---|
compiler-micros:ast-walk-15k |
Walks a kitchen-sink AST through optimizeAST 15000 times. Merge, hoist, and recurse pass. |
compiler-micros:parse-cold-complex-200 |
Compiles a feature-dense kitchen-sink template 200 times. Catches parser regressions on uncommon block paths. |
compiler-micros:parse-cold-normal-500 |
Compiles a TodoMVC-style component template 500 times. Headline metric for normal-component compile throughput. |
compiler-micros:snippet-args-5k |
Parses four representative subtemplate-call shapes 5000 times each. Snippet args extraction. |
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. |
signal:computed-chain-10x60k |
Propagates a value change from root to leaf through a 10-deep chain of derived signals 60000 times. |
signal:reaction-coalesce-400x100 |
Sets one signal 100 times then flushes once across 400 bursts so 100 subscribers wake one time per burst. |
signal:reaction-dep-diff-45k |
Toggles which of two signals a subscriber reads across 45000 cycles. Per-run dep-set diffing. |
signal:reaction-flush-noop-5m |
Calls Reaction.flush() 5000000 times with no pending work. Scheduler dispatch overhead. |
signal:reactive-fanout-500x1200 |
Fans out one signal's value change to 500 subscribers across 1200 successive updates. |
signal:reactive-list-filter-1000x300 |
Changes a search-term signal 300 times, re-scanning a 1000-item list on each change. |
signal:reactive-list-replace-1000x1000 |
Replaces a 1000-item list signal with a fresh 1000-item array and rescans it 1000 times. |
signal:reactive-multi-read-5x160k |
Changes five signals in turn for 32000 rounds with one subscriber reading all five. |
signal:reactive-push-2000x20 |
Appends 20 items onto an empty list signal with a subscriber, across 2000 reset cycles. |
signal:reactive-set-index-300 |
Replaces one item by index in a 1000-item list signal across 300 updates, with a subscriber. |
signal:reactive-set-property-by-id-200 |
Finds an item by id and updates one field in a 1000-item list signal across 200 alternating updates. |
signal:set-same-10m |
Sets a signal to its current value 10000000 times. Exercises the no-op fast path when nothing changes. |
signal:sub-unsub-100k |
Creates and tears down a subscriber on one signal across 100000 cycles. Subscription churn cost. |
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-100 |
Mutates one snippet arg's source across 100 invocations. 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-100 |
100 subtemplates, 4 inner bindings where three call helpers shaped like userland reality — Intl.NumberFormat, Array.fin… |
template:subtemplate-helpers-light-100 |
100 subtemplates, 4 inner bindings each calling formatDate / classIf / capitalize. Mutates one source signal — under pe… |
template:subtemplate-reactive-data-100 |
Mutates one verbose reactiveData field across 100 child subtemplates. Only the changed field re-evaluates. |
template:subtemplate-shorthand-props-100 |
Mutates one shorthand prop's source across 100 child subtemplates. 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 / 260 max · Noise floor: ±2% · Timeout: 3min · Wall-clock: 14m06s
Two V8 hidden-class regressions surfaced on the bench-bot rerun: todo:remove-middle-10 +244% and todo:remove-5-back +220%. Both trace back to per-record shape variance that turned a hot inline cache megamorphic. buildArgsRecord built records via Object.create(target) where target is each subtemplate clone's instance.data — a fresh object per record. V8 chains the resulting hidden class off target's identity, so 100 records end up with 100 distinct hidden-class chains. The IC at every binding's `data[token]` read site went megamorphic and stayed slow for the rest of the session. Switch to a flat record (proto = Object.prototype) with target's own descriptors copied via the descriptor-aware extend(), then declared getters defined on top. All records of the same template share the same own-property progression in the same order, so V8 consolidates to a single hidden class. assignInPlace's preserveGetters branch walked target via for...in, which iterates inherited keys too. For each inherited key the descriptor lookup returned undefined, the getter check was skipped, and the loop attempted `delete target[key]` — a no-op on inherited properties but enough to deopt V8's hidden class on the target. Switch the preserveGetters path to Object.keys(target). Own-only iteration matches the descriptor check's contract — getter descriptors are an own-property concept — and avoids the failed-delete deopts.
The flat-record reshape (ad95... in this branch) traded one regression set for another. Reverting buildArgsRecord to Object.create(target) while keeping the assignInPlace own-keys fix. Diagnosis: every block update calls setDataContext(blobData) → assignInPlace(record, blobData, preserveGetters: true). For reactiveData-only subtemplates blobData is empty, so assignInPlace deletes every non-getter own key. With the flat record those keys included target's eagerly-merged copies (state, instance, helpers), so each toggle thrashed V8's hidden class as keys cycled out and back in via Template.render. With Object.create(target) those keys live on the prototype where `delete record[k]` is a no-op. Net of the two perf passes: - assignInPlace's preserveGetters branch now uses Object.keys(target) instead of for...in (kept). Avoids failed-delete attempts on prototype-chain keys that deopt the hidden class. - buildArgsRecord stays on Object.create(target). The bench rerun showed this combination recovers remove-middle-10 and remove-5-back while preserving the subtemplate-data-blob -30% win.
The remove-middle-10 regression came from Object.create(target) — V8 chains hidden classes off target's identity and a 100-item each-block mints 100 instance.data targets, splitting the binding read-site IC into 100 shapes. Switching to a flat record consolidates them. The toggle-last-10 regression that introduced last round came from a destructive sync cycle: every block update called `setDataContext(blobData)` with blobData typically empty, which deleted every non-getter own key on this.data, only for the immediate `renderInstance → instance.render(blobData)` to merge blobData via additionalData and re-assignInPlace the full set. The cycle thrashed the record's hidden class on every flush. The block-level setDataContext call is structurally redundant — render already syncs the full dataContext through setDataContext. Removing it eliminates the destructive cycle entirely. dataReplaced flag is still set by render's setDataContext when source values genuinely change, so dataDep bumps still fire for blob-data subtemplates. Net: flat record (recovers remove-middle-10) + no redundant sync (recovers toggle-last-10). assignInPlace's preserveGetters Object.keys fix from the previous round stays.
filter-cycle-20 was the only ms-meaningful regression after main merged its bench amplification (commit 6d286ac). The cause: 100 fresh todoItem renders per filter transition × 20 cycles = 2000 fresh records, each landing on the assignInPlace cycle inside Template.render(). The source-iteration phase computed `target[key] !== source[key]` for every key in the merged dataContext. For declared-getter keys both target and source carry the same lazy descriptor (extend copies it descriptor-aware), so the compare invokes evaluator.lookupExpressionValue twice — and any write would hit ABSORB_SET as a no-op anyway. Roughly ~16k wasted getter invocations per filter-cycle measurement. Skip declared-getter keys in the source-iteration phase when preserveGetters is on. Same opt-in flag, more thorough about what "preserve" means: assignInPlace shouldn't read or write absorb-set properties at all in this mode. Behavior on non-getter keys is unchanged. This is a tighter version of the v4 "skip the redundant sync" insight applied at the right level — instead of dropping a whole setDataContext call, drop the per-key compare for the keys that have no observable write semantic.
vitest hoists vi.mock to module top regardless of nesting and warns about the implicit hoist for inline calls — flagging the future deprecation. The hoist also produced an intermittent CI failure on the "should handle SSR scenario in willUpdate" test where the mock race with iframe setup left isServer reading its real value. Move the mock outside the it() block. Behavior is unchanged: vitest already applied the mock file-wide via the hoist. The other tests in the file don't read isServer-conditioned paths the SSR-only mock would change.
Alternative implementation of #183. Same per-key isolation contract through a smaller surface.
Primary goal is simplification. The Proxy approach is leaky in a way native getters aren't. Every cross-package iteration through the data context — extend, descriptor copies, the templating render chain — touches the trap surface. Native accessors move the contract back into vanilla JS, where every reader behaves the way you'd expect.
Perf nets out as a different trade. The Proxy approach was tuned for read-heavy workloads. Native accessors give back ~35% on the heaviest synthetic read fanout in exchange for material wins on mount-heavy paths. The architectural contract metric still holds well above main on both approaches.
Changes
Performance
Bench compares against #183.
Wins on mount-heavy paths and the data-merge chain. The blob-path number is a measurement artifact resolving once both sides compile under the same rules.
Losses on the heaviest synthetic read fanout. Native accessors trigger dictionary-mode hidden classes; reads pay the slow path. The realistic-helpers contract metric drops below #183's ceiling but stays well above main.
Risk
5/10. Renderer hot path on every subtemplate mount.
Failure modes:
How to Test
Per-key isolation tests in
packages/renderer/test/browser/subtree-spurious.test.js. Visual regression acrossdocs/src/examples. Bench comment for the perf delta.