Skip to content

frames: Phase B — server-announced tier mechanism (seam only, no tier cut) - #3854

Closed
ryansolid wants to merge 2 commits into
wip/frames-pass-integration-2from
feat/frames-tier-mechanism
Closed

ryansolid wants to merge 2 commits into
wip/frames-pass-integration-2from
feat/frames-tier-mechanism

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Summary

Phase B of the frames savings pass (documentation/plans/frames-savings-pass.md §2, §3 row B): the server-announced tier mechanism, as a seam only — no tier is cut, every capability stays eager. Base: #3849 (wip/frames-pass-integration-2 @ 0aab93230); rebase onto next when it squash-merges.

The server learns at render time which frames-client capabilities a response or 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. The announcement is a warm start, never a dependency: a readiness check that finds a tier absent starts the load itself and holds, so an un-announced response converges to the same DOM.

  • Server mint sites (sink.needs(tier)): a binding-slot position read → bind; a nested server-content region → regions; an assets chunk → assets; a traced container in a slot arg → trace; a live response → wire. Holes: none (decision 1, 8.0 — holes eager).
  • Stream face: X-Frame-Tiers: bind,regions set by serverComponentResponse from what the sync render pass minted (the ReadableStream start runs synchronously before the Response is built, so the head carries them); a tier first needed after the head rides in-band as tiers on the next chunk out.
  • Document face: _$HY.r["sc:tiers"] = ["bind", …], re-written cumulative at each new mint (every pre-shell write lands in the shell's data script, the last assignment wins — a later component's tier is not lost), plus <link rel="modulepreload"> per tier whose chunk URL the integration gave.
  • Client: installServerComponents(host?, { tiers }) takes the name → 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 exactly like a recordless called occurrence. Adopt path: the frame's hold registers (hydration-done waits, the delegated-event replay window stays open). Stream path: the record stays pending in the store until the install's flush. The trace predicate (the args marker scan) lands with C3, where S1's scan is.

Measured-before-written

Edited dist copies through scripts/size's own bundler (re-attribution §7 method; the harness's Rolldown, min / br), then the real build. Base = 0aab93230; next = 9d89df731.

variant (edited dist) frames eager Δ min / br page base page live non-SC scenarios
first honest cut (Map/Set runtime, installTier fn, exports) +745 / +233 +684 / +171 +684 / +231 0
… no codec await +717 / +217 0
… no in-band, no codec await +689 / +209 0
… no header read +638 / +188 0
lean (held-set waiters) +611 / +178 +599 / +152 +599 / +249 0
lean (live-frame registry) +576 / +170 +564 / +143 +564 / +219 0
golfed: object load table, no promise for a loaderless name, folded codec await, prepareTier unexported +476 / +144 +481 / +187 +481 / +178 0
real build (this PR) +473 / +145 +478 / +177 +478 / +158 0

Verdict: ≤ +150 br on frames eager; the plan's ≈ +100 estimate came in at ×1.45 after golfing (×2.3 before — the maintainer's ×2.5 rule held for the first cut). Page brotli deltas are layout noise around the same +478 min (the gate's minified rule applies). Server: frames server dist +793 min (est. ≈ 300–450), @solidjs/web's own server entry 0.

Public API changes (all @experimental, @solidjs/web)

  • installServerComponents(host?, options?) — new second parameter InstallOptions { tiers?: Record<string, () => Promise<{ install?(): void }>> }: the client's tier → loader map (new type exported from @solidjs/web/frames).
  • frameTransformDirectResult(value, { id, args, tierUrls? }) — new optional tierUrls: Record<tier, url>; given, the document emits a modulepreload per announced tier; absent, the record alone announces.
  • FRAME_TIERS_HEADER ("X-Frame-Tiers") — new export from @solidjs/web/frames/server (beside FRAME_STREAM_HEADER). Deliberately not re-exported from the client entry (its one use inlines).
  • FrameChunk gains an optional tiers?: string[] on every member (TierAnnouncement, new exported interface).
  • createFrameSink's returned sink (@internal) gains needs(tier) and announce().
  • Not exported (deliberate narrowing from the plan's sketch, to flag): prepareTier(name) is exported from the internal runtime module (frames/src/frame-client.ts) for the frames client and its tests, not from the public @solidjs/web/frames entry — 9 B br; worth granting when a tier exists for an integration to warm. installTier is the load's own continuation, not a function. The test-only tier module is wired through installServerComponents({ tiers }).
  • FrameHostOptions.prepareData is unchanged (S1's prepareData / prepareArgs never shipped; nothing to replace).
  • FrameOptions.hold's doc now names the tier wait as one of the reasons it fires; no signature change.

Wire (additive — decision 3, RFC 11 addendum)

  • Response header X-Frame-Tiers: <name>[,<name>…] on a frame-stream response; omitted when the sync pass minted nothing.
  • FrameChunk.tiers?: string[] — the in-band form, on whichever chunk leaves next after a mint the head could not carry; a data chunk carrying it is awaited on those tiers before decoding. Absent on every chunk of a response that minted nothing after its head. An old client ignores it (chunkToRecords reads named fields only).
  • Hydration record _$HY.r["sc:tiers"]: string[], cumulative, re-written at each new mint; <link rel="modulepreload" href> per tier with a known URL.
  • Nothing else changes: chunk kinds, store keys, DOM markers, _$SC, the registered symbols.

Pins

  • test/server/tier-announce.spec.tsx (14): stream face — nothing minted → no header, no tiers; bind / regions / wire on the head; several tiers comma-joined; a late segment's assets + bind in-band on the next chunk (header absent); the trace's announcement rides its own data chunk (initial node), once; renderServerComponent consumed without a Response announces in-band only. Document face — nothing minted → no record, no link; bind → record + modulepreload in the head with tierUrls; no tierUrls → record only; regions + trace across two components → cumulative (["regions"] then ["regions","trace"]); inline live → wire; renderToString announces nothing.
  • test/consistency/tier-prepare.spec.tsx (8): the 3.1 hold — two adopted boundaries with _s: positions and a deferred bind loader, un-announced: the sync starts the load (once), positions stay at the server's values, isHydrationInProgress() true and onHydrationEnd not fired; on install both mount (one install() call, one flush per frame), hydration-done follows the mounts, no unclaimed warnings. The stream path — X-Frame-Tiers: regions starts the load before the body is read; the record stays pending in the store (shell applied, occurrence unmounted) until the install's flush mounts it with its region (buffer-and-retry). prepareTier idempotence (one import, same promise, r stamp; loaderless name resolves at once; a module without install). The record read at install (named tiers only; no record → no load). In-band: chunk.tiers starts the load; a data chunk awaits it (nothing decoded, the drain waits) and decodes on install; a non-data chunk's tiers does not wait and a data chunk without tiers decodes at once.
  • test/runtime/preload-links.spec.js: the late assets chunk now carries tiers: ["assets"].
  • Artifacts: 5 of 150 re-recorded — frame-live-document-{loaded,streamed,switched}.json (announce wire), welcome-status-{loaded,streamed}.json (announce trace). None that mints nothing changed.

Size

scripts/size on this head (min / br), vs the base 0aab93230 and vs next 9d89df731 (both built fresh, measured with this head's harness):

scenario head vs base vs next cap
frames: eager client consumer 43,452 / 13,949 +473 / +145 +38 / +162 13.79 KB — over by 159 B
page: base server components 146,097 / 45,210 +478 / +177 +340 / +328 44.89 KB — over by 320 B
page: live server components 158,060 / 48,873 +478 / +158 +340 / +278 48.60 KB — over by 273 B
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)
every other scenario 0 / 0 0 / 0

No cap was raised. The frames / page caps were already over at the Phase A gate (held by the gate's minified rule: 43,414 recorded); this PR adds +473 min on frames, beyond the 20 B minified allowance — the size gate will be red on frames and both pages by this step's own bytes, as the plan's row B budgets (≈ +100 br gross). The cap decision is the maintainer's (Size-Exception at the integration PR, as Phase A's were).

Frames server dist +1,724 raw / +793 min / +254 br; @solidjs/web dist/server.js 0.

Decision for the maintainer — tier chunk URLs (recorded under §6 decision 3)

The server does not know the client bundle's chunk URLs. Built (i) + (ii) together, as the task recommended:

  • (i) the integration passes URLs: frameTransformDirectResult(value, { id, args, tierUrls }) (SolidStart from its manifest). The document emits a modulepreload per announced tier only when its URL is given, through registerAsset("module", url) — it joins the shell head before the flush, writes into the stream after, and dedupes with manifest links.
  • (ii) the client resolves tiers itself: installServerComponents(host, { tiers: { bind: () => import("…") } }) (the built-in table once a tier's chunk exists), and starts each import from the sc:tiers record at install — later than the preload's t = 0, earlier than any adopt-time sync.

Names ride the wire always; links only with URLs; the import map on the client always. Not built: resolving on the server through ctx.resolveAssets with a frames-owned module key — the manifest's key shape is the integration's (Vite's relative source path; SolidStart's bridge), so guessing it from @solidjs/web would couple the frames server to one bundler. Accept (i)+(ii), or name a third?

Also to confirm: prepareTier unexported from the public client entry until a tier exists to warm (9 B br); 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).

Not done

  • No tier cut (by design — B is the seam). No loader is configured by default; every name is resident. The test-only tier module is the loaders the specs pass through installServerComponents({ tiers }).
  • The trace tier's held-set predicate (the args marker scan) — C3's, with S1's needsContainerTraceMaterializer; B's codec-face await is the chunk.tiers form, so C3 needs no node scan on the stream face.
  • #segmentReady's "assets tier absent" readiness term and the wire tier's onLive preload-at-call hook — C5's and C2's.
  • The sc:tiers record on a sync renderToString document; the record is not re-read after install (a post-install write is covered by the hold).
  • Harness: SC arm 500 cases seeds 3289 / 91501 0 / 0; generic arm with CONSISTENCY_IGNORE=C1,C9,C19,E 0 / 0. Suites: web client (126 files), server (159), hydrate (78) green; test-types green.

ryansolid and others added 2 commits October 6, 2026 16:04
…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>
@changeset-bot

changeset-bot Bot commented Oct 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e05ba02

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

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

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

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

@ryansolid

Copy link
Copy Markdown
Member Author

Folded into #3860 (wip/frames-tiers-integration, the tiers integration PR — Phase B + C3 + C4 + C5 together) per the maintainer. The two commits here are the first on the integration branch (fast-forwarded on the Phase A base 0aab93230); the three tiers land on the seam above them. The branch is kept.

On the merged tree: tier-announce (14) and tier-prepare (8) are green; the 5 re-recorded artifacts are the only artifact changes vs the base (C3 / C4 / C5 / the integration: 0). The mechanism as it ships after three tiers: tierLoads[name].r is the installed module (truthy = resident), TierModule is { install?(): void; [applier: string]: unknown }, the built-in loader table carries trace / regions / assets. The tier-URL sub-decision ((i)+(ii)), prepareTier unexported, the sync renderToString announcing nothing, and the "no loader ⇒ resident" rule (unsafe for a module-backed tier when the runtime is used without the entry — recommend "fails loudly in dev") are #3860's decisions 1–4. Integrated: frames eager 13,083 br (−704 vs next), page base 37,772 (−7,110), page live 41,427 (−7,168).

— Claude via Cursor

@ryansolid ryansolid closed this Oct 7, 2026
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.

1 participant