Repository navigation
Conversation
…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 detectedLatest commit: e05ba02 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
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 ontonextwhen 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.
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; aliveresponse →wire. Holes: none (decision 1, 8.0 — holes eager).X-Frame-Tiers: bind,regionsset byserverComponentResponsefrom what the sync render pass minted (theReadableStreamstartruns synchronously before theResponseis built, so the head carries them); a tier first needed after the head rides in-band astierson the next chunk out._$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.installServerComponents(host?, { tiers })takes the name → loader map, reads the record and starts each load;applyFrameResponsereads the header before the body andchunk.tiersper chunk; adatachunk awaits the tiers it names before decoding.prepareTieris idempotent per name; a name with no loader is resident (every name today). The install calls the module'sinstall()and flushes every live frame.bind, a region-carrying record'sregions— 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.Map/Setruntime,installTierfn, exports)prepareTierunexportedVerdict: ≤ +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 parameterInstallOptions { 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 optionaltierUrls: Record<tier, url>; given, the document emits amodulepreloadper announced tier; absent, the record alone announces.FRAME_TIERS_HEADER("X-Frame-Tiers") — new export from@solidjs/web/frames/server(besideFRAME_STREAM_HEADER). Deliberately not re-exported from the client entry (its one use inlines).FrameChunkgains an optionaltiers?: string[]on every member (TierAnnouncement, new exported interface).createFrameSink's returned sink (@internal) gainsneeds(tier)andannounce().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/framesentry — 9 B br; worth granting when a tier exists for an integration to warm.installTieris the load's own continuation, not a function. The test-only tier module is wired throughinstallServerComponents({ tiers }).FrameHostOptions.prepareDatais unchanged (S1'sprepareData/prepareArgsnever 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)
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; adatachunk 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 (chunkToRecordsreads named fields only)._$HY.r["sc:tiers"]: string[], cumulative, re-written at each new mint;<link rel="modulepreload" href>per tier with a known URL._$SC, the registered symbols.Pins
test/server/tier-announce.spec.tsx(14): stream face — nothing minted → no header, notiers;bind/regions/wireon the head; several tiers comma-joined; a late segment'sassets+bindin-band on the next chunk (header absent); the trace's announcement rides its owndatachunk (initial node), once;renderServerComponentconsumed without a Response announces in-band only. Document face — nothing minted → no record, no link;bind→ record +modulepreloadin the head withtierUrls; notierUrls→ record only;regions+traceacross two components → cumulative (["regions"]then["regions","trace"]); inlinelive→wire;renderToStringannounces nothing.test/consistency/tier-prepare.spec.tsx(8): the 3.1 hold — two adopted boundaries with_s:positions and a deferredbindloader, un-announced: the sync starts the load (once), positions stay at the server's values,isHydrationInProgress()true andonHydrationEndnot fired; on install both mount (oneinstall()call, one flush per frame), hydration-done follows the mounts, no unclaimed warnings. The stream path —X-Frame-Tiers: regionsstarts 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).prepareTieridempotence (one import, same promise,rstamp; loaderless name resolves at once; a module withoutinstall). The record read at install (named tiers only; no record → no load). In-band:chunk.tiersstarts the load; adatachunk awaits it (nothing decoded, the drain waits) and decodes on install; a non-data chunk'stiersdoes not wait and adatachunk withouttiersdecodes at once.test/runtime/preload-links.spec.js: the late assets chunk now carriestiers: ["assets"].frame-live-document-{loaded,streamed,switched}.json(announcewire),welcome-status-{loaded,streamed}.json(announcetrace). None that mints nothing changed.Size
scripts/sizeon this head (min / br), vs the base0aab93230and vsnext9d89df731(both built fresh, measured with this head's harness):nextNo 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/webdist/server.js0.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:
frameTransformDirectResult(value, { id, args, tierUrls })(SolidStart from its manifest). The document emits amodulepreloadper announced tier only when its URL is given, throughregisterAsset("module", url)— it joins the shell head before the flush, writes into the stream after, and dedupes with manifest links.installServerComponents(host, { tiers: { bind: () => import("…") } })(the built-in table once a tier's chunk exists), and starts each import from thesc:tiersrecord 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.resolveAssetswith 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/webwould couple the frames server to one bundler. Accept (i)+(ii), or name a third?Also to confirm:
prepareTierunexported from the public client entry until a tier exists to warm (9 B br); a syncrenderToStringdocument announces nothing (no shared render slot to dedupe on — addinglive: {}to the sync context is a core change on therenderToStringfloor; the client detects).Not done
installServerComponents({ tiers }).needsContainerTraceMaterializer; B's codec-face await is thechunk.tiersform, so C3 needs no node scan on the stream face.#segmentReady's "assets tier absent" readiness term and the wire tier'sonLivepreload-at-call hook — C5's and C2's.sc:tiersrecord on a syncrenderToStringdocument; the record is not re-read after install (a post-install write is covered by the hold).CONSISTENCY_IGNORE=C1,C9,C19,E0 / 0. Suites: web client (126 files), server (159), hydrate (78) green;test-typesgreen.