Repository navigation
feat(frames): a write during deferred hydration mounts only its readers - #4134
Conversation
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 detectedLatest commit: 47a217f The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 |
Size (brotli, eager entry chunk)
|
Merging this PR will improve performance by 8.63%
|
| 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
Footnotes
-
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. ↩
-
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
Coverage Report for CI Build 39202478308Coverage decreased (-0.009%) to 77.555%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - 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
|
CodSpeed flags one benchmark,
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 |
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:
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;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 (
createStoreover 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.
signals/src/core/identities.ts, which only the frames client imports. It installs into a null-checked slot thatcreateSignalcalls.signalName(node))._depGen) moves on.createStorecalls the same slot with the store's root target. A store setter hands its proxy to the write hook at entry, andstoreNameresolves 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.
solid/src/server/edges.ts, which only the frames server imports.edgeHooks) and capture nothing new:disposeOwnerhas no reset sites;renderToStreamsetscontext.edgeswhen 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.createStorereturns a read-tracking view of its state: anyget/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'screateProjectionno longer builds its seed throughcreateStore, so projections stay unnamed and counts line up with the client.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>:"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:Identity drift check (dev only). Each deferred mount is watched through a
DEV.hooks.onReadhook. 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(addToCartpushes a line), and each card keeps its color, quantity and favourite in one card-localcreateStore. Values are medians in ms, written ashandler / next paint.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.The search-results regression CodSpeed reported (1.4 → 2.6 ms) was a tier-up moving into the measured call. Under Node 24,
--no-optstill leaves Maglev on. A per-rowedgeHooks.resetcall 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, andresetruns once per pass for retries. Node 22, which I measured on before, never showed it.createSignals(signals) runs the same code asnextapart from one null check increateSignal.Size
Measured locally (linux) against
next@ fb49849. Brotli in B; minified delta in parentheses.Re-measured after merging
next@ ed51af3 (#4139 landed first), with these caps (each is the measurement + 10 B, rounded up to 0.01 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 inscripts/size/floor-caps.json.gate.mjspasses every scenario; its remaining warnings are brotli shifts on scenarios whose minified size equalsnext'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
!) mount on every named write.How did you test this change?
New tests, each confirmed to fail without the change:
packages/web/test/server/frame-dependency-edges.spec.tsxchecks the emitted record:item#0-3cart#0*!packages/web/test/hydration/frame-dependency-edges*.spec.tsx:cart(plus the!fill), and a later currency write adds the items;packages/signals/tests/signal-identities.test.tscovers client naming:packages/solid/test/server/signal-identities.spec.tscovers server naming: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 eithernextor this branch, and nothing here touches that path. The test's 20 ms timer starts inside the handler, butcontinuationMscounts from the handler's return. The assertion now measures from the frame's open (handlerMs + continuationMs >= 15,continuationMs > 0).Suites run:
signals: 5345 passedsolid: 877 passedwebclient: 1375 passed (1383 after merging next)webserver: 1719 passed. The one failure, "keeps a rebuild failure's own error", also fails onnextunder this machine's Node 22.webhydrate: 516 passed (519 after merging next). Before the merge, the failure wasc03-hydration-done-counts-holds(b) (its (control) case also fails on some runs); both fail the same way withnext's sources and builds.solidandwebtest-types andwebtypes pass.node scripts/size/gate.mjs: every scenario passes.🤖 Generated with Claude Code
https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2