diff --git a/.changeset/frames-tier-mechanism.md b/.changeset/frames-tier-mechanism.md new file mode 100644 index 000000000..c69d38390 --- /dev/null +++ b/.changeset/frames-tier-mechanism.md @@ -0,0 +1,11 @@ +--- +"@solidjs/web": patch +--- + +Frames: the server-announced tier mechanism (frames savings pass §2, Phase B — the seam alone; no tier is cut yet, every capability stays eager). + +The server learns at render time which frames-client capabilities a response or a document needs — it mints each feature — and announces their names so the client can start the tier's import in parallel with the content instead of at first use: on a frame stream, the `X-Frame-Tiers` response header (the tiers the sync render pass minted) and, for a tier first needed after the head left, an in-band `tiers` member on the next chunk out (`FrameChunk.tiers`, additive); on a document, the `_$HY.r["sc:tiers"]` hydration record (cumulative, re-written at each new mint) plus a `` per tier whose chunk URL the integration gave (`frameTransformDirectResult(value, { id, args, tierUrls })` — the server does not know the client's chunks; absent, the record alone carries the name). The sink's `needs(tier)` runs at the mint sites: 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`). A sync `renderToString` document announces nothing (no shared render slot); the client detects. + +The client (`@solidjs/web/frames`): `installServerComponents(host?, { tiers })` takes the tier → loader map (`() => import(...)`, the module exporting `install()`), reads the document's record and starts each named load; `applyFrameResponse` reads the header before the body and `chunk.tiers` at each chunk, and a `data` chunk awaits the tiers it names before it decodes (its node tree needs them). A fresh mount whose tier is not resident — a data occurrence's `bind`, a region-carrying record's `regions` — waits like a recordless called occurrence: on the adopt path that wait is one more reason in the frame's registered hold (frames-rulings 3.1 — the event-replay window stays open, hydration-done waits), on the stream path the record stays pending in the store; the readiness check itself starts the load when nothing announced it (detection is the fallback), and the install flushes every live frame so the held occurrence mounts with the record it was held on. A name with no loader is resident (eager), which is every name today. + +Measured before written (edited dist copies, `scripts/size`): frames eager client +473 B min / +145 B br (≤ +150 budget; the plan's ≈ +100 estimate, ×2.5 pre-estimate ≈ +250); pages +478 min; every non-server-component scenario 0 B. Frames server dist +793 B min; `@solidjs/web`'s own server entry 0 B. diff --git a/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index 7dc23231e..03168446c 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -241,6 +241,48 @@ and the links; the consistency laws un-ignored for the tiers. read, not configured); `ServerComponentHandlerOptions` gains nothing. The header name and the hydration key are wire (above). +**Landed (2026-10-06, `feat/frames-tier-mechanism`, measured before +written on edited dist copies per re-attribution §7).** Frames eager +**+473 min / +145 br** against the ≈ +100 br estimate (the ×2.5 +pre-estimate was ≈ +250; the first honest cut of the client code measured ++745 / +233 and was golfed to the shape that shipped: the load table a +plain object, a name with no loader minting no promise, the registry of +live frames the install flushes, the header read and the codec await +folded into one `Promise.all`); pages +478 min (brotli +177 base / +158 +live — layout; the gate's minified rule applies); the four non-SC +scenarios **0 B**. The frames server dist +793 min (est. ≈ 300–450; +`needs` / `announce` / `documentNeeds` / the pipe hook / nine mint sites / +the `tierUrls` plumbing); `@solidjs/web`'s own server entry 0. What +differs from the sketch above: (i) `installServerComponents` **does** gain +an option — `{ tiers }`, the name → loader map (decision 3's (ii): the +client resolves chunk URLs, the server announces names), the seam the +test-only tier module plugs into since no real tier has a chunk yet; +(ii) the document face's `sc:tiers` is re-written cumulative at each mint +(the hydration serializer parses a value when written, and a later +component's tier would otherwise be lost) and the `modulepreload` goes +through `registerAsset("module", url)` with a URL only the integration +can supply (`frameTransformDirectResult(value, { id, args, tierUrls })`); +(iii) the in-band form is **a `tiers` member on the next chunk out** +(`FrameChunk.tiers`), so the trace mint — which precedes the serializer's +synchronous emission of the record's initial node — lands on the very +`data` chunk that needs it, and the client's codec face awaits +`chunk.tiers` without a node scan; the header carries the sync pass's +mints (a pending projection's slot call is a hole re-pulled after the +pass, so its trace rides in-band); (iv) `prepareTier` / `installTier` are +internal (the install is the load's own continuation; `prepareTier` is +exported from the frames runtime module, not the public entry — the 9 B +br export is the maintainer's to grant when a tier exists to warm); (v) a +sync `renderToString` document announces nothing (no shared render slot +to dedupe on — the client detects); (vi) the held set's predicates in +this step are `bind` (positions) and `regions` (`record.regions`); the +trace predicate lands with C3, where S1's marker scan is. Pins: +`test/server/tier-announce.spec.tsx` (14), `test/consistency/tier-prepare.spec.tsx` +(8 — the 3.1 hold with two frames and one install, the stream path's +buffer-and-retry, idempotence, the record / header / in-band reads, the +codec await). Artifacts: 5 of 150 re-recorded (`frame-live-document-*` +×3 announce `wire`, `welcome-status-*` ×2 announce `trace`); none that +mints nothing changed. + --- ## 3. Steps @@ -307,24 +349,24 @@ live +120 br with −138 min (over their caps by 143 / 115 B brotli, held by the gate's minified rule). Frames eager measures 14 B over its 13.79 KB cap on the same terms; no frames / page cap moved. -| # | step | Δ br (frames eager / page base / page live / compiled hydrating) | chunks created | gates (pins flip; size) | depends on | surface | -| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **A0** | **C18 — classification waits for the drain** (rulings step 1, 3d / 3.5; **in flight**). The predicate is one term in `adoptBoundary.recordsPending`: a `prop#n` occurrence is classified only after every delivered record has drained. The only page-halting red; lands as the rulings specified it. A1 then makes the mechanism moot (one write per drain; `#` decides the class — C18 becomes unrepresentable) and the pins stay as the assertion of the pending read. | **≈ +15 / +15 / +15 / 0** (the rulings' ≈ +50 min / +15 br; ≈ +25 br with the batched drain, which A1 supersedes) | none | **flip:** C18 ×3. Size: ±0.02 KB on every scenario (frames eager ≤ 13.8). | #3813 landed | none | -| **A1** | **S-flush + the R deletions it unlocks.** `content = createMemo(() => host.landing(binding()))` — one reactive node per bound address resolved at the version's first root / error write (the host's `landing(address)`: a promise for a cold store, the value for a warm one); the enclosing `` pends on it, a switch is a new flight (`_inFlight` supersession, 1.6 (i) by construction), a refetch's landing is staged by the Transaction that read it (G7 closes). Deletes: **R.gate** (`arm`/`release`/`settle`/`setGate`/`mountGate`, the adopted twin), **R.stage** (`stage` / `stageTables` / `stagedContent` / `CONTENT_TOKEN` / `STAGED_DATA` / `FrameImpl#preview` / `#regionsChange` / `host.preview`; the chunk buffer-until-`complete` stays, one write), **R.version** (2a: one applied record keyed by identity; `#appliedRoot`), **R.dedupe** (per-prop memos in `slotArgsProxy`; `argsEquivalent` / `#refArgsUnchanged` / `#slotResolvedRefs` go), **R.error**'s latch. Files: `frames/src/client.ts` (`boundaryComponent`, `adoptBoundary`, `followAddress`), `frame-transport.ts` (`stage*`, `handle`), `frame-client.ts` (`#apply`, `#flush`, `preview`, `#syncSlots`' dedupe arms). | **≈ −1,150 / ≈ −1,150 / ≈ −1,150 / 0** (`est.` from the measured R total −1,863 with every feature kept, scaled to the 4,170 of 6,655 R-min these groups are; S-flush's own glue ≈ +110–190 min / +40 br is inside this) | none | **flip:** C5 (a, b, e) with the per-response data cell (1.2), C6 (b2), C7 (c), C17 (a); **C17 (c) re-pins** to 1.6 (i) `waiting → B`; C6 (b1) inverts (asserts the opposite of A0). Size: frames eager ≤ 12.7 KB (from 13.78), page base ≤ 43.7, live ≤ 47.5. | A0 | **removed / changed:** `ServerComponentHandlerOptions.onStream`, `FrameHostOptions.resolve` / `FrameHost.resolve`, `FrameHost.preview` / `Frame.preview`, `STAGED_DATA` — the rulings' step-4 list. **New:** `FrameHost.landing(address)` (internal). | -| **A2** | **C3 via `initBoundaryResume` — S-hold** (the rulings' 3a, pulled forward: **this is what lets any later hold register**). `hydrateWindow(id, fn, roots?)` factored out of `resumeBoundaryHydration`; `initBoundaryResume`'s registration reachable from the adopter (`sharedConfig.resumeBoundary`); `adoptBoundary` registers the adopted frame's owner while `#syncSlots` leaves any adopt-time occurrence deferred (the held set — one registration per frame, 3.2) and releases when a sync leaves none or the frame disposes. The #2968 `setTimeout` poll becomes the registration with the drain's end as its bound (3.5); the resumed fill re-enters hydration through the window and claims under the producer's keys (C1 / C9 stay green). **With it, 3e ported onto `next`** (the detached root + the parked backlog beyond the snapshot, 3.6 (iii), without S1's `claiming` plumbing — the rulings' "else ≈ +90 / +25" arm, because S1 no longer lands first and the Phase A gate counts C19), and 3.2's release order (claim → hold release → done → backlog) pinned. Deletes **R.claim** (the range-scoped registry beside `gatherHydratable(el, root)`) and the counter half of **R.drain**. **Landed in two parts** — #3837 (`holdBoundary`) and #3840 (`hydrateWindow` + the R.claim deletion; the park unconditional, rulings 3.6 "Landed") — measured **frames −109 br / hydrating +105 br** against the −130 / +40 estimate; the maintainer accepted the hydrating cost (2026-10-06; caps raised under a Size-Exception at the next integration PR), and **every further solid-side seam (S-adopted next) is to be measured on an edited dist copy before it is written.** | **≈ −130 / ≈ −130 / ≈ −130 / ≈ +40** (`est.`: R.claim ≈ 472 min + R.drain's defer ≈ 270 min ≈ −215 br of cuts; the registration ≈ +100 min frames ≈ +60 br incl. the `hold` option; the 3e port ≈ +90 min / +25 br; solid `hydrateWindow` + the reach ≈ +40–65 min ≈ +12–20 br, the detached root ≈ +20 br) | none | **flip:** C3 (a) + the harness's C3 replay; C19 ×2 (3e); S1's C3 (b) flips at C3 (the traces tier). Size: frames eager ≤ 12.55; **app hydrating / compiled hydrating +≈ 40 br — the first cap raise, the maintainer's** (compiled hydrating is at its cap on this head: 30,943 vs 30.93 KB). | A1 (the deferred set is right only once the drain is one write and the landing node exists) | **solid:** `sharedConfig.resumeBoundary` or an `internal` export of the registration — no new counter, no new done path (3.1 ruled; the draft's `holdHydration` withdrawn); the detached projection root (3e). **frames:** `FrameOptions.hold(): () => void` (internal, wired by `adoptBoundary`). | -| **A3** | **C2 / C4 — a reveal is an apply (S-reveal, interim 2b).** `fr.subscribe((_, parent) => el.contains(parent) && frame.sync(parent))` — a document `$df` into adopted content syncs the frame (2.3, 2.4); a bare `children` mounts at the revealed range (C2 b); a `#`-named occurrence found recordless is a pending read (C2 a2, through A2's hold). Deletes **R.reveal**'s readiness / retry model (`#segmentReady`'s retry loop, the `#revealed` / `#fallbackShown` second set) — the segment swap's DOM half stays (T.morph). DR-4's structural form (2c, the document fragment as a store write) is its own plan and not this step. **Landed** (2026-10-06, `fix/frames-a7-a3-error-throws-reveal-deletion`, with A7): the document-face half was #3837's (the empty write from the reveal cascade); the stream face's own half here — a revealed segment's content is applied as it is revealed, nested segments included (`#revealSegments(root)`), so `#flush` makes one pass and the second ledger (`#revealed` / `#fallbackShown` / `isRevealed`) deletes; the applied state is the content record / fallback gate by identity in the one applied map. **Measured −45 B min / ±0 br** on the frames eager client against A7's head: the fallback pass and the style gate — half the estimate's bytes — stay (F.assets keeps the gate; the pass keeps the reveal-before-fallback order). Pinned: two nested arms in the lifecycle matrix, one inside a pending boundary's detached content (which never revealed before). | **≈ −115 / ≈ −115 / ≈ −115 / 0** (`est.`: R.reveal 467 min ≈ −140 br; the one-liner +58 min / +23 br, measured as `Tglue-reveal` − `L8`) | none | **flip:** C2 (a2, b) + the harness's C2 replay; C4 (d) (the ledger is the store; the drain is one write). Size: frames eager ≤ 12.45. | A1, A2 | a `Frame` sync hook for the document reveal — internal, through the spread-cast options seam `adoptBoundary` already uses (rulings' list) | -| **A4** | **C5 / C6 / C17 residue — S-record, S-ref.** **S-ref:** the codec table answers an undelivered `{$ref}` with a pending promise rejected at `complete` / `:error` (L1 — closes the silent-ref hole, re-attribution §5.3 item 1); a record's refs resolve through the table current at its apply (1.3) — the per-response data cell (1.2, ≈ 140 min, replacing `stageTables`) that A1 left as the C5 condition. **S-record:** the server half — the document sink writes `sc:slot::` as a **declared** pending ref at the marker (as `registerFragment` writes `_fr`) and settles it with the args, so `readHydratedValue`'s `.then` path carries the wait — or the solid write hook on `_$HY.r` (+40 B); either removes the poll's last reason. Deletes **R.refwait** (`#refsUnresolved`, the threaded `resolve`) and the poll half of **R.drain**. | **≈ −15 / ≈ −15 / ≈ −15 / 0** (`est.`: R.refwait 145 min + the poll ≈ 120 min ≈ −75 br; the cell ≈ +45 br + reject-at-complete ≈ +15; decode chunk +≈ 60 B min for pending-on-missing — lazy, not counted) | `decode.js` +≈ 60 B (S-ref) | **flip:** C6 (a1); C5 (a, b, e) if A1 shipped them conditional; C17 (c) confirmed under 1.6 (i). Size: frames eager ≤ 12.45 (±). | A1 (the landing node), A2 (a pending read is a hold) | **server:** the declared slot record (output shape, +≈ 30 B/record) **or solid:** the `_$HY.r` write hook (+40 B) — one of the two, the maintainer's pick (the declared record is recommended: it is A5's shape). | -| **A5** | **C12 (c) client half + `claimRegionFragments` — S-adopted, S-key.** **S-adopted:** `_adoptedRoots: Set` in `hydration.ts`; `fragmentPolicy` swaps an unclaimed fragment after `_hydrationDone` when its `pl-` placeholder is inside an adopted root (`_$HY.fr.adopt(el)` / `unadopt(el)` from `adoptBoundary`, ≈ 30 B frames) — G4 closes and **`claimRegionFragments`** (R.claimant) deletes. **S-key:** `whenRevealed` published on `_$HY.fr` (+≈ 15 B solid) and the SC reference carries its covering fragment key (+≈ 30 B server); `installRevealHook`'s rescan and `boundaryWaiters` (D) collapse into `whenRevealed(key).then(...)`. **C12 (c) client half:** the adopted face shows what the server rendered (A0 withdraws the pin's expectation, 3.3); post-done the swap goes through S-adopted rather than freezing the fallback — the pin's **server half** (the sink's error markup) is A6's draft. **Landed as A5′ (2026-10-06, ruled 12:55: _a placeholder inside a server component's element is the frame's content by rendering, not by adoption_).** Measured before written: A5 as specified came in at **+504 min** solid on hydrating (no stores) (S-adopted +221 / S-key +283), so the shape changed — **(h)** an ownership predicate `_$HY.fa(placeholder)` the ledger's `fragmentPolicy` asks (geometry: the `pl-*` inside a live `data-fid` element), no `_adoptedRoots`, no claim, no replay, `_$HY.fr.claim`/`release` removed; **G9** by collapsing `documentBoundary`'s wait onto the intercept's `awaitBoundary` (`boundaryWaiters` deleted); the exhaustion fix (`fragmentPending` reads a revealed fragment from `_$HY.v` before its `_fr` stamp — the real producer order); a C14 guard (`disposedFrames`, a boundary disposed in place disowns its placeholders). **Measured:** hydrating (no stores) **+50 min / +5 br** (est. ≈ +20 min solid), frames eager **−204 / −51** (est. −135 min / ≈ −65 br; the pre-guard (h)+G9 edit measured −320 — the C14 guard is the ≈ +116 between), page base **−156 / −81**, live **−156 / +3**. **S-key not built** (reachability: the hydrating flow never reaches the late-boundary wait — a client `` twin's resume gates it; the intercept has no key to collapse onto; nested splices need covering-chain semantics; measured frames half −18 min for +283 solid). | **≈ −65 / ≈ −65 / ≈ −65 / ≈ +5** (`est.`: R.claimant 153 min + `boundaryWaiters` ≈ 110 min + the rescan's rebind ≈ 80 min ≈ −95 br; frames glue ≈ +30 br; solid `_adoptedRoots` + `whenRevealed` ≈ +20 min net of the detached root already paid in A2) | none | **flip:** C12 (c) client arm (shows the server's outcome; swaps post-done); the G4 and G9 timing pins (new: `adopted-swap-post-done.spec`, `boundary-arrival.spec`). Size: frames eager ≤ 12.35; hydrating scenarios +≈ 5 (inside A2's raise). | A2 (the adopted frame's registration is what `fr.adopt` keys off), A3 | **solid:** `_$HY.fr.adopt/unadopt`, `whenRevealed` on `_$HY.fr`. **server:** the fragment key on the SC reference (+30 B of output). | -| **A6** | **Server-half drafts — design, no wire change in this step.** (i) **C13's sweep delimiter** (R7): a multi-record `FrameChunk` member `{ type: "ops", ops: [...] }` the sink emits per sweep and the client applies as one write — the only wire item in the rulings' list, drafted as an RFC 11 addendum with the client's one-write apply (A1's shape already applies a write atomically). (ii) **The plain-response streaming bound** (§6 decision 4): `complete` gains `bound: "yields" \| "time"`; the sink ends a plain response at the bound. (iii) **C12 (c)'s error template**: the document face renders a rejected server ``'s error outcome into the fragment (3.3's server half) instead of the blank. Each is a design note + a `test.fails` pin written against the draft; the server PRs follow the drafts and flip C13 (a, b) and C12 (c)'s server arm — Phase A is taken as done when the drafts are reviewed and those PRs are open. | 0 (design) | — | the three drafts reviewed; pins written (`.fails`). **Phase A gate taken here:** 22 reds green / unrepresentable except the three server-half arms, drafts attached; harness clean on two seeds; frames eager ≈ 12.3 < 13.77. | A1–A5 | **wire (drafted, not shipped):** the `ops` chunk member; `complete.bound`. Decision 4. | -| **B** | **The tier mechanism** (§2): `sink.needs(tier)` at the five mint sites; `X-Frame-Tiers` at first flush; `sc:tiers` record + `modulepreload` links on the document face; `prepareTier(name)` + `installTier`; the **held set is A2's registered set** — a tier's adopt-path hold is one more reason an occurrence is deferred, so it registers under 3.1 from day one. No tier is cut yet — this step is the seam alone, measured. S1's `prepareData` / `prepareArgs` are not in the tree (S1 has not merged); the general seam is built directly and S1 re-bases onto it at C3. | **≈ +100 / ≈ +100 / ≈ +100 / 0** gross (`est.`); server ≈ +300–450 min. S1's two faces (+543 min / +134 br) are never shipped — the ≈ −35 net the earlier draft credited here appears at C3 instead, as "S1 re-based costs less than S1 as built". | none new | `tier-announce.spec`, `tier-prepare.spec` (new); artifacts re-recorded once. Size: frames eager ≤ 12.45. | **the Phase A gate**; A2 (the holds register), A1 (the `landing` node is what an installed tier's `flush()` wakes) | **wire (additive):** `X-Frame-Tiers`, `_$HY.r["sc:tiers"]`, the links. Decision 3. | -| **C1** | **Holes tier** (E.a1; cheapest, buffer-only). `tier-holes.js` = `#applyHole`, `#applyAttrs` (less its owned-position arms, which are bind's), `findLiveTarget`, the hole pass, `pumpLiveChannel` + the op log + `applyLiveOp`. The eager client keeps `chunkToRecords`' `hole` / `attr` cases (records must land in the store before the tier is resident) and a one-line dispatch in `#flush`. Under the **8.0 reading** this step is skipped and holes stay eager (§6 decision 1). | **−546 / −508 / −508 / 0** (measured: `T+holes` → `L8`; page `T+holes` → `L8`; live page the same cut) | `tier-holes.js` ≈ 1,900 min / **≈ 620 br** (`est.`: the 2,116-min cut as its own module + the install glue) | `tier-holes-buffer.spec` (new, §1); C13 control + C18 catch-up arms unchanged; `frames-live-holes-*`, `document-live-*` green through the tier. Size: frames eager ≤ 11.9. | B | none (the record shapes and `sc:live` are unchanged; the hole appliers were never exported) | -| **C2** | **Live wire tier** (E.a2; preload-at-call). `tier-wire.js` = `connections` / `hold` / the join-or-hold arm of `handle`, `resume` + `encodeHaveList` / `FRAME_HAVE_*`, the have-list ledger (`#have` / `have()` / `#recordHave` and the record fields that feed it), `applyFrames`' connection wiring + `connection.cancel`, `isEventStream` + the SSE reader selection, `deserializeStream`'s live arm. The eager client keeps a one-line `LIVE_WIRE` dispatch in `handle` and `bump`'s cancel hook (a no-op without the tier). `live()`'s decorator fires the `onLive` hook (set by frames through `configureServerFunctionsClient`) that calls `prepareTier("wire")` before its first fetch; the arm awaits it. | **−433 / −355 / −355 / 0** (measured: `T+wire` → `L8`; the live page keeps the chunk lazy — its eager measurement drops the same bytes) | `tier-wire.js` ≈ 1,300 min / **≈ 470 br** (`est.`) | `tier-wire-preload.spec` (new); the live suite green; the audit's `live` branch gap (22/61) closed to ≥ 45/61 in the same PR (the tier's own tests). Size: frames eager ≤ 11.5; live page unchanged ±50 (the chunk is reported, not counted). | B; independent of C1 | `ServerFunctionsClientConfig.onLive` (new, internal hook on `configureServerFunctionsClient`); `FRAME_HAVE_HEADER` / `FRAME_HAVE_BUDGET` are exported constants today and move to the tier's module — **re-export from the eager entry** to keep the surface, or flag the move | -| **C3** | **Traces tier = S1 re-based** (§5; S1 merges **here**, not first). The materializer entry (`solid-js/internal/container-trace`) and `loadContainers` as S1 built them; S1's `prepareData` / `prepareArgs` / `#argsUnprepared` become B's `prepareTier("trace")` + A2's registered held set; the codec-face node scan stays as the un-announced fallback behind the header flag; the eager half of F.trace (`reviveContainerTraces` / `materialize` / `isContainerTraceMarker` / `isMaterializedContainer` / `setContainerTraceMaterializer` / `getFrameHost.revive`) moves into `container-trace.js`'s `installTier`, leaving a ≈ 150-min trigger. S1's commit 3 re-bases onto A2's park: the `claiming` hint (`revive(value, claiming?)`) and the held-record mount land here, the detached root and the backlog are already on `next`. **`container-trace-hold-hydration-end` re-pins under 3.1 at merge** (A2 is in). The +134 B frames exception S1 as built would have needed **never needs granting**: B's seam is already paid and the tier cut is a saving. | **≈ −250 / ≈ −6,640 / ≈ −6,600 / 0** (S1's measured page savings −6,390 / −6,352 plus F.trace's eager half: 843 attributed, −289 measured as `T+trace` → `L8`, less the trigger ≈ −250; S1's +134 on frames does not recur — its two faces are B's seam) | `container-trace.js` 24.3 KB / **7.86 KB br measured** (S1) + the eager half (≈ +700 min / +200 br → ≈ 8.1 KB br) | S1's seven surviving pins green through the general seam (`frames-container-lazy-{codec,document}`, `hydration/welcome-status-lazy`, `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}`); **re-pin** `container-trace-hold-hydration-end` (_hydration waits for the load; the mount claims before done_); **flip** S1's C3 (b); the `.fails` id-drift pin → 3.4 (3c). Size: frames eager ≤ 11.25; page base ≤ 36.0, live ≤ 39.8 (S1's caps 38.45 / 42.12 are superseded by these at landing). | B, A2 (the hold registers; the park is on `next`), A3 | S1's: `revive(value, claiming?)`, `setContainerTraceMaterializer(…, claiming?)`, the entry, `withStoreHydration` / `applyPatches` / `forwardIteratorReturn` `@internal` on the main entry; **not shipped:** S1's `prepareData` / `prepareArgs` (replaced by `prepareTier` before they exist on `next`). `reviveContainerTraces` / `setContainerTraceMaterializer` move behind the tier — **flag**: re-export lazily-resolving wrappers or accept the move | -| **C4** | **Regions tier.** `tier-regions.js` = `#bindRegions` / `#regionsFor` / `#discoverRegions` / `collectRegionElements` / `disposeRegions` / `makeFrameElement` / `isFrameRef`, the `{$frame}` arm of `#resolveArgs`, the `resolveSlot` / `resolveSlotRecord` / `removeSlotRecord` thread-up, `tableFor`'s prefix walk, `drainRecords`' `sc:region:` arm. The eager client keeps the `{$frame}` detection in `#resolveArgs` (one `isFrameRef` test → hold, registered). The rename machinery (`renameRegion` / `#reconcileRegions`, D) deletes outright — it is not moved. | **−489 / ≈ −480 / ≈ −480 / 0** (measured on frames: `T+regions` → `L8`; pages `est.` at the same cut) | `tier-regions.js` ≈ 1,900 min / **≈ 540 br** (`est.`) | `tier-regions-hold.spec` (new); `frames-regions-*`, lifecycle matrix region rows green; principles §4 row 19 (the rename compensations) deleted with D. Size: frames eager ≤ 10.75. | B, A2 (the adopt-path hold registers — no 3.1 gap to flag) | none (`createFrameElement` stays eager — it is `@experimental` public API, re-attribution §5.3 item 5) | -| **C5** | **Assets tier.** `tier-assets.js` = `ensureStylesheet` / `ensurePreload` / `ensureModulePreload` / `applyInlineStyles` / `qualifierValue` / `findHeadElement` / `PRELOAD_QUALIFIERS` / `#processedAssets` / `#styleFlush` / the assets pass; the eager client keeps `chunkToRecords`' `assets` case, `host.write`'s `seg::assets` accumulate, and the `#segmentReady` term. **Pin the two untested functions first** (`ensureStylesheet`, `applyInlineStyles` — the audit's 0-coverage gap) in the same PR. Alternative under decision 2: S10's route-through-`web` instead of a tier. | **−684 / ≈ −665 / ≈ −665 / 0** (measured on frames: `T+assets` → `L8`; the `noassets` full-client cut −665) | `tier-assets.js` ≈ 2,400 min / **≈ 760 br** (`est.`) | `tier-assets-ready.spec` (new, the FOUC guard); `frames-assets-*` green; the two new coverage pins. Size: frames eager ≤ 10.05. | B | none | -| **C6** | **Binding-slot tier** (E.c; largest, last of the tiers — its fallback needs the 3.1 hold for the event-replay window, which A2 provides). `tier-bind.js` = `bindDataOccurrence` (+ `valuesFor` / `write` / `release` / `writeText`; its second diff layer above `assign` — ≈ 300 B, D — deletes rather than moves), `slotPositions` / `slotEntry` / `textPosition` / `consumersOf` / `consumersEqual` / `ownedPositions` / `morphOwnedClass` / `morphOwnedStyle` / `applyOwned`, the `_s:` branch of `collectSlots`, the consumer-rebind arm of `#syncSlots`, the owned-position arms of `morphAttributes` / `reconcileChildren` / `#applyAttrs`, the `ctx.positions` branch of `slotsFor`; **`assign` leaves the eager frames client with it** (the page then keeps `assign` only through `dynamic`'s string tag — B.3, D). | **−1,546 / −2,504 / −2,542 / 0** (frames measured `T+bind` → `L8`; pages: the audit's E.c measurement — `assign` leaves on the page too) | `tier-bind.js` ≈ 5,000 min / **≈ 1,650 br** on frames (`est.`); on a page it carries `assign` as well (≈ +3,000 min / +900 br) unless B.3 has already made it lazy | `tier-bind-hold.spec` (new, incl. the click-replay arm); `frames-binding-slot-*`, `slot-positions-*`, #3704 / #3714 suites green. Size: frames eager ≤ 8.5 (both readings), page base ≤ 32.35, live ≤ 36.1. | B, **A2** (the hold registers; the replay window stays open); C3 (the `installTier` shape proven on the biggest chunk first) | none public (the `_s:` marker grammar is unchanged; `bindDataOccurrence` was never exported) | -| **D** | **Packaging remnants from the SC audit, if still relevant after tiering.** **S2 / C** `preserveModules` for `solid-js` / `@solidjs/web` (0 on single-entry scenarios; the enabler): lets the store **hydration adapters** (≈ 2.6 KB min, the ≈ 1.3 KB br S1 fell short of B.2's floor by) follow the engine into `container-trace.js`, and lets **B.3** (`dynamic`'s string-tag branch lazy, `staticElement` behind the seam) take `assign` off the page. **B.3:** page −2,372 / −2,391 br (audit measured), frames 0. **E.b** (sf natural-encoding bodies, codec-args message, `Retry-After` / trailer parsing lazy): −65 frames / −476 base / −519 live (audit floor). **E.c's other half** is C6. **Lazy codec:** already a chunk (22,986 / 6,074) — nothing to do. **Claims + event** (F.claims, F.event, 331 br): not a frames tier — they ride the router's chunk (the router installs `CLAIM_SEAM`); the frames client keeps the ≈ 60-B seam. | **≈ −400 / ≈ −4,100 / ≈ −4,200 / 0** (`est.`: claims+event −331 frames; B.3 −2,372, the adapters ≈ −1,300, E.b −476 on page base) | `dynamic-static.js` ≈ 8,000 min / ≈ 2.4 KB br; the sf natural-body chunk ≈ 1,600 min / ≈ 480 br; the router's claims chunk ≈ 900 min / ≈ 330 br | the audit's S2 band (single-entry scenarios ≤ ±50 B); B.3's hydration specs; `CLAIM_SEAM` tests with the router. Size: frames eager ≤ 8.1, page base ≤ 28.2, live ≤ 31.8. | C3, C6 (so what leaves with the engine and with `assign` is known) | B.3: `dynamic`'s string-tag branch becomes async-loading on first use (behaviour change accepted in audit §7 Q5 / B.3); the `CLAIM_SEAM` install moves to the router | -| **E** | **Budget restatement — principles §6 as per-tier lines** (§4's table is the draft). One line per eager default (both readings written, one picked), one per tier chunk, the page lines, the ratchet rule unchanged ("a ceiling increase requires a new mechanism row citing its axiom"), `floor-caps.json` gains the tier chunks as reported-not-counted lines with their own caps. | 0 | — | `check-floor-caps` clean on `next` | all | — | +| # | step | Δ br (frames eager / page base / page live / compiled hydrating) | chunks created | gates (pins flip; size) | depends on | surface | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A0** | **C18 — classification waits for the drain** (rulings step 1, 3d / 3.5; **in flight**). The predicate is one term in `adoptBoundary.recordsPending`: a `prop#n` occurrence is classified only after every delivered record has drained. The only page-halting red; lands as the rulings specified it. A1 then makes the mechanism moot (one write per drain; `#` decides the class — C18 becomes unrepresentable) and the pins stay as the assertion of the pending read. | **≈ +15 / +15 / +15 / 0** (the rulings' ≈ +50 min / +15 br; ≈ +25 br with the batched drain, which A1 supersedes) | none | **flip:** C18 ×3. Size: ±0.02 KB on every scenario (frames eager ≤ 13.8). | #3813 landed | none | +| **A1** | **S-flush + the R deletions it unlocks.** `content = createMemo(() => host.landing(binding()))` — one reactive node per bound address resolved at the version's first root / error write (the host's `landing(address)`: a promise for a cold store, the value for a warm one); the enclosing `` pends on it, a switch is a new flight (`_inFlight` supersession, 1.6 (i) by construction), a refetch's landing is staged by the Transaction that read it (G7 closes). Deletes: **R.gate** (`arm`/`release`/`settle`/`setGate`/`mountGate`, the adopted twin), **R.stage** (`stage` / `stageTables` / `stagedContent` / `CONTENT_TOKEN` / `STAGED_DATA` / `FrameImpl#preview` / `#regionsChange` / `host.preview`; the chunk buffer-until-`complete` stays, one write), **R.version** (2a: one applied record keyed by identity; `#appliedRoot`), **R.dedupe** (per-prop memos in `slotArgsProxy`; `argsEquivalent` / `#refArgsUnchanged` / `#slotResolvedRefs` go), **R.error**'s latch. Files: `frames/src/client.ts` (`boundaryComponent`, `adoptBoundary`, `followAddress`), `frame-transport.ts` (`stage*`, `handle`), `frame-client.ts` (`#apply`, `#flush`, `preview`, `#syncSlots`' dedupe arms). | **≈ −1,150 / ≈ −1,150 / ≈ −1,150 / 0** (`est.` from the measured R total −1,863 with every feature kept, scaled to the 4,170 of 6,655 R-min these groups are; S-flush's own glue ≈ +110–190 min / +40 br is inside this) | none | **flip:** C5 (a, b, e) with the per-response data cell (1.2), C6 (b2), C7 (c), C17 (a); **C17 (c) re-pins** to 1.6 (i) `waiting → B`; C6 (b1) inverts (asserts the opposite of A0). Size: frames eager ≤ 12.7 KB (from 13.78), page base ≤ 43.7, live ≤ 47.5. | A0 | **removed / changed:** `ServerComponentHandlerOptions.onStream`, `FrameHostOptions.resolve` / `FrameHost.resolve`, `FrameHost.preview` / `Frame.preview`, `STAGED_DATA` — the rulings' step-4 list. **New:** `FrameHost.landing(address)` (internal). | +| **A2** | **C3 via `initBoundaryResume` — S-hold** (the rulings' 3a, pulled forward: **this is what lets any later hold register**). `hydrateWindow(id, fn, roots?)` factored out of `resumeBoundaryHydration`; `initBoundaryResume`'s registration reachable from the adopter (`sharedConfig.resumeBoundary`); `adoptBoundary` registers the adopted frame's owner while `#syncSlots` leaves any adopt-time occurrence deferred (the held set — one registration per frame, 3.2) and releases when a sync leaves none or the frame disposes. The #2968 `setTimeout` poll becomes the registration with the drain's end as its bound (3.5); the resumed fill re-enters hydration through the window and claims under the producer's keys (C1 / C9 stay green). **With it, 3e ported onto `next`** (the detached root + the parked backlog beyond the snapshot, 3.6 (iii), without S1's `claiming` plumbing — the rulings' "else ≈ +90 / +25" arm, because S1 no longer lands first and the Phase A gate counts C19), and 3.2's release order (claim → hold release → done → backlog) pinned. Deletes **R.claim** (the range-scoped registry beside `gatherHydratable(el, root)`) and the counter half of **R.drain**. **Landed in two parts** — #3837 (`holdBoundary`) and #3840 (`hydrateWindow` + the R.claim deletion; the park unconditional, rulings 3.6 "Landed") — measured **frames −109 br / hydrating +105 br** against the −130 / +40 estimate; the maintainer accepted the hydrating cost (2026-10-06; caps raised under a Size-Exception at the next integration PR), and **every further solid-side seam (S-adopted next) is to be measured on an edited dist copy before it is written.** | **≈ −130 / ≈ −130 / ≈ −130 / ≈ +40** (`est.`: R.claim ≈ 472 min + R.drain's defer ≈ 270 min ≈ −215 br of cuts; the registration ≈ +100 min frames ≈ +60 br incl. the `hold` option; the 3e port ≈ +90 min / +25 br; solid `hydrateWindow` + the reach ≈ +40–65 min ≈ +12–20 br, the detached root ≈ +20 br) | none | **flip:** C3 (a) + the harness's C3 replay; C19 ×2 (3e); S1's C3 (b) flips at C3 (the traces tier). Size: frames eager ≤ 12.55; **app hydrating / compiled hydrating +≈ 40 br — the first cap raise, the maintainer's** (compiled hydrating is at its cap on this head: 30,943 vs 30.93 KB). | A1 (the deferred set is right only once the drain is one write and the landing node exists) | **solid:** `sharedConfig.resumeBoundary` or an `internal` export of the registration — no new counter, no new done path (3.1 ruled; the draft's `holdHydration` withdrawn); the detached projection root (3e). **frames:** `FrameOptions.hold(): () => void` (internal, wired by `adoptBoundary`). | +| **A3** | **C2 / C4 — a reveal is an apply (S-reveal, interim 2b).** `fr.subscribe((_, parent) => el.contains(parent) && frame.sync(parent))` — a document `$df` into adopted content syncs the frame (2.3, 2.4); a bare `children` mounts at the revealed range (C2 b); a `#`-named occurrence found recordless is a pending read (C2 a2, through A2's hold). Deletes **R.reveal**'s readiness / retry model (`#segmentReady`'s retry loop, the `#revealed` / `#fallbackShown` second set) — the segment swap's DOM half stays (T.morph). DR-4's structural form (2c, the document fragment as a store write) is its own plan and not this step. **Landed** (2026-10-06, `fix/frames-a7-a3-error-throws-reveal-deletion`, with A7): the document-face half was #3837's (the empty write from the reveal cascade); the stream face's own half here — a revealed segment's content is applied as it is revealed, nested segments included (`#revealSegments(root)`), so `#flush` makes one pass and the second ledger (`#revealed` / `#fallbackShown` / `isRevealed`) deletes; the applied state is the content record / fallback gate by identity in the one applied map. **Measured −45 B min / ±0 br** on the frames eager client against A7's head: the fallback pass and the style gate — half the estimate's bytes — stay (F.assets keeps the gate; the pass keeps the reveal-before-fallback order). Pinned: two nested arms in the lifecycle matrix, one inside a pending boundary's detached content (which never revealed before). | **≈ −115 / ≈ −115 / ≈ −115 / 0** (`est.`: R.reveal 467 min ≈ −140 br; the one-liner +58 min / +23 br, measured as `Tglue-reveal` − `L8`) | none | **flip:** C2 (a2, b) + the harness's C2 replay; C4 (d) (the ledger is the store; the drain is one write). Size: frames eager ≤ 12.45. | A1, A2 | a `Frame` sync hook for the document reveal — internal, through the spread-cast options seam `adoptBoundary` already uses (rulings' list) | +| **A4** | **C5 / C6 / C17 residue — S-record, S-ref.** **S-ref:** the codec table answers an undelivered `{$ref}` with a pending promise rejected at `complete` / `:error` (L1 — closes the silent-ref hole, re-attribution §5.3 item 1); a record's refs resolve through the table current at its apply (1.3) — the per-response data cell (1.2, ≈ 140 min, replacing `stageTables`) that A1 left as the C5 condition. **S-record:** the server half — the document sink writes `sc:slot::` as a **declared** pending ref at the marker (as `registerFragment` writes `_fr`) and settles it with the args, so `readHydratedValue`'s `.then` path carries the wait — or the solid write hook on `_$HY.r` (+40 B); either removes the poll's last reason. Deletes **R.refwait** (`#refsUnresolved`, the threaded `resolve`) and the poll half of **R.drain**. | **≈ −15 / ≈ −15 / ≈ −15 / 0** (`est.`: R.refwait 145 min + the poll ≈ 120 min ≈ −75 br; the cell ≈ +45 br + reject-at-complete ≈ +15; decode chunk +≈ 60 B min for pending-on-missing — lazy, not counted) | `decode.js` +≈ 60 B (S-ref) | **flip:** C6 (a1); C5 (a, b, e) if A1 shipped them conditional; C17 (c) confirmed under 1.6 (i). Size: frames eager ≤ 12.45 (±). | A1 (the landing node), A2 (a pending read is a hold) | **server:** the declared slot record (output shape, +≈ 30 B/record) **or solid:** the `_$HY.r` write hook (+40 B) — one of the two, the maintainer's pick (the declared record is recommended: it is A5's shape). | +| **A5** | **C12 (c) client half + `claimRegionFragments` — S-adopted, S-key.** **S-adopted:** `_adoptedRoots: Set` in `hydration.ts`; `fragmentPolicy` swaps an unclaimed fragment after `_hydrationDone` when its `pl-` placeholder is inside an adopted root (`_$HY.fr.adopt(el)` / `unadopt(el)` from `adoptBoundary`, ≈ 30 B frames) — G4 closes and **`claimRegionFragments`** (R.claimant) deletes. **S-key:** `whenRevealed` published on `_$HY.fr` (+≈ 15 B solid) and the SC reference carries its covering fragment key (+≈ 30 B server); `installRevealHook`'s rescan and `boundaryWaiters` (D) collapse into `whenRevealed(key).then(...)`. **C12 (c) client half:** the adopted face shows what the server rendered (A0 withdraws the pin's expectation, 3.3); post-done the swap goes through S-adopted rather than freezing the fallback — the pin's **server half** (the sink's error markup) is A6's draft. **Landed as A5′ (2026-10-06, ruled 12:55: _a placeholder inside a server component's element is the frame's content by rendering, not by adoption_).** Measured before written: A5 as specified came in at **+504 min** solid on hydrating (no stores) (S-adopted +221 / S-key +283), so the shape changed — **(h)** an ownership predicate `_$HY.fa(placeholder)` the ledger's `fragmentPolicy` asks (geometry: the `pl-*` inside a live `data-fid` element), no `_adoptedRoots`, no claim, no replay, `_$HY.fr.claim`/`release` removed; **G9** by collapsing `documentBoundary`'s wait onto the intercept's `awaitBoundary` (`boundaryWaiters` deleted); the exhaustion fix (`fragmentPending` reads a revealed fragment from `_$HY.v` before its `_fr` stamp — the real producer order); a C14 guard (`disposedFrames`, a boundary disposed in place disowns its placeholders). **Measured:** hydrating (no stores) **+50 min / +5 br** (est. ≈ +20 min solid), frames eager **−204 / −51** (est. −135 min / ≈ −65 br; the pre-guard (h)+G9 edit measured −320 — the C14 guard is the ≈ +116 between), page base **−156 / −81**, live **−156 / +3**. **S-key not built** (reachability: the hydrating flow never reaches the late-boundary wait — a client `` twin's resume gates it; the intercept has no key to collapse onto; nested splices need covering-chain semantics; measured frames half −18 min for +283 solid). | **≈ −65 / ≈ −65 / ≈ −65 / ≈ +5** (`est.`: R.claimant 153 min + `boundaryWaiters` ≈ 110 min + the rescan's rebind ≈ 80 min ≈ −95 br; frames glue ≈ +30 br; solid `_adoptedRoots` + `whenRevealed` ≈ +20 min net of the detached root already paid in A2) | none | **flip:** C12 (c) client arm (shows the server's outcome; swaps post-done); the G4 and G9 timing pins (new: `adopted-swap-post-done.spec`, `boundary-arrival.spec`). Size: frames eager ≤ 12.35; hydrating scenarios +≈ 5 (inside A2's raise). | A2 (the adopted frame's registration is what `fr.adopt` keys off), A3 | **solid:** `_$HY.fr.adopt/unadopt`, `whenRevealed` on `_$HY.fr`. **server:** the fragment key on the SC reference (+30 B of output). | +| **A6** | **Server-half drafts — design, no wire change in this step.** (i) **C13's sweep delimiter** (R7): a multi-record `FrameChunk` member `{ type: "ops", ops: [...] }` the sink emits per sweep and the client applies as one write — the only wire item in the rulings' list, drafted as an RFC 11 addendum with the client's one-write apply (A1's shape already applies a write atomically). (ii) **The plain-response streaming bound** (§6 decision 4): `complete` gains `bound: "yields" \| "time"`; the sink ends a plain response at the bound. (iii) **C12 (c)'s error template**: the document face renders a rejected server ``'s error outcome into the fragment (3.3's server half) instead of the blank. Each is a design note + a `test.fails` pin written against the draft; the server PRs follow the drafts and flip C13 (a, b) and C12 (c)'s server arm — Phase A is taken as done when the drafts are reviewed and those PRs are open. | 0 (design) | — | the three drafts reviewed; pins written (`.fails`). **Phase A gate taken here:** 22 reds green / unrepresentable except the three server-half arms, drafts attached; harness clean on two seeds; frames eager ≈ 12.3 < 13.77. | A1–A5 | **wire (drafted, not shipped):** the `ops` chunk member; `complete.bound`. Decision 4. | +| **B** | **The tier mechanism** (§2): `sink.needs(tier)` at the five mint sites; `X-Frame-Tiers` at first flush; `sc:tiers` record + `modulepreload` links on the document face; `prepareTier(name)` + `installTier`; the **held set is A2's registered set** — a tier's adopt-path hold is one more reason an occurrence is deferred, so it registers under 3.1 from day one. No tier is cut yet — this step is the seam alone, measured. S1's `prepareData` / `prepareArgs` are not in the tree (S1 has not merged); the general seam is built directly and S1 re-bases onto it at C3. **Landed** (2026-10-06, `feat/frames-tier-mechanism`, measured before written — §2 "Landed"): frames eager **+473 min / +145 br** (≤ the +150 budget; est. ≈ +100, ×2.5 pre-estimate ≈ +250), pages +478 min, non-SC scenarios 0; frames server dist +793 min. `installServerComponents` gained `{ tiers }` (the client's loader map); the in-band form is `FrameChunk.tiers` on the next chunk out; the document face writes `sc:tiers` cumulative and a `modulepreload` only when `frameTransformDirectResult` is given `tierUrls` (decision 3 sub-item). Pins `tier-announce.spec` (14) / `tier-prepare.spec` (8); 5 of 150 artifacts re-recorded. | **≈ +100 / ≈ +100 / ≈ +100 / 0** gross (`est.`); server ≈ +300–450 min. S1's two faces (+543 min / +134 br) are never shipped — the ≈ −35 net the earlier draft credited here appears at C3 instead, as "S1 re-based costs less than S1 as built". **Measured: +145 / +177 / +158 / 0** br (+473 / +478 / +478 / 0 min); server +793 min. | none new | `tier-announce.spec`, `tier-prepare.spec` (new); artifacts re-recorded once. Size: frames eager ≤ 12.45. **Landed:** both specs green; 5 artifacts; frames eager 13,949 br (the 12.45 gate assumed Phase A savings that did not materialize — the cap is the maintainer's). | **the Phase A gate**; A2 (the holds register), A1 (the `landing` node is what an installed tier's `flush()` wakes) | **wire (additive):** `X-Frame-Tiers`, `_$HY.r["sc:tiers"]`, the links. Decision 3. **Landed:** also `installServerComponents(host?, { tiers })`, `frameTransformDirectResult`'s `tierUrls`, `FRAME_TIERS_HEADER` (server entry), `FrameChunk.tiers` (`TierAnnouncement`). | +| **C1** | **Holes tier** (E.a1; cheapest, buffer-only). `tier-holes.js` = `#applyHole`, `#applyAttrs` (less its owned-position arms, which are bind's), `findLiveTarget`, the hole pass, `pumpLiveChannel` + the op log + `applyLiveOp`. The eager client keeps `chunkToRecords`' `hole` / `attr` cases (records must land in the store before the tier is resident) and a one-line dispatch in `#flush`. Under the **8.0 reading** this step is skipped and holes stay eager (§6 decision 1). | **−546 / −508 / −508 / 0** (measured: `T+holes` → `L8`; page `T+holes` → `L8`; live page the same cut) | `tier-holes.js` ≈ 1,900 min / **≈ 620 br** (`est.`: the 2,116-min cut as its own module + the install glue) | `tier-holes-buffer.spec` (new, §1); C13 control + C18 catch-up arms unchanged; `frames-live-holes-*`, `document-live-*` green through the tier. Size: frames eager ≤ 11.9. | B | none (the record shapes and `sc:live` are unchanged; the hole appliers were never exported) | +| **C2** | **Live wire tier** (E.a2; preload-at-call). `tier-wire.js` = `connections` / `hold` / the join-or-hold arm of `handle`, `resume` + `encodeHaveList` / `FRAME_HAVE_*`, the have-list ledger (`#have` / `have()` / `#recordHave` and the record fields that feed it), `applyFrames`' connection wiring + `connection.cancel`, `isEventStream` + the SSE reader selection, `deserializeStream`'s live arm. The eager client keeps a one-line `LIVE_WIRE` dispatch in `handle` and `bump`'s cancel hook (a no-op without the tier). `live()`'s decorator fires the `onLive` hook (set by frames through `configureServerFunctionsClient`) that calls `prepareTier("wire")` before its first fetch; the arm awaits it. | **−433 / −355 / −355 / 0** (measured: `T+wire` → `L8`; the live page keeps the chunk lazy — its eager measurement drops the same bytes) | `tier-wire.js` ≈ 1,300 min / **≈ 470 br** (`est.`) | `tier-wire-preload.spec` (new); the live suite green; the audit's `live` branch gap (22/61) closed to ≥ 45/61 in the same PR (the tier's own tests). Size: frames eager ≤ 11.5; live page unchanged ±50 (the chunk is reported, not counted). | B; independent of C1 | `ServerFunctionsClientConfig.onLive` (new, internal hook on `configureServerFunctionsClient`); `FRAME_HAVE_HEADER` / `FRAME_HAVE_BUDGET` are exported constants today and move to the tier's module — **re-export from the eager entry** to keep the surface, or flag the move | +| **C3** | **Traces tier = S1 re-based** (§5; S1 merges **here**, not first). The materializer entry (`solid-js/internal/container-trace`) and `loadContainers` as S1 built them; S1's `prepareData` / `prepareArgs` / `#argsUnprepared` become B's `prepareTier("trace")` + A2's registered held set; the codec-face node scan stays as the un-announced fallback behind the header flag; the eager half of F.trace (`reviveContainerTraces` / `materialize` / `isContainerTraceMarker` / `isMaterializedContainer` / `setContainerTraceMaterializer` / `getFrameHost.revive`) moves into `container-trace.js`'s `installTier`, leaving a ≈ 150-min trigger. S1's commit 3 re-bases onto A2's park: the `claiming` hint (`revive(value, claiming?)`) and the held-record mount land here, the detached root and the backlog are already on `next`. **`container-trace-hold-hydration-end` re-pins under 3.1 at merge** (A2 is in). The +134 B frames exception S1 as built would have needed **never needs granting**: B's seam is already paid and the tier cut is a saving. | **≈ −250 / ≈ −6,640 / ≈ −6,600 / 0** (S1's measured page savings −6,390 / −6,352 plus F.trace's eager half: 843 attributed, −289 measured as `T+trace` → `L8`, less the trigger ≈ −250; S1's +134 on frames does not recur — its two faces are B's seam) | `container-trace.js` 24.3 KB / **7.86 KB br measured** (S1) + the eager half (≈ +700 min / +200 br → ≈ 8.1 KB br) | S1's seven surviving pins green through the general seam (`frames-container-lazy-{codec,document}`, `hydration/welcome-status-lazy`, `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}`); **re-pin** `container-trace-hold-hydration-end` (_hydration waits for the load; the mount claims before done_); **flip** S1's C3 (b); the `.fails` id-drift pin → 3.4 (3c). Size: frames eager ≤ 11.25; page base ≤ 36.0, live ≤ 39.8 (S1's caps 38.45 / 42.12 are superseded by these at landing). | B, A2 (the hold registers; the park is on `next`), A3 | S1's: `revive(value, claiming?)`, `setContainerTraceMaterializer(…, claiming?)`, the entry, `withStoreHydration` / `applyPatches` / `forwardIteratorReturn` `@internal` on the main entry; **not shipped:** S1's `prepareData` / `prepareArgs` (replaced by `prepareTier` before they exist on `next`). `reviveContainerTraces` / `setContainerTraceMaterializer` move behind the tier — **flag**: re-export lazily-resolving wrappers or accept the move | +| **C4** | **Regions tier.** `tier-regions.js` = `#bindRegions` / `#regionsFor` / `#discoverRegions` / `collectRegionElements` / `disposeRegions` / `makeFrameElement` / `isFrameRef`, the `{$frame}` arm of `#resolveArgs`, the `resolveSlot` / `resolveSlotRecord` / `removeSlotRecord` thread-up, `tableFor`'s prefix walk, `drainRecords`' `sc:region:` arm. The eager client keeps the `{$frame}` detection in `#resolveArgs` (one `isFrameRef` test → hold, registered). The rename machinery (`renameRegion` / `#reconcileRegions`, D) deletes outright — it is not moved. | **−489 / ≈ −480 / ≈ −480 / 0** (measured on frames: `T+regions` → `L8`; pages `est.` at the same cut) | `tier-regions.js` ≈ 1,900 min / **≈ 540 br** (`est.`) | `tier-regions-hold.spec` (new); `frames-regions-*`, lifecycle matrix region rows green; principles §4 row 19 (the rename compensations) deleted with D. Size: frames eager ≤ 10.75. | B, A2 (the adopt-path hold registers — no 3.1 gap to flag) | none (`createFrameElement` stays eager — it is `@experimental` public API, re-attribution §5.3 item 5) | +| **C5** | **Assets tier.** `tier-assets.js` = `ensureStylesheet` / `ensurePreload` / `ensureModulePreload` / `applyInlineStyles` / `qualifierValue` / `findHeadElement` / `PRELOAD_QUALIFIERS` / `#processedAssets` / `#styleFlush` / the assets pass; the eager client keeps `chunkToRecords`' `assets` case, `host.write`'s `seg::assets` accumulate, and the `#segmentReady` term. **Pin the two untested functions first** (`ensureStylesheet`, `applyInlineStyles` — the audit's 0-coverage gap) in the same PR. Alternative under decision 2: S10's route-through-`web` instead of a tier. | **−684 / ≈ −665 / ≈ −665 / 0** (measured on frames: `T+assets` → `L8`; the `noassets` full-client cut −665) | `tier-assets.js` ≈ 2,400 min / **≈ 760 br** (`est.`) | `tier-assets-ready.spec` (new, the FOUC guard); `frames-assets-*` green; the two new coverage pins. Size: frames eager ≤ 10.05. | B | none | +| **C6** | **Binding-slot tier** (E.c; largest, last of the tiers — its fallback needs the 3.1 hold for the event-replay window, which A2 provides). `tier-bind.js` = `bindDataOccurrence` (+ `valuesFor` / `write` / `release` / `writeText`; its second diff layer above `assign` — ≈ 300 B, D — deletes rather than moves), `slotPositions` / `slotEntry` / `textPosition` / `consumersOf` / `consumersEqual` / `ownedPositions` / `morphOwnedClass` / `morphOwnedStyle` / `applyOwned`, the `_s:` branch of `collectSlots`, the consumer-rebind arm of `#syncSlots`, the owned-position arms of `morphAttributes` / `reconcileChildren` / `#applyAttrs`, the `ctx.positions` branch of `slotsFor`; **`assign` leaves the eager frames client with it** (the page then keeps `assign` only through `dynamic`'s string tag — B.3, D). | **−1,546 / −2,504 / −2,542 / 0** (frames measured `T+bind` → `L8`; pages: the audit's E.c measurement — `assign` leaves on the page too) | `tier-bind.js` ≈ 5,000 min / **≈ 1,650 br** on frames (`est.`); on a page it carries `assign` as well (≈ +3,000 min / +900 br) unless B.3 has already made it lazy | `tier-bind-hold.spec` (new, incl. the click-replay arm); `frames-binding-slot-*`, `slot-positions-*`, #3704 / #3714 suites green. Size: frames eager ≤ 8.5 (both readings), page base ≤ 32.35, live ≤ 36.1. | B, **A2** (the hold registers; the replay window stays open); C3 (the `installTier` shape proven on the biggest chunk first) | none public (the `_s:` marker grammar is unchanged; `bindDataOccurrence` was never exported) | +| **D** | **Packaging remnants from the SC audit, if still relevant after tiering.** **S2 / C** `preserveModules` for `solid-js` / `@solidjs/web` (0 on single-entry scenarios; the enabler): lets the store **hydration adapters** (≈ 2.6 KB min, the ≈ 1.3 KB br S1 fell short of B.2's floor by) follow the engine into `container-trace.js`, and lets **B.3** (`dynamic`'s string-tag branch lazy, `staticElement` behind the seam) take `assign` off the page. **B.3:** page −2,372 / −2,391 br (audit measured), frames 0. **E.b** (sf natural-encoding bodies, codec-args message, `Retry-After` / trailer parsing lazy): −65 frames / −476 base / −519 live (audit floor). **E.c's other half** is C6. **Lazy codec:** already a chunk (22,986 / 6,074) — nothing to do. **Claims + event** (F.claims, F.event, 331 br): not a frames tier — they ride the router's chunk (the router installs `CLAIM_SEAM`); the frames client keeps the ≈ 60-B seam. | **≈ −400 / ≈ −4,100 / ≈ −4,200 / 0** (`est.`: claims+event −331 frames; B.3 −2,372, the adapters ≈ −1,300, E.b −476 on page base) | `dynamic-static.js` ≈ 8,000 min / ≈ 2.4 KB br; the sf natural-body chunk ≈ 1,600 min / ≈ 480 br; the router's claims chunk ≈ 900 min / ≈ 330 br | the audit's S2 band (single-entry scenarios ≤ ±50 B); B.3's hydration specs; `CLAIM_SEAM` tests with the router. Size: frames eager ≤ 8.1, page base ≤ 28.2, live ≤ 31.8. | C3, C6 (so what leaves with the engine and with `assign` is known) | B.3: `dynamic`'s string-tag branch becomes async-loading on first use (behaviour change accepted in audit §7 Q5 / B.3); the `CLAIM_SEAM` install moves to the router | +| **E** | **Budget restatement — principles §6 as per-tier lines** (§4's table is the draft). One line per eager default (both readings written, one picked), one per tier chunk, the page lines, the ratchet rule unchanged ("a ceiling increase requires a new mechanism row citing its axiom"), `floor-caps.json` gains the tier chunks as reported-not-counted lines with their own caps. | 0 | — | `check-floor-caps` clean on `next` | all | — | **Dependency check under the new order.** S-hold (A2) now precedes everything that holds: B's held set is A2's registered set; C3's, C4's and @@ -521,6 +563,31 @@ Each a yes/no with a recommendation. what turns the register's races from "likely" into "theoretical". **Ruled 2026-10-06: yes** — `modulepreload` + `_$HY.r["sc:tiers"]` + `X-Frame-Tiers`, as additive wire. + - **Sub-item for the maintainer (B, 2026-10-06): how the client finds + a tier's chunk URL.** The server does not know the client bundle's + chunks, so B built both halves of the recommended answer and they + compose: **(i)** the integration passes the URLs — + `frameTransformDirectResult(value, { id, args, tierUrls: { bind: +"/assets/tier-bind-abc.js", … } })` (SolidStart from its manifest; the + document emits a `` per announced tier only + when its URL is given, through the render's own asset registry so it + joins the shell head before the flush and dedupes with manifest links) + — and **(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 + (record, header, in-band); links only with URLs; the import map on the + client always. The alternative not built: resolving the URL 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` stays unexported from the + public client entry until a tier exists to warm (9 B br), and a sync + `renderToString` document announces nothing (no shared render slot; + the client detects — adding `live: {}` to the sync context would be a + core change on the `renderToString` floor). 4. **The plain-response streaming bound** — _"presumably not infinitely"_: should the server end a plain (non-`live`) response after a bound, with detectable truncation, `live` being the declared way past it? — diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index a98dbb91c..0b70a6e89 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -35,7 +35,14 @@ import type { Element as SolidElement } from "solid-js"; // already has). Kept external in rollup.config.js for the same reason the // server-functions/client import below is. import { insert, assign } from "@solidjs/web"; -import { createFrame, createFrameElement, createFrameHost, FRAME_ID_ATTR } from "./frame-client.js"; +import { + createFrame, + createFrameElement, + createFrameHost, + FRAME_ID_ATTR, + prepareTier, + tierLoaders +} from "./frame-client.js"; import { COMPONENT_BINDING, callFor, @@ -1622,6 +1629,21 @@ function adoptBoundary( return el as unknown as SolidElement; } +/** + * Options for `installServerComponents`. + * @experimental + */ +export interface InstallOptions { + /** + * Frames-client tiers by name → loader. A tier's module exports + * `install()`, called once the import resolves; every live frame is then + * flushed so what the tier makes applicable applies (a held occurrence + * mounts). A name with no loader is resident (eager). See + * `installServerComponents`. + */ + tiers?: Record Promise<{ install?(): void }>>; +} + /** * Installs the server-component transport policy on the server-function * client — the identity split (DR-1): CONTENT is keyed by the call's @@ -1637,13 +1659,26 @@ function adoptBoundary( * Call once in the client entry (an explicit call — the package is * `sideEffects: false`, so a bare import would be tree-shaken away); * call again to rebind to a custom host. + * + * `options.tiers` maps a frames-client tier's name to its loader (`() => + * import(...)`, the module exporting `install()`): the client resolves + * tier chunks itself, so the server announces NAMES only + * (`_$HY.r["sc:tiers"]`, `X-Frame-Tiers`) and the loads start here from + * the document's record — the `modulepreload` the document may also carry + * made the fetch warm. Nothing is tiered yet; the map is the seam a tier + * plugs into. * @experimental */ -export function installServerComponents(host: any = getFrameHost()) { +export function installServerComponents(host: any = getFrameHost(), options?: InstallOptions) { + const g = globalThis as any; + // The tier seam (frames savings pass §2): the loaders, then the tiers + // the document announced with its first server component's records — + // their imports start now, ahead of the adopt-time syncs that need them. + Object.assign(tierLoaders, options?.tiers); + g._$HY?.r?.["sc:tiers"]?.forEach(prepareTier); // Upgrade the document shell's placeholder bootstrap (if present): the // hydration data scripts resolved server-component references to stable // per-id placeholders; installing `impl` makes them mount-adopting. - const g = globalThis as any; if (!g._$SC) { // Mirror of the document bootstrap (frame-sink's // SERVER_COMPONENT_BOOTSTRAP_EXPR), for a page whose data scripts carried diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 9b8541e0b..6627c9915 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -23,10 +23,11 @@ import { DEV, OBSERVE } from "solid-js"; /** - * One transport chunk of a frame stream, addressed by frame `id`. + * One transport chunk of a frame stream, addressed by frame `id`. Any + * chunk may carry `tiers` (see `TierAnnouncement`). * @experimental */ -export type FrameChunk = +export type FrameChunk = ( | { type: "start"; id: string; version: number } | { type: "html"; @@ -124,7 +125,25 @@ export type FrameChunk = */ bound?: "yields" | "time"; } - | { type: "error"; id: string; version: number; key?: string; error: unknown }; + | { type: "error"; id: string; version: number; key?: string; error: unknown } +) & + TierAnnouncement; + +/** + * The in-band tier announcement (frames savings pass §2; additive — RFC 11 + * addendum): the frames-client tiers the render first needed since the + * last chunk left — `"bind"`, `"regions"`, `"assets"`, `"trace"`, + * `"wire"` — on whichever chunk leaves next. The response head carries the + * tiers minted before it (`X-Frame-Tiers`); a tier minted later rides + * here. The client starts each named tier's load before applying the + * chunk; a `data` chunk additionally AWAITS them (its node tree needs the + * tier to decode). Absent on every chunk of a response that minted nothing + * after its head, and ignored by a client that predates it. + * @experimental + */ +export interface TierAnnouncement { + tiers?: string[]; +} /** * One store write applied to a frame: `r` maps record keys to values @@ -377,8 +396,9 @@ export interface FrameOptions { reveal?(seam: { before: Node; fallback: Node[]; content: () => Node | DocumentFragment }): void; /** * Adopt path only. Called when a sync leaves an adopted occurrence - * waiting for its args record while none was before; returns the release, - * called when a sync leaves none waiting or the frame disposes. The + * waiting — for its args record, for a read of it to settle, or for the + * tier its mount needs to load — while none was before; returns the + * release, called when a sync leaves none waiting or the frame disposes. The * integration registers the hold with whatever counts its page as not * yet settled (hydration-done counts it as a pending boundary — * frames-rulings 3.1): a claim the frame has not made yet is page work @@ -1067,6 +1087,71 @@ export function createFrameHost(options = {}) { */ export const FRAME_APPLIED_EVENT = "frame:applied"; +// === Tiers (frames savings pass §2: the server-announced tier mechanism) === +// +// A TIER is a named slice of the frames client's capability that may live +// in its own chunk and load on demand: `bind` (binding-slot positions), +// `regions` (nested server-content regions), `assets`, `trace` (container +// traces), `wire` (the live loop). The server knows at render time which +// of them a response or a document needs — it mints the feature — and +// ANNOUNCES the names (`X-Frame-Tiers` on a stream, `_$HY.r["sc:tiers"]` +// plus `modulepreload` links on a document, `chunk.tiers` in-band), so the +// client starts the import in parallel with the content instead of at the +// 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. +// +// Nothing is tiered in this step — every capability is eager, so no name +// has a loader and every tier is resident by definition. `tierLoaders` is +// the seam a tier plugs into (`installServerComponents({ tiers })`, or the +// built-in table once a tier's chunk exists): `name -> () => import(...)`, +// the module exporting an `install()` that registers its appliers into +// this runtime's dispatch. +/** @type {Record Promise<{ install?(): void }>>} */ +export const tierLoaders = {}; +// `name -> the load`, a promise stamped `r` (resident) once the module has +// installed. One per name for the page's lifetime: tiers never uninstall. +const tierLoads = {}; +// Every live frame, so an install can wake them all: a frame whose sync +// held an occurrence on the tier re-syncs and mounts it; the rest see a +// no-op flush. +const liveFrames = new Set(); + +/** + * Start (or join) a tier's load. Idempotent per name; a name with no loader + * is resident already and resolves at once. Resolves once the module has + * installed and every live frame has been flushed. + * @internal The frames client's own seam (the announcement reads call it). + */ +export function prepareTier(name: string): Promise; + +export function prepareTier(name) { + let load = tierLoads[name]; + if (!load) { + const loader = tierLoaders[name]; + tierLoads[name] = load = loader + ? loader().then(module => { + // The install: the module registers its appliers, then one flush + // per live frame — the write is empty, so a frame re-walks what + // it holds and applies what the tier now makes applicable (the + // held occurrence mounts and its hold releases; a buffered + // record applies). A frame with no version yet keeps none. + load.r = true; + module.install?.(); + for (const frame of liveFrames) frame.apply({ version: frame.version, r: {} }); + }) + : Promise.resolve(); + } + return load; +} + +/** + * Whether a tier's code is resident — eager (no loader), or installed. A + * tier that is neither has its load started here (the un-announced + * fallback: detection at the readiness check), and the caller holds. + */ +const tierReady = name => !tierLoaders[name] || prepareTier(name).r; + class FrameImpl { // A frame renders either into an element (element boundary: #start/#end // null) or between two comment markers within some parent (range boundary). @@ -1153,6 +1238,8 @@ class FrameImpl { this.#end = end; this.#options = options; this.#slots = options.slots; + // Enumerable for a tier's install (see `prepareTier`), until disposal. + liveFrames.add(this); // Adopt: the boundary already holds server-rendered content, so the first // root apply morphs against it rather than materializing from scratch. // That content never ran compiled creation code, so sweep its claimable @@ -1553,7 +1640,24 @@ class FrameImpl { // boundary (which would hold the frame's own address follow behind // the fallback). A read the response never answers settles rejected // at its end: the mount runs then and the read throws (L1). - if (!mounted && record && record.pending) { + // + // A fresh mount also waits for the TIER its occurrence needs (frames + // savings pass §2 — the server-announced tier mechanism): a data + // occurrence needs `bind` (its positions), a called occurrence whose + // record names a region needs `regions`. Resident tiers (every tier, + // until one is cut) cost one test; an absent one has its load started + // by the check (`tierReady`) and the occurrence stays as the server + // left it — its interior on screen, its positions at the server's + // values — until the install's flush re-syncs. On the adopt path this + // wait is one more reason in the frame's registered hold (3.1): the + // delegated-event replay window stays open, hydration-done waits. + // (A trace in the args is the trace tier's reason — it lands with the + // tier's cut, where the marker scan is.) + if ( + !mounted && + ((record && record.pending) || + (consumers ? !tierReady("bind") : record && record.regions && !tierReady("regions"))) + ) { waiting = true; continue; } @@ -1669,10 +1773,11 @@ class FrameImpl { } // The frame's hold (frames-rulings 3.2): ONE registration with the // integration while a sync leaves an occurrence waiting to mount — - // for its record, or for the record's reads to settle — released by - // the first sync that leaves none, or by disposal. The waits are - // bounded as a `` resume's is: the record by its declared - // value settling, the read by the stream's `complete`/`:error`. + // for its record, for the record's reads to settle, or for its tier + // — released by the first sync that leaves none, or by disposal. The + // waits are bounded as a `` resume's is: the record by its + // declared value settling, the read by the stream's + // `complete`/`:error`, the tier by its load settling. if (waiting && !this.#hold) this.#hold = this.#options.hold?.(); else if (!waiting && this.#hold) this.#releaseHold(); } @@ -2014,6 +2119,7 @@ class FrameImpl { const { host, id } = this.#options; if (host && id !== undefined) host.unregister(id, this); this.#disposed = true; + liveFrames.delete(this); this.#releaseHold(); for (const key of [...this.#slotCleanups.keys()]) this.#runSlotCleanups(key); // Release this frame's occurrences' records from the store that owns them diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 1bb3af1c5..781b9c59a 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -219,6 +219,7 @@ import { observeFrame } from "../../src/server-observe.js"; import { FRAME_HAVE_HEADER, FRAME_STREAM_HEADER, + FRAME_TIERS_HEADER, decodeHaveList, SERVER_COMPONENT, SERVER_COMPONENT_ADDRESS, @@ -387,11 +388,35 @@ function withHoles(chunk, holes) { */ export function createFrameSink(write, frame, have, hooks) { const { id, version } = frame; - // Every emission passes here, so a sweep knows whether it produced one. + // The tiers this render has minted (frames savings pass §2, the + // server-announced tier mechanism): the frames-client capabilities the + // response needs, learned where the sink mints each feature — a nested + // region (`regions`), an assets chunk (`assets`), a binding-slot position + // read (`bind`), a container trace in an arg (`trace`), a `live` response + // (`wire`). `announce()` hands the set to the response head + // (`X-Frame-Tiers`); a name minted since the last chunk left rides + // IN-BAND on the next one (`chunk.tiers`) — so a tier the head could not + // know (a later segment's) is still announced ahead of, or with, the + // chunk that needs it. Both are warm starts for the client's loads; its + // readiness checks detect and hold regardless. + const tiers = new Set(); + let staged = []; + // Every emission passes here, so a sweep knows whether it produced one, + // and a staged tier announcement leaves with the next chunk. const emit = chunk => { if (swept) swept.emitted = true; + if (staged.length) { + chunk.tiers = staged; + staged = []; + } write(chunk); }; + const needs = name => { + if (!tiers.has(name)) { + tiers.add(name); + staged.push(name); + } + }; // Conditional emission (Stage 8 B4, RFC 11 §9.5 Server face 2). `have` // is the client's ledger for this address; `conditional` arms once the // shell decides the client's structure stands (skeleton digests equal) @@ -552,6 +577,7 @@ export function createFrameSink(write, frame, have, hooks) { chunk.preloads.push(wirePreload(entry)); } } + needs("assets"); emit(chunk); } emit(withHoles({ type: "html", id, version, html, digest }, holeDigests(html, attrText))); @@ -633,6 +659,7 @@ export function createFrameSink(write, frame, have, hooks) { if (inline.length) { chunk.inlineStyles = inline.map(e => ({ id: e.id, content: e.content, attrs: e.attrs })); } + needs("assets"); emit(chunk); } emit( @@ -692,8 +719,10 @@ export function createFrameSink(write, frame, have, hooks) { // style links on the fragment path. Emitting them here too would // duplicate, mis-keyed to the root. if (type === "module") { + needs("assets"); emit({ type: "assets", id, version, key: "", modules: [value] }); } else if (type === "preload") { + needs("assets"); emit({ type: "assets", id, version, key: "", preloads: [wirePreload(value)] }); } }, @@ -728,7 +757,9 @@ export function createFrameSink(write, frame, have, hooks) { // A nested server-content region (a `{$frame}` slot arg): its html is a // chunk addressed to the CHILD frame id — the consumer binds a nested // frame to the arg's marker range and the host routes/buffers by id. + // The first region minted is the `regions` tier announced. region(childId, html) { + needs("regions"); emit({ type: "html", id: childId, version, html }); }, // A live-hole re-emission (Stage 3): the hole's re-resolved HTML, keyed @@ -785,6 +816,23 @@ export function createFrameSink(write, frame, have, hooks) { /** The current commit epoch (see the ledger comment above). */ get epoch() { return epoch; + }, + // ---- tiers (see `tiers` above) ---- + /** + * This render needs the named frames-client tier: announced on the + * response head if it has not left, in-band on the next chunk if it + * has. Called where the sink mints the tier's feature; idempotent. + */ + needs, + /** + * The tiers minted so far, for the response head — every one the sync + * render pass produced, the stream face's `X-Frame-Tiers`. Names it + * returns need no in-band announcement; what is minted later rides the + * next chunk. + */ + announce() { + staged = []; + return [...tiers]; } }; } /** @@ -901,6 +949,11 @@ const DEFAULT_MAX_DURATION_MS = 30_000; // The abort reason a response's own `cancel` tears its render down with: // the reader left, so the stream must not dress the end as a bound. const DISCONNECTED = Symbol("solid.frames.disconnected"); +// A pipe writable's hook for the tiers the sync render pass minted (see +// `createFrameSink`'s `announce`): `serverComponentResponse` writes them +// into the response head, which it builds right after the pass. Module- +// private — the `pipe` writable's public shape is `write`/`end`. +const ANNOUNCE = Symbol("solid.frames.announce"); function frameStream(makeCode, options) { const { id = "", version = 1 } = options.frame || {}; @@ -985,6 +1038,9 @@ function frameStream(makeCode, options) { } : undefined ); + // A `live` response is the wire tier's: the standing connection the + // client holds after this answer is that tier's code. + if (options.live) sink.needs("wire"); w.write({ type: "start", id, version }); // A caller already gone has nobody to render for. if (upstream && upstream.aborted) return finish(); @@ -1026,6 +1082,10 @@ function frameStream(makeCode, options) { observation && observation.settle("error", err); finish(); } + // The sync render pass is over: the tiers it minted are what the + // response head can carry (the shell's own flush, and everything after + // it, is asynchronous — those mints ride in-band). + if (w[ANNOUNCE]) w[ANNOUNCE](sink.announce()); } return { pipe: stream, @@ -1095,13 +1155,18 @@ function isRangeKey(key) { const c = key.charCodeAt(0); return c === 36 /* $ */ || (c >= 48 && c <= 57) /* index */ || RANGE_KEYS.has(key); } -function slotProxy(range, occurrence, face, content, onData) { +// `needs(tier)` is the face's tier announcement (createFrameSink's `needs` +// on a stream, `documentNeeds` on a document): a property read is a +// binding-slot position minted, and the `bind` tier announced with it. +function slotProxy(range, occurrence, face, content, onData, needs) { return new Proxy(range, { get(target, key, receiver) { if (typeof key !== "string" || isRangeKey(key)) return Reflect.get(target, key, receiver); // The first property read fixes the proxy's face as DATA (see // repeatKey): a placed range never gets here. if (onData) onData = void onData(); + // A read off markup binds nothing (a dev finding at the position). + if (face !== SLOT_FACE_MARKUP) needs("bind"); return slotValue(occurrence, key, content ? content[key] : undefined, face); } }); @@ -1117,8 +1182,8 @@ function slotProxy(range, occurrence, face, content, onData) { * client an object where it expects the value (`{ k, v, f }` — and on the * document face `v` is the t=0 value, which hydration then contradicts). */ -function argBorderForm(value, key, occurrence) { - return toBorderForm(withoutStandIns(value, key, occurrence), true); +function argBorderForm(value, key, occurrence, needs) { + return toBorderForm(withoutStandIns(value, key, occurrence, needs), true); } /** @@ -1130,12 +1195,18 @@ function argBorderForm(value, key, occurrence) { * objects alone; anything exotic is the app's and is not read. Both faces * take the same arg: the document face's t=0 fill reads what hydration * will (see createDocumentSlotProps), the records carry it. + * + * A container met here (at any depth) is a trace the serializer will + * stamp: the `trace` tier announced (`needs`, the face's). */ -function withoutStandIns(value, key, occurrence) { +function withoutStandIns(value, key, occurrence, needs) { return rewriteTree( value, (v, path) => { - if (isContainerTraced(v)) return v; + if (isContainerTraced(v)) { + needs("trace"); + return v; + } if (Object.getPrototypeOf(v) === Object.prototype && isSlotValue(v)) { if ("_SOLID_DEV_") standInArgFinding(v, key, occurrence, path); return undefined; @@ -1469,7 +1540,14 @@ export function createDocumentSlotProps(clientProps, frameId) { r.$occurrence = occurrence; const face = slotFace(r, content); r.$face = face; - return slotProxy(r, occurrence, face, face === SLOT_FACE_DATA ? content : undefined); + return slotProxy( + r, + occurrence, + face, + face === SLOT_FACE_DATA ? content : undefined, + undefined, + documentNeeds + ); }; // Client content renders under a per-occurrence hydration-key OWNER // scope, so the adopting client re-renders each slot under the SAME @@ -1655,12 +1733,17 @@ export function createDocumentSlotProps(clientProps, frameId) { // itself: settled reads pass through; a pending read throws // not-ready into the hole machinery — a per-arg suspend, the // value tier's own behavior. The record ships the proxy, which - // the serializer's trace plugin carries as snapshot + patches. + // the serializer's trace plugin carries as snapshot + patches + // — the `trace` tier, announced. + documentNeeds("trace"); resolved[key] = value; } else if (isServerContent(value)) { const childId = `${frameId}.${occurrence}.${key}`; const region = { key, childId, value, used: false, locked: false }; regions.push(region); + // Used or occluded, the record names it `{$frame}`: the + // `regions` tier, announced. + documentNeeds("regions"); resolved[key] = () => { // Streaming occlusion lock: the usage flip below runs at the // wrapper's SYNCHRONOUS return, but a wrapper that places this @@ -1714,7 +1797,7 @@ export function createDocumentSlotProps(clientProps, frameId) { // `undefined` in the record (argBorderForm), so the t=0 fill // takes the same value — the one-record shape holds for the // args the fill saw, not only the ones it shipped. - resolved[key] = vals[key] = withoutStandIns(value, key, occurrence); + resolved[key] = vals[key] = withoutStandIns(value, key, occurrence, documentNeeds); } } const out = suppressedFill(() => @@ -1747,7 +1830,7 @@ export function createDocumentSlotProps(clientProps, frameId) { if (!isContainerTraced(value) && isServerContent(value)) continue; // Containers (at any depth) ride the record as trace envelopes; // everything else passes through by reference. - args[key] = argBorderForm(value, key, occurrence); + args[key] = argBorderForm(value, key, occurrence, documentNeeds); } // A CLONE settles the declaration (or IS the record, on a sync // render); `args` stays canonical for the ledger below — @@ -1790,7 +1873,7 @@ export function createDocumentSlotProps(clientProps, frameId) { evals[key], states[key], value => { - args[key] = argBorderForm(value, key, occurrence); + args[key] = argBorderForm(value, key, occurrence, documentNeeds); liveArgs.slot(frameId, occurrence, { ...args }); } ); @@ -1875,6 +1958,43 @@ const FRAME_ELEMENT_CLOSE = ``; * never saw the first) rides `ctx.live`, the shared slot the root context * creates and every derived context inherits by reference. */ +/** + * The document face's tier announcement (frames savings pass §2): the + * `sc:tiers` hydration record — the names of the frames-client tiers the + * document's server components have minted, `_$HY.r["sc:tiers"] = + * ["bind", …]` — and, when the integration gave the tier's chunk URL + * (`frameTransformDirectResult`'s `tierUrls`), a `modulepreload` for it in + * the head. The client's `installServerComponents` reads the record and + * starts each import; the preload made it warm. + * + * The record is RE-WRITTEN at each new mint with the cumulative list: + * written once at the first mint, a later component's tier (or one inside + * a boundary that resumes before the shell) would be missing from it, + * and the hydration serializer parses a value when it is written. Every + * write before the shell flush lands in the shell's data script (the last + * assignment wins — the complete set); one after it is a later script, + * which the client may or may not have run by install — the adopt-time + * sync's own detection covers that (the hold). The link goes through the + * render's asset registry (`registerAsset("module")`: joins the shell head + * before the flush, writes into the stream after; identity-deduped). + * + * Carrier: `ctx.live`, the one slot the streaming root context shares by + * reference with every component's context copy (see + * `armDocumentLiveHoles`). A sync render (`renderToString`) has no shared + * slot and no later script — it announces nothing; the client detects. + */ +function documentNeeds(name) { + const ctx = sharedConfig.context; + const live = ctx && ctx.live; + if (!live || !ctx.serialize) return; + const tiers = live.tiers || (live.tiers = []); + if (tiers.includes(name)) return; + tiers.push(name); + ctx.serialize("sc:tiers", tiers.slice()); + const url = live.tierUrls && live.tierUrls[name]; + if (url && ctx.registerAsset) ctx.registerAsset("module", url); +} + function armDocumentLiveHoles(ctx) { if (!ctx || ctx.liveHoles !== undefined) return; const live = ctx.live; @@ -2025,7 +2145,21 @@ function armDocumentLiveHoles(ctx) { */ export function frameTransformDirectResult( value: T, - options: { id: string; args?: unknown[] } + options: { + id: string; + args?: unknown[]; + /** + * The client chunk URL of each frames-client tier, by tier name (the + * integration's, from its manifest — the server does not know the + * client's chunks). Given, the document emits a `modulepreload` for a + * tier when a server component first mints its feature, beside the + * `sc:tiers` record it writes either way; absent, the record alone + * announces the name and the client's `installServerComponents` starts + * the import from it. Document-wide: the first call's map is the + * render's. + */ + tierUrls?: Record; + } ): T; /** @@ -2036,7 +2170,7 @@ export function frameTransformDirectResult( * ELEMENT around it, document-mode slot props inside. HTTP calls are * untouched (`frameTransformResult` owns that leg). */ -export function frameTransformDirectResult(value, { id, args }) { +export function frameTransformDirectResult(value, { id, args, tierUrls }) { if (typeof value !== "function") return value; const component = value; const wrapped = props => [ @@ -2048,6 +2182,10 @@ export function frameTransformDirectResult(value, { id, args }) { serverOwned(() => { const page = sharedConfig.context; armDocumentLiveHoles(page); + // The tier chunk URLs, for the document's `modulepreload` links (see + // documentNeeds) — on the shared slot, so every component's mint + // reads them. + if (tierUrls && page && page.live && !page.live.tierUrls) page.live.tierUrls = tierUrls; // Handler positions: arm the compiled `ssrClaim` guard — and the // spread walk's slot probes — for this subtree, on a render context // DERIVED from the page's (prototype: every shared field and method @@ -2071,8 +2209,11 @@ export function frameTransformDirectResult(value, { id, args }) { // every async source inside takes its first value and closes — the // document completes, and the standing render is the client's // connection after hydration (RFC 11 §9.5, Server face 3). Read at - // render, not at wrap: the brand arrives from `live`, outside. - return serverComponentScope(() => component(slotProps), !!wrapped[LIVE_SOURCE]); + // render, not at wrap: the brand arrives from `live`, outside. That + // connection is the wire tier's — announced with the document. + const live = !!wrapped[LIVE_SOURCE]; + if (live) documentNeeds("wire"); + return serverComponentScope(() => component(slotProps), live); } finally { sharedConfig.context = page; } @@ -2311,6 +2452,9 @@ export function createSlotProps(sink, frame) { const getters = new Map(); // Repeated calls are one occurrence within a render (see repeatKey). const repeats = new Map(); + // The tier announcement (createFrameSink's `needs`); a composed sink + // without one announces nothing — the client detects and holds. + const needs = sink.needs || (() => {}); return new Proxy(Object.create(null), { // Every key virtually exists — a prop is a *position* the client may // fill, and the server cannot know which ones the client supplied. This @@ -2332,7 +2476,7 @@ export function createSlotProps(sink, frame) { if (!fn) { fn = (...callArgs) => { if (callArgs.length === 0 || callArgs[0] === undefined) { - return slotProxy(slotRange(prop), prop, SLOT_FACE_STREAM, undefined); + return slotProxy(slotRange(prop), prop, SLOT_FACE_STREAM, undefined, undefined, needs); } const rk = repeatKey(prop, callArgs[0]); const repeat = rk !== undefined && repeats.get(rk); @@ -2480,8 +2624,11 @@ export function createSlotProps(sink, frame) { } else { const ref = `arg:${occurrence}:${key}`; // Containers (at any depth) swap for their trace envelopes - // before the value meets seroval — see toBorderForm. - ctx.serialize(ref, argBorderForm(value, key, occurrence)); + // before the value meets seroval — see toBorderForm. A + // container announces the `trace` tier (`sink.needs`) + // before the serializer emits the record's data chunk, so + // the announcement rides that very chunk. + ctx.serialize(ref, argBorderForm(value, key, occurrence, needs)); args[key] = { $ref: ref }; if (evaluate && !state) state = { settled: true, last: value }; } @@ -2511,7 +2658,7 @@ export function createSlotProps(sink, frame) { } else { const ref = `arg:${occurrence}:${key}@${sink.nextArgRef(ledgerKey)}`; sink.mintRef(ref); - ctx.serialize(ref, argBorderForm(value, key, occurrence)); + ctx.serialize(ref, argBorderForm(value, key, occurrence, needs)); args[key] = { $ref: ref }; } sink.slot(occurrence, { ...args }); @@ -2526,7 +2673,8 @@ export function createSlotProps(sink, frame) { occurrence, SLOT_FACE_STREAM, undefined, - rk === undefined || keyed ? undefined : () => repeats.has(rk) || repeats.set(rk, out) + rk === undefined || keyed ? undefined : () => repeats.has(rk) || repeats.set(rk, out), + needs ); if (keyed) repeats.set(rk, out); return out; @@ -2654,7 +2802,16 @@ export function serverComponentResponse(component, options = {}, init = {}) { closed = true; } }, - end + end, + // The tiers the sync render pass minted, into the response head + // (`X-Frame-Tiers`; frames savings pass §2). `start` runs + // synchronously inside the `ReadableStream` constructor, so this + // fires before the `Response` below is built — the head carries + // what the render knew at its first flush; anything later rides + // in-band on a chunk. Omitted when the pass minted nothing. + [ANNOUNCE](tiers) { + if (tiers.length) headers.set(FRAME_TIERS_HEADER, tiers.join(",")); + } }); }, cancel() { diff --git a/packages/web/frames/src/frame-transport.ts b/packages/web/frames/src/frame-transport.ts index ecb223c7f..2c59c3498 100644 --- a/packages/web/frames/src/frame-transport.ts +++ b/packages/web/frames/src/frame-transport.ts @@ -33,7 +33,7 @@ const IS_OBSERVE = "_SOLID_OBSERVE_" as unknown as boolean; // experimental preview, excluded from the 2.0 stability guarantee: API // shapes and the wire format may change between prereleases (RFC 11). // Every export in this module is @experimental. -import { FrameChunk, FrameHost } from "./frame-client.js"; +import { FrameChunk, FrameHost, prepareTier } from "./frame-client.js"; import { JSONCodecOptions } from "../../serialization/src/serializer-decode.js"; @@ -113,6 +113,20 @@ export interface ServerComponentHandlerOptions { */ export const FRAME_STREAM_HEADER = "X-Frame-Stream"; +/** + * The tiers a frame stream needs (frames savings pass §2, the + * server-announced tier mechanism): the names, comma-separated, of the + * frames-client tiers the render had minted when the response head left + * — `bind,regions,assets,trace,wire` — so the client starts their loads + * before it reads the body. Omitted when nothing was minted. A tier first + * needed after the head rides in-band instead: `tiers` on the next chunk + * (`FrameChunk.tiers`). Additive wire: an old client ignores both; a new + * client without them falls back to detection at the readiness check and + * holds (frames-rulings 3.1) — the same DOM, later. + * @experimental + */ +export const FRAME_TIERS_HEADER = "X-Frame-Tiers"; + /** * The resume request's have-list (RFC 11 §9.5, Resume request): the * digests the client holds for the address it is reconnecting — the root @@ -247,6 +261,11 @@ async function observedApplyFrameResponse(response, host, options = {}) { */ function applyFrames(response, host, options = {}, observation) { const rootId = response.headers.get(FRAME_STREAM_HEADER) ?? ""; + // The announced tiers (see FRAME_TIERS_HEADER): their loads start here, + // before the body is read, so a tier downloads in parallel with the + // stream and is resident by the time a frame's sync needs it. Idempotent + // and a no-op for a tier with no loader (eager). + response.headers.get(FRAME_TIERS_HEADER)?.split(",").forEach(prepareTier); const as = options.as; const version = options.version; const perFrame = typeof version === "function" ? new Map() : null; @@ -311,13 +330,22 @@ function applyFrames(response, host, options = {}, observation) { // The observe tier's chunk census (see `observedApplyFrameResponse`); // folds with the literal. if (IS_OBSERVE && observation) observation.chunk(chunk, wireId); + // In-band tier announcement (`chunk.tiers`, see FRAME_TIERS_HEADER): + // a tier the render first needed after the head left names itself + // on the next chunk out; its load starts here. A `data` chunk that + // carries the name is one whose node tree NEEDS the tier to decode + // (the serializer minted the feature on this very record — a + // container trace's plugin node materializes into a live container + // only with the tier resident), so it awaits the load, as it awaits + // the codec below. + const needs = chunk.tiers?.map(prepareTier) || []; // Codec-free until a `data` chunk actually arrives: a host whose // deserializer loads lazily (`prepareData`) gets awaited here, and // because the loop is sequential every later chunk — the records // referencing this data included — queues behind the load. Chunk // ORDER is the only contract downstream (network jitter already // stretches time between chunks), so nothing else observes the wait. - if (chunk.type === "data" && host.prepareData) await host.prepareData(); + if (chunk.type === "data") await Promise.all([host.prepareData?.(), ...needs]); host.apply(chunk); } result = await reader.next(); diff --git a/packages/web/frames/src/server.ts b/packages/web/frames/src/server.ts index 5d24b1879..57fcf08ce 100644 --- a/packages/web/frames/src/server.ts +++ b/packages/web/frames/src/server.ts @@ -155,5 +155,8 @@ export { FRAME_STREAM_HEADER, FRAME_HAVE_HEADER, FRAME_HAVE_BUDGET, + // The tier announcement's header name (frames savings pass §2). The client + // entry reads it but does not re-export it (its one use inlines). + FRAME_TIERS_HEADER, isFrameStreamResponse } from "./frame-transport.js"; diff --git a/packages/web/test/consistency/support.ts b/packages/web/test/consistency/support.ts index d6ee7efa8..6e7916ddd 100644 --- a/packages/web/test/consistency/support.ts +++ b/packages/web/test/consistency/support.ts @@ -332,7 +332,16 @@ export interface Page { * shell parses. `fetch` is stubbed to THROW unless the test re-stubs it — * the document face must never request what the page carries. */ -export function bootPage(shellHtml: string, options: { hostOptions?: Record } = {}) { +export function bootPage( + shellHtml: string, + options: { + hostOptions?: Record; + /** Tier loaders for `installServerComponents({ tiers })` (frames savings pass §2). */ + tiers?: Record Promise<{ install?(): void }>>; + /** `_$HY.r` records present before the frames client installs (the shell's data script). */ + records?: Record; + } = {} +) { installDocumentRuntime(); const hy: any = { events: [], completed: new WeakSet(), r: {}, fe() {} }; (globalThis as any)._$HY = hy; @@ -357,6 +366,7 @@ export function bootPage(shellHtml: string, options: { hostOptions?: Record table.apply(c), @@ -372,9 +383,9 @@ export function bootPage(shellHtml: string, options: { hostOptions?: Record void; + const promise = new Promise<{ install?(): void }>(r => (resolve = r)); + const install = vi.fn(); + const loader = vi.fn(() => promise); + return { loader, install, resolve: () => resolve({ install }) }; +} + +/** A held frame-stream Response with the given extra headers. */ +function heldResponse(id: string, headers: Record = {}) { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(c) { + controller = c; + } + }); + return { + response: new Response(body, { headers: { "X-Frame-Stream": id, ...headers } }), + send: (chunk: any) => controller.enqueue(createChunk(JSON.stringify(chunk))), + close: () => controller.close() + }; +} + +let page: Page | undefined; +const disposers: (() => void)[] = []; +afterEach(async () => { + for (const d of disposers.splice(0)) d(); + await page?.cleanup(); + page = undefined; + vi.unstubAllGlobals(); + delete (globalThis as any)._$SC; + document.body.innerHTML = ""; +}); + +describe("the held set — bind (adopt path, un-announced: detection starts the load)", () => { + // The ONE test for `bind` while it is not resident (see the module doc). + test("an adopted data occurrence waits for its tier: positions untouched, the hold registers under 3.1, hydration-done waits; the install flushes every frame and mounts them", async () => { + const bind = deferredTier(); + const fidA = freshFid("tier-bind-a"); + const fidB = freshFid("tier-bind-b"); + // Two boundaries, each one binding-slot occurrence with the server's + // t=0 value at its position. + const row = (fid: string) => + frameHtml(fid, `
  • item
`); + // No `sc:tiers` record — the page did not announce; the records the + // occurrences need are present, so only the tier holds them. + page = bootPage(row(fidA) + row(fidB), { tiers: { bind: bind.loader } }); + page.slotRecord(fidA, "row#0", { id: 1 }); + page.slotRecord(fidB, "row#0", { id: 2 }); + // Nothing announced: no load yet. + expect(bind.loader).not.toHaveBeenCalled(); + const A = (globalThis as any)._$SC.r(fidA); + const B = (globalThis as any)._$SC.r(fidB); + const runs: number[] = []; + let runsAtEnd = -1; + let inProgressAtEnd: boolean | undefined; + const fill = (p: any) => { + runs.push(p.id); + return { cls: `client-${p.id}` }; + }; + const dispose = hydrate(() => [, ], page.container); + disposers.push(dispose); + onHydrationEnd(() => { + runsAtEnd = runs.length; + inProgressAtEnd = hydrationInProgress(); + }); + await quiesce(); + // The adopt-time sync found the tier absent: it started the load + // (detection, the un-announced fallback) — once for two frames… + expect(bind.loader).toHaveBeenCalledTimes(1); + // …and HELD: the fills did not run, the positions are the server's. + expect(runs).toEqual([]); + const lis = () => [...page!.container.querySelectorAll("li")]; + expect(lis().map(li => li.className)).toEqual(["server", "server"]); + // The hold is a pending boundary (3.1): hydration is not done. + expect(hydrationInProgress()).toBe(true); + expect(runsAtEnd).toBe(-1); + // The tier lands: its install hook runs once, then one flush per live + // frame — both occurrences mount with the records they were held on. + bind.resolve(); + await quiesce(); + await quiesce(); + expect(bind.install).toHaveBeenCalledTimes(1); + expect(runs.sort()).toEqual([1, 2]); + expect( + lis() + .map(li => li.className) + .sort() + ).toEqual(["client-1", "client-2"]); + // The hold released; hydration-done came after the mounts, not before. + expect(hydrationInProgress()).toBe(false); + expect(inProgressAtEnd).toBe(false); + expect(runsAtEnd).toBe(2); + expect(page.warnings.filter(w => w.includes("unclaimed"))).toEqual([]); + expect(page.errors).toEqual([]); + // Resident from here on: a later ask resolves without a second import. + await prepareTier("bind"); + expect(bind.loader).toHaveBeenCalledTimes(1); + }); +}); + +describe("the stream path — regions (announced on the head; the record buffers until the install)", () => { + // The ONE test for `regions` while it is not resident. + test("`X-Frame-Tiers` starts the load before the body is read; the occurrence's record stays pending in the store until the install's flush mounts it", async () => { + const regions = deferredTier(); + const WIRE = "tier/regions-wire"; + const fid = freshFid("tier-regions"); + const getPanel = createServerReference(fid); + const held = heldResponse(WIRE, { "X-Frame-Tiers": "regions" }); + vi.stubGlobal("fetch", async () => held.response); + const { host } = makeHost(); + installServerComponents(host, { tiers: { regions: regions.loader } }); + const Page = dynamic(() => getPanel() as any); + let mounts = 0; + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
+ fallback}> + { + mounts++; + return
{p.body}
; + }} + /> +
+
; + document.body.appendChild(div); + return d; + }); + disposers.push(dispose); + await pump(); + // The head announced it: the load started at the response, before + // any chunk of the body was read. + expect(regions.loader).toHaveBeenCalledTimes(1); + // The body: the record names a region, the shell carries its range, + // the region's own html follows. + held.send({ type: "start", id: WIRE, version: 1 }); + held.send({ + type: "slot", + id: WIRE, + version: 1, + key: "panel#0", + args: { body: { $frame: `${WIRE}.panel#0.body` } } + }); + held.send({ + type: "html", + id: WIRE, + version: 1, + html: "
" + }); + held.send({ + type: "html", + id: `${WIRE}.panel#0.body`, + version: 1, + html: "server body" + }); + held.send({ type: "complete", id: WIRE, version: 1 }); + held.close(); + await pump(); + // The shell landed (the frame has content); the occurrence did NOT + // mount — its tier is absent, so the record stays pending in the store. + expect(div.querySelector("article")).not.toBeNull(); + expect(mounts).toBe(0); + expect(div.querySelector(".wrap")).toBeNull(); + // The install: one flush, the pending record applies — the occurrence + // mounts with the record it was held on, its region inside. + regions.resolve(); + await pump(); + expect(regions.install).toHaveBeenCalledTimes(1); + expect(mounts).toBe(1); + expect(div.querySelector(".wrap em")!.textContent).toBe("server body"); + // Idempotent: the header's ask and the sync's are one load. + expect(regions.loader).toHaveBeenCalledTimes(1); + }); +}); + +describe("prepareTier", () => { + test("is idempotent per name: one import, the same promise; a name with no loader is resident at once", async () => { + const t = deferredTier(); + Object.assign(tierLoaders, { "tier/idem": t.loader }); + const p1 = prepareTier("tier/idem"); + const p2 = prepareTier("tier/idem"); + expect(p1).toBe(p2); + expect(t.loader).toHaveBeenCalledTimes(1); + expect((p1 as any).r).toBeUndefined(); + t.resolve(); + await p1; + expect(t.install).toHaveBeenCalledTimes(1); + expect((p1 as any).r).toBe(true); + expect(prepareTier("tier/idem")).toBe(p1); + expect(t.loader).toHaveBeenCalledTimes(1); + // No loader: nothing to import, resolved. + const eager = prepareTier("tier/no-such-loader"); + await eager; + expect(prepareTier("tier/no-such-loader")).toBe(eager); + }); + + test("a module without an `install` hook installs too (resident on load)", async () => { + let resolve!: (m: any) => void; + Object.assign(tierLoaders, { "tier/bare": () => new Promise(r => (resolve = r)) }); + const p = prepareTier("tier/bare"); + resolve({}); + await p; + expect((p as any).r).toBe(true); + }); +}); + +describe('the document face — `_$HY.r["sc:tiers"]`', () => { + test("installServerComponents reads the record and starts each named tier's load; no record, no load", async () => { + const t = deferredTier(); + const u = deferredTier(); + const fid = freshFid("tier-record"); + page = bootPage(frameHtml(fid, "

plain

"), { + tiers: { "tier/rec-a": t.loader, "tier/rec-b": u.loader, "tier/rec-c": u.loader }, + records: { "sc:tiers": ["tier/rec-a", "tier/rec-b"] } + }); + // Started at install — before any boundary adopted. + expect(t.loader).toHaveBeenCalledTimes(1); + expect(u.loader).toHaveBeenCalledTimes(1); + t.resolve(); + u.resolve(); + await quiesce(); + expect(t.install).toHaveBeenCalledTimes(1); + // The record's names only: "tier/rec-c" was never asked for. + expect(u.install).toHaveBeenCalledTimes(1); + }); + + test("a page with no record announces nothing: no loader runs at install", async () => { + const t = deferredTier(); + const fid = freshFid("tier-norecord"); + page = bootPage(frameHtml(fid, "

plain

"), { tiers: { "tier/none": t.loader } }); + await quiesce(); + expect(t.loader).not.toHaveBeenCalled(); + }); +}); + +describe("the stream face — in-band and the codec", () => { + test("`chunk.tiers` starts the load at the chunk; a `data` chunk awaits the tiers it names before it decodes", async () => { + const t = deferredTier(); + Object.assign(tierLoaders, { "tier/data": t.loader }); + const applied: string[] = []; + const { host } = makeHost({ + applyData: (c: any) => applied.push(c.key) + }); + const WIRE = "tier/data-wire"; + const held = heldResponse(WIRE); + const done = applyFrameResponse(held.response, host, { as: WIRE, version: 1 }); + held.send({ type: "start", id: WIRE, version: 1 }); + // A record whose node tree needs the tier: the chunk names it. + held.send({ + type: "data", + id: WIRE, + version: 1, + key: "arg:x", + node: null, + initial: true, + tiers: ["tier/data"] + }); + held.send({ type: "html", id: WIRE, version: 1, html: "

after

" }); + await pump(); + // The announcement started the load… + expect(t.loader).toHaveBeenCalledTimes(1); + // …and the data chunk is held behind it: nothing decoded, and the + // chunks behind it in the sequential drain wait too. + expect(applied).toEqual([]); + expect(host.get(WIRE)).toBeUndefined(); + t.resolve(); + await pump(); + expect(applied).toEqual(["arg:x"]); + held.send({ type: "complete", id: WIRE, version: 1 }); + held.close(); + await done; + }); + + test("a non-data chunk's `tiers` starts the load but does not wait; a `data` chunk without `tiers` decodes at once", async () => { + const t = deferredTier(); + Object.assign(tierLoaders, { "tier/nowait": t.loader }); + const applied: string[] = []; + const { host } = makeHost({ applyData: (c: any) => applied.push(c.key) }); + const WIRE = "tier/nowait-wire"; + const held = heldResponse(WIRE); + const done = applyFrameResponse(held.response, host, { as: WIRE, version: 1 }); + held.send({ type: "start", id: WIRE, version: 1 }); + held.send({ type: "html", id: WIRE, version: 1, html: "

shell

", tiers: ["tier/nowait"] }); + held.send({ type: "data", id: WIRE, version: 1, key: "arg:y", node: null, initial: true }); + held.send({ type: "complete", id: WIRE, version: 1 }); + held.close(); + await pump(); + expect(t.loader).toHaveBeenCalledTimes(1); + // Neither the html nor the data waited on the still-pending load. + expect(applied).toEqual(["arg:y"]); + await done; + t.resolve(); + }); +}); diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json b/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json index 1198e0748..1e11542f2 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-loaded", - "shell": "

shell-fallback

", + "shell": "

shell-fallback

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json b/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json index 425d5129e..124c57d98 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-streamed", - "shell": "

shell-fallback

", + "shell": "

shell-fallback

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-switched.json b/packages/web/test/harness/__artifacts__/frame-live-document-switched.json index 394a680a4..98dbe2201 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-switched.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-switched.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-switched", - "shell": "

shell-fallback

", + "shell": "

shell-fallback

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json index 7201e0bd7..c6900fe80 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json @@ -1,5 +1,5 @@ { "name": "welcome-status-loaded", - "shell": "
1…
", - "rest": "" + "shell": "
1…
", + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json index d48a7bb80..455132146 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json @@ -1,5 +1,5 @@ { "name": "welcome-status-streamed", - "shell": "
1…
", - "rest": "" + "shell": "
1…
", + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/runtime/preload-links.spec.js b/packages/web/test/runtime/preload-links.spec.js index 3dbc67cd3..fe21a1f2c 100644 --- a/packages/web/test/runtime/preload-links.spec.js +++ b/packages/web/test/runtime/preload-links.spec.js @@ -671,7 +671,10 @@ describe("typed preload links", () => { id: "late-links", version: 1, key: "", - preloads: [{ href: "/late-frame.webp", attrs: { as: "image", fetchpriority: "high" } }] + preloads: [{ href: "/late-frame.webp", attrs: { as: "image", fetchpriority: "high" } }], + // The first assets chunk of the stream, minted after the head left: + // it announces the `assets` tier in-band (frames savings pass §2). + tiers: ["assets"] } ]); }); diff --git a/packages/web/test/server/tier-announce.spec.tsx b/packages/web/test/server/tier-announce.spec.tsx new file mode 100644 index 000000000..002925af3 --- /dev/null +++ b/packages/web/test/server/tier-announce.spec.tsx @@ -0,0 +1,278 @@ +/** + * @jsxImportSource @solidjs/web + */ +// The server-announced tier mechanism, server half (frames savings pass +// §2, Phase B): both faces announce the frames-client tiers the render +// MINTED — the stream face on the response head (`X-Frame-Tiers`, the tiers +// the sync render pass produced) and in-band (`chunk.tiers` on the next +// chunk out, for a tier first needed after the head left); the document +// face as the `_$HY.r["sc:tiers"]` hydration record (re-written cumulative +// at each new mint) plus a `modulepreload` per tier whose chunk URL the +// integration gave (`frameTransformDirectResult`'s `tierUrls`). Nothing is +// announced when nothing is minted: a response or page with no tiered +// feature carries no header, no `tiers` member, no record, no link. +// +// The mint sites: 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`). +import { describe, expect, it } from "vitest"; +import { Loading, renderToStream, renderToString, useHead } from "@solidjs/web"; +import { createMemo, createProjection } from "solid-js"; +import { + frameTransformDirectResult, + renderServerComponent, + serverComponentResponse, + ServerComponentPlugin +} from "../../frames/src/frame-sink.js"; +import { FRAME_TIERS_HEADER } from "../../frames/src/frame-transport.js"; +import { ChunkReader } from "../../server-functions/src/shared.js"; + +const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); +const collect = (stream: any): Promise => stream; + +/** Every chunk of a frame-stream Response's body, decoded. */ +async function bodyChunks(response: Response): Promise { + const reader = new ChunkReader(response.body!); + const out: any[] = []; + for (let r = await reader.next(); !r.done; r = await reader.next()) + out.push(JSON.parse(r.value as string)); + return out; +} + +function document(code: () => any, options: Record = {}): Promise { + return new Promise(resolve => { + const chunks: string[] = []; + renderToStream(code, { plugins: [ServerComponentPlugin], ...options } as any).pipe({ + write: (c: string) => chunks.push(c), + end: () => resolve(chunks.join("")) + }); + }); +} + +/** The value of the LAST `sc:tiers` assignment in a document, or undefined. */ +function tiersRecord(html: string): string[] | undefined { + // Seroval may assign the value a cross-reference slot (`$R[n]=`) first. + const all = [...html.matchAll(/_\$HY\.r\["sc:tiers"\]=(?:\$R\[\d+\]=)?(\[[^\]]*\])/g)]; + return all.length ? JSON.parse(all[all.length - 1][1].replace(/`/g, '"')) : undefined; +} + +// A server component with nothing tiered: markup and a direct-insert slot. +const Plain = (props: any) => ( +
+

Story

+ {props.children} +
+); +// A binding slot: one property read off the call's return is a position. +const Bound = (props: any) => { + const row = props.row({ id: 1 }); + return
  • item
  • ; +}; +// A region: server JSX passed as a slot arg. +const Regioned = (props: any) =>
    {props.panel({ body:

    server body

    })}
    ; +// A trace: a live projection (one over an async source — a settled sync +// projection is a constant and ships as plain data) passed as a slot arg. +const Traced = (props: any) => { + const user = createMemo(() => wait(5).then(() => ({ n: 1 }))); + const proj = createProjection((draft: any) => { + draft.n = (user() as any).n; + }, {} as any); + return
    {props.row({ data: proj })}
    ; +}; + +describe("tier announcement — stream face", () => { + it("announces nothing when nothing is minted: no header, no `tiers` on any chunk", async () => { + const response = serverComponentResponse(Plain, { frame: { id: "ta-none" } }); + expect(response.headers.has(FRAME_TIERS_HEADER)).toBe(false); + const chunks = await bodyChunks(response); + expect(chunks.some(c => "tiers" in c)).toBe(false); + expect(chunks.find(c => c.type === "html").html).toContain("

    Story

    "); + }); + + it("`bind`: a binding-slot position read in the sync pass is on the response head", async () => { + const response = serverComponentResponse(Bound, { frame: { id: "ta-bind" } }); + expect(response.headers.get(FRAME_TIERS_HEADER)).toBe("bind"); + const chunks = await bodyChunks(response); + // The head carried it; nothing is owed in-band. + expect(chunks.some(c => "tiers" in c)).toBe(false); + expect(chunks.find(c => c.type === "html").html).toContain('_s:class="row#0:cls"'); + }); + + it("`regions`: a nested region is announced, once, and rides the head", async () => { + const response = serverComponentResponse(Regioned, { frame: { id: "ta-reg" } }); + expect(response.headers.get(FRAME_TIERS_HEADER)).toBe("regions"); + const chunks = await bodyChunks(response); + expect(chunks.find(c => c.type === "slot").args.body).toEqual({ + $frame: "ta-reg.panel#0.body" + }); + }); + + it("`trace`: a traced container in a slot arg announces on its own data chunk", async () => { + const response = serverComponentResponse(Traced, { frame: { id: "ta-tr" } }); + const chunks = await bodyChunks(response); + // The announcement rides the record's data chunk itself: the mint + // (`argBorderForm` meeting the container) precedes the serializer's + // synchronous emission of the initial node, so the chunk whose node + // tree needs the tier is the chunk that names it — the client awaits + // the tier before decoding exactly that chunk. (A live projection is + // pending at first render; the hole that calls the slot re-pulls after + // the sync pass, so the head did not know — the in-band form is what + // carries this one.) + const data = chunks.filter(c => c.type === "data" && c.key === "arg:row#0:data"); + expect(data.length).toBeGreaterThan(0); + expect(data[0].tiers).toEqual(["trace"]); + expect(data[0].initial).toBe(true); + // Once: the later patch nodes and every other chunk carry nothing. + expect(chunks.filter(c => c.tiers).length).toBe(1); + // Head and body agree: a tier is announced by one or the other. + const head = response.headers.get(FRAME_TIERS_HEADER); + expect(head === null || head === "trace").toBe(true); + }); + + it("`wire`: a `live` response announces the wire tier", async () => { + const response = serverComponentResponse(Plain, { frame: { id: "ta-wire" }, live: true }); + expect(response.headers.get(FRAME_TIERS_HEADER)).toBe("wire"); + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + // Not read: a live body stays open for its heartbeat; cancel it. + await response.body!.cancel(); + }); + + it("several tiers: comma-separated, in mint order", async () => { + const Both = (props: any) => { + const row = props.row({ id: 1 }); + return ( +
      +
    • {props.panel({ body:

      x

      })}
    • +
    + ); + }; + const response = serverComponentResponse(Both, { frame: { id: "ta-both" } }); + expect(response.headers.get(FRAME_TIERS_HEADER)!.split(",").sort()).toEqual([ + "bind", + "regions" + ]); + await bodyChunks(response); + }); + + it("a tier first minted after the head left rides in-band on the next chunk (`assets`, `bind` inside a late segment)", async () => { + // The style-gated fragment: a stylesheet registered inside a boundary + // that resolves after the shell flush. Its assets chunk is the first + // the sink emits after the mint, so the chunk carries the name. The + // binding-slot read inside the same late content is minted after the + // head too; both ride the next chunk out. + const Late = (props: any) => { + const data = createMemo(() => wait(10).then(() => "late")); + return ( + waiting

    }> + {(() => { + const v = data() as any; + useHead({ tag: "link", props: { rel: "stylesheet", href: "/late.css" } }); + const row = props.row({ id: v }); + return {v}; + })()} +
    + ); + }; + const response = serverComponentResponse(Late, { frame: { id: "ta-late" } }); + // The head knew nothing yet: no header. + expect(response.headers.has(FRAME_TIERS_HEADER)).toBe(false); + const chunks = await bodyChunks(response); + const announced = chunks.filter(c => c.tiers); + expect(announced.length).toBeGreaterThan(0); + const names = announced.flatMap(c => c.tiers).sort(); + expect(names).toEqual(["assets", "bind"]); + // The announcement precedes (or is) the chunk that needs it: the + // `assets` chunk carries `assets`; the fragment with the `_s:` marker + // does not precede the chunk naming `bind`. + const assets = chunks.find(c => c.type === "assets"); + expect(assets.tiers).toContain("assets"); + const bindAt = chunks.findIndex(c => c.tiers && c.tiers.includes("bind")); + const fragmentAt = chunks.findIndex(c => c.type === "fragment"); + expect(bindAt).toBeLessThanOrEqual(fragmentAt); + expect(chunks[fragmentAt].html).toContain("_s:class="); + // Reveal is style-gated, as before. + expect(chunks.find(c => c.type === "reveal").waitForStyles).toBe(true); + }); + + it("a stream consumed without a Response (renderServerComponent) announces in-band only", async () => { + const chunks = await collect(renderServerComponent(Bound, { frame: { id: "ta-direct" } })); + const announced = chunks.filter(c => c.tiers); + expect(announced.length).toBe(1); + expect(announced[0].tiers).toEqual(["bind"]); + // On the first emission after the read: the slot chunk left at the + // call, the property read came after it, so the shell's html — the + // chunk that carries the `_s:` marker — is what announces it. + expect(announced[0].type).toBe("html"); + expect(announced[0].html).toContain('_s:class="row#0:cls"'); + }); +}); + +describe("tier announcement — document face", () => { + it("announces nothing when nothing is minted: no record, no link", async () => { + const Inline = frameTransformDirectResult(Plain, { + id: "td-none", + tierUrls: { bind: "/tier-bind.js" } + }) as any; + const html = await document(() => Inline({ children: () => client })); + expect(html).toContain("

    Story

    "); + expect(tiersRecord(html)).toBeUndefined(); + expect(html).not.toContain("modulepreload"); + }); + + it("`bind`: the record names it; a `modulepreload` for the given URL joins the head", async () => { + const Inline = frameTransformDirectResult(Bound, { + id: "td-bind", + tierUrls: { bind: "/assets/tier-bind.js" } + }) as any; + const html = await document(() => ( + + + t + + {Inline({ row: () => ({ cls: "x" }) })} + + )); + expect(tiersRecord(html)).toEqual(["bind"]); + expect(html).toContain(''); + // In the head, with the shell. + expect(html.indexOf("modulepreload")).toBeLessThan(html.indexOf("")); + expect(html).toContain('_s:class="row#0:cls"'); + }); + + it("without `tierUrls` the record alone announces the name — no link", async () => { + const Inline = frameTransformDirectResult(Bound, { id: "td-nourl" }) as any; + const html = await document(() => Inline({ row: () => ({ cls: "x" }) })); + expect(tiersRecord(html)).toEqual(["bind"]); + expect(html).not.toContain("modulepreload"); + }); + + it("`regions` and `trace`: minted by the document slot props, cumulative across components", async () => { + const R = frameTransformDirectResult(Regioned, { id: "td-reg" }) as any; + const T = frameTransformDirectResult(Traced, { id: "td-tr" }) as any; + const html = await document(() => [ + R({ panel: (p: any) =>
    {p.body}
    }), + T({ row: (p: any) => {p.data.n} }) + ]); + // The last write is the complete set; the first component's tier is + // not lost to the second's. + expect(tiersRecord(html)).toEqual(["regions", "trace"]); + expect(html).toMatch(/_\$HY\.r\["sc:tiers"\]=(?:\$R\[\d+\]=)?\["regions"\]/); + }); + + it("`wire`: an inline `live` answer announces the wire tier", async () => { + const Inline = frameTransformDirectResult(Plain, { id: "td-wire" }) as any; + // The in-process `live` declaration's brand (server-functions' `brandLive`). + Inline[Symbol.for("solid.LiveSource")] = true; + const html = await document(() => Inline({ children: () => c })); + expect(tiersRecord(html)).toEqual(["wire"]); + }); + + it("a sync render (renderToString) announces nothing — the client detects", () => { + const Inline = frameTransformDirectResult(Bound, { id: "td-sync" }) as any; + const html = renderToString(() => Inline({ row: () => ({ cls: "x" }) }), { + plugins: [ServerComponentPlugin] + } as any); + expect(html).toContain('_s:class="row#0:cls"'); + expect(tiersRecord(html)).toBeUndefined(); + }); +});