Skip to content

frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) - #3860

Merged
ryansolid merged 58 commits into
nextfrom
wip/frames-tiers-integration
Oct 7, 2026
Merged

ryansolid merged 58 commits into
nextfrom
wip/frames-tiers-integration

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Summary

The frames savings pass, Phase B through C2 plus B.3's sync form, in one PR (documentation/plans/frames-savings-pass.md §2–§3; frames-residue-pass.md; frames-b3-sync.md §5 option (a)): the server-announced tier mechanism, the five tiers cut on it — traces, regions, assets, binding slots, the live wire — the four residue cuts between them, and dynamicComponent, the component-only sibling of dynamic that lets a server-component page drop the element runtime. Collapsed per the maintainer ("merging is a bottleneck"): this branch was fast-forwarded to the top of a linear stack of ten drafts, their own commits only, each based on the previous — #3860 as it was (Phase B + C3 + C4 + C5, itself the fold of #3854 / #3856 / #3858 / #3857), then #3861 (the trace update-site wait — its one commit rides the chain as C6's cherry-pick, same patch-id), #3862 (C6, the binding-slot tier + option (a)), #3863 (residue 1: the D list, FRAME_HAVE_* off the client entry, the lean re-ask), #3864 (residue 2: the preview pull form measured and stopped — pin + docs only), #3866 (residue 3: every fill through insert, createFrame marker-less), #3867 (residue 4: the pending {$ref} read owned by the decode table), #3868 (C2, the live wire tier), #3870 (dynamicComponent — B.3 option (a): dynamic stays the combo, the sibling has no tag arm, the server-component docs and the size fixtures mount through it), #3875 (two scripts/size scenarios: the base and live server-component pages as compiled JSX through the native compiler — the number a real server-component page moves by; and bundle.mjs counting the entry's static-import closure as eager). Every step measured its cut on an edited dist copy before writing it; each folded body carries its own table and its step's decisions; this body carries the union. Cumulative vs next (both built fresh with the native compiler, measured with this head's scripts/size harness; the page numbers are #3875's measurement vs next @ 11e9fb653, the frames eager number #3870's vs next @ 53ef0e69e): frames eager 32,802 min / 10,888 br (−11,230 / −3,115); page base 104,586 / 33,570 (−41,837 / −11,569 br, −25.6%); page live 116,485 / 37,241 (−42,051 / −11,647, −23.8%); compiled page base 108,634 / 34,869 (−40,186 / −11,031); compiled page live 121,963 / 40,266 (−39,270 / −9,455). The hydrating / +stores / compiled-app / CSR scenarios are 0 / 0 vs #3849's head (they carry Phase A's +110 / +142 / +52 br vs next, its Size-Exception). Six lazy chunks ride beside the eager one (reported, not counted — below). Frames eager absolute: 10,888 br — 888 B above the ≤ 10 KB target; what remains of the incremental path is C1 (holes, ≈ −440) and the claims chunk (≈ −270), both rulings (Not in this PR).

Base and CI. Base is #3849 (wip/frames-pass-integration-2 @ 0aab93230, Phase A), still open — this PR stays a draft until it squash-merges. main-ci.yml triggers on pull_request: branches: ['*'] and size.yml on [main, next]; a * pattern does not match /, so a PR against wip/frames-pass-integration-2 runs neither (the folded drafts had only the Socket checks for the same reason). After #3849 lands: git rebase --onto next 0aab93230 wip/frames-tiers-integration (the chain's own commits are 0aab93230..3b70dd4c5), retarget to next, undraft; if CI's brotli bytes then differ from the local ones, the ratcheted caps (the three tier caps and #3870's two page floors) and #3875's two inline compiled-page caps follow CI's per the ratchet rule. The equivalent ran locally on this head (Pins › Suites, Size).

Update 2026-10-07 — on next, by merge (no rebase, per the maintainer). #3849 squash-merged as 3c1d51267; this PR is retargeted to next (still a draft). The branch merges #3849's final head 991418926 (same tree as 3c1d51267) and then next, so it is next plus this PR's own changes: the diff against next is the same 130 files as 0aab93230..3b70dd4c5, patch-identical outside scripts/size but for context lines. Resolutions: the frames client's slot-arg memo keeps #3874's dev-only _supersedes (IS_DEV ? { transparent, equals: sameArg, _supersedes: true } : { transparent, equals: sameArg }) and both comment paragraphs; server-functions/src/client.ts keeps this PR's provideRPC without #3849's createServerReference (git's auto-merge re-added it — residue 1 removes it, Public API › Removed); the size ledger keeps next's notes, then this branch's, and every ratcheted cap is re-derived from CI on the merge (Size). server.ts: no attrName call survives; this PR's spread additions ride #3871's isAttrName guard. Cumulative vs next @ 3c1d51267, CI: frames eager 33,418 / 11,112 (−10,178 / −2,900); page base 105,252 / 33,908 (−41,032 / −11,383 br, −25.1%); page live 117,301 / 37,512 (−41,096 / −11,450, −23.4%); compiled pages 109,300 / 35,079 and 122,779 / 40,593 (new scenarios; next cannot bundle their fixtures). The Summary's numbers above are the pre-merge measurement; next's bytes since (#3846's ChunkReader, #3865, #3874) add +616 / +666 / +816 B minified to the frames / base / live rows.

Public API changes

All @experimental unless marked; deduped from the ten bodies (#3860, #3861, #3862, #3863, #3864, #3866, #3867, #3868, #3870, #3875). #3861, #3864 and #3875 changed no surface.

New export — @solidjs/web (not @experimental; the one non-frames surface item)

  • dynamicComponent on the client entry (src/index.ts) and the server entry (src/index.server.ts), in types/index.d.ts with JSDoc stating the cost model. Signature: dynamicComponent<C extends Component<any>>(source: () => C | Promise<C> | AsyncIterable<C> | null | undefined | false, options?: DynamicOptions): Component<ComponentProps<C>> — no string in the union; DynamicOptions (static, deferStream) shared with dynamic. The component-only sibling of dynamic: dynamic must be able to render a tag name, so one dynamic anywhere on a page retains the element runtime (staticElement, createElement, spread, the prop-collection helpers, the SVG/MathML tables, getNextElement) for everyone; dynamicComponent has no tag arm and never references it. Implementation: dynamic is factored into a shared dynamicCore(source, options, tagArm?) and the tag arm — the whole hoisted/per-instance/render memo shape (A7's boxed FLIGHT, [2.0 rc.9] Async dynamic() inside Loading hydrates an absent fallback and replaces SSR DOM #3666's async-memo adoption, C16/C17's kept-resolution delivery) is shared; the arm decides only what a string renders as (dynamic: staticElement; dynamicComponent: nothing, like any other non-component value — the type is the guard). The server twin is factored the same way (the arm is one ssrElement()), so hydration-id consumption is identical across the four combinations. A server-component reference is typed as the server function's declared answer, a Component<P> (LiveSource<Component<P>> for a live declaration), so Component<any> covers it without a brand. (web: dynamicComponent — component-only sibling of dynamic #3870)
  • dynamic: unchanged contract, signature, behaviour. Its JSDoc gains the cost-model note and a pointer to dynamicComponent. No new diagnostic codes — the dev-only static-source guard still reads dynamic(): a static source must resolve synchronously… for both entry points (shared code). (web: dynamicComponent — component-only sibling of dynamic #3870)
  • Docs moved to the sibling: documentation/solid-2.0/11-server-components.md (the mount, the "entire client surface" sentences), server-components/server-components.md, server-components-principles.md, solid-2.0/10-server-functions.md (the Feed live mount), 03-control-flow.md (one note line under dynamic); scripts/size/sc-base-app.js / sc-live-app.js mount through it. Not touched: examples/{hackernews,notes,room} still mount with dynamic (working code; a follow-up if the examples should be on the documented mount); MIGRATION.md; the scenario names (… + dynamic + …, floor-caps.json keys). Changeset .changeset/web-dynamic-component.md (@solidjs/web patch). (web: dynamicComponent — component-only sibling of dynamic #3870)

Removed

New export paths

New

@solidjs/web/frames (client entry)

@solidjs/web/frames/server

@solidjs/web/server-functions (client config)

  • ServerFunctionsClientConfig.responseHandler.onLive?(): void — optional; fired by live() at the call, before its first fetch, per call (the preload-at-call hook; the maintainer's C2 adjustment 2 — no new ServerFunctionsClientConfig key). (frames: C2 — live wire tier #3868)
  • ServerComponentHandler.onLive(): void — the frames handler's implementation (prepareTier("wire")). (frames: C2 — live wire tier #3868)
  • responseHandler.handle's ctx gains retry(): Promise<unknown>; the responseHandler type now declares the existing intercept(info) seam, whose info carries retry() too. createServerComponentHandler's returned handle / intercept signatures carry retry?(). ServerComponentHandlerOptions unchanged. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)

@solidjs/web/serialization

solid-js

Moved @internals (module exports, never on a public entry)

Changed

Behaviour

  • Held occurrences under frames-rulings 3.1 for every tier. The held set is A2's registered set: a fresh adopt-path mount whose tier is absent — a { $tr } marker anywhere in its literal args (needsTrace), a {$frame} region in its record (needsRegions), a _s:* slot marker met at the walk (hasSlotMarker), an assets record naming stylesheets or inline styles (the reveal-readiness term) — waits exactly like a recordless called occurrence: the server interior stays on screen, the frame's hold registers (hydration-done waits), the install's flush mounts it. Every check that finds a tier absent starts its load (the un-announced fallback). A 3.1 hold does NOT keep the delegated-event replay window open — option (a)'s stamp does (Output-shape). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)
  • The update-site wait covers the trace tier too (needsRegions(record) || needsTrace(record.args)): a mounted occurrence's new record whose literal args carry { $tr } while the tier is absent stays pending in the store — the live binding keeps the args it shows, the load starts, the install's flush applies the record as an args change into the same mount, the marker materialized. Before, the marker went into the binding raw. (frames: trace tier — update-site wait for a mounted occurrence's new { $tr } record #3861)
  • Template occurrences mount with the record they were held on (#heldRecords) and apply a record that replaced it meanwhile as an args change; data occurrences mount with the CURRENT record — their positions are written whole at the bind, so there is no claim to match the markup to. A data occurrence's fresh mount also waits on the regions / trace tiers when its record names them — one hold predicate for both occurrence classes. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)
  • The park is keyed on the claim again (rulings 3.6 "Landed" closed): #invokeSlot's adopted → #resolveArgs(…, claiming) → revive(value, claiming?) → materializeContainerTrace(marker, claiming). A fresh mount reads the fold of its whole backlog at once; a claim — t = 0, under the tier's hold, at a reveal, after done — parks the backlog beyond the snapshot until hydration ends. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  • Trace occurrences on a document page claim after the chunk import. _$HY.r["sc:tiers"] naming trace starts the import at installServerComponents; the adopt-time sync holds the occurrence; hydration-done follows the mount (container-trace-hold-hydration-end re-pinned under 3.1 — same claim assertions, opposite order). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  • Inline styles hold while the assets tier is absent. A segment whose assets record carries stylesheets or inline styles is not ready while the tier is not resident, and once resident, not until every named sheet has settled (load or error, the $dfc policy). The reveal is at max(tier load, stylesheet load); no segment reveals unstyled. Once resident, inline styles never gate — the assets walk lands them at the record's arrival, before #revealSegments, so a segment's <style> precedes its content. A stream with no asset records never loads the tier. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  • S1's codec-face node scan dropped (+79 B min / +25 B br to keep): B's in-band tiers rides the very chunk the scan would have found the node in. Consequence: a data chunk from a producer that predates the tier, un-announced, decodes the inert marker (the plugin's hookless fallback) rather than converging — a server/client skew one package never ships. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  • The region rename machinery is moved, not deleted (principles §4 row 19): a single-flight response renders a shown boundary's regions under the call's address while a direct response and the document render them under the function id, so a flight refresh renames every region — live, two pins assert the rebind. It rides regions.js (≈ 140 B min there, 0 eager) and deletes with S7's store-boundary normalization. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  • Event-slot consumers' pre-bind events queue and replay (option (a)). Head-of-line: an event on a stamped consumer that never binds (no client fill for its prop) stays at the head of the queue until hydration-done drops the buffer — events queued behind it on other not-yet-completed elements wait with it. Without a fill the handler would not exist either way; flagged for the record. The two halves of (a) ship together — a stamp the tier does not complete is worse than none (even a click AFTER the bind is lost). (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)
  • A fill answering a t=0 claim with nodes that never entered the DOM is the core's hydration mismatch — the server node stays, the render is dropped, hydration reports the node unclaimed (what every compiled hole does). Before, the frame replaced the range. C1 (b) re-pinned to the core's rule — the fill lifecycle is the core's. (frames: residue 3 — every fill through insert; createFrame marker-less #3866)
  • A static fill creates no render effect (one insertExpression); a fill's binding owner is transparent — no ambient id consumed at t=0 (the reactive path used to consume one, so a keyed sibling after the frame keyed by timing). (frames: residue 3 — every fill through insert; createFrame marker-less #3866)
  • The pending {$ref} read is the table's (L1): a slot record's {$ref} to a key its response has not delivered is answered by that response's createJSONDataTable with a promise it owns, settled by the key's data chunk through the table's own apply and rejected by close(error) when the response ends — a value that never comes is an error where it is read, naming the key (Stream ended without delivering {$ref: "k"}; before: one generic error for every key, reached by no assertion). Frames keeps the count (record.pending) and one settle per read. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)
  • An adopted mount with no binding (the per-function placeholder rendered directly, _$SC.r(id)) binds the function id, not an _$SC.a address record; every production path carries the address with the binding. The re-ask carries the original call's per-call options (a caller-supplied signal still owns the wire). (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)
  • Preload-at-call for the wire tier: live()'s makeIterable fires responseHandler.onLive() at the call, before its first fetch — the import races only the request; a non-live call never loads the tier. handle's live arm awaits residency before the body is read, so a live connection without the tier cannot happen. (frames: C2 — live wire tier #3868)
  • The degraded first live connect sends no have-list and no ordinal. Content a plain call applied with digests while the tier was gated → the live call's first connect carries neither X-Frame-Have nor Last-Event-ID → full snapshot; the reconnect after a death carries both from the live root's ledger. The brief described this case as "sends the ordinal with no have-list"; with resume fully delegated (the lean form) the ordinal goes too. The server never read it for a frame render (liveSource compares Last-Event-ID to a value digest a frame answer does not have; only decodeHaveList(X-Frame-Have) is read in the frame sink), so the outcome is the same full snapshot. Pinned as built. (frames: C2 — live wire tier #3868)
  • Deleted with the D list (no surface): documentAddress and its call; the second dispose map (folded into fillScopes); the zombie heuristic and the #slotNodes map it read (its three dead writes went first); the text pair's interior is walked as ordinary nodes (the pair's own position is the tier's). (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862, frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)

Output-shape

  • _hk on event-slot consumers, document face. Every element the document face renders with an _s:on:* position carries a bare _hk after its markers — 4 bytes per element; ref-only and attribute-only consumers unstamped; the stream face unstamped (the bootstrap stops capturing at _$HY.done). The tier completes the element at the bind (completed.add(element); runHydrationEvents()) — option (a), the maintainer's Phase 2 ruling, closing the click-replay window. Bare: nothing gathers or claims it, so the dev completion sweep never reports it. 8 document-face expectations in server/frame-binding-slots.spec updated; a new pin fixes the scope. (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)

Wire (additive — decision 3, RFC 11 addendum)

Inherited from the base — #3849 (Phase A), not this PR's diff

Listed because the cumulative vs-next numbers above include them and a reader may look for them here; they landed with #3849 (3c1d51267 on next) and are not in this PR's diff against next. Removed Frame.isRevealed(segment) (A3; no caller — #3848) and _$HY.fr.claim(id) / _$HY.fr.release(id) (_$HY.fr is { pending, subscribe } — #3847). Behaviour: _$HY.fr.pending() reads false for a revealed fragment before its _fr stamp (the producer's real order; a page's last reveal reads as exhaustion — #3847); a post-done $df for a placeholder inside server-component markup no client has adopted yet proceeds (was: held, then replayed at adoption — #3847, adopted-swap-post-done). See #3849's body for the rest of Phase A.

Pins

Added (new spec files on the chain; test/consistency unless noted):

  • server/tier-announce (14) — stream and document faces of the announcement, both forms, cumulative record, renderToString announces nothing. tier-prepare (8) — the 3.1 hold on bind (releases the real module), the stream path's buffer-and-retry on regions, prepareTier idempotence (.r is the module), the record read at install, in-band chunk.tiers + the data await. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  • tier-trace-hold (4 — incl. frames: trace tier — update-site wait for a mounted occurrence's new { $tr } record #3861's update-site wait: on the base the pin fails at its first assertion), tier-regions-hold (4), tier-assets-ready (9 — the FOUC guard; a mutant without the readiness term fails 5 of 7 of the original pins), frames-assets-client (17). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860, frames: trace tier — update-site wait for a mounted occurrence's new { $tr } record #3861)
  • tier-bind-hold (6) — un-announced hold → the install binds every position, hydration-done after, no orphan _hk, a post-bind click dispatches; replay arm (i) a click queued before hydrate() against a stamped consumer replays into the bound handler at the install; replay arm (ii) a click and an input during the hold replay at the bind, in order; the mutant — no stamp loses both arms; a record replaced during the hold → one mount with the replacement; announced → the import starts at install. The client-half mutant (completed.add + runHydrationEvents disabled) fails 4 of 6. server/frame-binding-slots gains the stamp-scope pin. (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)
  • c15-staging-atomic (e) — the gap frames: A1b + A4 — stage/preview deletion, declared slot records, S-ref #3844 named, by name: a same-address refetch enters the transaction (the fill HAS derived the new arg while the DOM still shows the old; the release lands the whole in one frame; the fill is not re-read at the commit). Green on the push form; any carrier of the staging must keep both halves. (frames: residue 2 — the preview pull form, measured and stopped (carrier +483 br over a −546 ceiling; net −63); C15 (e) pins #3844's gap #3864)
  • sref-table-pending-read (3) — (a) the table alone: one read per key, the s = 0 mark, apply settles it stamped s = 1 / v and runs c in that apply even when the delivered value is a pending promise the read adopts, close() rejects the rest stamped s = 2 naming the key, a second close is a no-op; (b) L1 through the production shared host — the mount waits, complete closes the table, the fill's read throws to the nearest <Errored> naming the key; (c) the control. No earlier pin reached the rejection. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)
  • tier-wire-preload (3) — the hook starts the import synchronously at the call, before any fetch; the response arm awaits residency; X-Frame-Tiers: wire joins the same load (loader called once); a non-live call never loads the tier; reconnect through the tier carries Last-Event-ID and the have-list, morphs in place, completes; the degraded first connect pinned as accepted. server-functions-live-loop (19, client suite) — live()'s loop driven from the consumer's side over hand-framed bodies (codec chunks and SSE with id: lines): the non-sf throw, a handler without intercept, a first-connect failure as the call's rejection, a death reconnecting on online with the cursor as Last-Event-ID, fail-fast 4xx, 408 / 425 / 429 retrying, Retry-After, return() mid-connect / mid-backoff / mid-seed, invoke's signal mid-stream / mid-backoff, no global EventTarget, live(GET(fn)), the handler's local answers. live's branch coverage 26 → 57 of 60 sites (91 → 129 of 132 paths). (frames: C2 — live wire tier #3868)
  • dynamicComponent parity, SSR + hydrate — test/harness/dynamic-component-parity.tsx, test/server/dynamic-component-parity.spec.tsx (2), test/hydration/dynamic-component-parity-{inline,streamed}{,-via-dynamic}.spec.tsx (4 arms, + -run.tsx; 2 recorded artifacts, deterministic across runs): one page — a client component (sync source) and a non-live server-component reference (async source, under <Loading>) — rendered through the server dynamic and the server dynamicComponent is byte-identical (same _hk keys, frame markup, _fr record), inline and streamed; the dynamicComponent document hydrated through each client entry point — no hydration key miss, record adopted (computes === 1, no request), same nodes, both client slots live. A7 / C16 / C17 through both entry points — frames-errored-reset-refetch (9 × 2), c16-reference-identity (2 × 2), c17-gate-bound-address (5 × 2) are describe.each over dynamic / dynamicComponent. Type test test/dynamic-component.type-tests.tsx — a tag-name source ("input", a "input" | "textarea" accessor, a promise of one, a Component | "input" union, an arbitrary string) is a compile error for dynamicComponent and fine for dynamic; Component, Promise<Component>, LiveSource<Component> and the nullish/false forms type-check; props flow from the resolved component. (web: dynamicComponent — component-only sibling of dynamic #3870)

Re-pinned / adjusted:

Still test.fails (unchanged across the chain; no other): #3841's 15 GH pins in the hydrate suite (consistency/generic/replay.spec ×12, consistency/generic/preload-hold.spec ×3); S1's id-drift pin (hydration/container-trace-hold-id-determinism, rulings 3.4 — the server's root-id consumption for a boundary component; the client now consumes no ambient id per fill, the id-neutral side of that gap); and the pre-existing, unrelated client 1 (optimistic-lazy-show-preview-3706, preview="find") / server 3 (loading-recreated-async-source-3734).

Suites on this head (99915141a, #3870's fresh build — packages/compiler pnpm build, turbo --force; #3875 on top is scripts/size tooling and fixtures only, nothing under packages/*/src): web client 130 files / 1,227 passed + 1 expected fail; server 160 / 1,513 + 3 expected fail, 2 skipped; hydrate + consistency 93 / 463 + 16 expected fail, 2 skipped (+ 1 file skipped, a campaign spec gated on CONSISTENCY_FUZZ); solid 42 / 833; test-types on web and solid green; tsc -p tsconfig.build.json clean; scripts/size gate tests 12/12; dist specs (dist-nested-manifests incl. the five tier chunks' self-imports, dist-frames-server-instance, solid's dist-{client,server}-artifact, internal-surface, published-declarations, export-parity, cross-package-fields) green. Harness CONSISTENCY_FUZZ=1, 500 cases, seeds 3289 / 91501: SC arm 0 / 0; generic arm with CONSISTENCY_IGNORE=C1,C9,C19,E 0 / 0. (The chain's earlier head d9395e398 ran the same suites green: client 130 / 1,218, server 159 / 1,511, hydrate 89 / 452 — #3870 adds 9 / 2 / 11 tests.)

Suites after the merge onto next (7f5b9e007; filtered turbo build of web + universal and their deps, the native compiler from sources identical to next's): #3874's frames-slot-arg-flights-3852 2 / 2; frames client specs 25 files / 182; frames + server-functions server specs 74 / 664; the server-functions client spec 19 / 19; full web — client 135 / 1263 + 1 expected fail, server 161 / 1524 + 3 expected fail + 2 skipped, hydrate 93 (+1 skipped) / 463 + 16 expected fail + 2 skipped; solid 42 / 833. packages/signals is byte-identical to next (this PR touches none of it), so the signals suite was not re-run. The full suite re-wrote welcome-status-streamed.json and write-before-resume.json (chunk boundaries / order only); in a lone run of the two generator specs write-before-resume reproduced the committed form and welcome-status-streamed did not — its fixture races a 15 ms stats timer against two 5 ms generator steps on real timers, under machine load here. Both reverted, not re-recorded (the flip this body already records on the untouched base); CI generates its own before the hydrate suite reads them.

Size

On next (2026-10-07, after the merge). CI's Size run 37671002618 on the merge 7f5b9e007 measured the head to the byte the same as local; the caps below are CI brotli + 10 B rounded up to 0.01 KB with CI's minified recorded (commit 70fe0c7b4, dated ledger lines citing #3860). The next column is #3849's CI head (the tree 3c1d51267 carries); CI's base job cannot bundle the five frames / page fixtures on next (no dynamicComponent, no compiled fixtures). Min / br B.

scenario next head Δ cap next → head
signals: core floor 20,177 / 7,382 same 0 / 0 7.40 KB
signals: + createStore 44,437 / 14,603 same 0 / 0 14.62 KB
signals: + isPending/latest 27,020 / 9,570 same 0 / 0 9.58 KB
app: render + one signal 27,730 / 9,878 same 0 / 0 9.88 KB
app: hydrating (no stores) 52,841 / 17,840 same 0 / 0 17.85 KB
app: hydrating + stores 92,114 / 29,079 same 0 / 0 29.09 KB
app: CSR 36,616 / 12,863 same 0 / 0 12.88 KB
app: CSR, observe tier 41,138 / 14,455 same 0 / 0 14.46 KB
app: CSR, observe + attribution 86,473 / 28,651 same 0 / 0 28.70 KB
app: compiled floor 28,260 / 10,068 same 0 / 0 10.07 KB
app: compiled CSR 78,944 / 25,187 same 0 / 0 25.22 KB
app: compiled hydrating 99,477 / 31,079 same 0 / 0 31.09 KB
frames: eager client 43,596 / 14,012 33,418 / 11,112 −10,178 / −2,900 14.02 → 11.13 KB
page: base SC (floor) 146,284 / 45,291 105,252 / 33,908 −41,032 / −11,383 (−25.1%) 45.14 → 33.92 KB
page: live SC (floor) 158,397 / 48,962 117,301 / 37,512 −41,096 / −11,450 (−23.4%) 48.91 → 37.53 KB
page: compiled base SC (new) — 109,300 / 35,079 — new: 35.09 KB (the branch's pre-merge local 34.88)
page: compiled live SC (new) — 122,779 / 40,593 (incl. the static web.js chunk) — new: 40.61 KB (pre-merge local 40.28)
server: floor 3,324 / 1,331 same 0 / 0 1.34 KB
server: renderToString 71,813 / 20,412 71,817 / 20,403 +4 / −9 20.42 KB

Gate on the head: every scenario passes, none over its cap; check-floor-caps.mjs origin/next: no cap raised (no Size-Exception: needed). No cap is raised above next's. The two compiled-page caps sit above this branch's own pre-merge local caps because next's bytes since the branch's base land under them (the same +666 / +816 B minified as the hand-written pages); they have no cap on next.

Before the merge (the folded chain's measurement, kept for the per-step record):

scripts/size on this head (min / br), vs next — both built fresh in their own worktrees (native compiler included, turbo --force, 0 cached) and measured with this head's harness; the next scratch tree removed after. The page rows are #3875's measurement (head 3b70dd4c5 vs next @ 11e9fb653; on next, where dynamicComponent does not exist, the four server-component fixtures mount with dynamic — what a next app ships); the frames eager row is #3870's (vs next @ 53ef0e69e); the rest are the chain's measurement vs next @ 9d89df731 (0 / 0 across #3870 / #3875). The vs-base column is from #3860's earlier fresh measurement of 0aab93230 (not re-measured for the collapse) plus #3870's −7,417 / −2,152 and −7,418 / −2,171 on the two pages.

scenario head vs base 0aab93230 vs next cap
frames: eager client consumer 32,802 / 10,888 −10,177 / −2,916 −11,230 / −3,115 13.35 → 10.90 KB (recorded minified 32,802)
page: base server components (hand-written) 104,586 / 33,570 −41,033 / −11,463 −41,837 / −11,569 (−25.6%) (next 146,423 / 45,139) 37.98 → 35.74 → 33.58 KB (104,586)
page: live server components (hand-written) 116,485 / 37,241 −41,097 / −11,474 −42,051 / −11,647 (−23.8%) (next 158,536 / 48,888) 41.67 → 39.43 → 37.26 KB (116,485)
page: compiled base server components (new, #3875) 108,634 / 34,869 — −40,186 / −11,031 (−24.0%) (next 148,820 / 45,900) inline 34.88 KB / 108,634 (CI must confirm)
page: compiled live server components (new, #3875) 121,963 / 40,266 — −39,270 / −9,455 (−19.0%) (next 161,233 / 49,721) inline 40.28 KB / 121,963 (CI must confirm; two eager chunks — below)
app: hydrating (no stores) 52,794 / 17,838 0 / 0 +168 / +110 (Phase A's, #3849) ok
app: hydrating + stores 91,858 / 29,066 0 / 0 +174 / +142 (Phase A's) ok
app: compiled hydrating 99,431 / 31,075 0 / 0 +174 / +52 (Phase A's) ok
app: CSR 36,568 / 12,905 0 / 0 0 / 0 (pre-existing +45 br over, 0 min)
server: renderToString 71,817 / 20,403 +4 / −9 +4 / −9 ok (C3's materialize parameter in the codec plugin the SSR runtime bundles)
every other scenario 0 / 0 0 / 0

Per step (frames eager, min / br, each measured on its own base): B + C3 + C4 + C5 −2,979 / −721 (+1 B of integration glue over the four PRs' sum); #3861 +12 / +3; C6 −4,005 / −1,185; residue 1 −757 / −247; residue 2 0 (measured and stopped); residue 3 −978 / −287; residue 4 −505 / −174; C2 −965 / −305; dynamicComponent 0 / 0 on frames eager (it is a page-level cut: −7,417 / −2,152 page base, −7,418 / −2,171 page live, 0 / 0 on the other 15 scenarios — the edited-dist model predicted −7,423 / −2,158 and −7,424 / −2,237; the build differs by +6 B minified of wrapper glue and brotli layout noise). The steps sum to the integrated head exactly — 0 B of collapse glue (a fast-forward). Every tier's glue overran its estimate ≈ 3× while every deletion held; recorded in the plan §3.

What dynamicComponent sheds from the page (rendered exports of web.js on page base, before → after): staticElement, createElement, spread, SVGElements, MathMLElements, getNextElement gone; assign / assignProp stay — through the bind tier's entry edge (isAsyncValue, #3862), not through any template or dynamic — so B.3's "assign lazy on the page" is still open (Not in this PR). On the compiled pages (#3875) the picture is the same: spread absent; className / style / setAttribute / addEvent / delegateEvents present on both the hand-written and compiled pages (hand-written: via assign; compiled: via class={…} / href={…} / onClick={…}); compiled-only template / getNextElement / getNextMarker (632), readShallow (238), setProperty (217), scope (35), and 1,071 B of signals store/utils.js — readShallow → sourceKeys(value, SOURCE_PROXY) retains the leaf/merge key walkers, so any compiled page with class={expr} carries it. dynamicComponent vs dynamic on the spread-free compiled base page: −5,812 / −1,681 (live −5,816 / −1,736) — less than the hand-written −7,417 / −2,152 because the compiled page's templates import part of the attribute runtime themselves.

Lazy chunks (page scenarios, reported not counted; min / br): trace.js 25,417 / 8,198; regions.js 1,858 / 798; assets.js 2,040 / 783; bind.js 4,781 / 1,834 (+ bind.dev.js 11,172 raw under the development condition); wire.js 1,922 / 934 (933 on the live page); decode.js 23,410 / 6,237 (+424 / +163 vs next's 22,986 / 6,074 — residue 4's table-owned read). Live page, honestly: it LOADS wire.js at its first live() call, so its total transfer is 37,241 + 933 = 38,174 br — the chunk's own weight is the price of the eager −305 on pages that never go live. The compiled live page's eager graph is two chunks (sc-live.js 55,609 / 18,354 + a statically-imported web.js 66,354 / 21,912): with isPending on the page, the tiers and the lazy comments route all reaching the core, Rolldown hoists the shared runtime into a chunk the entry imports statically — fetched before the entry runs, so eager. The two compressions cost ≈ 1,223 B br over one chunk and the export glue ≈ 1.3 KB min, which is in the 40,266 above. The hand-written live page does not split only because its lazy page is empty; on next (no tiers) neither does. Not traced into Rolldown's chunk assignment — it is Rolldown's default chunking, which is what Vite ships, so an application page of this shape gets the same two files. (#3875)

Caps (pre-merge; superseded by the table at the top of this section): the three tier caps ratcheted along the chain and the two page floors ratcheted again by #3870 (35.74 → 33.58 KB / 104,586; 39.43 → 37.26 KB / 116,485) — all to measured + 10 B at the 0.01 KB step, with dated ledger lines in scenarios.js and recorded minified in floor-caps.json. #3875's two compiled-page scenarios carry inline limit: / capMinified (the gate has no report-only mode): 34.88 KB / 108,634 and 40.28 KB / 121,963, from local bytes — CI must confirm: set to CI's measurement + 10 B if CI reads differently, never higher than that; no floor-caps.json entry (the compiled app scenarios carry inline caps too). The integrated head's bytes equal the folded PRs' recorded bytes exactly, so no cap moves for the collapse. check-floor-caps.mjs 0aab93230: no cap raised (against origin/next it reports the hydrating floor 17.73 → 17.85 KB — #3849's Size-Exception, not this PR's; it clears once #3849 merges). The gate (head vs next): every scenario passes; the three pre-existing over-cap scenarios (signals core floor +11, isPending +23, CSR +45 br, all 0 min, byte-identical to next) warn within the minified allowance — the known local-vs-CI brotli difference. The ratchet would also lower two caps this PR does not move (signals: + createStore 14.56 → 14.55, app: CSR observe + attribution 28.66 → 28.65); left alone. No cap raised.

Harness (scripts/size) — #3875

Tooling and fixtures only; nothing under packages/*/src, no changeset.

  • Two scenarios, page: compiled base server components (…; no spread) and page: compiled live server components (…) — fixtures/compiled/sc-base.jsx / sc-live.jsx + sc-shell.jsx (+ a lazy sc-comments.jsx route, reported not counted), compiled through the native @solidjs/compiler the way the app: compiled … scenarios are (size: compiled-template scenarios on the gate (compiled floor, JSX todo app CSR + hydrating) #3810): hydratable: true, dev: false, generate: "dom". The same mount as the hand-written pages (installServerComponents(), createServerReference("story", "getStory"), dynamicComponent(() => getStory()), hydrate() into document.body) inside a compiled shell — a <For> of nav links (href from a prop → setAttribute; class / style as dynamic values → className / style under readShallow; a click handler passed as a prop → addEvent; delegateEvents), a search input with a value binding (setProperty) and a delegated onInput, <Show> with a function child, <Errored> + <Loading> around the server component, a lazy() route under its own <Loading>, the client signal + memo the hand-written page keeps. The live page adds live(GET(createServerReference(…))), an action dispatched from a compiled click handler, isPending read in a template's class, latest in a text hole. No element spread on either — the maintainer's ruling (2026-10-07): most server-component apps do not have client element spreads, so the compiled baseline must not carry one; a separate "+ element spread" line was considered and dropped. Why: the hand-written pages never compile a template, so the attribute runtime reaches them only through the bind tier's assign import — a change that removes that one edge "saves" ~4 KB minified on the fixture and saves nothing on a page an application ships, where every compiled template imports the runtime directly. These scenarios are the number a real server-component page moves by; the hand-written pair stays as the frames-only floor.
  • bundle.mjs — flagged. It treated every non-entry chunk as lazy; the compiled live page's statically-imported runtime chunk (above) would have read as 18.35 KB — less than half the page. It now measures the eager graph as the entry plus every chunk it reaches through static imports (each file brotli'd on its own); size.mjs, attribute.mjs and the PR comment name such chunks as eager (… counted); the JSON carries an eager array beside lazy. No other scenario splits; every existing number is unchanged (verified against the pristine bundle.mjs on the same build). scripts/size/README.md documents both.
  • Not done: page: compiled base SC + router (needs size: add page base + router scenario #3838's router wiring — @solidjs/router@2.0.0-next.35 in scripts/size/package.json, the lockfile, .npmrc — not on this head; a follow-up once both land).

Decisions for the maintainer

Carried forward from the folded bodies; each is a standing question, none blocks the merge.

  1. Tier chunk URLs — (i) + (ii), as built (plan §6 decision 3): the integration passes tierUrls to frameTransformDirectResult for the modulepreload (SolidStart from its manifest; nothing passes them yet), and the client resolves tiers itself through the built-in loader table (trace, regions, assets, bind, wire) + installServerComponents({ tiers }), starting each import from the sc:tiers record at install. Not built: resolving on the server through ctx.resolveAssets with a frames-owned module key (couples the frames server to one bundler's manifest shape). Accept (i)+(ii), or name a third? (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  2. prepareTier stays unexported from the public client entry (9 B br). Five tiers now exist to warm. Export it, or leave the warm start to the announcement? (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  3. A sync renderToString document announces nothing — no shared render slot to dedupe on; adding live: {} to the sync context is a core change on the renderToString floor. The client detects (the un-announced fallback). Leave as is? (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  4. B's "no loader ⇒ resident" rule is unsafe for a module-backed tier when the runtime is used without the entry. tierReady(name) returns true for a name with no loader, so frame-client.ts driven directly (no client.ts, so no built-in table) reaches tierLoads.regions.r.resolve(...) / tierReady("assets").gate(...) with nothing there — a TypeError, not a hold. Recommend "no loader ⇒ the load fails loudly in dev" (a _SOLID_DEV_ throw naming the tier; prod unchanged) — a separate small change if you agree; not made here since it changes B's pinned semantics. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  5. The region rename machinery: moved, not deleted (principles §4 row 19). If you want it deleted regardless, it is two lines in regions-tier.ts and two pins flip to GAP (lifecycle-matrix/call-driven-args › regions rebind, frames-optimistic-hold › rename). (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  6. reconcileChildren's text-pair arm stays eager (+39 br) — the "tight-nopair" variant deleted it and broke "a refetch re-sends the empty pairs: the morph keeps the client's text (and its node)": reconciled as ordinary nodes the client's text node is removed, and with the start marker unchanged the consumer set compares equal — no rebind, text lost. A tier dispatch point there costs what the arm costs (+109 min / +39 br either way), so the arm is kept with a comment. (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)
  7. Head-of-line on a never-binding stamped consumer (Behaviour): an event queued under an _hk whose element never binds holds the queue until hydration-done. Accept as the cost of the stamp, or stamp only consumers the fill set is known to bind (needs the fill set on the server)? (frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862)
  8. The separate assets walk before the segments (+61 min / +27 br over folding it into the hole loop) keeps a segment's inline <style> ahead of its content inside one flush. Say the word and it folds. (frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) #3860)
  9. FrameOptions.reveal kept optional via the default seam (−19 vs −30 at the ceiling of making it required; ~10 test files construct frames without reveal). The missing-closing-comment case is now "not revealed, dev error". Confirm. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)
  10. C1 (b) re-pinned to the core's rule — a fill answering a t=0 claim with fresh nodes is a hydration mismatch (the server node stays, reported), not a frame-side replacement. If the frame-side replacement is wanted instead, residue 3's static path cannot be insert (a claim pass cannot replace) — the item would keep normalizeSlotContent + settle + #replaceRange for the mismatch case, ≈ 70 br of the item. Confirm or reverse. (frames: residue 3 — every fill through insert; createFrame marker-less #3866)
  11. The capture arm kept (contentHTML + the host's last-unmount capture; −272 / −90 if cut): pinned by lifecycle-matrix/remount › "away/back over a t=0 adopted boundary" — without it an away/back over a document-adopted boundary shows the covering <Loading>'s fallback while the refetch is in flight instead of the interior it showed. A holds-latest ruling across a remount of the document face, not a cut. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)
  12. The re-ask's signal: the retry thunk carries the caller's options whole, so a caller-aborted call's boundary reset re-asks on the aborted signal (the sf client's rule — a caller-supplied signal owns the wire). Stripping it is ~20 min on the sf client. Flagged in case the re-ask should run on a fresh signal. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)
  13. The preview pull form — recommended against at −63 (§3.1 "As measured"). The push form stays. If ever taken, it removes FrameHost.preview / Frame.preview / stagedContent / contentAddress and adds FrameHost.staged(id) / promote(id, version?) / apply(chunk, stage?) / SlotContext.staged() — a custom FrameHost not implementing them would lose atomic staging. (frames: residue 2 — the preview pull form, measured and stopped (carrier +483 br over a −546 ceiling; net −63); C15 (e) pins #3844's gap #3864)
  14. A raw Slot callback that still returns nodes is silently ignored (=> void; the type and the doc carry the contract). A _SOLID_DEV_-only warning is a new diagnostic code — its own surface item; worth it if raw consumers exist outside the test suite. (frames: residue 3 — every fill through insert; createFrame marker-less #3866)
  15. close does not abort the table's deserializer (createJSONDeserializer.abort): a seroval {p, s, f} promise whose settling chunk never comes stays pending after the response ends, as before this pass (the sf path aborts its decoder at an unexpected end; frames never has). L1 for delivered-but-unsettled async values is a separate ruling — the hole is named, not taken. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)
  16. The test harnesses' private hosts (lifecycle-matrix/harness.ts makeHost and ~18 spec-local ones sharing one table across responses) do not wire closeData — with one table per test a close at any response's end would reject every response's reads. Their pins do not exercise L1; the production shared host does, and the new pin covers it there. (frames: residue 4 — pending {$ref} reads owned by the decode table #3867)
  17. The degraded first live connect — restore the ordinal at ≈ +40 br? The lean form drops it with the have-list while the tier is absent; a two-line resume fallback in the client would send Last-Event-ID alone. The server never read it for a frame render, so the outcome is the same full snapshot either way; pinned as built. (frames: C2 — live wire tier #3868)
  18. The s / v stamp on the frames copy in slotArgsProxy still lacks 2.0.0-rc.0 | Error Boundary not showing when expected #2997's rejection-observe for s === 2; the export it needs (readHydratedValue, module-private in solid/src/client/hydration.ts) would be a new solid-js/internal export, not a 0-B re-export. Skipped per the brief; the export would fix both. (frames: residue 1 — D list, FRAME_HAVE_* off the client entry, lean re-ask #3863)

Not in this PR

  • The claims entry (size/frames-claims-entry, ≈ −270) — stopped at the gate; three forms for ruling. C1, the holes tier (≈ −440) — ruling. Together they are the remaining path from 10,888 to ≤ 10 KB (≈ 120 B of margin after residue 2's finding).
  • The _$SC mirror (−234 / −80) — needs the server to emit the bootstrap unconditionally on SC pages (≈ +200 B of document output); a server-side change (residue §3.7). The s / v stamp (Decisions 18). Phase D (preserveModules — web: preserveModules + client.ts source split (attribute runtime, store adapters as modules) #3872, on hold on a Rolldown second-eager-chunk issue; the store hydration adapters ≈ 1.3 KB br stay eager on the pages). B.3's remaining half — assign / assignProp (283 + 921 min) still ride the eager page through the bind tier's entry edge (isAsyncValue, frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window #3862), on the hand-written and the compiled pages alike; dynamicComponent (this PR) took the element-runtime half.
  • The examples (examples/{hackernews,notes,room}) still mount with dynamic; moving them to dynamicComponent is a follow-up if the maintainer wants them on the documented mount. page: compiled base SC + router waits on size: add page base + router scenario #3838.
  • live's last three branch sites (a return() race and two stopped-while-pending elses) — not reachable deterministically from the consumer's side.
  • The un-announced codec face (an old producer, a new client) decodes an inert marker — S1's node scan would make it converge at +79 B min; dropped per the brief. The sc:region: drain arm stays eager (moving it needs the tier to re-drain _$HY.r on install — more glue than the ≈ 30 br it saves). The sc:tiers record is not re-read after install (a post-install write is covered by the hold).
  • Undraft — the maintainer's call. Retargeted to next and updated by merge on 2026-10-07 (Summary › Base and CI › Update).

ryansolid and others added 30 commits October 6, 2026 13:46
…er hydration ends (frames-rulings 3.6 (iii), the 3e port)

S1's third commit (9927ddd) ported onto next, minus its `claiming` hint:

- `materializeContainerTrace` parks a replayed backlog beyond the snapshot
  (`limit = 1`) until hydration ends — `onHydrationEnd`, the next microtask
  when none is in progress — then releases it with a version bump so the
  compute drains the backlog as one ordinary update. A trace is materialized
  at a fill's arg-read; when that fill CLAIMS adopted markup the snapshot is
  what the markup was rendered from, the claim trusts the markup (a text
  hole is never rewritten during a claim), and a store already past it left
  the DOM diverged for good. Release order (3.2): claim → the frame's hold
  release → done → backlog. A failure applies in order, after the parked
  patches.

  The park is unconditional (every replayed backlog), not keyed on
  hydration being in progress at materialization: the projection's first
  compute runs at creation, materialization precedes the frame's claim
  window, and a claim made AFTER hydration-done — an occurrence inside a
  server <Loading> whose fragment reveals after done (corollary 4: the
  server's inner loading state registers nothing) — has no hydration state
  that says "claim". Keyed on `isHydrationInProgress()` the harness kept
  7 / 7 such cases red on the two seeds. A fresh mount pays one beat for
  not being told apart: its backlog lands a microtask after the snapshot,
  before any paint.

- The projection's root is DETACHED (`runWithOwner(null, createRoot)`):
  rooted under the reading owner, a trace revived at t=0 consumed one child
  id while one revived by a late claim consumed none, and a keyed sibling
  after the frame hydrated under different keys in the two runs.

Pins: harness C19 ×2 flip to `test`; `c19-claim-reads-snapshot.spec.tsx`
(the t=0 claim past the markup, the deferred claim under the hold, the
release order, the post-done claim at a reveal, the C11 consequence, id
determinism — S1's container-trace-hold-{snapshot, hydration-end,
id-determinism} re-cut for next); solid `container-trace.spec.ts` (id
neutrality, the park's three timings, the in-order failure).

Harness oracle: the settled trace law exempts a settle point inside a
claim's park (contract C11, "every observable point" read as "outside a
claim's park"); the end stays strict. Campaign, 500 cases: seed 3289
83 → 0, seed 91501 79 → 0, every law un-ignored.

Contract: C11 amended, C19 re-read under (iii), R10 corrected (S1's path
is the park, evidence for (iii) not (i)), the fix order's item 3 decided.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ation window (hydrateWindow); R.claim deleted

The savings plan's A2, S-hold's window form (frames-rulings 3.1 / 3.2):
the frame's hold is `initBoundaryResume`'s registration (#3831's
`holdBoundary`, kept), and the resumed fill re-enters hydration through the
same window a streamed <Loading> resume opens — instead of a registry and a
hydrating flag of the frames client's own.

solid: `hydrateWindow(id, fn, scope?, o = getOwner())` factored out of
`resumeBoundaryHydration` and exposed as `sharedConfig.hydrateWindow`
(@internal): the keys under `id` gathered into the registry (the captured
`scope` pair when another hydrate() root replaced the live one, #2917),
hydrating on, `o` the claim owner (#3504 — a render the window forces
elsewhere is a client render), and `o` the snapshot + live scope when none
is open: a late claim no longer re-marks the root's scope through
`markTopLevelSnapshotScope` and leaves it unreleased (two C11 cases on seed
91501 with a first cut that skipped this), and inside an open scope (the
root pass, an enclosing window) the claim joins it and releases with it. A
window opened after hydration-done turns snapshot capture on for its span
and clears it, as the old setter dance did. `sharedConfig.claimRoots`
typed; the claimant sets it. The resume path's behaviour is unchanged
(hydration mode off after a resume, whatever the flag read before).

web/frames: `claimRender` is one `createOwner({ id: prefix })` and the
window call. Deleted: `gatherClaims` (the range walk), `hasPendingFragment`
(the window always engages), the root-registry hand-over, and the flip of
`sharedConfig.hydrating` through its setter — which reset hydration-done
and re-ran `checkHydrationComplete` from outside the runtime. `adoptBoundary`
captures the registry/gather pair it adopts under so a claim long after
(under the hold, at a fragment's reveal) gathers against the frame's root
— pinned by c01-claim-window-roots (fails without the capture).

web: `gatherHydratable`'s prefix gather selects natively (`[_hk^="…"]`);
it runs once per adopted occurrence now, not only per late resume.

Size (local, vs the integration branch): frames eager 43,414 → 42,932 min
/ 13,787 → 13,678 br (−482 / −109); app hydrating (no stores) 52,626 →
52,744 min (+118) / 17,728 → 17,833 br; compiled hydrating 99,257 →
99,381 (+124) / 31,023 → 31,099; page base 145,757 → 145,501 (−256) /
44,882 → 44,885; page live 157,720 → 157,464 (−256) / 48,595 → 48,631.
The hydrating scenarios are over their caps (brotli); no cap raised.

Pins: solid hydrate-window.spec (the seam's contract), web
c01-claim-window-roots (multi-root). Suites: solid 830; web client 1142 /
1 xfail, server 1474 / 3 xfail, hydrate 367 / 4 xfail; harness 500 cases
× 2 seeds, 0 findings.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
… claim window (A2b); the multi-root pin listed

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ark is unconditional; hydrateWindow's hydrating cost accepted

- frames-rulings 3.6: "Landed" — the park is unconditional (post-done claims under corollary 4 have no hydration state that says "claim"); S1's `claiming` hint at plan step C3 keys it on the claim again.
- frames-rulings 3.2: cost as landed — #3837 + #3840, frames −109 br / hydrating +105 br; the hydrating cost accepted, caps raised under a Size-Exception at the next integration PR.
- consistency contract R10 / C19: the park's key described as it landed.
- savings-pass plan §3 row A2: landed in two parts, measured vs estimate, further solid-side seams measured on an edited dist copy before they are written.
…e response's table; the dedupe, the latches, stageTables/STAGED_DATA/onStream and the threaded resolver deleted

S-ref (frames-rulings 1.3, L1). `createFrameHost.apply` settles a `slot`
chunk's `{$ref}` args at the write, through the integration's table for
the chunk's RESPONSE (`FrameHostOptions.resolve(ref, frameId, version,
current)`), so the record the store holds — and every mount reads —
carries values, never refs, and a later response's data can answer none
of them. A key the response has not delivered yet resolves to a pending
read owned by the store (keyed by version and key; stamped `s`/`v` as a
serialized promise once it settles): its `data` chunk settles it and
re-applies the records it was the last open read of, the stream's
`complete` or `error` rejects it (L1 — a value that never comes surfaces
where it is read, instead of leaving the record silently unapplied), a
bump drops the superseded response's waits unanswered. A FRESH mount waits
for its record to settle (`record.pending`), so the frame's shell shows at
its landing with the range as the server left it and the mount's covering
boundary pends on the landing alone (A0, corollary 4 — a fill whose first
read pended into that boundary would hold the frame's own address follow
behind the fallback); a MOUNTED occurrence takes the read as it is and its
prop holds what it shows (A17, the DR-2 value tier). The frame no longer
resolves refs: `#refsUnresolved`, `#resolveRef`, `#slotResolvedRefs`,
`#refArgsUnchanged`, `#reconcileRegions` and the `resolve` parameter
threaded through `preview` delete; the host notes a record's `{$frame}`
args (`record.regions`) and decoded args (`record.decoded`) so the frame
never probes a value (a decoded value may be a live container).

Codec data tables are per RESPONSE (frames-rulings 1.2): `client.ts` keys
them by frame id and version — the shown response's and a staged refetch's
coexist — and drops versions below the store's `current` at the next use.
`stageTables`, `STAGED_DATA`, `beginStream`, the prefix walk and
`ServerComponentHandlerOptions.onStream` delete; a staged response's data
chunks write through to the host as they arrive (into their own table).

R.dedupe. A re-sent record always updates the live occurrence's props; the
fill's props read through one memo per prop whose `equals` (`sameArg`:
identity, then structural for decoded plain data; containers, async values
and nodes by identity) is the dedupe — a record whose refs decode to equal
values churns no reader. A live container is held boxed in its memo (the
core probes a memo's result for `.then`; a pending container's trap throws).
`FrameHostOptions.isContainer` / `FrameHost.isContainer` have no caller and
delete.

R.version / R.error. The applied root and the notified error are keyed by
record identity (`#appliedRoot`, `#appliedError`; frames-rulings 2.1/2.2);
`Frame.rebase()` (no caller since #3830) deletes.

NOT deleted — the gap, reported: `stage`'s buffer + `CONTENT_TOKEN` +
`stagedContent.preview/commit` + `FrameImpl#preview`/`host.preview`
(reduced). The token is how a refetch of the address a mount SHOWS enters
the reactive graph at all (`dynamic` delivers a kept resolution only when
its address differs), and the compute-half preview is what stages the
refetch's slot args with the transaction that read it: without it a fill
deriving optimistic intent over an arg re-derives from the OLD arg in the
pass that dissolves the intent (`frames-optimistic-hold` ×3, principles
§9.2.2) — one flush before the effect-half commit pushes the new one. C15
itself stays green either way (the DOM never shows the tear; a memo does).

Pins: C5 (b, c) re-pinned to observe the rotation through the fill (the
host's resolver is no longer a surface); `frames-flight-delivery`'s
`onStream` probe → a probe registration seeded from the resident store.
… its marker; the #2968 poll deletes

The document face (`createDocumentSlotProps`) writes `sc:slot:<fid>:<occ>`
as a DECLARED pending value under its key — serialized at the marker,
ahead of the fill and of any fragment that carries the range — and
settles it with the args once they are classified: the shape a fragment's
`<key>_fr` takes, so the adopting client awaits a record that trails its
range's reveal through the value's own `.then` instead of polling the
registry for a plain write. `adoptBoundary.drainRecords` reads a settled
declaration's stamp synchronously (an adopt-time claim must not take a
pending beat — readHydratedValue's rule), awaits a pending one, observes a
rejected one. A sync render (`renderToString`) has no later script and no
serializer for a promise: it writes the settled value, as before. A fill
that throws settles its declaration empty so the response is not held open.

Output shape: ≈ +32–38 B per document slot record (measured over the eight
artifacts carrying one; the resolver/settle helpers are the page's `_fr`
declarations' already). The 148 artifacts re-recorded: 8 changed.

Client: `#recordRefresh` and the `setTimeout` re-drain poll delete, with
`FrameOptions.recordsPending` / `FrameOptions.drainRecords` (no caller); a
recordless called occurrence waits — the declaration's settle is the write
that re-syncs it. The hold (3.2) is unchanged. No new wire TYPE: the
declaration is the hydration-value mechanism the page already uses for
fragments, applied to slot records (document face only).

Pins: C2 (a2) flips to `test` (the record settled after the reveal, parser
done, no fragment pending). The late-record pins re-shaped to the declared
protocol (declared at boot/with the fragment, settled where the plain write
was): c01 (window roots), c03 (a), c04 (b), c09 (c), c10 (b), c14 (a),
c19 (b, c, e), hydration/adopted-slot-late-record; the harness declares a
render occurrence's record with the markup that carries its range and
settles it at the `record` event. Campaign: 500 cases × seeds 3289, 91501 —
0 findings.
…A1b removals landed (A1b + A4); the preview kept as the Transaction's carrier; public surface updated
…landing rejects on :error, the content node throws to the client <Errored>, reset re-asks (a new flight for the same address)

A0, corollary 4 outward (frames-rulings 3.3): a frame is one async value, and its `:error`
is that value rejecting — exactly as any `createAsync` that rejects. `FrameHost.landing`
rejects with the error record at the response's error write; the mount's content node
throws it to the nearest client `<Errored>`, so the covering `<Loading>` never releases
over an empty `<solid-frame>`. An error AFTER the landing (the same response erroring
after its root, a cut-off stream, a refetch's response erroring) is the L2 "errored flight
after a landing" case and errors the node as an async iterable that yielded and then
threw does — the shown value is not kept beside the error. With no client `<Errored>` the
core halts (REACTIVITY_HALTED), as for any uncaught async error.

`reset` re-asks: the `<Errored>`'s reset recomputes the node that threw; it re-reads
`host.landing(address)`, and an errored landing is not a landing for a fresh consumer —
the re-read is a promise for the next flight, which the mount opens itself (`dynamic`'s
factory is hoisted and never re-runs for a reset): the handler records the call behind
every address it handles or the document answered (`callFor`, @internal) and the client
re-invokes it through the same declaration shape (a GET read stays GET). A response for
an address whose mounts show an error writes through instead of being staged.

Pins: frames-errored-reset-refetch.spec (new, A6's .fails pin made test + 8 arms); the
lifecycle error arms, the switch-errored arm, C17 (b) and frames-live's error arms
re-pinned to the new reading; four switch pins now send `complete` before closing (a
close before complete is a death, which now errors the frame).

Co-authored-by: Claude <noreply@anthropic.com>
…bound RPC seam, not by importing the transport

The first cut imported `createServerReference`/`GET`/`invoke`/`withMeta` from the
server-function client entry, which retained the whole transport in the frames eager
graph (+11 KB min on the frames scenario). The registry's RPC slot
(`provideServerFunctionRPC`, read by routers for the same reason) now carries
`createServerReference(id)` on the client half, and the frames client reads the slot
and the declaration-metadata brand through their registered symbols — the pattern
the frame runtime already uses for every cross-bundle brand — so neither bundle
retains a second registry copy. The error tick shared by both mount faces is one
helper (`failing`).

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…s/retry model deleted

frames-rulings 2.3 (a reveal is an apply) applied to the stream face's own segments: a
revealed segment's content is applied against the store as it is revealed — its fills
mount (as before) and the segments whose placeholders it carries reveal INSIDE it, in
the same flush and before a reconstructed boundary commits the content — so `#flush`
makes one pass over the frame's segments instead of retrying until no pass progresses
(the store-model analogue of `$dfd` the re-attribution classed R.reveal), and a segment
nested in content a pending fill holds reveals into the detached fragment instead of
waiting for a chunk that never comes (pinned: two nested arms in the lifecycle matrix).

The second ledger goes with it: `#revealed` / `#fallbackShown` / `isRevealed` — a
reveal's applied state is the content record it applied, a fallback's the gate it
materialized, by identity (2.1), in the one applied map the hole appliers already use;
whether a segment is SHOWN is the DOM's to say (2.4: its placeholder is gone once it
swapped). The segment swap's DOM half, the style gate and the per-flush content + gate
+ placeholder test stay (T.morph, F.assets, and the test the re-attribution says always
stays). `Frame.isRevealed(segment)` (public, @experimental) is removed — no caller.

Measured (frames eager, min): −45 against A7's head; the estimate (≈ −140 br) counted
the fallback pass and the style gate, both kept.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…A7: the landing rejects, a later error errors the value, reset re-asks; the reach gap recorded), 2.3's stream-face half landed (A3); the plan's A3 row measured

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ror op errors a dynamic mount over the document's element, reset re-asks the intercepted call; the direct-placeholder gap

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…onFragments deleted

Ruled 2026-10-06 (12:55): a placeholder inside a server component's element
is the frame's content by rendering, not by adoption.

solid-js (hydration ledger):
- fragmentPolicy swaps post-done when the fragment is claimed OR owned —
  ownedFragment(id): the pl-<id> template is in the document and
  _$HY.fa(pl) says the integration owns it. No _adoptedRoots, no replay.
- _$HY.fr is { pending, subscribe }; claim/release removed (releaseFragment
  deleted; claimFragment stays for initBoundaryResume).
- fragmentPending reads a revealed fragment from _$HY.v before its _fr
  stamp (the producer's real order: swap script, fe, then the settle) —
  the latent exhaustion bug under which a page's last reveal never read
  as exhaustion.

@solidjs/web (frames client):
- installRevealHook installs _$HY.fa once: pl.closest("[data-fid]") not
  in disposedFrames (C14: a boundary disposed in place marks its element
  and the region elements inside it).
- claimRegionFragments / claimedFragments / cascade claim half / release
  loop deleted; the dev-only (c1) rejection report stays as an IS_DEV
  pl-* scan (0 prod bytes).
- documentBoundary pends on awaitBoundary(id) — the intercept's one
  deferred answer; boundaryWaiters deleted (G9).

Pins: adopted-swap-post-done.spec (G4 — adopted/then post-done; swap
before the adoption with synchronous record read; nested region;
held-outside control), boundary-arrival.spec (G9 — pend then adopt;
exhaustion in the real order through the shipped $df; shared answer;
disposed during the wait), C14 (e) dispose in place + control, C12 (c3)
post-done client half, C2 (c2) re-pinned (no hold, no replay),
frames-late-boundary-client swapIn stamps _fr after fe.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ng (measured table), decided-by-default 8 superseded, contract mechanism refs, plan row A5

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The sink's `sweep()` collects the hole / attr re-emissions one pass
produces and emits them as ONE `{ type: "ops", id, version, ops: [...] }`
chunk on the stream face (one wire line) and one `sc:live` op of the same
shape on the document face; a sweep that changes one binding emits that
member alone, addressed, as before. Members ride unaddressed — the
envelope addresses them.

Client: `chunkToRecords` merges a unit's members into one record map, so
`FrameImpl.apply` writes them in one write and `#flush` lands them in one
hole pass; the hole pass now announces `frame:applied` ONCE per flush (it
announced per hole, which let a listener read the DOM with one hole of a
sweep moved and its sibling not). `applyFrames.drain` and `applyLiveOp`
pass the unit through unchanged. The document op log (`liveOps`,
last-value-wins per target) flattens a unit into its members.

Pins: `c13-sweep-atomic` (a, b) flip to `test`, fed the unit as the sink
now emits it, plus a (log) arm for the catch-up replay;
`test/server/frame-sweep-ops.spec.tsx` pins the sink on both faces (a
two-hole sweep → one unit; a one-hole sweep → the plain member).
`frame-live-holes-projection` reads the unit's members.

Wire: additive `FrameChunk` member — RFC addendum in
documentation/server-components/frame-streams-rfc.md, with what an old
consumer does with it (drops the write; under-updates, never tears).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A plain (non-`live`) server component whose content reads a standing
source kept its response open until the source settled — never, for a
generator that never returns — with none of `live`'s reconnect semantics.
`frameStream` now ends such a response at a bound, detectably:

- `bound: "yields"` after `maxYields` emitting sweeps past the first flush
  (default 64; a sweep that emits nothing is not a yield — the sink's new
  `onYield` hook counts them);
- `bound: "time"` `maxDurationMs` after the first flush (default 30 000),
  or when the request's `signal` aborts after the first flush (a platform
  deadline the client can tell from a death); before the first flush, or
  for a `live` response, the abort stays the death it was.

The sink's end-latch runs as for any completion, `complete` carries the
bound, the body closes, and the render is torn down quietly — the abort
reason carries `quiet: true`, which `renderToStream` reads as "the
response chose to end" (no SSR_STREAM_ABANDONED). A body's own cancel
(the reader left) stays a death: never dressed as a bound. A `live`
response is never bounded.

Client: `:bound` is stored beside `:complete`; the frame lands as on any
`complete`; in dev the host warns once per cut-off, naming `live()`.

Public surface: `FrameStreamOptions.maxYields` / `maxDurationMs` (new);
`createFrameSink(emit, frame, have, hooks?)` (fourth parameter, `onYield`)
and the sink's `end(bound?)`; `FrameChunk`'s `complete` gains `bound?`.
RFC addendum in frame-streams-rfc.md. `frameTransformResult` takes the
defaults (it is a policy function with no options object).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…C12 c)

A server `<Loading>` inside a server component that fails after the first
flush has no client twin to render over its position; the fragment used
to carry a blank (`" "`) and the position emptied silently (R6). The
document face now renders what the SERVER rendered for the outcome, as
the stream face's `meta.error` path already revealed (frames-rulings 3.3,
A0 corollary 4 inward):

- the nearest SERVER `<Errored>`'s fallback for the error, at the
  `<Loading>`'s position — asked through the boundary error handler's new
  `outcome` mode (`createErrorBoundary` answers with its fallback as
  finished markup when it is inside the component's scope; a `<Loading>`
  between passes the question up; an `<Errored>` outside the component —
  the app's twin at t = 0 — answers nothing). Rule: "a post-flush error
  inside a server component's <Loading> shows the nearest <Errored>'s
  fallback at the boundary's position". Ids are the component's own
  hydration-free scope's; head / asset registrations drop as before;
- with no server `<Errored>` the error ESCAPES the component — the frame
  as one async value errored, the outward face: the stream face's unkeyed
  `error` chunk (`:error`); the document face's `sc:live`
  `{ type: "error", fid, error }` op, which only the owning adopted
  boundary applies (`applyLiveOp`'s `fid` gate now covers unkeyed error
  ops) — and the position keeps the boundary's own fallback, never a
  blank. `_fr` still rejects and the keyed error chunk still rides.

Outside a server component nothing changes: the blank the client twin
renders fresh over.

Pins: C12 (c2) flips to `test` (the page carries the Errored's fallback
as the server now writes it); (c3) added for the escape arm;
`test/server/frame-fragment-error-outcome.spec.tsx` pins the sink on
both faces, the nested-Loading pass-up, and the unchanged non-component
case. The maintainer's refetch question (a) is pinned
`test.fails` in `test/frames-errored-reset-refetch.spec.tsx`: it fails
at its first step — the client `<Errored>` never catches a frame's
`:error` on this branch (the landing resolves on it) — and the two-part
fix is described in the pin and in frames-rulings 3.3.

Internal surface: `HydrationContext.registerFragment`'s resolver takes a
third `escaped?: { frame?: string }`; `HydrationContext.frameId`
(`@internal`, set by `frameTransformDirectResult`); the module-internal
`ErrorContext` handler takes `(err, outcome?: true)`.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… the 2026-10-06 approvals, the refetch rule under 3.3

- The server-half drafts section gets a status block: the three drafts as
  built on `fix/frames-a6-server-half`, each with where it differs from the
  draft (the per-flush `frame:applied`, the signal-as-time-bound, the
  document face's escape op) and what is not built (the client's outward
  `:error` face).
- The maintainer's approvals of 2026-10-06 recorded: A4 = the declared
  slot record (not the `_$HY.r` hook); A1b's surface removals approved
  ("not even beta"); S-adopted to be built; the 30 KB target stated against
  `page: base` without the router, to be reviewed after the first size pass.
- 3.3 gains the refetch rule (a) as a ruling-by-A0 with its pin status
  (`test.fails`, failing at the client <Errored> never catching) and the
  two-part fix; (b) what slot content can reach today (nothing
  frame-specific; full app context) and the smallest addition
  (`SlotContext.address`).
- The as-landed table's A6 row, the still-red list and the public-surface
  list updated with A6's items.
- Savings pass §6 decision 5: "accepted for now; review after the first
  size pass" (maintainer, 2026-10-06).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
`chunkToRecords`' `ops` arm as one `Object.assign` over the mapped
members; `:bound` written beside `:complete` unconditionally (`undefined`
for a settled response, as `holes` is for a root without them);
`applyLiveOp`'s frame gate as `op.fid && op.fid !== id`. Frames eager:
+99 B min / +46 B br over the branch (was +155 / +42).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Answers "are the frames consistency reds SC-only, or generic hydration
holes?" for Solid 2's plain `hydrate()`: classifies C1–C19 / R1–R10, drives
the generic twin of every candidate through a frames-free page (two
streamed `<Loading>` boundaries over module-level state, rendered by
`renderToStream` in both fragment orders), by hand and under a property
harness, and pins what it finds. Tests and docs only; no engine change.

Six generic reds (`test.fails`, each with a control):

- GH1 C19 — a memo created before snapshot capture (module-level) is read
  live by a boundary resume's claim pass; the server text stays.
- GH2 C19 — a shell async memo adopted pending (no creation snapshot),
  re-run by a client write before a later boundary resumes — same.
- GH3 C19/C1 — a store write to a leaf no reader materialized is not
  snapshotted: `<For>` at the resume renders the post-write length, key
  miss for the extra row, list short until the next structural change.
- GH4 C9/C12/events — a boundary resuming while a shell async source is
  in flight commits its fallback over the settled server content at the
  snapshot release (unrevealed core boundary); the server nodes are
  detached then re-attached, a click queued on them is lost.
- GH5 C3 — hydration-done does not count a root's module preload: a
  second root finishing synchronously drains done while the first still
  waits; the bootstrap then stops capturing events for it.
- GH6 C14 — disposing a root during its module preload does not cancel
  the deferred render; the root mounts after dispose and stays live.

Holds confirmed on plain pages (1000 harness cases, two seeds, nothing
outside the six): C2, C3 for `<Loading>` holds, C10, C12 at settle
points, C14 for boundary disposal, queued-event replay, the #3504
snapshot for plain signals.

packages/web/test/consistency/generic/: scenario.ts, run.tsx (oracle),
campaign.spec.tsx (opt-in, `CONSISTENCY_FUZZ=1`, same knobs as
test/consistency/harness), replay.spec.tsx (GH1–GH4 + holds),
preload-hold.spec.tsx (GH5, GH6), README.md. Shared page
test/harness/generic-hydration.tsx; artifacts from
test/server/generic-hydration.gen.spec.tsx. Contract doc: new section
"Generic hydration — classification and pins (2026-10-06)".

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…aps unchanged

The three hydrating scenarios carry A2b's sharedConfig.hydrateWindow and
A5′'s _$HY.fa in solid-js — costs the maintainer accepted 2026-10-06
(frames-rulings 3.2 "Cost as landed", 3.3). Caps set at measured + 10 B
at the 0.01 KB step, recorded minified updated:

  app: hydrating (no stores)      17.73 -> 17.85 KB  (52,626 -> 52,794 min)
  app: hydrating + stores         28.93 -> 29.08 KB  (91,684 -> 91,858 min)
  app: compiled hydrating         31.03 -> 31.09 KB  (99,257 -> 99,431 min)

No ratchet: frames eager / page base / page live measure over their caps
by 14 / 143 / 115 B brotli with minified −435 / −138 / −138 vs the
recorded sizes (the gate's minified rule passes them with a warning);
nothing is below its cap by a 0.01 KB step that this PR moved.
…table, harness clean on two seeds, frames eager measured against the target; rulings status → Phase A landed

Co-authored-by: Cursor <cursoragent@cursor.com>
…stepped 1 ms — its three flush points are three chunks on every host

CI (#3849) ran the server suite under load: the page's 5 ms data value and
its 15 ms fragment fell due in one event-loop turn, deferFlush wrote them
as one chunk, and the hydrate suite then read a two-chunk artifact — every
generic schedule's chunk indices shifted (C2 with no chunk → the C14
dispose pin threw at 'matchAll'; the GH1/GH2 'write between the reveals'
pins became writes after both reveals and passed, failing as test.fails).
Locally the same artifact self-re-recorded under the parallel suite. The
generator now steps a fake clock (setTimeout + setImmediate faked) one
millisecond at a time so each timer fires in its own turn with its flush;
the chunks are byte-identical to the committed artifacts and stable under
load (10/10 runs beside the full server suite). The assertions pin the
shape: exactly three chunks, the first a script with no template, the
other two fragment templates.

Co-authored-by: Cursor <cursoragent@cursor.com>
…tier cut)

The server knows at render time which frames-client capabilities a
response or a document needs — it mints each feature — and announces
their names so the client starts the tier's import in parallel with the
content instead of at first use (frames savings pass §2; decision 3's
additive wire):

- sink: `needs(tier)` at the mint sites — a binding-slot position read
  (`bind`), a nested region (`regions`), an assets chunk (`assets`), a
  traced container in a slot arg (`trace`), a `live` response (`wire`);
  `announce()` for the response head.
- stream face: `X-Frame-Tiers` set by `serverComponentResponse` from
  what the sync render pass minted (the `ReadableStream` start runs
  before the Response is built); a tier minted after the head rides
  in-band as `tiers` on the next chunk out (`FrameChunk.tiers`) — the
  trace mint precedes the serializer's synchronous emission of the
  record's initial node, so it lands on the very `data` chunk whose node
  tree needs it.
- document face: `_$HY.r["sc:tiers"]`, re-written cumulative at each
  mint (every pre-shell write lands in the shell script; the last wins);
  a `modulepreload` per tier whose URL the integration gave
  (`frameTransformDirectResult(value, { id, args, tierUrls })`), through
  `registerAsset("module")`. A sync `renderToString` document announces
  nothing (no shared render slot) — the client detects.
- client: `installServerComponents(host?, { tiers })` takes the loader
  map, reads the record and starts each load; `applyFrameResponse` reads
  the header before the body and `chunk.tiers` per chunk; a `data` chunk
  awaits the tiers it names before decoding. `prepareTier` is idempotent
  per name; a name with no loader is resident (every name today). The
  install calls the module's `install()` and flushes every live frame.
- the held set is A2's registered set (frames-rulings 3.1 / 3.2): a fresh
  mount whose tier is absent — a data occurrence's `bind`, a
  region-carrying record's `regions` — waits like a recordless called
  occurrence; on the adopt path the frame's hold registers (hydration-done
  waits, the replay window stays open), on the stream path the record
  stays pending; the readiness check starts the load itself when nothing
  announced it (detection is the fallback), and the install's flush
  mounts it with the record it was held on.

Measured before written (edited dist copies, scripts/size): frames eager
+473 min / +145 br (≤ +150 budget; the ≈ +100 estimate's ×2.5 pre-estimate
was ≈ +250; the first honest cut measured +745 / +233 and was golfed),
pages +478 min, non-SC scenarios 0; frames server dist +793 min, the web
server entry 0.

Pins: test/server/tier-announce.spec.tsx (both faces announce what was
minted, nothing when nothing is; the in-band form; the trace on its data
chunk; the record cumulative; links only with URLs; sync renders silent),
test/consistency/tier-prepare.spec.tsx (idempotence; the record and
header reads; in-band + the codec await; the 3.1 hold with two frames
and one install — positions untouched, hydration-done waits, both mount;
the stream path's buffer-and-retry). preload-links.spec: the late assets
chunk now carries `tiers: ["assets"]`. Artifacts: 5 of 150 re-recorded
(frame-live-document-* announce `wire`, welcome-status-* `trace`).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…s ≈ +100 est.), decision 3 sub-item: tier chunk URLs (tierUrls + the client loader map)

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…hanism

The container-trace materializer is the store engine's one edge into a
server-component page (signals `store/*`, the projection/reconcile machinery,
~8 KB brotli) — and the frames client imported it from `solid-js/internal`
and installed it at module load. It is now the frames client's TRACES TIER
(frames savings pass §3 row C3 — S1 re-based onto Phase B's tier mechanism,
not merged as built), loaded through `prepareTier("trace")`:

- `solid-js/internal/container-trace` — a NEW `solid-js` dist entry carrying
  `materializeContainerTrace(marker, claiming?)` (S1 commit 1's entry, with
  commit 3's `claiming`). Its own entry because the main build is one flat
  module: any binding in it that reaches the engine welds the engine to
  whoever imports the binding. It reaches `createProjection` through
  `@solidjs/signals` and reads the store wrappers' hydration dispatch
  (`withStoreHydration`, new, `@internal`), `applyPatches` and
  `forwardIteratorReturn` back from `solid-js` by name; the server mirrors
  the three as inert stubs. The materializer leaves `solid-js`'s main and
  `solid-js/internal` entries.
- `@solidjs/web/frames/trace` — a NEW `@solidjs/web` export path, the tier
  module (`frames/src/trace-tier.ts`): solid's entry plus the plugin's client
  half (`frame-container-plugin.js`: the shared hook state, the memo, the
  marker test, the deep revive walk). Its `install()` — B's `install()`
  shape, unchanged — sets the materializer on the plugin's registered state
  and the shared host's `revive` (`getFrameHost().revive`, assignable; the
  eager client wires no reviver). In the dist the tier's import of the
  client entry is externalized to `@solidjs/web/frames` (instance identity).
- The eager client keeps the trigger alone: the loader entry
  (`tierLoaders.trace`), the container probe read off the plugin's
  registered state (no copy of the plugin rides the eager client), and the
  held-set predicate — `needsTrace`: a `{ $tr }` marker anywhere in an
  adopt-time record's literal args while the tier is not resident holds the
  occurrence (its server interior on screen; the frame's hold registered as a
  pending boundary under frames-rulings 3.1, hydration-done waits) and
  starts the load. The walk runs only while the tier is absent: once it is
  resident a decoded arg may be a live container whose traps throw, and
  before it is resident no live container can exist.
- S1 commit 3's two frame-side fixes ported: the `claiming` hint
  (`#invokeSlot`'s `adopted` → `#resolveArgs` → `FrameHostOptions.revive
  (value, claiming?)` → `reviveContainerTraces` → `materialize`) keys the
  materializer's parked backlog on the claim again (rulings 3.6 "Landed":
  the port's unconditional park made every fresh mount pay one beat; now a
  fresh mount reads the fold of its whole backlog at once); the held-record
  mount (`#heldRecords`): an adopt-path occurrence held — on its tier, on
  its record's reads — mounts with the record it was held on (what the
  server interior was rendered from) and applies a record that replaced it
  meanwhile as the args change it is. Generalized: every adopt-path hold
  (`bind`, `regions`, `trace`) uses it.
- Not shipped: S1's `FrameHostOptions.prepareData(chunk)` / `prepareArgs`
  / `#argsUnprepared` (B's `prepareTier` + the held set replace them) and
  S1's codec-face node scan — B's in-band `tiers` rides the very `data`
  chunk that carries the plugin's node, so the transport awaits the tier
  before the chunk decodes without a scan (measured +79 B min / +25 B br to
  keep; dropped). An un-announced chunk from a producer that predates the
  tier decodes the inert marker — a skew one package never ships.

Measured before written (edited dist copies through scripts/size's bundler,
re-attribution §7; then the real build) against Phase B's head e05ba02:

  frames eager  43,452 / 13,949 -> 42,968 / 13,866   -484 min /   -83 br
  page base    146,097 / 45,210 -> 122,028 / 38,598  -24,069   / -6,612
  page live    158,060 / 48,873 -> 133,899 / 42,147  -24,161   / -6,726
  app hydrating (no stores), + stores, compiled hydrating, CSR:   0 / 0
  trace.js (lazy, reported not counted)         25,409 min / 8,170 br

The deletion alone (F.trace's eager half out of the frames client, the
engine + materializer out of the pages) measured -951 / -239 on frames and
-28,763 / -8,016 on page base; the trigger left behind +467 min / +156 br
on frames (loader entry + probe +160 / +45, the held-set predicate +126 /
+67, the claiming thread +6 / -9, the held-record mount +175 / +53) — about
3x the plan's ~150-min estimate, which counted the loader alone. Against
`next` @ 9d89df7: frames -446 min / +79 br (Phase A's and B's bytes), page
base -6,284 br, page live -6,448 br. `@solidjs/web` server floor +4 B min
(`materialize`'s second parameter in the codec plugin the SSR runtime
bundles; brotli -9). Caps lowered (the ratchet): page base 44.89 -> 38.61 KB,
page live 48.60 -> 42.16 KB; frames eager's cap stays 13.79 KB (76 B over by
the earlier steps' bytes; its recorded minified lowered to 42,968). No cap
raised.

Pins — S1's seven ported through the general seam (the hold re-armed per
test by dropping the tier's load through `tierLoads`, the runtime's test
seam): `frames-container-lazy-codec` (announced form: the `data` chunk's
`tiers` is the trigger), `frames-container-lazy-document` (the production
loader), `hydration/welcome-status-lazy` (the artifact's `sc:tiers` record
starts the import at install), `container-trace-hold-{id-determinism,
interruption, record-retention, snapshot}`; `container-trace-hold-hydration-
end` RE-PINNED under 3.1 — hydration waits for the load, the mount claims
before done (S1 asserted the opposite order). New `consistency/tier-trace-
hold.spec` (§1's pin: the un-announced hold with the held-record mount, the
announced start-at-install, the codec wait). C3 (b) asserts the hold and
the 3.1 order (it was never `.fails` on `next`). The id-drift `.fails` pin
stays red under rulings 3.4 (unfixed; a keyed sibling after the frame).
The resident cells (`container-args`, C11, C19, the harness) warm the tier
with `prepareTier("trace")`. Solid's `container-trace.spec` pins the keyed
park. Artifacts: 0 of 150 changed (no wire change). Harness 500 cases,
seeds 3289 / 91501: SC arm 0 / 0; generic arm with CONSISTENCY_IGNORE=
C1,C9,C19,E 0 / 0.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ured −83 / −6,612 / −6,726 br; glue +467 min vs the ≈ 150 estimate), §5 S1 re-based; rulings 3.6 the park keyed on the claim again, 3.4 the id-drift pin on the tree

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Nested server-content regions — server content passed as a PROP to a client
fill (`{$frame}` in the occurrence's record), rendered as a `<solid-frame>`
region element with a frame bound over it that the host routes the region's
chunks to — are now the frames client's REGIONS TIER (frames savings pass §3
row C4), loaded through `prepareTier("regions")`:

- `@solidjs/web/frames/regions` — a NEW `@solidjs/web` export path, the tier
  module (`frames/src/regions-tier.ts`): the per-frame region cache (a
  `WeakMap` the tier keeps, no frame field), element discovery in an adopted
  interior (`collectRegionElements`, the range walk), the `{$frame}` arm of
  arg resolution (`resolve`: mint / reuse / rename), the bind of a frame over
  each region element (`bind`: `createFrame` from the eager entry, the parent
  linked as `options.parent`), disposal (`unmount`), and the staged preview's
  two region reads (`changed`, `frames`). The module's exports ARE its
  appliers: `prepareTier` records the installed module in a dispatch table
  (`tierModules`) and the frame calls through it; no `install()` — nothing of
  this tier lives outside the runtime's dispatch. In the dist the tier's
  import of the client entry is externalized to `@solidjs/web/frames`
  (instance identity: `createFrame` must be the runtime the app's host
  routes to); frame-client's pure DOM helpers (`eachInRange`,
  `makeFrameElement`, `isFrameElement`, exported `@internal`) bundle as the
  chunk's own copy.
- The eager client keeps: `needsRegions` — one test on the host's
  `record.regions` note (no arg walk) at TWO sites: a fresh mount waits in
  the held set (adopt path: the hold registers under frames-rulings 3.1, the
  server interior stays on screen), and a MOUNTED occurrence's new record
  naming a region stays pending in the store (the live binding keeps the args
  it shows) until the install's flush — either way the check starts the load
  (the un-announced fallback); the document face's `sc:region:` drain arm in
  client.ts (an occluded region's html lands in the store regardless; the
  frame the tier binds on install seeds from it); the loader entry; an
  `options` getter on the frame (`@internal`, not on `Frame`) the tier reads
  the parent's host / claim scope / owner scope from. The slot-resolution
  thread-up (`#resolveSlot` / `#resolveSlotRecord` / `#removeSlotRecord`)
  walks a `parent` link (`#outer`) by private access across instances —
  the three per-region option closures are gone.
- The rename machinery (`renameRegion`, the rename arm) is MOVED into the
  tier, not deleted — flagged: it is live, not dead. A single-flight response
  renders a shown boundary's regions under the call's address while a direct
  response and the document render them under the function id, so a flight
  refresh renames every region of the boundary (`preview`'s region check
  anticipates exactly this; `lifecycle-matrix/call-driven-args` › regions and
  `frames-optimistic-hold` pin the rebind). It deletes with S7's store-boundary
  normalization (principles §5.3), not here. Eager cost of keeping it: 0.
  Already gone on the base: `isFrameRef` (the host's inline `$frame` note is
  the detection), `tableFor`'s prefix walk and `#reconcileRegions` (A1b).
- `InstallOptions.tiers` types a tier's module as the new exported
  `TierModule` (`{ install?(): void; [applier: string]: unknown }`): the old
  `{ install?(): void }` is a TypeScript weak type a module whose exports are
  its appliers cannot satisfy.

Measured before written (edited dist copies through scripts/size's bundler,
re-attribution §7), then the real build, against C3's head 89954fa:
frames eager −1,078 min / −237 br (the deletion alone −1,596 / −423; the glue
+518 / +186 — over the ≈ 150-min line, reported: the installed-module table,
the loader entry, `needsRegions` at both sites, the tier calls at bind /
resolve / unmount / preview, the `options` getter, the `parent` thread-up;
TypeScript's TS18030 — no private name in an optional chain — forced the
longer `#outer && #outer.#x()` form, +33 min over the edited copy's −250 br);
page base −1,083 / −281; page live −1,083 / −261; the four non-SC scenarios
0. Chunk `regions.js` 1,872 min / 805 br (reported, not counted). Vs `next`
9d89df7: −1,524 / −158, −24,812 / −6,565, −24,904 / −6,709. Caps lowered
(the ratchet, measured + 10 B at the 0.01 KB step): frames eager 13.79 →
13.64 KB (under the cap for the first time since Phase A), page base 38.61 →
38.33 KB, page live 42.16 → 41.90 KB; no cap raised.

Pins: new `consistency/tier-regions-hold.spec` (4 — un-announced `{$frame}`
in an adopted record → held under 3.1, interior and region content on screen,
mounts with the held record, the adopted region element discovered and bound,
no TypeError, hydration-done after; announced → the import starts at install;
an occluded `sc:region:` record drained before the tier seeds the bound
frame; stream face: a mounted occurrence's region-naming record waits and
applies at the install into the live binding). `tier-prepare`'s regions test
releases the real module. Resident cells warm with `prepareTier("regions")`:
`lifecycle-matrix/call-driven-args`, `frames-hn-client`,
`frames-occlusion-client`, `frames-optimistic-hold`,
`frames-used-region-client`; `test/server/frame-hn` (the runtime without the
entry) registers the loader itself. Harness 500 × seeds 3289 / 91501: SC arm
0 / 0; generic arm (`C1,C9,C19,E` ignored) 0 / 0. Wire unchanged; 0 of 150
artifacts changed. Suites: web client (128 files), server (159), hydrate +
consistency (85), solid (42) green; `test-types` green on web and solid.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ured −237 / −281 / −261 br; deletion −423, glue +186 br vs the ≈ 150-min line; chunk 1,872 min / 805 br); principles §4 row 19 re-dispositioned — the region rename is live (the flight path renames under the call's address), moved into the regions tier, deletes with S7's normalization

Co-authored-by: Cursor <cursoragent@cursor.com>
…nsureStylesheet, applyInlineStyles)

The SC layer audit's one 0-coverage gap (§2.4): no client test reached
frame-client's stylesheet gate or inline-style application. Pinned before
the assets tier moves them (frames savings pass §3 row C5), as the code
behaves today:

- a stylesheet named by a segment's assets record is inserted once content
  and reveal are in the store (the placeholder may still be missing) and the
  segment reveals when the link settles — load or error; a link already in
  the document counts as settled; one link per href; one waiter per frame
  on a pending link however many flushes re-check; a disposed frame's
  settle is inert
- the gate is the assets record, not the reveal chunk's waitForStyles flag
- the link is requested at the reveal record, not at the assets record
- inline styles never gate; they land in the head in entry order, deduped
  by data-asset id (a document-emitted element is adopted), before the
  segment's content, after its stylesheet gate
…/ live 39.92 KB); residue doc §7c landed table; plan §3 row R3; C1's contract text

Caps lowered to measured + 10 B at the 0.01 KB step: frames eager 11.67 →
11.38 KB (34,272 min / 11,367 br), page base 36.50 → 36.29 (113,443 /
36,271), page live 40.14 → 39.92 (125,315 / 39,904); no cap raised.

Residue doc: status line, §2 row 3 / §4 row 4 / §5 row 4 marked landed,
§7c the measured → built table (ceiling −769 / −230; carrier −791 / −229,
−600 / −160 with `#replaceRange` kept; the dead range mode −196 / −67;
one copy −988 / −301; built −978 / −287), what `insert` owns, the two
things the build found (no re-gather on the second claim window; the
transparent binding owner), the C1 (b) re-pin, the `Slot` surface change.
Plan §3 row R3. Consistency contract: C1's second arm is the core's
mismatch rule; the mechanism and table rows name `insert`.
ryansolid and others added 2 commits October 6, 2026 23:32
A slot record's `{$ref}` to a key its response has not delivered yet is
answered by that response's `createJSONDataTable` with a PENDING READ the
table owns: one promise per undelivered key, marked `s = 0` (so the host
tells it from a delivered value that is itself a promise — an async arg
passed whole), carrying `c`, the callbacks the table runs in the `apply` /
`close` that settles it. The key's `data` chunk settles it through the
table's own `apply`, stamped `s = 1` / `v` (the hydration serializer's
marks, which a fill's prop read adopts synchronously); the response's end
closes the table and every read it never answered rejects, stamped
`s = 2` (L1 — a value that never comes is an error where it is read, not
a silence; the error names the key). A version bump drops the superseded
table with its reads unanswered, as before.

The frames host keeps the COUNT and nothing else of the machinery:
`settleArgs` counts a record's pending reads (`record.pending` — a fresh
mount waits for its record, the covering `<Loading>` pends on the landing
alone; a mounted occurrence takes the read as it is) and pushes one
`settle` on each read's `c`, which re-applies the record to the frames
when the last settles and the record is still the store's (a bump, a
re-sent record or a staged refetch's preview make it not). `pendingRef`,
`settleWait`, the per-store `waits` (and `write`'s reset of them), the
data arm's wait lookup and the end arm's rejection loop are gone; the
end arm tells the integration the response ended (`closeData`), and the
shared host closes that response's table — never minted for a response
that carried no data, never a superseded version's. The callbacks, not
the promise's `then`: a delivered promise value is ADOPTED by the read,
so its `then` would fire at the inner value while the mount must run at
the delivery (the DR-2 value-tier and container pins). `instanceof
Promise` before the mark: a decoded value may be a live container whose
property reads throw not-ready.

The transport awaits `prepareData` before a `slot` chunk whose args carry
a `{$ref}`, as it does before a `data` chunk: the pending read is the
table's, so the table must exist when the record resolves. The producer
emits the data ahead of the record that names it, so on its wire the
await is already answered.

Pin added: `consistency/sref-table-pending-read` — the table's read
lifecycle (mark, stamp, callbacks at delivery even for a promise-valued
key, close with and without the response's error, idempotence) and L1
through the production shared host (a never-delivered key rejects at
`complete`; the fill's read throws to the nearest `<Errored>`, naming the
key; the control delivers and nothing rejects).

frames eager 34,272 / 11,367 → 33,767 / 11,193 (−505 min / −174 br);
page base −505 / −200; page live −505 / −157; the lazy decode chunk
+420 / +161; non-SC scenarios 0 / 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
…/ live 39.76 KB); residue doc §3.2 as built + §7d; plan §3 row R4

Caps lowered to measured + 10 B at the 0.01 KB step: frames eager 11.38 →
11.21 KB (33,767 min / 11,193 br), page base 36.29 → 36.09 (112,938 /
36,071), page live 39.92 → 39.76 (124,810 / 39,747); no cap raised. The
lazy `decode.js` chunk of the page scenarios grows +420 / +161 (22,990 /
6,076 → 23,410 / 6,237) — reported, not counted.

Residue doc: status line; §2 row 12 / §4 row 7 / §5 row 5 marked landed;
§3.2 "As built" (the measured table — ceiling −879 / −280, the carrier
+394 / +118 over it, the transport's await 61 / 7, the fan-out fold,
built −505 / −174; what the table owns, what frames kept, the two things
the build found — the settle as the table's callback at the delivery and
the `instanceof` guard — the soundness item the design did not name, why
the glue is +110 and not +40); §7d the landed table, the pins, the public
surface, the running number. Plan §3 row R4.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ume in a lazy chunk; `live` coverage closure

The live wire moves out of the eager frames client into the sixth lazy
tier, `@solidjs/web/frames/wire` (frames/src/wire-tier.ts → wire.js,
1,922 min / 934 br): `connect` — the per-address connection a live()
loop's response runs under (the open-frame count its end is judged by:
a death with frames open vs a completion; the join of a second loop onto
an address already live; `connection.ended` / `cancel`; the SSE reader
selection, proxied so a drained body applies nothing further), `cancel`
(supersession from `bump`), `have` / `haveOf` (the have-list ledger per
mount, RFC 11 §9.5, fed by the client's `#recordHave` at its three
sites) with `encodeHaveList` and the FRAME_HAVE_* literals bundled, and
`resume` (the ordinal + the have-list header). The eager client keeps:
`applyFrames` reading `options[LIVE_WIRE]` as its reader; `handle`'s
live arm awaiting `prepareTier("wire")` before the body is read; `onLive`
→ `prepareTier("wire")`; `resume` and `bump`'s cancel delegating through
`tierLoads.wire?.r`.

Preload-at-call: `live()`'s makeIterable fires the new optional
`responseHandler.onLive()` before the first fetch, so the import races
only the request; a non-live call never loads the tier. Accepted degraded
case: content applied before the chunk was resident kept no ledger, and
`resume` answers nothing while the tier is absent — that first connect
sends neither X-Frame-Have nor Last-Event-ID and gets a full snapshot
(the server never read the ordinal for a frame render); pinned.

Public surface: `Frame.have?()` removed from the Frame interface (the
ledger is the tier's `haveOf(frame)`); `encodeHaveList` moved from
frame-transport to the tier (@internal); `@solidjs/web/frames/wire`
export path added; `responseHandler.onLive?()` and
`ServerComponentHandler.onLive()` added. `ServerFunctionsClientConfig`
gains no key.

Pins: consistency/tier-wire-preload.spec (3) — the hook starts the import
at the call before any fetch, the arm awaits residency, X-Frame-Tiers:
wire joins the same load, a non-live call never loads the tier,
reconnect through the tier with Last-Event-ID + the have-list, the
degraded first connect. frames-live-resume re-pointed to the tier's
haveOf / encodeHaveList; frames-live / frames-live-showing warm the tier.
The coverage closure: server-functions-live-loop.spec (19, client suite)
drives live()'s loop over hand-framed chunk / SSE bodies — first-connect
failure, the fail-fast 4xx set and 408 / 425 / 429 / Retry-After, the
cursor as Last-Event-ID, the online wake, return() mid-connect /
mid-backoff / mid-seed, invoke's signal mid-stream / mid-backoff, no
global EventTarget, live(GET(fn)), the handler's local answers — `live`
per-function branch coverage (audit §2.4 method, client + hydrate) 26/59
sites (91/129 paths) on the base → 57/60 (129/132).

Size (scripts/size, this head vs the base 55eb4c3): frames eager
33,767 / 11,193 → 32,802 / 10,888 (−965 min / −305 br; cap 11.21 → 10.90
KB); page base 112,938 / 36,071 → 112,003 / 35,722 (−349 br; cap 36.09 →
35.74); page live 124,810 / 39,747 → 123,903 / 39,412 (−335 br eager; cap
39.76 → 39.43; the page loads wire.js at its first live() call — total
transfer 40,345 br, +598 against the base); non-SC scenarios 0 / 0; no
cap raised. Measured before writing on edited dist copies through the
harness's bundler: ceiling −1,432 / −472, lean −965 / −310, the carrier
≈ +467 / +162.

Suites: web client 130 / 1,218, hydrate + consistency 89 / 452, server
159 / 1,511, solid 42 / 833, types + test-types clean; harness 500 ×
{3289, 91501} SC arm 0, generic arm (CONSISTENCY_IGNORE=C1,C9,C19,E) 0.
Docs: savings plan §1 wire row built, §3 row C2 landed, §4 chunk table;
residue doc status + §7e.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid ryansolid changed the title frames: tier mechanism + traces, regions, assets tiers (Phase B, C3–C5) frames: tiers, binding-slot tier, residue cuts, live wire tier (Phase B, C2–C6, residue 1–4) Oct 7, 2026
ryansolid and others added 2 commits October 7, 2026 08:47
Ruling (2026-10-07): `dynamic` stays the combo (component or tag name);
`dynamicComponent` is the component-only sibling; the server-component
docs mount with `dynamicComponent(() => getStory())`. Option (a) of
documentation/plans/frames-b3-sync.md §5.

`dynamic` is factored into a shared core and the tag arm: `dynamicCore(
source, options, tagArm?)` is the whole implementation — the hoisted lazy
factory memo (FLIGHT box, never re-runs for a reset), the per-instance
value memo (async-memo adoption under hydration, the `latest` token, the
`untrack(cached)` warm-up), the three-memo owner shape matching
index.server.ts, the render memo's per-site `createSignal(address, {
ownedWrite })` + `sites` + `untrack(() => binding.component(props,
address))`, kept-resolution delivery (`resolveBinding` / `sameInstance` /
`deliveredAddress`), `static: true`. The only thing the arm decides is
what a string value renders as: `dynamic` passes `staticElement`,
`dynamicComponent` passes nothing. The core never names the element
runtime, so a bundle whose only consumer is `dynamicComponent` sheds
`staticElement`, `createElement`, `spread`, the prop-collection helpers,
the SVG/MathML tables and `getNextElement`.

Server twin in index.server.ts, same factoring (the arm is one
`ssrElement()`), identical hydration-id consumption: the parity spec
renders one page — a client component and a non-live server component
reference — through both entry points and asserts the documents are
byte-identical (same `_hk` keys, same frame markup, same `_fr` record),
inline and streamed; four hydration arms replay the `dynamicComponent`
document through each client entry point (adopted, no request, same
nodes, both slots live).

Type: `dynamicComponent<C extends Component<any>>(source: () => C |
Promise<C> | AsyncIterable<C> | null | undefined | false, options?)` —
no `string` in the union. A server-component reference has no type-level
brand: it is typed as the server function's declared answer, a
`Component<P>` (`LiveSource<Component<P>>` for `live`, an intersection
that is still one), so `Component<any>` covers it. The type test pins a
tag-name source as a compile error here and fine on `dynamic`.

Pins: A7 reset/re-ask (9 arms), C16, C17 parametrized over both entry
points; every other `dynamic` pin unchanged (shared code).

Size (built, vs base d9395e3): page base −7,417 min / −2,152 br →
33,570; page live −7,418 / −2,171 → 37,241; every other scenario 0 / 0.
Floors ratcheted to 33.58 / 37.26 KB (measured + 10 B at the 0.01 KB
step). The edited-dist model said −2,158 / −2,237 before the source was
written.

Fixtures (sc-base-app, sc-live-app) and the server-component docs' mount
examples switched to `dynamicComponent`; `dynamic`'s docs carry a
one-line pointer.

Public API: new export `dynamicComponent` on `@solidjs/web` (client and
server entries). `dynamic` unchanged.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Two scenarios on the gate: the base and live server-component pages as
compiled JSX (fixtures/compiled/sc-base.jsx, sc-live.jsx, the shared
sc-shell.jsx and a lazy sc-comments.jsx), compiled hydratable by the
measured checkout's @solidjs/compiler like the compiled app scenarios.
The hand-written sc-base-app.js / sc-live-app.js keep the runtime alive as
values and never compile a template, so the attribute runtime a compiled
template imports (className, style, setAttribute, addEvent, delegateEvents,
spread/assign) is reached on those pages only through the bind tier's
`assign`, and a cut that drops it there drops nothing from a real page.
The compiled pages mount the same server component through
dynamicComponent() over the same server-function reference (live/GET,
action, isPending/latest on the live one) inside a shell with the templates
a real page has: links with href / dynamic class / dynamic style / a handler
passed through as a prop, one element spread, For, Show, Errored/Loading, a
lazy child. The hand-written pages stay (the frames-only floor).

Caps inline at local measured + 10 B at the 0.01 KB step (35.93 KB /
112,967 and 41.42 KB / 126,294), to be confirmed against CI.

bundle.mjs: the eager graph is the entry plus every chunk it imports
statically. With isPending on the page, the frames tiers and the lazy
comments route all reaching the core, Rolldown hoists the shared runtime
(70,576 B minified) into a web.js the compiled live entry imports at its
top; it ships eagerly, so it is counted (each file brotli'd on its own) and
named in the output. No other scenario splits; every existing number is
unchanged.

Tooling only; nothing under packages/*/src. No changeset.
Maintainer's ruling (2026-10-07): most server-component apps do not have
client element spreads, so the compiled baseline must not carry one. The
shell's `<input {...props}>` is gone; the search input binds `value` and
`onInput` directly. The ordinary dynamic attribute forms stay (dynamic
class/style values, href from a prop, the handler passed through as a
prop, For/Show, Errored/Loading, the lazy route).

`spread` is absent from both pages' rendered web.js. Still present:
`assign`/`assignProp` (through the bind tier's `assign` edge, exactly as on
the hand-written pages) and 1,071 B of signals' store/utils.js — the
leaf/merge key walkers `readShallow` reaches through `sourceKeys(value,
SOURCE_PROXY)`; `readShallow` is what the compiler wraps dynamic class/style
values in. `merge`/`omit` themselves are absent.

Re-measured: base 108,634 / 34,869 (was 112,967 / 35,914), live 121,963 /
40,266 (was 126,294 / 41,406; still two eager chunks). Caps re-set at local
measured + 10 B at the 0.01 KB step: 34.88 KB / 108,634 and 40.28 KB /
121,963 — CI to confirm, lower only. On `next` @ 11e9fb6 the stack is
−11,031 br (base) / −9,455 br (live) on these pages; `dynamicComponent` vs
`dynamic` on the spread-free compiled base is −1,681 br / −5,812 min.

Tooling only; no changeset.
ryansolid and others added 2 commits October 7, 2026 11:19
Conflicts:
- packages/web/frames/src/client.ts: the slot-arg memo keeps both sides —
  this branch's equals: sameArg and #3874's dev-only _supersedes:
  IS_DEV ? { transparent, equals: sameArg, _supersedes: true }
         : { transparent, equals: sameArg }.
- scripts/size/scenarios.js, floor-caps.json: next's caps and ledger taken;
  this branch's caps are re-derived from fresh measurements in a follow-up
  commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
CI Size run 37666601776 on the merge measured the three hydrating
scenarios over next's caps (+168 / +174 / +174 B minified: A2b's
sharedConfig.hydrateWindow and A5′'s _$HY.fa in solid-js). Caps set at
CI brotli + 10 B rounded up to 0.01 KB, recorded minified from CI:

- app: hydrating (no stores) 17.73 -> 17.85 KB (floor; 52,841 B min)
- app: hydrating + every store primitive family 29.02 -> 29.09 KB (92,114 B)
- app: compiled hydrating 31.06 -> 31.09 KB (99,477 B)

Under the PR body's Size-Exception.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid changed the base branch from wip/frames-pass-integration-2 to next October 7, 2026 18:53
ryansolid and others added 2 commits October 7, 2026 11:54
#3849 landed on next as the squash 3c1d512 (same tree as 9914189);
merging its head first brings next @ 3c7631a in with #3849's recorded
resolutions, so the following merge of next is a no-op on content.

Conflicts:
- packages/web/frames/src/client.ts: the slot-arg memo comment keeps this
  branch's container-probe text and #3874's SUPERSEDES paragraph; the
  options are next's (IS_DEV ? { transparent, equals: sameArg,
  _supersedes: true } : { transparent, equals: sameArg }).
- scripts/size/scenarios.js, floor-caps.json: next's ledger notes then this
  branch's; caps provisionally this branch's, re-derived from measurement in
  a follow-up commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
next's tree equals 9914189 (#3849's head, squashed as 3c1d512), which
the previous merge brought in; every conflict and auto-merge resolves to
this branch's side. Notably server-functions/src/client.ts keeps this
branch's provideRPC without #3849's createServerReference (residue step 1
replaced the RPC-seam re-ask with ctx.retry).

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Oct 7, 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.38 KB 0 B 0 B −1 B 7.40 KB ✅
signals: + createStore 14.60 KB 0 B 0 B −2 B 14.62 KB ✅
signals: + isPending/latest 9.57 KB 0 B 0 B +58 B 9.58 KB ✅
app: render + one signal (the simple-app floor) 9.88 KB 0 B 0 B −1 B 9.88 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.84 KB 0 B 0 B 0 B 17.85 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.08 KB 0 B 0 B 0 B 29.09 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.86 KB 0 B 0 B −1 B 12.88 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.46 KB 0 B 0 B +47 B 14.46 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.65 KB 0 B 0 B +32 B 28.70 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.07 KB 0 B 0 B −1 B 10.07 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.19 KB 0 B 0 B −2 B 25.22 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.08 KB 0 B 0 B 0 B 31.09 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.11 KB −2900 B (−20.7%) −10178 B 0 B 11.13 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.91 KB — — 0 B 33.92 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.21 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.51 KB — — 0 B 37.53 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.21 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.08 KB — — 0 B 35.09 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.18 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.59 KB — — 0 B 40.61 KB ✅ eager (counted): web.js 21.97 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.20 KB, wire.js 0.93 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB −9 B (−0.0%) +4 B +4 B 20.42 KB ✅

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).

CI Size run 37671002618 on the merge, caps at CI brotli + 10 B rounded up
to 0.01 KB, recorded minified from CI. Against next's caps every one is
lower:

- frames: eager client 14.02 -> 11.13 KB (33,418 B min)
- page: base SC (floor) 45.14 -> 33.92 KB (105,252 B)
- page: live SC (floor) 48.91 -> 37.53 KB (117,301 B)

The two compiled-page scenarios (new here, not on next) move from this
branch's local pre-merge caps to CI's bytes on the merge, which carry
next's bytes since the branch's base:

- page: compiled base SC 34.88 -> 35.09 KB (109,300 B)
- page: compiled live SC 40.28 -> 40.61 KB (122,779 B)

Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

coveralls commented Oct 7, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37671603021

Coverage increased (+0.06%) to 76.43%

Details

  • Coverage increased (+0.06%) from the base build.
  • Patch coverage: 2 uncovered changes across 1 file (59 of 61 lines covered, 96.72%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/solid/src/client/container-trace.ts 60 58 96.67%
Total (2 files) 61 59 96.72%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1227
Covered Lines: 996
Line Coverage: 81.17%
Relevant Branches: 958
Covered Branches: 674
Branch Coverage: 70.35%
Branches in Coverage %: Yes
Coverage Strength: 28.53 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 188 untouched benchmarks


Comparing wip/frames-tiers-integration (70fe0c7) with next (3c1d512)

Open in CodSpeed

@ryansolid
ryansolid marked this pull request as ready for review October 7, 2026 19:34
@ryansolid
ryansolid merged commit 7233451 into next Oct 7, 2026
8 checks passed
ryansolid added a commit that referenced this pull request Oct 7, 2026
Conflict: scripts/size/README.md's scenario list keeps both — next's two
compiled server-component pages (#3875 via #3860) and this branch's two
router pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

2 participants