Skip to content

Refactor: Fine-Grained Reactive Data Context#183

Open
jlukic wants to merge 31 commits intomainfrom
feat/fine-grained-reactivity
Open

Refactor: Fine-Grained Reactive Data Context#183
jlukic wants to merge 31 commits intomainfrom
feat/fine-grained-reactivity

Conversation

@jlukic
Copy link
Copy Markdown
Member

@jlukic jlukic commented May 5, 2026

Implements Fine-Grained Reactive Data Context.

Per-key reactive isolation in the native renderer. A binding reading one field of an item no longer re-evaluates when a different field changes.

Mount-heavy paths regress on the heaviest synthetic workloads. Per-record setup pays for steady-state savings that compound on every update.

Changes

  • Per-key dependency primitive across the three data-context push sites: each-block items, subtemplate reactive props, and snippet args.
  • Each-block updates fire only on the fields that changed.
  • Subtemplates and snippets route through one shared mechanism — getter descriptors on a per-mount data context that inherits from the parent.
  • Hydration adopts the per-key reactivity per server-rendered record.

Performance

Mostly improvements, some regressions. Wins concentrate on realistic update workloads. Regressions concentrate on mount-heavy operations and one synthetic read-fanout workload. Bench comment has live numbers.

Deferred

Per-field isolation for the {#each todo in todos} form is a separate architectural change. Two tests pin the contract for the followup. Plan at ai/plans/active/fgr-as-mode-per-field-isolation.md.

Risk

8/10. Renderer hot path on every reactive update and every subtemplate mount.

Failure modes:

  • Closures over data captured in component setup work transparently with the new path. Verified via tests.
  • In-place item mutation in the {#each todo in todos} form fans out across all bindings reading that item. Documented gap, covered by the followup plan.

How to Test

The contract tests in packages/renderer/test/browser/subtree-spurious.test.js pin per-key isolation. Visual regression across docs/src/examples. Bench comment for the perf delta.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 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 7, 2026 8:40pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
mcp Ignored Ignored Preview May 7, 2026 8:40pm

Request Review

@semantic-performance-bot
Copy link
Copy Markdown

semantic-performance-bot Bot commented May 5, 2026

🟡 Mixed (mostly faster) for 19f39de on Benchmark Suite 📊

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

Refactor: Fine-Grained Reactive Data Context

Warning

This PR improves ✅ 23 tests while regressing on ❌ 8 tests.

✅ 23 faster · ❌ 8 slower · 🔍 12 unsure · ⚪ 23 no change · 🏆 3 new peaks · 📜 11 reopened


✅ Faster (23) — top 5 shown

Metrics where this PR confidently improved performance compared to main.

metric Improvement
template:subtemplate-helpers-heavy-100 -98% (460ms) 🏆
krausest:remove-row-front-20 -98% (567ms) 🏆
template:snippet-in-subtemplate-100x1k -98% (1235ms) 🏆
template:subtemplate-helpers-light-100 -98% (344ms) 🏆
template:subtemplate-shorthand-props-100 -98% (249ms) 🏆
Show all 23 faster metrics
metric Improvement
template:subtemplate-helpers-heavy-100 -98% (460ms) 🏆
krausest:remove-row-front-20 -98% (567ms) 🏆
template:snippet-in-subtemplate-100x1k -98% (1235ms) 🏆
template:subtemplate-helpers-light-100 -98% (344ms) 🏆
template:subtemplate-shorthand-props-100 -98% (249ms) 🏆
template:subtemplate-reactive-data-100 -97% (258ms) 🏆
krausest:remove-row-middle-20 -96% (275ms) 🏆
todo:remove-first-100 -96% (1419ms) 🏆
todo:remove-50-front -96% (373ms) 🏆
todo:remove-middle-100 -93% (709ms) 🏆
todo:remove-50-middle -90% (181ms) 🏆
todo:clear-completed-250 -36% (24ms) 🌟
template:subtemplate-data-blob-100 -25% (60ms) ⭐
krausest:append-1k -19% (33ms) ⭐
template:active-indicator-nested-200 -18% (4ms) ⭐
todo:toggle-all-200 -16% (342ms) ⭐
todo:filter-cycle-20 -14% (47ms)
krausest:swap-rows-20 -11% (14ms)
todo:edit-start-10 -9% (12ms)
todo:rename-500 -6% (19ms)
krausest:clear-10k -5% (5ms)
todo:edit-cycle-5 -4% (6ms)
todo:remove-50-back -4% (1ms)

❌ Slower (8)

Metrics where this PR confidently regressed performance compared to main.

metric Regression
todo:add-20 +31% (3ms) ❗
todo:toggle-100 +23% (10ms) ❗
todo:bulk-add-500 +18% (36ms) ❗
template:active-indicator-200 +13% (4ms)
template:each-mount-1000 +13% (5ms)
todo:remove-last-100 +12% (6ms)
krausest:remove-row-back-100 +8% (3ms)
krausest:replace-1k +5% (7ms)

🏆 New peaks (3)

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

metric improvement prior peak likely candidates
krausest:clear-10k 9% bd35392 e1b9cac, b485010, 382c19b (+4 more)
krausest:swap-rows-20 6% 091f84f e1b9cac, b485010, 382c19b (+3 more)
template:snippet-args-per-key-100 4% 97a1938 e1b9cac, b485010, 382c19b (+6 more)

📜 Regressions from peak (11)

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-data-blob-100 75% 091f84f e1b9cac, b485010, 382c19b (+3 more)
todo:add-20 31% 36cb6ed e1b9cac, b485010, 382c19b (+5 more)
template:each-mount-1000 24% bd35392 e1b9cac, b485010, 382c19b (+4 more)
todo:edit-cycle-5 24% c6cee04 e1b9cac, b485010, 382c19b (+2 more)
todo:edit-start-10 23% c6cee04 e1b9cac, b485010, 382c19b (+2 more)
todo:clear-completed-250 16% 97a1938 e1b9cac, b485010, 382c19b (+6 more)
template:active-indicator-200 14% 36cb6ed e1b9cac, b485010, 382c19b (+5 more)
todo:toggle-100 11% 382c19b e1b9cac, b485010
krausest:replace-1k 10% b485010 e1b9cac
todo:filter-cycle-20 6% e1b9cac
todo:toggle-last-100 6% 382c19b e1b9cac, b485010
⚪ No Change (23)

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

metric Change
compiler-micros:ast-walk-15k -0.4% – +1.7%
renderer-micros:build-html-string-10k -0.8% – +1.6%
signal:computed-chain-10x60k -0.4% – +0.9%
renderer-micros:dom-walker-1000x15 -1.0% – +1.4%
hydrate:each-100 +0.1% – +1.3%
hydrate:each-100-mount -1.2% – +0.3%
renderer-micros:expr-js-10k -1.9% – +0.8%
renderer-micros:expr-lisp-50k -0.8% – +1.3%
renderer-micros:expr-simple-100k -0.5% – +1.8%
hydrate:helper-100-mount -0.7% – +1.0%
compiler-micros:parse-cold-complex-200 -1.1% – +0.5%
compiler-micros:parse-cold-normal-500 -1.2% – +2.0%
signal:reaction-coalesce-400x100 -1.4% – +0.4%
signal:reactive-fanout-500x1200 -0.8% – +0.7%
signal:reactive-list-replace-1000x1000 -1.3% – +1.1%
signal:reactive-multi-read-5x160k -0.9% – +0.8%
signal:reactive-push-2000x20 -0.8% – +1.2%
signal:reactive-set-index-300 -0.4% – +1.4%
signal:reactive-set-property-by-id-200 -0.6% – +0.8%
krausest:select-40 -0.6% – +0.5%
signal:set-same-10m -0.5% – +0.8%
compiler-micros:snippet-args-5k -1.4% – -0.1%
signal:sub-unsub-100k -0.9% – +0.7%
🔍 Unsure (12)

Too Fast to Measure Precisely (12)

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:create-10k +1.5% – +2.4% ~1193ms ±1%
krausest:create-1k +0.6% – +3.3% ~133ms ±3%
hydrate:helper-100-state-change-1k -4.2% – -0.8% ~4ms ±7%
signal:reaction-dep-diff-45k -2.2% – +0.9% ~38ms ±4%
signal:reaction-flush-noop-5m -4.9% – +0.0% ~52ms ±4%
signal:reactive-list-filter-1000x300 +0.4% – +3.6% ~122ms ±5%
template:snippet-args-per-key-100 -7.3% – -0.9% ~5ms ±8%
template:stable-ref-mutate-500 -7.5% – -0.3% ~13ms ±11%
todo:toggle-first-100 -4.2% – +0.0% ~63ms ±5%
todo:toggle-last-100 +0.9% – +5.1% ~61ms ±6%
todo:toggle-middle-100 +1.7% – +5.4% ~46ms ±6%
krausest:update-10th-50 +1.4% – +4.4% ~156ms ±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: 15m37s

@github-actions github-actions Bot added Reactivity Modifies reactivity package CI modifies continuous integration labels May 6, 2026
…rappers

Each per-key Signal allocates a wrapper object plus its own Dependency.
At 1000+ records × ~5 keys at mount, wrappers dominate the per-record
allocation count and visibly cost on bulk-add-500 / create-1k /
each-mount-1000. Replaces the per-key Signal Map with two stores: a
values Map (always allocated) and a deps Map (lazy, allocated on first
reactive read). Per-key Dependencies allocate only when something
actually subscribes — purely closure-captured records pay zero per-key
dep cost.

Equality dedup is preserved by snapshotting Signal.equalityFunction at
RDC construction. Mirrors Signal's own per-instance snapshot semantics:
late overrides of the static do not retroactively retarget already-
constructed instances. setKey writes parent[key] unconditionally
(matching the prior signal.set + writeToParent ordering) and only fires
the per-key Dependency when the equality check sees a real change.
keySetVersion still fires on first observation of a new key so readers
that fell through to parent rewire on next run.

writeToParent and registerItemContext semantics unchanged. Proxy traps
unchanged in shape; trapGet now reads from target.values + lazily
populates target.deps. Module-scoped HANDLER_RO / HANDLER_RW preserved.

3046 logical tests pass (1 flaky iframe CORS in node-types.test.js;
file passes solo).
…ject

Every record in a bench-todo / bench-krausest mount adds the same keys
in the same order, letting V8 establish a stable hidden-class chain
across the whole list. Plain-object property access inline-caches at
the call site once the shape is stable; Map.get always pays a virtual
call. trapGet's hot path is now a single (prop in values) check + one
property read instead of two Map ops.

Existence checks use (prop in target.values) — fast on null-prototype
since there is no prototype chain to walk. dispose() reassigns
this.values = Object.create(null) so the hidden-class chain stays
clean across record reuse.

3631 logical tests pass (1 flaky iframe CORS, file passes solo).
jlukic added 2 commits May 6, 2026 12:31
unpackBlobData now folds node.reactiveData into the cloned template's
data synchronously, before instance.initialize() invokes
createComponent. Without this, closures captured at createComponent
time (e.g. methods reading data.lineNumbers) saw an empty data ref
because reactiveData arrived later via setupReactiveSubtemplate's
Reaction. Restores parity with main's pre-FGR behavior; setKey's
writeToParent mirror still owns ongoing updates.

Reads are wrapped in Reaction.nonreactive so source-signal deps register
on the subtemplate's per-key Reaction set up by setupReactiveSubtemplate,
not on whatever Reaction is currently mounting the parent.

Adds a regression test in subtree-misc that captures data.lineNumbers
inside an onCreated hook running on a closure built by createComponent.
Test fails before the fix on the native engine (lit handles seeding
through a separate path), passes after.
Adds explicit failing tests for the per-key isolation contracts FGR was
designed to deliver. Currently 9 of these fail — they pin the contract
so subsequent code work has clear targets and so a future regression
can't quietly creep back in.

Restored:
- Test 25 in subtree-caching ("closure-captured data in subtemplate")
  captures the data.todo.completed-via-setProperty contract that the
  CodePlayground regression demonstrated is real, not a fallacy. Removed
  in 942f887 on flawed reasoning that "data is snapshot"; the
  CodePlayground bug confirms the contract is needed.

New in subtree-spurious "FGR per-key isolation contract":
- each-block in-place mutation per-field isolation
- each-block cross-item isolation (sibling items don't re-fire)
- subtemplate reactiveData shorthand syntax per-key isolation
- subtemplate-inside-each composition (bench-todo case) per-key
  isolation across the each → subtemplate boundary

Existing kept:
- it.fails 'reactiveData per-key' verbose syntax (line 525)
- snippet args per-key isolation (passes — lazy-getter path)
- blob data coarse fanout (passes — negative control)
- per-key isolation across N subtemplates at scale (the bench scenario)

Currently failing tests document the gap between FGR design and
delivery. Diagnosis from runtime trace: subtemplate bindings register
on the renderer's coarse `dataDep` via `lookupExpression`, and
`bumpDataVersion` fires on every parent update via the
setDataContext → render() path. The per-key Deps in `ReactiveDataContext`
are correctly populated by `setupReactiveSubtemplate`, but coarse fanout
defeats their isolation downstream.
@github-actions github-actions Bot removed the CI modifies continuous integration label May 6, 2026
jlukic added 2 commits May 6, 2026 19:21
The shorthand invocation `{>child data=getCardData}` parses as
`reactiveData = {data: "getCardData"}` (key='data'), not as the blob
argument. The proxy's lazy getter for key 'data' is never called by
the inner `{label}` / `{status}` bindings, so they fall through to an
empty data object and render nothing. Both main and FGR produce the
same output; main fires reactions wastefully through the dataDep
fanout, FGR doesn't fire at all because the lazy proxy decouples the
outer block reaction from state when no binding reads the reactive
key. That made the negative-control metric read -99.9% on FGR — a
methodology artifact, not a semantic break.

Switching to the explicit form `{> template name='child' data=expr}`
makes the compiler emit `node.data = "getCardData"` so the bench
actually exercises the blob fanout path it was designed to lock in
as coarse-by-design.
The lazy-getter Proxy that fronts every reactiveData subtemplate /
snippet invocation allocated a fresh handler object with five
closure-captured trap functions per call. V8 saw a different handler
shape per Proxy and fell back to polymorphic dispatch on every trap
hit — the same problem `2ea9cd0` solved on the each-block side via
module-scoped HANDLER_RO / HANDLER_RW.

Replaces the closure-captured handler with a module-scoped
ARGS_HANDLER. Per-instance state (target / allGetters / getterKeys)
moves onto a small holder object that becomes the Proxy target, so
every Proxy under buildArgsProxy uses the same handler identity and
the same target shape. defineProperty / deleteProperty /
getPrototypeOf are added to the trap surface and forward to the
underlying data so descriptor-style writes (e.g.
overlaySettingsSignals) land where reads see them.

No-args fast path: invocations with neither node.data nor
node.reactiveData (no-arg snippets like {>name}) skip the Proxy
entirely and return the parent data context directly. One less
allocation, one less indirection on every binding inside.

Targets bulk-add-500 (+70%), filter-cycle-20 (+44%), add-20 (+76%),
which wall-clock dominated by per-mount Proxy / closure allocation
cost when the lazy proxy moved onto the subtemplate path.
jlukic added 2 commits May 6, 2026 19:26
The two FGR contract tests asserting per-FIELD isolation under
`{#each todo in todos}` syntax fail because notifyKey fires the
whole-item dep, waking every binding that reads `todo.X`. Splitting
the as-key into per-field deps is a separate architectural change
beyond FGR's three-site scope (each items / subtemplate reactiveData
/ snippet args), called out as out of scope in the plan.

Marking these as `it.fails` keeps the contracts as forward-looking
assertions — a future per-FIELD fix will light them up green —
while making the suite honest about what FGR shipped vs what's
still gapped. Same pattern lit-engine uses for its acknowledged
over-fires.
Per-record dep storage was a lazily-allocated `Map<key, Dependency>`,
allocated on first reactive read. trapGet's hot path paid Map.get
on every read of a per-key value — a virtual call into Map's
implementation, with no inline-cache opportunity.

Switching to `Object.create(null)` (already the shape `values` uses)
lets V8 inline-cache the property access at the trap dispatch site
once the shape stabilizes. Eager allocation removes the
`deps === null` guard from the hot path entirely. The per-key
Dependency object itself is still allocated lazily on first reactive
read of the key, so closure-only records pay zero per-key dep cost.

Targets active-indicator-200 (+15%) where each per-item Reaction
hits the trapGet hot path 200 times per cycle reading `proxy.item`,
and the snippet-args-per-key path that shares the same machinery.
@github-actions github-actions Bot added Templating Modifies templating package Utils Modifies utilities package labels May 7, 2026
Two related changes targeting `each-mount-1000` and `active-indicator-200`.

**Eager `Dep` allocation at `setKey`.** The per-key `Dependency` and the
`deps` Map were lazily allocated on first reactive read inside `trapGet`.
That triggered a per-record `target.deps: null → Map` hidden-class
transition during the firstRun phase, exactly when V8's IC for the trap
dispatch was warming up. Moving the allocation into `setKey` keeps the
RDC's hidden class stable from construction. trapGet's hot path drops
the lazy-init branch and becomes a single `target.deps.get(prop).depend()`
call. Pays one extra `Dependency` per declared key per record (≈80
bytes); recovers ~250µs of mount-phase IC pessimization on a 1000-item
list.

**Seal `keySetVersion` in `as`-mode each-blocks.** `getEachData` for
as-mode returns a fixed shape `{ [as], [indexAs] }` — no key is ever
added after the seed `replace()` call. Subscribing to `keySetVersion`
on every parent-context fallthrough read (helpers, parent state,
anything not in the value-key set) was pure waste — the Dep can never
fire. The seal: `sealKeysAfterReplace: true` opt-in flips a `keysSealed`
flag at the end of `replace()`. trapGet skips `keySetVersion.depend()`
when sealed; setKey skips `keySetVersion.changed()` when sealed.

Spread-mode keeps the unsealed default — spread item shapes can gain
keys, and the as-mode-per-field-isolation followup plan relies on
spread's per-field reactivity staying intact.

Active-indicator-200 was paying ~60,000 wasted Set ops per cycle from
keySetVersion subscribe/cleanup churn (200 records × 100 cycles × 3 Set
ops per round-trip on a Dep with no possible firing path).
`getEachData` for as-mode returns a fixed shape — `{ [as], [indexAs] }`.
The value-key set never gains a key after the seed `replace()` call.
Subscribing to `keySetVersion` on every parent-context fallthrough read
(helpers, parent state, anything not in the value-key set) was pure
waste — the Dep can never fire under as-mode.

The seal: `sealKeysAfterReplace: true` opt-in flips a `keysSealed` flag
at the end of `replace()`. trapGet skips `keySetVersion.depend()` when
sealed; setKey skips `keySetVersion.changed()` when sealed.

`active-indicator-200` was paying ~60,000 wasted Set ops per cycle from
keySetVersion subscribe/cleanup churn (200 records × 100 cycles × 3 Set
ops per round-trip on a Dep with no possible firing path). Closes
roughly half the regression vs main.

Spread-mode keeps the unsealed default — spread item shapes can gain
keys, and the as-mode-per-field-isolation followup plan relies on
spread's per-field reactivity staying intact.

The companion `eager Map+Dep allocation at setKey` change measured
mixed: helped active-indicator-200 incrementally, hurt each-mount-1000
and remove-last-100 by adding ~1 Dep allocation per record × 1000
records. Sequenced separately; eager allocation parked until a
narrower targeting strategy emerges.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Docs Modifies documentation Reactivity Modifies reactivity package Templating Modifies templating package Tests Modifies tests Utils Modifies utilities package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant