Skip to content

feat(frames): a write during deferred hydration mounts only its readers - #4134

Merged
ryansolid merged 9 commits into
nextfrom
claude/project-thread-58026x-edges
Oct 19, 2026
Merged

ryansolid merged 9 commits into
nextfrom
claude/project-thread-58026x-edges

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Summary

Before. Under deferred hydration, a signal or store write that lands while slot occurrences are still unmounted first mounts every pending occurrence and only then applies ("finish-then-apply"). On a page with many deferred occurrences, a cart click pays for mounting every product card, even though only the cart widget reads the cart.

After. The server records which slot occurrences read which plain signal or plain store. A write mounts only those occurrences, then applies. Writes the records cannot vouch for still mount everything first, as before. There is no new API and no mode: this is simply how deferred hydration behaves now. Eager vs deferred switching works as it already did on next.

Apps without server components pay nothing:

  • their signals and app bundles keep next's minified size, except the compiled hydrating app (+3 B: a store setter hands its proxy to the write hook), and the server's renderToString floor is byte-identical;
  • a server render that is not configured for server components runs the plain paths.

How

Naming. A plain signal created under an id-bearing owner is named <owner id>~<n>, where n counts per owner and restarts on each pass of that owner.

A plain store (createStore over an object) is named the same way, on the same count, and the whole store goes by its root's name. Edges are per store, not per property: any read of the store counts as a read of the root, and any setter call on it counts as a write to the root.

Client.

  • The namer lives in signals/src/core/identities.ts, which only the frames client imports. It installs into a null-checked slot that createSignal calls.
  • Names live in a side table (signalName(node)).
  • A count restarts when the owner's pass generation (_depGen) moves on.
  • createStore calls the same slot with the store's root target. A store setter hands its proxy to the write hook at entry, and storeName resolves a proxy (root or nested) or a store property node up to its root. A projection or optimistic family, a store chained over another store's proxy, a raw object that is the root of two stores, or a root named twice has no name.

Server.

  • Naming, memo read tracking and the projection wide sink live in solid/src/server/edges.ts, which only the frames server imports.
  • The render paths keep one null-checked slot (edgeHooks) and capture nothing new:
    • an owner that names a signal registers a cleanup that drops its count, so disposeOwner has no reset sites;
    • memos and id scopes key their state by their owner and scope function.
  • The hooks are installed only while an edges render is in flight. renderToStream sets context.edges when the server-components codec (ServerComponentPlugin) is among its plugins; such a render installs the hooks when it starts and removes them when it is disposed or throws (refcounted, so concurrent renders share one install). Importing frames installs nothing, so a process that serves SC and plain pages pays nothing on its plain renders. Inside an install the hooks still check the render's context, so an interleaved non-edges render bails before allocating.
  • In such a render, createStore returns a read-tracking view of its state: any get/has/ownKeys/descriptor read, at any depth (nested objects are viewed lazily and cached per root), reports the root's name. A store nested inside another store is returned as is, and its holder is marked wide. The server's createProjection no longer builds its seed through createStore, so projections stay unnamed and counts line up with the client.
  • Deps records are emitted only from such a render. Without them, the client mounts everything on a write, as before.

Server records. Slot fills run under a read sink. A memo re-reports its last reads when it is read, so an edge through a memo lands on its sources. Each frame batch emits sc:deps:<site>:<n>:

  • each signal name maps to folded occurrence runs ("card#0-399 cart#0");
  • * lists wide signals, read inside a projection's compute;
  • ! lists fills that read client-only state or left async holes.

Client write hook. It receives the written node, looks up its name, and mounts only those occurrences (a nested one through its enclosing chain). The ! occurrences mount on every named write. Everything left mounts when:

  • the write has no name (derived or optimistic signals; projections, optimistic stores, and stores that are unnamed or whose root cannot be placed);
  • the signal is wide;
  • the document is still streaming;
  • or a deferred frame's root has no records.

Identity drift check (dev only). Each deferred mount is watched through a DEV.hooks.onRead hook. If it reads a named signal its record lacks, it warns once per signal: [deferred hydration] The slot occurrence "…" read the signal "…" while mounting, but the server did not record that read. Production builds strip it.

Timing

Product page with 400 deferred cards plus a sidebar, 4x CPU, 15 runs (three interleaved batches of 5). The cart is a createStore (addToCart pushes a line), and each card keeps its color, quantity and favourite in one card-local createStore. Values are medians in ms, written as handler / next paint.

case finish-then-apply edges eager
few readers (cart store write) 381 / 520 33 / 96 16 / 64
card-local (one card's own store) 159 / 224 21 / 64 11 / 48
all readers (currency signal, every card) 129 / 344 174 / 504 21 / 392
after deferral finished 68 / 128 18 / 48 112 / 440

In the cart-store case, the write hook spends 328 ms mounting a median of 221 occurrences under finish-then-apply, against 7 ms mounting 2 (the cart summary and the shipping bar) with edges. A card-local store write mounts nothing extra. In the all-readers case both deferred modes mount every card. In the last row, "eager" is measured while eager hydration is still running, so it is not comparable. Load time shows no consistent difference between the two deferred modes, and eager is the heaviest at load.

Benchmarks (the lanes that regressed on earlier pushes)

Measured the way CodSpeed's simulation does — Node 24 (CI's .nvmrc), its V8 flags (--no-opt --predictable …), 7 warm-up calls, gc(), then callgrind (with cache simulation) over the measured call.

lane next (fb49849) this PR
search-results: 50 items (renderToString) 2,501,003 2,510,627 (+0.4%)
hn story page (1,475 anchors): no link handler 45,651,282 45,803,603 (+0.3%)

The search-results regression CodSpeed reported (1.4 → 2.6 ms) was a tier-up moving into the measured call. Under Node 24, --no-opt still leaves Maglev on. A per-row edgeHooks.reset call in mapArray's row loop added enough bytecode that the loop's Maglev compile, plus an immediate deopt, landed inside the measured iteration: about 1.5M instructions. On next it lands after. The row loops now carry no edge code. A row's signal count remembers the owner id it was taken under, so a reused row owner starts at 0 when its id moves to the next row, and reset runs once per pass for retries. Node 22, which I measured on before, never showed it.

createSignals (signals) runs the same code as next apart from one null check in createSignal.

Size

Measured locally (linux) against next @ fb49849. Brotli in B; minified delta in parentheses.

scenario next this PR delta new cap
all signals and app scenarios but one n/a same minified 0 (0) next's caps, unchanged
app: compiled hydrating 31,950 31,896 brotli noise (+3) next's cap, unchanged
server: renderToString 20,624 20,624 0 (0) next's cap, unchanged
frames: eager client consumer 12,235 12,784 +549 (+1,948) 12.80 KB
page: base SC 33,441 34,065 +624 (+2,476) 34.08 KB
page: live SC 37,565 38,322 +757 (+2,482) 38.34 KB
page: compiled base SC 35,408 36,087 +679 (+2,481) 36.10 KB
page: compiled live SC 41,439 42,169 +730 (+2,531) 42.18 KB
page: base + router 40,817 41,647 +830 (+2,477) 41.66 KB
page: live + router 47,006 47,722 +716 (+2,542) 47.74 KB

Re-measured after merging next @ ed51af3 (#4139 landed first), with these caps (each is the measurement + 10 B, rounded up to 0.01 KB):

scenario brotli / minified cap
frames: eager client consumer 12,980 / 39,466 12.99 KB
page: base SC 34,270 / 105,776 34.28 KB
page: live SC 38,560 / 119,182 38.57 KB
page: compiled base SC 36,407 / 112,125 36.42 KB
page: compiled live SC 42,375 / 127,178 42.39 KB
page: base + router 41,799 / 129,611 41.81 KB
page: live + router 47,954 / 144,465 47.97 KB

Each cap's recorded minified size equals the measurement. Each has a dated ledger note in scripts/size/scenarios.js. The two frozen page floors are updated in scripts/size/floor-caps.json. gate.mjs passes every scenario; its remaining warnings are brotli shifts on scenarios whose minified size equals next's.

Size-Exception: dependency edges for deferred hydration. Frames client and server-component pages grow +549 to +830 B brotli (about +1.9 to +2.5 KB minified) for the edge index, reader-only mounts, the plain-signal and store-root namer and the dev drift check. Signals, app and server floors keep their minified size, except the compiled hydrating app (+3 B minified). Accepted by the maintainer (2026-10-19).

Known limitations

  • Identity drift is only detected in dev. In production, a drifted name mounts the wrong readers until the fallback or idle mounting catches up.
  • Occurrences whose reads are client-only (!) mount on every named write.
  • Client naming stays on after deferral finishes.
  • Store edges are per store, not per property: a write to any part of a named store mounts every occurrence that read any part of it.
  • A store held inside another store makes the holder wide, and a raw object shared by two stores (or a store chained over another's proxy) is unnamed; both fall back to mounting everything on a write.
  • Server naming is per render, not per page content. A shell's plain signals are created before the first slot fill renders, so the decision has to be made when the render starts. The integration passes the server-components codec to every route of a server-components app, so a page there with no server component still names its plain signals. Renders without the codec pay nothing.

How did you test this change?

New tests, each confirmed to fail without the change:

  • packages/web/test/server/frame-dependency-edges.spec.tsx checks the emitted record:
    • currency → item#0-3
    • cart → cart#0
    • wide source in *
    • client-source memo fill in !
    • a plain store named by its root; no names for derived signals
  • packages/web/test/hydration/frame-dependency-edges*.spec.tsx:
    • a cart write mounts only cart (plus the ! fill), and a later currency write adds the items;
    • a click on one card's own state mounts nothing extra;
    • a write to a named store (at its root or through a nested proxy) mounts only that store's readers, and they render the pre-write value before the write applies;
    • a card-local store write mounts nothing extra;
    • projection, unnamed-store, wide and derived writes fall back to mounting everything;
    • in dev, drifted identities produce exactly one warning naming the occurrence.
  • packages/signals/tests/signal-identities.test.ts covers client naming:
    • names, and the named node passed to the write hook;
    • a store going by its root's name from the root or any nested proxy, and stores it cannot place (outside an owner, a raw object shared by two stores, projections, optimistic stores) having none;
    • the count restarting on rerun;
    • no names when naming is off;
    • the dev read watch.
  • packages/solid/test/server/signal-identities.spec.ts covers server naming:
    • off by default, and off inside a render that did not ask;
    • names and reads through memos;
    • a plain store's reads at any depth recorded under its root, projections unnamed, and a store held in another store making the holder wide;
    • the count restarting on a retry reset (this fails without the dispose cleanup).

Also changed: tests/attribution-interactions.test.ts "with hold tracking off, the record still waits but nothing is judged". This failed once in CI coverage (continuationMs 13.15, expected at least 15). It is a pre-existing timing flake: it did not reproduce in 26 coverage runs on either next or this branch, and nothing here touches that path. The test's 20 ms timer starts inside the handler, but continuationMs counts from the handler's return. The assertion now measures from the frame's open (handlerMs + continuationMs >= 15, continuationMs > 0).

Suites run:

  • signals: 5345 passed
  • solid: 877 passed
  • web client: 1375 passed (1383 after merging next)
  • web server: 1719 passed. The one failure, "keeps a rebuild failure's own error", also fails on next under this machine's Node 22.
  • web hydrate: 516 passed (519 after merging next). Before the merge, the failure was c03-hydration-done-counts-holds (b) (its (control) case also fails on some runs); both fail the same way with next's sources and builds.
  • Type checks: solid and web test-types and web types pass.
  • node scripts/size/gate.mjs: every scenario passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2

Server: a plain signal created under an id-bearing owner is named
`<owner id>~<n>` (n per owner, reset wherever the child counter resets:
retries, row owners, hole scopes, the boundary resolve scope). Reads
report the name to the current sink: a slot fill's, a memo's (re-reported
when the memo is read, so an edge through a memo lands on its sources), or
the request's wide sink for a projection's compute. The document sink
aggregates each frame's fills into `sc:deps:<site>:<n>` records,
`sid -> "card#0-399 cart#0"`, plus `*` (wide signals) and `!` (fills that
left async holes).

Client: with the `sc-edges=1` cookie the frames client names plain
signals the same way and its pre-write hook mounts only the unmounted
occurrences that read the written signal (from the frame's own walk; a
nested one through the enclosing chain). Anything the records cannot
vouch for mounts everything, as before: no name (stores, derived or
optimistic signals), a wide signal, a write while the document streams, or
a deferred frame under a root without records. `sc-defer=0` turns
deferral off. A/B switches and `__sc*` debug globals are spike-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2
A write during deferred hydration mounts only the slot occurrences the
server recorded as readers of the written signal, instead of finishing
every pending mount first. The cookie switches and debug globals from
the spike are gone; finish-then-apply remains only as the fallback for
writes the records cannot vouch for (no signal name, a wide signal, a
write while the document streams, a deferred frame without records).

- signals: a recompute that made only plain signals (no child owners)
  restarts their names, so a rerun names them the same way again.
- dev only: each deferred mount is watched; a read of a named signal the
  server did not record for that occurrence warns once per signal
  (identity drift between server and client creation order).
- tests: server record emission; hydrate specs for reader-only mounts,
  store/wide/derived fallbacks, local-state writes and the drift warning;
  signal naming in the signals suite.
- size: caps raised to measured + 10 B with dated ledger notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2
@changeset-bot

changeset-bot Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 47a217f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@solidjs/signals Patch
solid-js Patch
@solidjs/web Patch
test-integration Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
board-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.25 KB 0 B 0 B +8 B 7.25 KB ✅
signals: + createStore 14.78 KB −24 B (−0.2%) 0 B +8 B 14.82 KB ✅
signals: + isPending/latest 9.96 KB 0 B 0 B 0 B 9.98 KB ✅
signals: + until/refresh (truth readers, no optimism) 7.86 KB 0 B 0 B +8 B 7.86 KB ✅
app: render + one signal (the simple-app floor) 9.56 KB 0 B 0 B −2 B 9.56 KB ⚠️ over by 3 B, 22 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.19 KB +18 B (+0.1%) 0 B −2 B 18.19 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.17 KB +32 B (+0.1%) 0 B −6 B 30.14 KB ⚠️ over by 28 B, 26 B minified headroom lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.13 KB +2 B (+0.0%) 0 B −2 B 13.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.88 KB +11 B (+0.1%) 0 B −2 B 14.91 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.44 KB −51 B (−0.2%) 0 B −2 B 29.48 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.76 KB 0 B 0 B 0 B 9.77 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.98 KB +9 B (+0.0%) 0 B −2 B 26.03 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.98 KB +50 B (+0.2%) +3 B +1 B 31.98 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.98 KB +535 B (+4.3%) +1950 B 0 B 12.99 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.27 KB +662 B (+2.0%) +2478 B 0 B 34.28 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, trace.js 9.03 KB, wire.js 1.33 KB
page: live server components (base + live/GET + action + isPending/latest) 38.56 KB +792 B (+2.1%) +2484 B 0 B 38.57 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.00 KB, wire.js 1.33 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 36.41 KB +838 B (+2.4%) +2483 B 0 B 36.42 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.04 KB, wire.js 1.33 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 42.38 KB +726 B (+1.7%) +2533 B 0 B 42.39 KB ✅ eager (counted): web.js 21.79 KB; assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.06 KB, sc-comments.js 0.19 KB, trace.js 9.07 KB, wire.js 1.33 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.80 KB +777 B (+1.9%) +2479 B 0 B 41.81 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.02 KB, serverForms.js 3.59 KB, trace.js 9.10 KB, wire.js 1.33 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.95 KB +734 B (+1.6%) +2544 B 0 B 47.97 KB ✅ eager (counted): client.js 26.92 KB; assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 9.07 KB, wire.js 1.34 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.62 KB 0 B 0 B −23 B 20.59 KB ⚠️ over by 34 B, 43 B minified headroom

⚠️ Over the brotli cap within the minified allowance (passes)

  • app: render + one signal (the simple-app floor): over brotli cap by 3 B; minified 26,662 B vs 26,664 B recorded with the cap (−2 B) — 22 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating + every store primitive family: over brotli cap by 28 B; minified 94,638 B vs 94,644 B recorded with the cap (−6 B) — 26 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • server: renderToString (the server-render floor): over brotli cap by 34 B; minified 72,131 B vs 72,154 B recorded with the cap (−23 B) — 43 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@codspeed

codspeed Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 8.63%

⚠️ 2 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 3 improved benchmarks
✅ 193 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 32 ms 28.5 ms +12.12%
⚡ build + consume 742.4 µs 694.2 µs +6.94%
⚡ store reconcile tree reverse: 1111 keyed nodes 21.2 ms 19.8 ms +6.91%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/project-thread-58026x-edges (7e42cf4) with next (ed51af3)2

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on next (7e42cf4) during the generation of this report, so ed51af3 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

Non-server-component apps paid for dependency-edge naming: the signals
core always carried the per-owner counter and its resets, and the server
named and counted every plain signal (materializing owner ids) and
allocated a read sink on every memo pull.

- signals: the namer moves to core/identities.ts. Core keeps one
  null-checked slot (`signalHook`) that createSignal calls, and the write
  hook now receives the written node. Names live in a side table
  (`signalName`) instead of a mangled field, and a count restarts when its
  owner's pass generation moves on, so core has no reset sites. The dev
  read watch is a `DEV.hooks.onRead` hook. Signals and app scenarios
  measure byte-identical to next.
- server: naming, memo read sinks and the projection wide sink are off
  until the frames server loads (`setServerSignalIdentities`). Counts move
  from an owner field to a WeakMap that exists only while naming is on.
- attribution test: the hold-tracking-off case measured its 20 ms wait
  from the handler's return. A slow return under coverage on a loaded
  runner shortens that, so it now measures from the frame's open.
- size: the signals, app and server caps go back to next's. Only the
  frames and page caps rise, with dated ledger notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2
@coveralls

coveralls commented Oct 17, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39202478308

Coverage decreased (-0.009%) to 77.555%

Details

  • Coverage decreased (-0.009%) from the base build.
  • Patch coverage: 2 uncovered changes across 1 file (7 of 9 lines covered, 77.78%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/solid/src/index.ts 2 0 0.0%
Total (2 files) 9 7 77.78%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1347
Covered Lines: 1103
Line Coverage: 81.89%
Relevant Branches: 1050
Covered Branches: 756
Branch Coverage: 72.0%
Branches in Coverage %: Yes
Coverage Strength: 27.55 hits per line

💛 - Coveralls

The server's naming, memo read tracking and projection wide sink move
into server/edges.ts. Only the frames server imports it. The render paths
keep one null-checked slot (`edgeHooks`) and capture nothing new. Two
places had reset sites:
- disposeOwner has none now: an owner that names a signal registers a
  cleanup that drops its count.
- memos and id scopes key their edge state by their owner and their scope
  function, so their closures stay the same size.

The hooks act only inside a render that asks for edges.
renderToStream sets `context.edges` when the server-components codec is
among its plugins, and every other render takes the plain paths. Deps
records are emitted only from such a render; without them the client
mounts everything on a write, as before.

server: renderToString is byte-identical to next (72,131 B minified).
Under CodSpeed's runner and V8 flags with callgrind, search-results
50 items is 3,024,959 instructions against next's 3,023,848, and the
hn story page with no link handler is 71,027,668 against 70,849,188.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2
frame-sink installed the edge hooks at module load, so any process that
imported frames paid the hook calls on every createSignal, memo and scope in
every render, SC or not. The hooks are now refcounted: renderToStream with
the server-component plugin installs them when it starts and removes them
when its render is disposed or throws. The hooks still check the render
context for interleaved async renders, and their non-edges paths bail before
allocating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2
The search-results lane regressed under CodSpeed (Node 24): the per-row
`edgeHooks.reset` call added bytecode to mapArray's row loop, which moved
that loop's Maglev tier-up from after the measured iteration into it
(about 1M instructions of compilation and an immediate deopt).

The rows' plain-signal counts now remember the owner id they were taken
under, so a reused row owner moving on to the next row's id starts at 0
by itself; `reset` runs once per pass, outside the loop, for retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2
A plain store root is named like a plain signal (`<owner id>~<n>`, on the
same per-owner count), and the whole store goes by that one name. On the
server, any read of the store inside a slot fill, at any depth, through
iteration or memos, records the root's name; a store held inside another
store makes the holder wide. On the client, a setter call on the store or
on any proxy nested in it reports the root at the setter's entry, so the
store's readers mount before the write applies. Projections, optimistic
stores, unnamed stores and stores whose root cannot be placed still mount
everything.

Size-Exception: dependency edges for deferred hydration now cover stores;
frames client and server-component pages, see scripts/size/scenarios.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2
Re-measure the frames client and server-component page caps over #4139.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2

Copy link
Copy Markdown
Member Author

CodSpeed flags one benchmark, projection derive: write one NESTED field (reference) (-24%). I don't think this PR causes it.

  • Nothing changed on the measured path. This PR's only edits to store.ts are a null-checked signalHook call in createStore (once per store, in setup) and passing the proxy to writeHook when it is non-null. Neither runs inside a projection derive, and both hooks are null in this bench.
  • The bench documents this artifact. Its header (projection-root-write.bench.ts) says V8's tier-up chunk moves between its three windows on unrelated changes, and that the untouched nested write has read as x2.4 before.
  • Local wall time shows no regression. I ran the bench against the built package, 3 interleaved runs of this branch vs next @ ed51af3 with the edges sources reverted. Nested-write min: branch 0.036-0.037 ms, next 0.037-0.039 ms. Means are within noise.

Every other check is green on 47a217f. CodSpeed's own report also warns that this comparison ran across different runtime environments.


Generated by Claude Code

@ryansolid
ryansolid merged commit a9f7e2d into next Oct 19, 2026
7 of 8 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.

3 participants