diff --git a/.changeset/frames-assets-tier.md b/.changeset/frames-assets-tier.md new file mode 100644 index 000000000..d6996c476 --- /dev/null +++ b/.changeset/frames-assets-tier.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: the assets tier (frames savings pass C5) — the head mirror a segment's `assets` record drives (the stylesheet gate, module and typed preloads, inline styles) leaves the eager frames client for the lazy chunk `@solidjs/web/frames/assets`, loaded through the tier mechanism. A segment whose assets record carries stylesheets or inline styles is not ready while the tier is not resident: the server's fallback stays on screen and the reveal happens at max(tier load, stylesheet load) — no segment reveals unstyled. Once the tier is resident only stylesheets gate; inline styles, modules and preloads apply at the record's arrival, and a segment with none of these never waits on the tier. New export path `@solidjs/web/frames/assets` (`@experimental`); `InstallOptions.tiers` loaders resolve `TierModule` (a module with or without `install()`). diff --git a/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index eed06d25f..9b9818e54 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -92,14 +92,14 @@ participant" means the hold registers as a pending boundary through event-replay window stays open); "runtime wait" means a post-first-flush buffer with no hydration involvement. -| tier | the race (what arrives before what) | symptom if lost | bound | class | pin | -| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **holes** (E.a1) | a `hole` / `attr` chunk — or an op on the document's `sc:live` stream — arrives before `tier-holes.js` has loaded. Always after the frame's first flush (holes are later yields; the markup that carries the `lh:` markers came first). | the frame freezes at its first yield (wrong content, silent). | **buffer, no hold.** Stream face: `chunkToRecords` writes `hole:` / `attr:` into the store regardless; `#flush`'s hole pass runs only when the tier is resident, and records it cannot apply **stay pending** (store-model: any later flush retries). The tier's load triggers one `flush()` on every registered frame. Document face: the `sc:live` `ReadableStream` buffers until `pumpLiveChannel` runs (after the load); the op log replays to adopters. The announcement (§2) starts the fetch at the header / at t = 0, so in practice the chunk is resident before the first later yield. | runtime wait | **existing:** C13 control arm (one hole alone applies), C18's pump-catch-up arm (ops before the adopter replay). **New:** `tier-holes-buffer.spec` — (a) stream: a `hole` chunk delivered before the tier resolves applies after it, nothing applied twice; (b) document: ops written to `sc:live` before the pump starts land after it; (c) a frame disposed during the wait applies nothing. The 3.1 question does not arise (post-first-flush). | -| **live wire** (E.a2) | a `live()` loop's first response (SSE framing, `LIVE_WIRE` in the call context) reaches `handle` before `tier-wire.js` has loaded. | the response is read as a plain frame stream: no join/hold, no reconnect with a have-list; a reconnect lands as a full snapshot (today's post-adoption connect already does). Degraded, not wrong. | **preload-at-call.** `live(fn)`'s client decorator fires a hook the frames client installs (`configureServerFunctionsClient({ onLive })` — frames already imports `configureServerFunctionsClient`, so the dependency direction holds) that calls `prepareTier("wire")` **before** the first `fetch`; `handle`'s `LIVE_WIRE` arm awaits the same promise before reading the body, so a live connection without the chunk is impossible by construction. No buffer, no hold. | runtime wait (none in practice — the import and the request race only against each other, and the dispatch awaits the import) | **existing:** the live suite (`server-functions-live-*`, `frames-live-*`; `live` is 22/61 branches covered — the audit's hot spot, §6.4). **New:** `tier-wire-preload.spec` — the first live response is held until the tier resolves; a response that completes before the tier never reaches the plain reader; `resume` sends the have-list on the first reconnect. | -| **traces** (S1 re-based) | an adopt-time record whose literal args carry a `{ $tr, $ta }` marker — or a `data` chunk whose node tree holds the trace plugin's node — before `container-trace.js` (the materializer + the store engine) has loaded. | the fill runs with an inert marker object (`TypeError` or wrong content); on the codec face the chunk decodes a marker instead of a container. | **announce + hold.** Document: the server knows it serialized a trace (the serializer stamps it) → `modulepreload` at t = 0 + the `sc:tiers` record; the occurrence is **held** (S1's `#argsUnprepared` → the general held set) with its server interior on screen, the mount being the hydration attach; it mounts with the record it was held on (S1 commit 3). Codec: `prepareData` scans the node tree and awaits the tier before the chunk decodes (S1 as built; the header flag makes the scan a confirmation). The claim reads the snapshot; the backlog lands after done (3.6 (iii), S1 commit 3). | **3.1 participant** (document face: the hold is an adopt-time occurrence deferred — registers through `initBoundaryResume`, A2) | **existing (S1):** `frames-container-lazy-codec` (loads before decode / loads nothing), `frames-container-lazy-document` (held with the interior on screen, then mounted), `hydration/welcome-status-lazy` (claims in place, no key misses), `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}`. **Re-pin:** `container-trace-hold-hydration-end` under 3.1 — _hydration waits for the load; the mount claims before done_ (rulings 3.1 "Consequences"). | -| **regions** | a `slot:` record naming a `{$frame}` ref — or a `data-fid` region element inside adopted content — before `tier-regions.js` has loaded. | the fill receives the raw `{$frame}` ref (wrong content); an occluded region cannot mount from the store. | **announce + hold.** The sink knows at render it passed server content as a prop (`{$frame}` minted) → announced. Adopt path: the occurrence is held in the same set as traces (its interior stays on screen; `#resolveArgs` runs after the load). Stream path: the record stays pending in the store until the load's flush (buffer). | **3.1 participant** on the adopt path; runtime wait on the stream path | **existing:** `frames-regions-*`, the lifecycle matrix's region rows. **New:** `tier-regions-hold.spec` — adopt-time `{$frame}` occurrence held, interior intact, mounts after the load with the held record; a stream record with a region ref applies after the load; hydration-done waits (3.1). | -| **assets** | a `reveal` for a segment whose `seg::assets` record names stylesheets (or `waitForStyles`) before `tier-assets.js` has loaded. Stream face only — the document face keeps the core's `$dfs`. | **without a bound: FOUC** — the segment reveals unstyled, then the sheet lands. With the bound: the fallback stays on screen longer. | **announce + reveal-readiness term.** The sink knows at render that a fragment is style-gated → announced in the header; `#segmentReady` gains one term: "the segment's `assets` record names styles **and** the tier is not resident → not ready". The fallback — the server's `` outcome — stays; the reveal happens at max(tier load, stylesheet load), and the stylesheet's own load dominates on every network. A segment without stylesheets never waits. Modules / preloads / inline styles are not reveal-gating today and stay so. | runtime wait (the segment swap is post-first-flush) | **existing:** `frames-assets-*` (`waitForStyles`, the style gate; note `ensureStylesheet` / `applyInlineStyles` have **0 client tests** — the audit's gap, to be pinned in C5 regardless of tiering). **New:** `tier-assets-ready.spec` — a `reveal` for a style-gated segment before the tier resolves keeps the fallback; reveals once the tier and the sheet are both in; an unstyled segment in the same stream reveals immediately. | -| **binding slots** | `_s:` markers (attribute / class / style / text positions, `_s:on:*` handlers, `_s:ref`) in markup — document or stream — before `tier-bind.js` has loaded. | positions sit at the server's values (inert attributes, classes, text); **handlers are not attached — a click in the window is lost** unless the hydration event-replay window is still open. | **announce + hold.** The sink knows at render it emitted `_s:` (binding positions are minted by the slot props' proxy) → announced at t = 0 / in the header. Adopt path: an occurrence with `ctx.positions` is **held** (same set), which keeps `_$HY.done` false and the delegated-event replay buffer open until attach — the click is replayed. Stream path: the occurrence stays pending in the store until the load's flush. The audit's §7 Q5 (one chunk load before a post-load stream's first binding) is accepted for B.3 and applies here. | **3.1 participant** on the adopt path; runtime wait on the stream path | **existing:** `frames-binding-slot-*`, `slot-positions-*`, the #3704 / #3714 suites. **New:** `tier-bind-hold.spec` — held occurrence's positions untouched until the load; attach after; **a click dispatched during the hold replays after attach**; hydration-done waits (3.1); a stream occurrence binds after the load. | +| tier | the race (what arrives before what) | symptom if lost | bound | class | pin | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **holes** (E.a1) | a `hole` / `attr` chunk — or an op on the document's `sc:live` stream — arrives before `tier-holes.js` has loaded. Always after the frame's first flush (holes are later yields; the markup that carries the `lh:` markers came first). | the frame freezes at its first yield (wrong content, silent). | **buffer, no hold.** Stream face: `chunkToRecords` writes `hole:` / `attr:` into the store regardless; `#flush`'s hole pass runs only when the tier is resident, and records it cannot apply **stay pending** (store-model: any later flush retries). The tier's load triggers one `flush()` on every registered frame. Document face: the `sc:live` `ReadableStream` buffers until `pumpLiveChannel` runs (after the load); the op log replays to adopters. The announcement (§2) starts the fetch at the header / at t = 0, so in practice the chunk is resident before the first later yield. | runtime wait | **existing:** C13 control arm (one hole alone applies), C18's pump-catch-up arm (ops before the adopter replay). **New:** `tier-holes-buffer.spec` — (a) stream: a `hole` chunk delivered before the tier resolves applies after it, nothing applied twice; (b) document: ops written to `sc:live` before the pump starts land after it; (c) a frame disposed during the wait applies nothing. The 3.1 question does not arise (post-first-flush). | +| **live wire** (E.a2) | a `live()` loop's first response (SSE framing, `LIVE_WIRE` in the call context) reaches `handle` before `tier-wire.js` has loaded. | the response is read as a plain frame stream: no join/hold, no reconnect with a have-list; a reconnect lands as a full snapshot (today's post-adoption connect already does). Degraded, not wrong. | **preload-at-call.** `live(fn)`'s client decorator fires a hook the frames client installs (`configureServerFunctionsClient({ onLive })` — frames already imports `configureServerFunctionsClient`, so the dependency direction holds) that calls `prepareTier("wire")` **before** the first `fetch`; `handle`'s `LIVE_WIRE` arm awaits the same promise before reading the body, so a live connection without the chunk is impossible by construction. No buffer, no hold. | runtime wait (none in practice — the import and the request race only against each other, and the dispatch awaits the import) | **existing:** the live suite (`server-functions-live-*`, `frames-live-*`; `live` is 22/61 branches covered — the audit's hot spot, §6.4). **New:** `tier-wire-preload.spec` — the first live response is held until the tier resolves; a response that completes before the tier never reaches the plain reader; `resume` sends the have-list on the first reconnect. | +| **traces** (S1 re-based) | an adopt-time record whose literal args carry a `{ $tr, $ta }` marker — or a `data` chunk whose node tree holds the trace plugin's node — before `container-trace.js` (the materializer + the store engine) has loaded. | the fill runs with an inert marker object (`TypeError` or wrong content); on the codec face the chunk decodes a marker instead of a container. | **announce + hold.** Document: the server knows it serialized a trace (the serializer stamps it) → `modulepreload` at t = 0 + the `sc:tiers` record; the occurrence is **held** (S1's `#argsUnprepared` → the general held set) with its server interior on screen, the mount being the hydration attach; it mounts with the record it was held on (S1 commit 3). Codec: `prepareData` scans the node tree and awaits the tier before the chunk decodes (S1 as built; the header flag makes the scan a confirmation). The claim reads the snapshot; the backlog lands after done (3.6 (iii), S1 commit 3). | **3.1 participant** (document face: the hold is an adopt-time occurrence deferred — registers through `initBoundaryResume`, A2) | **existing (S1):** `frames-container-lazy-codec` (loads before decode / loads nothing), `frames-container-lazy-document` (held with the interior on screen, then mounted), `hydration/welcome-status-lazy` (claims in place, no key misses), `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}`. **Re-pin:** `container-trace-hold-hydration-end` under 3.1 — _hydration waits for the load; the mount claims before done_ (rulings 3.1 "Consequences"). | +| **regions** | a `slot:` record naming a `{$frame}` ref — or a `data-fid` region element inside adopted content — before `tier-regions.js` has loaded. | the fill receives the raw `{$frame}` ref (wrong content); an occluded region cannot mount from the store. | **announce + hold.** The sink knows at render it passed server content as a prop (`{$frame}` minted) → announced. Adopt path: the occurrence is held in the same set as traces (its interior stays on screen; `#resolveArgs` runs after the load). Stream path: the record stays pending in the store until the load's flush (buffer). | **3.1 participant** on the adopt path; runtime wait on the stream path | **existing:** `frames-regions-*`, the lifecycle matrix's region rows. **New:** `tier-regions-hold.spec` — adopt-time `{$frame}` occurrence held, interior intact, mounts after the load with the held record; a stream record with a region ref applies after the load; hydration-done waits (3.1). | +| **assets** | a `reveal` for a segment whose `seg::assets` record names stylesheets (or `waitForStyles`) before `tier-assets.js` has loaded. Stream face only — the document face keeps the core's `$dfs`. | **without a bound: FOUC** — the segment reveals unstyled, then the sheet lands. With the bound: the fallback stays on screen longer. | **announce + reveal-readiness term.** The sink knows at render that a fragment is style-gated → announced in the header; `#segmentReady` gains one term: "the segment's `assets` record names styles **and** the tier is not resident → not ready". The fallback — the server's `` outcome — stays; the reveal happens at max(tier load, stylesheet load), and the stylesheet's own load dominates on every network. A segment without stylesheets never waits. Modules / preloads are not reveal-gating today and stay so. **Built (2026-10-06, C5, and its follow-up the same day):** the term is `(assets.styles | | assets.inlineStyles) && !tierReady("assets")?.gate(assets.styles | | [], this)`in`#segmentReady`— a segment whose record carries stylesheets **or inline styles** holds while the tier is absent (the inline`