diff --git a/.changeset/frames-a5-fragment-ownership-by-rendering.md b/.changeset/frames-a5-fragment-ownership-by-rendering.md new file mode 100644 index 000000000..e0b410a07 --- /dev/null +++ b/.changeset/frames-a5-fragment-ownership-by-rendering.md @@ -0,0 +1,10 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +Frames A5′ — a deferred fragment's placeholder inside a server component's element is the frame's content by rendering, not by adoption (frames-rulings 3.3, ruled 2026-10-06). + +**`solid-js`:** the document fragment ledger's `fragmentPolicy` lets a post-done swap proceed when the fragment is owned — its `pl-*` template is in the document and the integration's ownership predicate `_$HY.fa(placeholder)` says so — beside the existing claimant case; no hold, no replay for owned fragments. `_$HY.fr.claim` / `_$HY.fr.release` are removed from the published ledger (`_$HY.fr` is `{ pending, subscribe }`); `_$HY.fa(placeholder): boolean` is the new integration hook. `fragmentPending` now reads a revealed fragment from its swap record (`_$HY.v`) before its `_fr` stamp: the producer emits the swap script and then the `_fr` settle in the same batch, so a `_$HY.fr.pending()` read inside the reveal notification saw the revealing fragment as still pending — a page's last reveal never read as exhaustion and a waiter released on exhaustion waited forever. + +**`@solidjs/web` (frames client):** installs `_$HY.fa` once (`pl.closest("[data-fid]")`, minus elements of a boundary disposed in place — C14); deletes `claimRegionFragments`, the per-adoption claim set, the cascade's claim half and the release loop (the dev-only rejection report over the region's `pl-*` templates stays, 0 prod bytes); `documentBoundary` pends on the intercept's one arrival answer (`awaitBoundary`) and `boundaryWaiters` is deleted (G9). A post-done swap into server-component markup no client has adopted yet now lands at once; the adoption that follows finds it in place and reads its declared records synchronously. diff --git a/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index 45cbc8001..b5785416e 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -284,7 +284,7 @@ harness clean on **two seeds** with every law un-ignored; frames eager | **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. | **≈ −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. | **≈ −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). | +| **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) | diff --git a/documentation/server-components/frames-consistency-contract.md b/documentation/server-components/frames-consistency-contract.md index fa8b22768..3c0a62af4 100644 --- a/documentation/server-components/frames-consistency-contract.md +++ b/documentation/server-components/frames-consistency-contract.md @@ -98,7 +98,10 @@ live fill behind it. - **Mechanism:** `frame-client.ts:FrameImpl.#syncSlots` (range discovery over the frame's content), `client.ts:adoptBoundary`'s `fr.subscribe` cascade - (`claimRegionFragments` + `drainRecords`, #2978/#2968), `#recordRefresh`. + (`drainRecords` + the reveal-is-an-apply write, #2968 / rulings 2.3; the + swap itself needs no claim from the adoption — a placeholder inside a + `data-fid` element is the frame's content by rendering, `_$HY.fa`, A5′), + `#recordRefresh`. A re-sync after a reveal happens only when the reveal brings a _new_ record (`drainRecords` → `host.apply` → `#flush` → `#syncSlots`); nothing re-syncs on the reveal itself. @@ -296,8 +299,11 @@ one — and changes only when the document (or a stream) delivers. - **Mechanism:** `solid/hydration.ts:hydratedCreateLoadingBoundary` (`_fr` states: pending / settled / parked / superseded / rejected), `fragmentPolicy` - (held swaps), `client.ts:adoptBoundary.claimRegionFragments` (#2978: the - adoption claims server-produced placeholders so a late swap lands). + (held swaps) with its ownership-by-rendering term (`_$HY.fa`, installed by + `client.ts:installRevealHook` — #2978: a server-produced placeholder inside + a live `data-fid` element is the frame's content, so a late swap lands with + or without an adoption on record; rulings 3.3, A5′), `adoptBoundary`'s + dev-only rejection report over the region's `pl-*` templates. - **Pin:** `c12-boundary-parity.spec.tsx` — a server `` inside the adopted frame: (a) pending at adopt (fallback shows, no fetch, ledger pending); (b) revealed after adopt (content replaces the fallback in one @@ -336,9 +342,11 @@ reveal touches the DOM or invokes a fill. - **Mechanism:** `frame-client.ts:FrameImpl.dispose` (unregister first, cleanups, record hygiene, `#recordRefresh` cleared), `createFrameHost.unregister`, - `client.ts:adoptBoundary`'s `onCleanup` (applier, `fr` unsubscribe, fragment - claims released), `client.ts:documentBoundary`'s `boundaryWaiters` cleanup, - `client.ts:followAddress.drop`. + `client.ts:adoptBoundary`'s `onCleanup` (applier, `fr` unsubscribe, the + element and its region elements entered in `disposedFrames` so `_$HY.fa` + disowns their placeholders — a boundary disposed _in place_ keeps its + element in the document), `client.ts:documentBoundary`'s `live` latch over + the shared arrival wait, `client.ts:followAddress.drop`. - **Pin:** `c14-dispose-clean.spec.tsx` — arms: (a) dispose during the record defer (`readyState` "loading"), the record lands after; (b) during a `{$ref}` wait on a stream, the data lands after; (c) during a late-boundary @@ -429,7 +437,7 @@ address's late chunks never release it. | C9 | no phantom | `claimRender`, `slotArgsProxy`, settled-branch hydration | `c09-no-phantom` | holds | | C10 | ids timing-independent | `claimRender` owner id, `#invokeSlot` ctx | `c10-ids-timing-independent` | holds | | C11 | trace equals oracle | `materializeContainerTrace`, `materialize` memo | `c11-trace-equals-oracle` | holds | -| C12 | boundary parity at claim | `hydratedCreateLoadingBoundary`, `claimRegionFragments` | `c12-boundary-parity` | **red** (c) | +| C12 | boundary parity at claim | `hydratedCreateLoadingBoundary`, `fragmentPolicy` + `_$HY.fa` | `c12-boundary-parity` | **red** (c2) | | C13 | one sweep, one frame | `applyFrames.drain`, `#flush` hole pass | `c13-sweep-atomic` | **red** (a, b) | | C14 | disposal leaves nothing | `dispose`, `unregister`, adopt cleanups | `c14-dispose-clean` | holds | | C15 | staged refetch lands whole | `stage`/`stagedContent`, `followAddress` | `c15-staging-atomic` | holds | diff --git a/documentation/server-components/frames-rulings.md b/documentation/server-components/frames-rulings.md index a6eb86400..d8e0c42e0 100644 --- a/documentation/server-components/frames-rulings.md +++ b/documentation/server-components/frames-rulings.md @@ -769,22 +769,69 @@ through the existing registration, not a new seam.** ### 3.3 A claim is a promise to account for the outcome -**The adoption that claims a fragment's placeholder — so its swap may land — -owns what the swap delivers: settled content syncs (2.3); a rejection shows an -error at the position and surfaces; a claimed fragment never swaps to a blank, -and `fr.pending()` reading false never means "the page converged" while a -claimed position shows nothing.** - -- **Mechanism today.** `adoptBoundary.claimRegionFragments` → `fr.claim(fragId)` - for every `pl-*` in the region (#2978 — so the held-swap policy does not hold - them forever); the ledger's `fragmentPolicy` swaps whatever template the - document wrote. The server's error path for a post-flush fragment writes a - blank content template (`sink.fragment(key, " ")`), activates it, and rejects - `_fr`. `hydratedCreateLoadingBoundary`'s `s === 2` branch (resume fresh, - error to the nearest ``) runs only for a boundary registered against - `_fr` — a client twin; a server-only `` has none, and the adoption - claimed the placeholder without consuming the rejection (R6). The serializer's - thenable swallows it. The page converges on an empty range, nothing logged. +**The frame whose rendering a fragment's placeholder sits inside — so its +swap may land — owns what the swap delivers: settled content syncs (2.3); a +rejection shows what the server rendered for it and surfaces; an owned +fragment never swaps to a blank, and `fr.pending()` reading false never means +"the page converged" while an owned position shows nothing.** + +**Ruled 2026-10-06 (12:55), A5′:** _a placeholder inside a server component's +element is the frame's content **by rendering, not by adoption**._ The +server rendered that `` inside the component; whether a client has +adopted the element yet is irrelevant to whose content it is. The ledger +therefore asks a geometric predicate (`_$HY.fa(placeholder)`, installed once +by the frames client: "is this `pl-*` inside a `data-fid` element that is not +disposed?") before holding a post-done swap — no per-fragment claim, no hold, +no replay. An adoption that follows the swap finds the settled markup in +place and reads its declared records synchronously (#3844). + +- **Mechanism (as landed, A5′).** `hydration.ts:fragmentPolicy` swaps post-done + when the fragment is claimed (a client `` registered against its + `_fr`) **or owned** (`ownedFragment`: `getElementById("pl-"+id)` exists and + `_$HY.fa(pl)`); `client.ts:installRevealHook` installs `fa` as + `pl.closest("[data-fid]")` not in `disposedFrames` (the C14 guard — a + boundary disposed _in place_ leaves its element standing, and a swap into it + would be the inert content #2964 holds against; the set holds the boundary + and the region elements inside it, so a placeholder in a nested region is + disowned too). `claimRegionFragments`, `claimedFragments`, the cascade's + claim half and the release loop are deleted; `_$HY.fr.claim`/`release` are + gone from the ledger's published surface. What remains of the region sweep + is dev-only: the (c1) rejection report (`console.error` naming the fragment + and the frame) over the `pl-*` templates at adopt time and at each reveal + into the region — 0 prod bytes. The server's error path for a post-flush + fragment still writes a blank content template (`sink.fragment(key, " ")`), + activates it, and rejects `_fr` — the server-half gap (c2) is + unchanged by A5′. `hydratedCreateLoadingBoundary`'s `s === 2` branch runs + only for a boundary registered against `_fr` — a client twin; a server-only + `` has none (R6's symptom was the blank, not the absence). +- **Measured before written (re-attribution §7 method; Δ min B on `app: + hydrating (no stores)` vs base `d9d217959`, edited dist copies).** The + plan's A5 as specified — S-adopted (`_adoptedRoots`, `fr.adopt`/`unadopt`, + held-swap replay) plus S-key (`whenRevealed` published, the covering + fragment key on the SC reference) — came in at **+504** solid-side (S-adopted + +221 with `claim`/`release` dropped, +290 kept; S-key +283), against a + budget of ≈ +80. Alternatives measured: (a) adopted-roots set without + replay +167, (b) +170, (c) +100, (d) +260, (e) +16 (semantically unusable), + (f) +149, (g) the exhaustion fix alone +13, **(h) ownership predicate +37** + (+106 with `claim`/`release` kept). A5′ = (h) + (g) + G9: **built: +50 min / + +5 br** on hydrating (no stores), frames eager **−204 / −51**, page base + **−156 / −81**. S-key was not built: hydrating flow never reaches the + late-boundary wait (a client `` twin's resume gates it), the + intercept has no key to collapse onto, and the covering-chain semantics a + nested splice needs (ids are prefix-closed, so a key must name the + outermost pending ancestor) make its frames half a wash (−18 min) for a + +283 solid cost — a reachability finding, recorded in the plan's A5 row. +- **Finding (the exhaustion bug, fixed with (g)).** The producer emits the + swap script, then the `_fr` settle, in the same task batch; `$dfr` marks + `_$HY.v[id]` and fans `_$HY.fe` out synchronously in between. A ledger + subscriber asking `fr.pending()` inside that notification saw the revealing + fragment's declaration unstamped, so a page's LAST reveal never read as + exhaustion and a waiter released on exhaustion (the late-boundary wait, the + intercept's deferred answer) waited forever. `fragmentPending` now reads a + revealed fragment from its swap (`hy.v[id]`) first. The late-boundary + spec's `swapIn` had the two steps in the wrong order and hid this; it now + stamps after `fe`, and `boundary-arrival.spec` (b) pins the real order + through the shipped `$df`. - **Lives twice in.** The two reveal engines again: the frame's engine has an error arm (`seg::error`, the reveal seam's boundary throwing to the nearest ``); the document engine has none for a server-only boundary. @@ -823,17 +870,19 @@ claimed position shows nothing.** - **Code sites corollary 4 says to change or re-read** — places where an inner server boundary is treated as client state: - `client.ts:adoptBoundary.claimRegionFragments` → `fr.claim(fragId)` → - `hydration.ts:claimFragment` (#2978): the adoption goes on record in the - fragment ledger as the _claimant_ of every `pl-*` in its region so the - post-done held-swap policy (`fragmentPolicy`, #2964) lets the swap land. - That is client claimant state for a server boundary. The principle's - shape: the ledger knows a fragment inside an adopted frame's range is - the **frame's content** (the frame is the claimant of everything in its - range by adoption, not fragment by fragment) — S9/DR-4's "the fragment - ledger not knowing adopted regions own their placeholders" is this same - finding from the size side. Change, under DR-4; the cascade is a - compensation until then. (Not `claimedBoundaries` — that set is one - adopter per _frame element_, the outward face, and is right.) + `hydration.ts:claimFragment` (#2978) — **changed (A5′, ruled + 2026-10-06).** The adoption went on record in the fragment ledger as the + _claimant_ of every `pl-*` in its region so the post-done held-swap + policy (`fragmentPolicy`, #2964) would let the swap land — client + claimant state for a server boundary. The principle's shape, as ruled: a + fragment inside a server component's element is the **frame's content by + rendering, not by adoption** — the ledger asks `_$HY.fa(pl)` (geometry: + inside a live `data-fid` element) instead of a claim, and the adoption + claims nothing fragment by fragment. S9/DR-4's "the fragment ledger not + knowing adopted regions own their placeholders" was this same finding + from the size side; `claimRegionFragments` and `fr.claim`/`release` are + deleted. (Not `claimedBoundaries` — that set is one adopter per _frame + element_, the outward face, and is right.) - `client.ts:revealSeam` — the stream face wraps each revealed segment's content in a reconstructed client `createLoadingBoundary`. Its stated job is to cover the **fills'** own async ("an unboundaried async fill's @@ -1235,7 +1284,14 @@ alternatives stay written as the record of what was weighed. (the blank template) is drafted below, not coded. 8. **S-adopted (`claimRegionFragments → fr.adopt`) documented, not built** (3.3's code-sites list): ≈ +80 B in solid on every hydrating page, on - top of 6's +59 — the maintainer's call. + top of 6's +59 — the maintainer's call. **Superseded 2026-10-06 + (12:55): built as A5′** — not the `fr.adopt`/`unadopt` shape (measured + +221 min solid, +504 with S-key) but the ownership predicate + `_$HY.fa(placeholder)` (3.3: "by rendering, not by adoption"; +50 min on + hydrating pages including the exhaustion fix), with `claimRegionFragments` + and `fr.claim`/`release` deleted and `documentBoundary` collapsed onto the + intercept's one arrival answer (G9). S-key stays unbuilt (3.3's + reachability finding). 9. **The harness's C3 law exempts a done that fired at or after the mount's disposal** (a disposed holder owes no claim; its release is what lets done fire — `initBoundaryResume`'s own rule). An oracle correction, diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 92eb50c72..0bc296868 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -1994,16 +1994,12 @@ export function enableHydration() { const hy = (globalThis as any)._$HY; if (hy && !hy.fr) { if (!hy.f) hy.f = fragmentPolicy; - // claim/release: the same claimant contract Loading boundaries use, for - // integrations that own server-rendered markup wholesale (#2978 — the - // frames document adoption claims the placeholders inside its region, - // whose producers ran on the server and have no client - // boundary to ever register). + // Integrations that own server-rendered markup wholesale (the frames + // document adoption) answer for their fragments through `_$HY.fa` + // (ownership by rendering, see the ledger) — no per-fragment claim API. hy.fr = { pending: anyFragmentPending, - subscribe: subscribeFragments, - claim: claimFragment, - release: releaseFragment + subscribe: subscribeFragments }; // Every $dfr announces its swap through `_$HY.fe`; fanning it out here // gives ledger subscribers one channel for "content just landed". @@ -2811,9 +2807,9 @@ function initBoundaryResume( // enableHydration() installs `_$HY.f` — from that moment every `$df(id)` // the stream emits routes here (the same one-owner handoff the head-patch // runtime uses via `_$HY.h`) — and publishes the ledger as `_$HY.fr` -// ({ pending, subscribe, claim, release }) so integrations (the frames -// client's document adoption) share this one answer instead of scanning for -// `pl-*` templates or patching `_$HY.fe` themselves. +// ({ pending, subscribe }) so integrations (the frames client's document +// adoption) share this one answer instead of scanning for `pl-*` templates +// or patching `_$HY.fe` themselves. // // Policy: while global hydration is still in progress, swaps proceed — // boundaries are coming to claim them. Once hydration completes, a swap only @@ -2824,6 +2820,17 @@ function initBoundaryResume( // leave inert nodes in a range the client may re-render (#2964). Unclaimed // late swaps are HELD (placeholder, fallback, and template all stay in // place) and replayed when their claimant registers. +// +// The one other post-done claimant is OWNERSHIP BY RENDERING (frames A5′, +// ruled 2026-10-06): a `` the server rendered inside a server +// component's element has no client boundary at all — its producer ran on +// the server — and its fragment is the component's content whether or not +// a client has adopted the element yet. An integration that owns ranges of +// server markup wholesale installs `_$HY.fa(placeholder)`, a predicate over +// the fragment's `pl-*` template; a post-done swap it owns proceeds. Nothing +// is ever held inside an owned range, so there is no claim to retire and no +// replay: disposal is geometry — a disposed range leaves the document, its +// placeholder with it, and a swap aimed at it is held like any other. const _fragments = new Map(); const _truncated = new Set(); const _revealSubs = new Set<(id: string, parent?: ParentNode) => void>(); @@ -2837,11 +2844,21 @@ function fragmentState(id: string) { function fragmentPolicy(id: string) { const f = fragmentState(id); - if (!_hydrationDone || f.claimed) return (globalThis as any).$dfr(id); + if (!_hydrationDone || f.claimed || ownedFragment(id)) return (globalThis as any).$dfr(id); f.held = true; return 0; } +// Ownership by rendering (see the ledger's policy above): the fragment's +// placeholder is in the document and the integration's predicate owns it. +// A placeholder GONE from the document (its range morphed away, or the +// owning range disposed) is nobody's: the swap holds. +function ownedFragment(id: string) { + const hy = (globalThis as any)._$HY; + const pl = document.getElementById("pl-" + id); + return !!(pl && hy.fa && hy.fa(pl)); +} + // A held swap replays the moment its boundary shows up — BEFORE any of the // boundary's paths walk the DOM. This covers the settled path too: a held // swap arrives in the same chunk that resolves the `_fr` ref, so a @@ -2857,20 +2874,13 @@ function replayHeldFragment(id: string) { // A boundary registering against a still-pending `_fr` goes on record as // the fragment's claimant, so a late swap lands for its resume to claim. The -// claim is cleared by release() when the boundary resumes or is disposed. +// claim is cleared by initBoundaryResume's release() when the boundary +// resumes or is disposed. function claimFragment(id: string) { fragmentState(id).claimed = true; replayHeldFragment(id); } -// Retire a claim (the disposal half of the ledger's claim/release seam): -// after the claimant is gone, a late swap must be held rather than landing -// in a range nobody will claim. -function releaseFragment(id: string) { - const f = _fragments.get(id); - if (f) f.claimed = false; -} - /** * May the document still deliver fragment `id`'s content? An unsettled * declaration is in flight; a settled one stays pending until its swap runs @@ -2881,6 +2891,14 @@ function releaseFragment(id: string) { * streamed, nothing is coming. (getElementById is an id-table lookup, not * the tree scan this ledger replaces.) * + * A REVEALED fragment is read from its swap (`_$HY.v`), not from its `_fr` + * stamp: the producer emits the swap script and then the `_fr` settle in + * the same task batch, so a reader inside the reveal notification + * (`_$HY.fe` → a ledger subscriber asking "is anything still pending?") sees + * the revealing fragment's declaration unstamped. Read by the stamp alone, + * the last reveal of a page never flipped `pending()` false, and a waiter + * released on exhaustion waited forever. + * * Content whose `pl-*` placeholder range is GONE can never swap either * (#2978, secondary defect): a frame refetch that morphs over the region * removes the placeholder, and the swap has nowhere to land — the stale @@ -2889,7 +2907,7 @@ function releaseFragment(id: string) { * content, so with the template present its absence can only mean removal. */ function fragmentPending(hy: any, id: string): boolean { - if (_truncated.has(id)) return false; + if (_truncated.has(id) || (hy.v && hy.v[id])) return false; const ref = hy.r[id + "_fr"]; if (!ref || typeof ref !== "object") return false; return !ref.s || fragmentParked(id); diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 74f016367..d28fadb44 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -1088,7 +1088,7 @@ let boundaryIndex: Map | null = null; // than one per nested region (a large comment thread carries hundreds). const isBoundaryId = (id: string) => !id.includes("."); function indexBoundaries(root: ParentNode) { - root.querySelectorAll(`[${FRAME_ID_ATTR}]`).forEach(el => { + root.querySelectorAll(FRAME_SELECTOR).forEach(el => { const key = el.getAttribute(FRAME_ID_ATTR); if (key && isBoundaryId(key) && !boundaryIndex!.has(key)) boundaryIndex!.set(key, el); }); @@ -1101,19 +1101,27 @@ function findBoundaryElement(id: string): Element | undefined { return boundaryIndex.get(id); } -// Boundaries whose element has not been delivered yet, waiting on the reveal -// that carries it. One waiter per id: a second mount while the first is still -// waiting takes the fresh-frame path, since only one frame may adopt an -// element. -const boundaryWaiters = new Map void>(); - -// Calls answered "not yet" by the intercept: a boundary the page may still -// deliver (see boundaryMayArrive) is a LOCAL answer that has not landed, not -// a miss — a fetch now would render on the wire what the document is -// already streaming. One promise per id, shared by every caller asking -// while it is outstanding; it settles at the reveal that carries the element -// (true) or once the page has no reveal left to deliver it (false). +// The one deferred answer for "the page may still deliver this boundary": +// a boundary not in the document yet while the document can still deliver +// it (see boundaryMayArrive) is a LOCAL answer that has not landed, not a +// miss — a fetch now would render on the wire what the document is already +// streaming, and a fresh mount now would orphan the markup when it lands. +// One promise per id, shared by every asker while it is outstanding — the +// intercept answering a call, and a placeholder mount pending on its +// element (frames A5′, G9: the two waiters this used to be asked one +// question); it settles at the reveal that carries the element (true) or +// once the page has no reveal left to deliver it (false). const arrivals = new Map; resolve: (v: boolean) => void }>(); + +// Frame elements — adopted boundaries and the region elements inside them — +// whose mount has been disposed (see installRevealHook's ownership +// predicate): a placeholder under one is no longer anyone's content. The +// regions are marked with their boundary so the predicate's nearest-frame +// lookup is the whole check (a placeholder in a nested region sees the +// region first). Weak — an element that leaves the document is forgotten +// with it. +const FRAME_SELECTOR = `[${FRAME_ID_ATTR}]`; +const disposedFrames = new WeakSet(); function awaitBoundary(id: string) { let arrival = arrivals.get(id); if (!arrival) { @@ -1148,41 +1156,60 @@ function boundaryMayArrive() { } /** - * Subscribe to the fragment ledger to learn when a late boundary lands. + * Install the frames client's two hooks on the hydration runtime's fragment + * ledger (idempotent — `_$HY.$sc`): * - * The ledger notifies on every fragment reveal — the only moment a boundary - * element can enter the page after the initial parse — with the revealed - * fragment's parent, and on truncation (no parent) so waiters the page can - * no longer answer re-evaluate. Scoping the rescan to the revealed - * fragment's parent (rather than the document) keeps this proportional to - * what just arrived. + * - Ownership by rendering (`_$HY.fa`, frames A5′ / rulings 3.3): a `pl-*` + * placeholder inside a server component's element is the component's + * content — the server rendered that `` inside the component, so + * no client boundary will ever register as its claimant. The ledger asks + * this predicate before holding a post-done swap; an owned swap proceeds + * whether or not a client has adopted the element yet (an adoption that + * follows finds the settled markup in place and drains its records). + * Disposal is mostly geometry — a disposed boundary's element normally + * leaves the document, so the placeholder the ledger looks up is gone + * and the swap is held like any other — but an adopted element whose + * mount is disposed IN PLACE (the element is the component's return + * value; a root disposed without detaching it leaves it standing) is + * dead markup nobody drives, and a swap into it would be exactly the + * inert content #2964 holds against (contract C14: a reveal after + * disposal touches nothing). `disposedFrames` records those elements + * (the boundary and the region elements inside it, since a placeholder + * in a nested region sees the region's `data-fid` first); the predicate + * disowns a placeholder whose nearest frame element is one of them. + * + * - The reveal subscription, to learn when a late boundary lands. The + * ledger notifies on every fragment reveal — the only moment a boundary + * element can enter the page after the initial parse — with the revealed + * fragment's parent, and on truncation (no parent) so waiters the page + * can no longer answer re-evaluate. Scoping the rescan to the revealed + * fragment's parent (rather than the document) keeps this proportional + * to what just arrived. */ function installRevealHook() { const hy = (globalThis as any)._$HY; if (!hy || hy.$sc || !hy.fr) return; hy.$sc = true; + hy.fa = (pl: Element) => { + const el = pl.closest(FRAME_SELECTOR); + return !!el && !disposedFrames.has(el); + }; hy.fr.subscribe((_id: string, parent?: ParentNode) => { // Nothing has looked a boundary up yet, so there is nothing to keep // current — the first lookup scans the document as it stands then. if (!boundaryIndex) return; const root = parent || (typeof document !== "undefined" ? document.body : null); if (root) indexBoundaries(root); - if (!boundaryWaiters.size && !arrivals.size) return; + if (!arrivals.size) return; // A waiter the page can no longer answer must not wait forever: once the // document is done and no fragment is left outstanding (truncated ones // included), nothing else can deliver this element, so release the - // waiter to mount fresh (the client-only shape) instead of holding the - // fallback on screen. + // waiter — the caller mounts fresh (the client-only shape) or goes to + // the wire — instead of holding the fallback on screen. (The ledger + // reads the revealing fragment as delivered from its swap, so the LAST + // reveal of a page is the exhaustion it looks like: the `_fr` stamp the + // same batch executes after this notification is not what it waits on.) const exhausted = hy.done && !hy.fr.pending(); - for (const [id, notify] of boundaryWaiters) { - const el = boundaryIndex && boundaryIndex.get(id); - if (!el && !exhausted) continue; - boundaryWaiters.delete(id); - notify(el); - } - // Deferred local answers settle the same way: the element landed (the - // caller's mount adopts it), or nothing is left to deliver it (the - // caller goes to the wire). for (const [id, arrival] of arrivals) { const el = boundaryIndex && boundaryIndex.get(id); if (!el && !exhausted) continue; @@ -1215,19 +1242,29 @@ function documentBoundary( // updates again. Suspend instead and adopt on delivery; the enclosing // goes on showing the server's fallback, which is exactly what the // document is displaying. - if (!claimed && !boundaryWaiters.has(id) && boundaryMayArrive()) { + // + // The wait is the intercept's deferred answer (`awaitBoundary`): one + // promise per id, settled by the reveal hook when the element lands or + // when the page has nothing left to deliver it. Every mount asking during + // the wait shares it; at the answer the first to resume adopts and any + // other finds the id claimed and mounts fresh (only one frame may adopt an + // element). A mount disposed during the wait resumes nothing. + if (!claimed && boundaryMayArrive()) { const owner = getOwner(); - const arrival = new Promise(resolve => boundaryWaiters.set(id, resolve)); - onCleanup(() => boundaryWaiters.delete(id)); + let live = true; + onCleanup(() => (live = false)); return createMemo(() => - arrival.then(node => - runWithOwner(owner, () => - // No element after all (the page ran out of reveals): mount fresh, - // exactly as an unwaited miss would have. - node - ? adoptBoundary(host, id, node, props, binding) - : boundaryComponent(host, id)(props, binding) - ) + awaitBoundary(id).then( + () => + live && + runWithOwner(owner, () => { + const node = claimedBoundaries.has(id) ? undefined : findBoundaryElement(id); + // No element after all (the page ran out of reveals, or another + // mount took it): mount fresh, exactly as an unwaited miss would. + return node + ? adoptBoundary(host, id, node, props, binding) + : boundaryComponent(host, id)(props, binding); + }) ) ) as unknown as SolidElement; } @@ -1281,46 +1318,42 @@ function adoptBoundary( // Deferred fragments in the adopted markup (#2978): a that // suspended inside the server component during document SSR left a `pl-*` // placeholder here, but its producer ran on the SERVER — no client - // boundary will ever register as the fragment's claimant. Post-done, the - // held-swap policy (#2964) would hold its $df forever: the fallback stays - // frozen on screen and `fr.pending()` never flips false, deadlocking the - // very classification gate that waits on it. The adoption owns this markup - // wholesale, so it goes on record as the claimant for every placeholder in - // its region — at adopt time, and again for content revealed into the - // region later (an outer fragment's payload can carry a nested pending - // one). Claims retire with the frame: a swap arriving after disposal must - // be held, not landed in a range nobody owns. - const claimedFragments = new Set(); - const claimRegionFragments = (root: ParentNode) => { + // boundary will ever register as the fragment's claimant. The ledger + // settles these by OWNERSHIP BY RENDERING (`_$HY.fa`, installRevealHook): + // a placeholder inside a `data-fid` element is the component's content, + // so its swap proceeds post-done whether or not this adoption has + // happened yet — nothing here to claim, nothing to release at disposal. + // + // What remains of the region sweep is dev-only diagnosis. A server + // `` inside a server component is the SERVER's boundary (A0, + // corollary 4 — inward): its outcome arrives as markup, and the client + // shows whatever the server rendered for it — never a client-invented + // error state. A rejected one has no client twin to surface its `_fr` + // rejection (hydratedCreateLoadingBoundary's `s === 2` arm runs only for a + // boundary registered against it), so dev names it here — at adopt time + // and for content revealed into the region later (an outer fragment's + // payload can carry a nested pending one); the server's error path writes + // a BLANK template for it today (web/src/server.ts, the `done` closure's + // `" "`), which is the server half's gap, not a client state to invent. + // (Every call site is `IS_DEV &&`-guarded so the sweep is 0 bytes in prod.) + const reportedFragments = new Set(); + const reportRegionFragments = (root: ParentNode) => { const hy = (globalThis as any)._$HY; - const fr = hy?.fr; - if (!fr || !fr.claim) return; + if (!hy || !hy.r) return; root.querySelectorAll('template[id^="pl-"]').forEach(tpl => { const fragId = tpl.id.slice(3); - if (claimedFragments.has(fragId)) return; - claimedFragments.add(fragId); - fr.claim(fragId); - // A server `` inside a server component is the SERVER's - // boundary (A0, corollary 4 — inward): its outcome arrives as markup, - // and the client shows whatever the server rendered for it — never a - // client-invented error state. A rejected one has no client twin to - // surface its `_fr` rejection (hydratedCreateLoadingBoundary's - // `s === 2` arm runs only for a boundary registered against it), so - // dev names it here; the server's error path writes a BLANK template - // for it today (web/src/server.ts, the `done` closure's `" "`), which - // is the server half's gap, not a client state to invent. - if (IS_DEV) { - const ref = hy.r && hy.r[fragId + "_fr"]; - ref && - typeof ref.then === "function" && - ref.then(undefined, (error: unknown) => - console.error( - `Server fragment "${fragId}" inside server component "${id}" rejected on ` + - `the server; the frame shows what the server rendered for that outcome.`, - error - ) - ); - } + if (reportedFragments.has(fragId)) return; + reportedFragments.add(fragId); + const ref = hy.r[fragId + "_fr"]; + ref && + typeof ref.then === "function" && + ref.then(undefined, (error: unknown) => + console.error( + `Server fragment "${fragId}" inside server component "${id}" rejected on ` + + `the server; the frame shows what the server rendered for that outcome.`, + error + ) + ); }); }; const drainRecords = () => { @@ -1372,7 +1405,7 @@ function adoptBoundary( } } }; - claimRegionFragments(el); + IS_DEV && reportRegionFragments(el); const fr = (globalThis as any)._$HY?.fr; // The adopting frame, bound below; the reveal cascade syncs it. let frame: ReturnType | undefined; @@ -1382,7 +1415,7 @@ function adoptBoundary( // (nested server async). Scoped to the revealed parent, so each // sweep is proportional to what just landed. const inside = !!parent && el.contains(parent as Node); - if (fr.claim && inside) claimRegionFragments(parent!); + IS_DEV && inside && reportRegionFragments(parent!); // A revealed fragment also brings its occurrences' ARGS RECORDS: a // slot invoked inside a server `` ships its `sc:slot:` // declaration with the fragment, ~the async's own delay after this @@ -1422,7 +1455,6 @@ function adoptBoundary( onCleanup(() => { liveAppliers.delete(applyLiveOp); unsubscribe && unsubscribe(); - if (fr && fr.release) for (const fragId of claimedFragments) fr.release(fragId); }); drainRecords(); // Catch-up: ops that arrived before this boundary adopted (the pump may @@ -1498,7 +1530,15 @@ function adoptBoundary( () => {} ); } - onCleanup(() => frame.dispose()); + onCleanup(() => { + frame.dispose(); + // Disown the element's placeholders (ownership by rendering, see + // installRevealHook): the boundary and every region element inside it + // — nothing can be revealed into a disposed element later, so what is + // inside it now is all there will be. + disposedFrames.add(el); + el.querySelectorAll(FRAME_SELECTOR).forEach(e => disposedFrames.add(e)); + }); // The boundary IS the element — hand hydration the single SSR'd node so it // claims it in place rather than re-rendering. return el as unknown as SolidElement; diff --git a/packages/web/test/consistency/adopted-swap-post-done.spec.tsx b/packages/web/test/consistency/adopted-swap-post-done.spec.tsx new file mode 100644 index 000000000..0654f6810 --- /dev/null +++ b/packages/web/test/consistency/adopted-swap-post-done.spec.tsx @@ -0,0 +1,210 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * G4 — a post-done swap into a server component's markup (frames A5′, + * frames-rulings 3.3, ruled 2026-10-06): + * + * "A placeholder inside a server component's element is the frame's content + * by rendering, not by adoption." + * + * The server rendered the `` whose fallback the placeholder stands + * for INSIDE the server component, so no client boundary will ever register + * as the fragment's claimant (#2978). Under the held-swap policy (#2964) a + * post-done `$df` with no claimant is held; here the claimant is the + * geometry: the ledger asks the frames client's ownership predicate + * (`_$HY.fa`, installed once by `installRevealHook`) whether the `pl-*` + * template sits inside a `data-fid` element, and an owned swap proceeds — + * whether the element has been adopted yet or not. No hold, no claim, no + * replay: an adoption that follows the swap finds the settled markup in + * place and reads its declared records synchronously (#3844). + * + * The negative control pins what did NOT change: a post-done swap for a + * placeholder OUTSIDE any frame element is held exactly as before. + */ +import { afterEach, describe, expect, test } from "vitest"; +import { createSignal, flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { + bootPage, + fillHtml, + fillHtml2, + frameHtml, + freshFid, + placeholderHtml, + quiesce, + slotRange, + watchFrames, + type Page +} from "./support.js"; + +let page: Page | undefined; +afterEach(async () => { + await page?.cleanup(); + page = undefined; +}); + +/** Latch global hydration done the way a completed root pass does. */ +function completeHydrationPass() { + const other = document.createElement("div"); + document.body.appendChild(other); + hydrate(() => null, other)(); + other.remove(); +} + +const shell = (fid: string, frag: string) => + frameHtml(fid, `
    ${placeholderHtml(frag, "loading")}
`); + +describe("G4 — post-done swap into server-component markup is owned by rendering", () => { + // Arm (a): adopted first (by the page's hydration pass, which completes + // with the fragment still pending), then the post-done reveal. The + // adoption put no claim on record (there is nothing to claim); the swap + // proceeds because the placeholder is inside the frame's element. One + // visible transition, the revealed occurrence claims the server's `
  • ` + // and is live. + test("(a) adopted, then a post-done reveal: the swap proceeds and the occurrence mounts live", async () => { + const fid = freshFid("g4a"); + const frag = "g4a"; + page = bootPage(shell(fid, frag)); + const fr = page.declareFragment(frag); + const Comp = (globalThis as any)._$SC.r(fid); + const [tick, setTick] = createSignal(0); + const invocations: number[] = []; + const frames = watchFrames(page.container); + const dispose = hydrate( + () => ( + { + invocations.push(1); + return ( +
  • + {p.text} + {tick()} +
  • + ); + }} + /> + ), + page.container + ); + await quiesce(); + await quiesce(); + expect(page.hy.done).toBe(true); + expect(frames.frames).toEqual(["loading"]); + expect(invocations.length).toBe(0); + expect(page.hy.fr.pending()).toBe(true); + + page.slotRecord(fid, "item#0", { text: "one" }); + const swapped = page.revealFragment( + frag, + slotRange("item#0", fillHtml2(fid, "item#0", "one", "0")) + ); + expect(swapped).toBe(1); + await quiesce(); + await quiesce(); + frames.sample(); + expect(fr.promise.s).toBe(1); + expect(frames.frames).toEqual(["loading", "one0"]); + expect(page.container.querySelector(`template#pl-${frag}`)).toBeNull(); + expect(invocations.length).toBe(1); + expect(page.hy.fr.pending()).toBe(false); + setTick(1); + flush(); + expect(page.container.textContent).toBe("one1"); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + frames.stop(); + dispose(); + }); + + // Arm (b): the reveal lands BEFORE any mount adopts the element (the + // #2978 order: the fragment's chunk executes while the client's mount + // of this boundary is still ahead). Post-done, with no adoption on + // record, the swap is NOT held — it proceeds at once (1); the ledger + // reads the fragment delivered, the placeholder is retired. The + // adoption that follows finds the markup in place: the declared record + // (`s === 1`) is read synchronously in the adopt-time drain, the fill + // claims the server's `
  • ` by key, and nothing re-renders. + test("(b) post-done reveal before the mount adopts: no hold, the adoption finds the markup in place and reads the record synchronously", async () => { + const fid = freshFid("g4b"); + const frag = "g4b"; + page = bootPage(shell(fid, frag)); + completeHydrationPass(); + const fr = page.declareFragment(frag); + page.slotRecord(fid, "item#0", { text: "one" }); + const swapped = page.revealFragment(frag, slotRange("item#0", fillHtml(fid, "item#0", "one"))); + expect(swapped).toBe(1); + expect(fr.promise.s).toBe(1); + // Delivered as far as the ledger is concerned — nothing held, nothing to + // replay, nothing pending. + expect(page.hy.fr.pending()).toBe(false); + expect(page.container.querySelector(`template#pl-${frag}`)).toBeNull(); + expect(page.container.textContent).toBe("one"); + const serverLi = page.container.querySelector("li")!; + + const Comp = (globalThis as any)._$SC.r(fid); + const invocations: number[] = []; + const dispose = hydrate( + () => ( + { + invocations.push(1); + return
  • {p.text}
  • ; + }} + /> + ), + page.container + ); + // The drain is synchronous for a settled record: the fill has run by the + // time hydrate() returns, against the server's element. + expect(invocations.length).toBe(1); + await quiesce(); + await quiesce(); + expect(invocations.length).toBe(1); + expect(page.container.querySelector("li")).toBe(serverLi); + expect(page.container.textContent).toBe("one"); + expect(page.warnings.filter(w => w.includes("Hydration key miss"))).toEqual([]); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + dispose(); + }); + + // Arm (c): the nested shape — the placeholder sits under a REGION element + // (`data-fid=".."`) inside the boundary. Ownership + // reads the nearest `data-fid` ancestor; a region is as much the frame's + // rendering as the boundary itself, so the swap proceeds. + test("(c) a placeholder under a nested region element is owned by rendering too", async () => { + const fid = freshFid("g4c"); + const frag = "g4c"; + page = bootPage( + frameHtml( + fid, + `
      ` + + `${placeholderHtml(frag, "loading")}
    ` + ) + ); + completeHydrationPass(); + page.declareFragment(frag); + expect(page.revealFragment(frag, "late")).toBe(1); + expect(page.container.textContent).toBe("late"); + expect(page.hy.fr.pending()).toBe(false); + expect(page.errors).toEqual([]); + }); + + // Negative control: the same post-done reveal for a placeholder that is + // NOT inside any frame element — the shell's own `` around a + // call, say — has no claimant and is HELD (#2964), as before A5′. + test("(control) a post-done reveal outside any frame element is still held", async () => { + const frag = "g4-held"; + page = bootPage(`
    ${placeholderHtml(frag, "loading")}
    `); + completeHydrationPass(); + const fr = page.declareFragment(frag); + expect(page.revealFragment(frag, "late")).toBe(0); + expect(fr.promise.s).toBe(1); + expect(page.container.textContent).toBe("loading"); + expect(page.container.querySelector(`template#pl-${frag}`)).not.toBeNull(); + // Held is still pending: the document may yet deliver it to a claimant. + expect(page.hy.fr.pending()).toBe(true); + expect(page.errors).toEqual([]); + }); +}); diff --git a/packages/web/test/consistency/boundary-arrival.spec.tsx b/packages/web/test/consistency/boundary-arrival.spec.tsx new file mode 100644 index 000000000..e6739bf02 --- /dev/null +++ b/packages/web/test/consistency/boundary-arrival.spec.tsx @@ -0,0 +1,220 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * G9 — one deferred answer for "the page may still deliver this boundary" + * (frames A5′): `documentBoundary` pends on `awaitBoundary(id)`, the same + * promise the intercept hands a CALL for a boundary that is still arriving. + * The reveal hook settles it when the element lands (adopt) or when the + * page has no reveal left to deliver it (mount fresh / go to the wire). + * + * The exhaustion arm runs the PRODUCER'S order through the shipped `$df` + * (`revealFragment`: swap script, `_$HY.fe`, then the `_fr` settle in the + * same batch) and so pins the ledger reading the revealing fragment as + * delivered from its swap (`_$HY.v`) rather than from a stamp that has not + * executed yet — the latent bug under which a page's LAST reveal never + * read as exhaustion and a waiter released on exhaustion waited forever. + */ +import { afterEach, describe, expect, test } from "vitest"; +import { createRoot, Loading } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { + bootPage, + fillHtml, + frameHtml, + freshFid, + placeholderHtml, + quiesce, + slotRange, + type Page +} from "./support.js"; + +let page: Page | undefined; +afterEach(async () => { + await page?.cleanup(); + page = undefined; +}); + +/** Latch global hydration done the way a completed root pass does. */ +function completeHydrationPass() { + const other = document.createElement("div"); + document.body.appendChild(other); + hydrate(() => null, other)(); + other.remove(); +} + +const frameSelector = (fid: string) => `solid-frame[data-fid="${fid}"]`; + +/** A placeholder mount of `fid` under a client `` (non-hydrating). */ +function mountPlaceholder(container: Element, fid: string, log: { invocations: number }) { + const Comp = (globalThis as any)._$SC.r(fid); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
    + fallback}> + { + log.invocations++; + return
  • {p.text}
  • ; + }} + /> +
    +
    ; + container.appendChild(div); + return d; + }); + return { div, dispose }; +} + +describe("G9 — a document boundary pends on the page's one arrival answer", () => { + // Arm (a): streaming (hydration not done), a non-hydrating render mounts + // the placeholder before its element has parsed. The mount pends on the + // arrival (fallback shown, nothing fetched — `bootPage`'s fetch throws); + // the reveal that carries the element answers it, and the mount adopts + // THAT element. + test("(a) mounted before the element parses: pends, then adopts the revealed element", async () => { + const fid = freshFid("g9a"); + const frag = "g9a"; + page = bootPage(placeholderHtml(frag, "loading")); + page.declareFragment(frag); + const log = { invocations: 0 }; + const { div, dispose } = mountPlaceholder(page.container, fid, log); + await quiesce(); + expect(div.textContent).toBe("fallback"); + expect(page.container.querySelectorAll(frameSelector(fid)).length).toBe(0); + + page.slotRecord(fid, "item#0", { text: "one" }); + page.revealFragment( + frag, + frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
    `) + ); + await quiesce(); + await quiesce(); + const frames = page.container.querySelectorAll(frameSelector(fid)); + expect(frames.length).toBe(1); + expect(frames[0].textContent).toBe("one"); + expect(log.invocations).toBe(1); + // The adopted element IS the component's return value: a non-hydrating + // mount inserts it at its own position (out of the fallback). + expect(div.contains(frames[0])).toBe(true); + expect(div.textContent).toBe("one"); + expect(page.errors).toEqual([]); + dispose(); + }); + + // Arm (c): two mounts of the same boundary both waiting share the one + // answer. At the arrival the first to resume adopts the element; the + // other finds the id claimed and mounts fresh (an element is adopted + // once). One adopted frame, one fresh — never two adoptions, never an + // orphaned element. + test("(c) two mounts waiting on one id: one adopts at the arrival, the other mounts fresh", async () => { + const fid = freshFid("g9c"); + const frag = "g9c"; + page = bootPage(placeholderHtml(frag, "loading")); + page.declareFragment(frag); + const log1 = { invocations: 0 }; + const log2 = { invocations: 0 }; + const m1 = mountPlaceholder(page.container, fid, log1); + const m2 = mountPlaceholder(page.container, fid, log2); + await quiesce(); + expect(m1.div.textContent).toBe("fallback"); + expect(m2.div.textContent).toBe("fallback"); + + page.slotRecord(fid, "item#0", { text: "one" }); + expect( + page.revealFragment( + frag, + frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
    `) + ) + ).toBe(1); + await quiesce(); + await quiesce(); + const frames = page.container.querySelectorAll(frameSelector(fid)); + expect(frames.length).toBe(2); + const adopted = [...frames].filter(f => f.textContent === "one"); + expect(adopted.length).toBe(1); + expect(log1.invocations + log2.invocations).toBe(1); + expect(page.errors).toEqual([]); + m1.dispose(); + m2.dispose(); + }); + + // Arm (d): disposed during the wait, the shared answer resumes nothing + // for the dead mount (C14 (c) pins the fresh-mount half). + test("(d) disposed during the wait: the arrival resumes nothing", async () => { + const fid = freshFid("g9d"); + const frag = "g9d"; + page = bootPage(placeholderHtml(frag, "loading")); + page.declareFragment(frag); + const log = { invocations: 0 }; + const { div, dispose } = mountPlaceholder(page.container, fid, log); + await quiesce(); + expect(div.textContent).toBe("fallback"); + dispose(); + div.remove(); + page.slotRecord(fid, "item#0", { text: "one" }); + expect( + page.revealFragment( + frag, + frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
    `) + ) + ).toBe(1); + await quiesce(); + await quiesce(); + expect(log.invocations).toBe(0); + expect(page.container.querySelectorAll(frameSelector(fid)).length).toBe(1); + expect(page.container.querySelector(frameSelector(fid))!.textContent).toBe("one"); + expect(page.errors).toEqual([]); + }); + + // Arm (b): exhaustion, in the real order, post-done. LAST in the file: + // `_hydrationDone` is a module latch the throwaway pass below sets for + // the rest of this worker, and the arms above need the pre-done policy + // (a top-level placeholder's swap proceeds only while hydration is in + // progress or a claimant is on record). Boundary A is + // adopted and live, with a server ``'s placeholder inside it — + // the page's last outstanding fragment. A placeholder mount of X waits + // (the page may still deliver: that fragment is pending). The fragment + // reveals UNRELATED content (owned by rendering, so the post-done swap + // proceeds); the hook runs with the `_fr` stamp still ahead, must read + // the page as exhausted, and releases X to mount fresh — a client-owned + // frame rather than a fallback frozen forever. + test("(b) the page's last reveal, unrelated to the waiter: read as exhaustion before the _fr stamp, the waiter mounts fresh", async () => { + const fidA = freshFid("g9b-a"); + const fidX = freshFid("g9b-x"); + const frag = "g9b"; + page = bootPage(frameHtml(fidA, `
      ${placeholderHtml(frag, "loading")}
    `)); + completeHydrationPass(); + await quiesce(); + expect(page.hy.done).toBe(true); + page.declareFragment(frag); + const CompA = (globalThis as any)._$SC.r(fidA); + const disposeA = createRoot(d => { +
  • {p.text}
  • } />; + return d; + }); + await quiesce(); + + const log = { invocations: 0 }; + const { div, dispose } = mountPlaceholder(page.container, fidX, log); + await quiesce(); + expect(div.textContent).toBe("fallback"); + expect(page.container.querySelectorAll(frameSelector(fidX)).length).toBe(0); + + // The producer's order, through the shipped runtime: `$df` (swap, `_$HY.v`, + // `_$HY.fe`) and only then the `_fr` settle. + const swapped = page.revealFragment(frag, "late"); + expect(swapped).toBe(1); + await quiesce(); + await quiesce(); + // Released: a client-owned frame for X, ready to take a call's stream. + expect(page.container.querySelectorAll(frameSelector(fidX)).length).toBe(1); + expect(div.textContent).not.toBe("fallback"); + expect(page.container.querySelector(frameSelector(fidA))!.textContent).toBe("late"); + expect(log.invocations).toBe(0); + expect(page.hy.fr.pending()).toBe(false); + expect(page.errors).toEqual([]); + dispose(); + disposeA(); + }); +}); diff --git a/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx b/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx index d2c351fc8..0e503eb40 100644 --- a/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx +++ b/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx @@ -13,10 +13,9 @@ * Mechanism meant to carry it: frames/src/frame-client.ts * `FrameImpl.#syncSlots` (range discovery over the frame's content) driven * by frames/src/client.ts `adoptBoundary`'s `fr.subscribe` cascade - * (`claimRegionFragments` + `drainRecords`). A re-sync after a reveal - * happens only when the reveal brings a NEW record (`drainRecords` → - * `host.apply` → `#flush` → `#syncSlots`); nothing re-syncs on the reveal - * itself. + * (`drainRecords` + the reveal-is-an-apply write). A swap into the region + * needs no claim from the adoption: a placeholder inside a `data-fid` + * element is the frame's content by rendering (`_$HY.fa`, frames A5′). * * Liveness is the assertion: a fill is "mounted" when a client signal it * reads drives the DOM. Every fill here reads `tick()` in a text hole @@ -283,11 +282,13 @@ describe("C2 — no inert server content", () => { // Arm (c2): reveal BEFORE hydrate, post-done. Global hydration has already // completed in this worker (forced here with a throwaway pass, so the arm - // does not depend on its position in the file), so the pre-hydrate `$df` - // is HELD by the ledger's policy (returns 0) and replayed when the - // adoption claims the placeholder (`claimRegionFragments` → `fr.claim` → - // `replayHeldFragment`). The final page must equal (c1)'s and (a1)'s. - test("(c2) reveal-before-hydrate, post-done held swap replayed by the adoption's claim: same final page", async () => { + // does not depend on its position in the file). The placeholder sits + // inside a `data-fid` element, so the pre-hydrate `$df` is the frame's + // content BY RENDERING (frames A5′, `_$HY.fa`): the ledger swaps it at + // once (returns 1) with no adoption on record — no hold, no replay — and + // the adoption that follows finds the markup in place and reads the + // record synchronously. The final page must equal (c1)'s and (a1)'s. + test("(c2) reveal-before-hydrate, post-done swap owned by rendering lands before the adoption: same final page", async () => { const fid = freshFid("c2c2"); const frag = "c2c2"; page = bootPage(pendingShell(fid, frag)); @@ -298,8 +299,9 @@ describe("C2 — no inert server content", () => { frag, slotRange("item#0", liveFillHtml(fid, "item#0", "one")) ); - expect(swapped).toBe(0); - expect(page.container.textContent).toBe("loading"); + expect(swapped).toBe(1); + expect(page.container.textContent).toBe("one0"); + const serverLi = page.container.querySelector("li")!; const Comp = (globalThis as any)._$SC.r(fid); const [tick, setTick] = createSignal(0); const invocations: number[] = []; @@ -323,6 +325,7 @@ describe("C2 — no inert server content", () => { await quiesce(); expect(page.container.textContent).toBe("one0"); expect(invocations.length).toBe(1); + expect(page.container.querySelector("li")).toBe(serverLi); setTick(1); flush(); expect(page.container.textContent).toBe("one1"); diff --git a/packages/web/test/consistency/c12-boundary-parity.spec.tsx b/packages/web/test/consistency/c12-boundary-parity.spec.tsx index 8109fa62a..99f1ac9c8 100644 --- a/packages/web/test/consistency/c12-boundary-parity.spec.tsx +++ b/packages/web/test/consistency/c12-boundary-parity.spec.tsx @@ -12,9 +12,12 @@ * * Mechanism meant to carry it: solid/src/client/hydration.ts * `hydratedCreateLoadingBoundary` (`_fr` states), `fragmentPolicy` (held - * swaps), frames/src/client.ts `adoptBoundary.claimRegionFragments` (#2978: - * the adoption goes on record as claimant of the server-produced `pl-*` - * placeholders in its region so a late swap lands). + * swaps) with its ownership-by-rendering term (`_$HY.fa`, installed by + * frames/src/client.ts `installRevealHook`: a server-produced `pl-*` + * placeholder inside a `data-fid` element is the frame's content, so a late + * swap lands with or without an adoption on record — #2978, frames A5′), + * and `adoptBoundary`'s dev-only region sweep that names a rejected server + * fragment. * * Shape: a SERVER `` inside the adopted frame — its producer ran * on the server, so there is no client boundary at this position; the @@ -177,6 +180,57 @@ describe("C12 — boundary parity at claim", () => { dispose(); }); + // Arm (c3): the client half, post-done. Global hydration has completed + // before the fragment settles — the #2978 shape: the server ``'s + // producer ran on the server, no client boundary ever registers for the + // fragment, and a held-swap policy (#2964) with no other claimant would + // freeze the fallback on screen forever. The placeholder is inside the + // adopted frame's element, so it is the frame's content BY RENDERING + // (frames A5′, `_$HY.fa`): the swap proceeds, the adopted face shows what + // the server rendered for the outcome, the ledger resolves. + test("(c3) settled post-done: the swap proceeds, the adopted face shows what the server rendered, no frozen fallback", async () => { + const fid = freshFid("c12c3"); + const frag = "c12c3-frag"; + page = bootPage(shell(fid, frag)); + const fetches = countFetches(); + const fr = page.declareFragment(frag); + const Comp = (globalThis as any)._$SC.r(fid); + const frames = watchFrames(page.container); + const invocations: number[] = []; + const dispose = hydrate( + () => ( + { + invocations.push(1); + return
  • {p.text}
  • ; + }} + /> + ), + page.container + ); + await quiesce(); + await quiesce(); + expect(page.hy.done).toBe(true); + expect(frames.frames).toEqual(["loading"]); + + page.slotRecord(fid, "item#0", { text: "one" }); + const swapped = page.revealFragment(frag, slotRange("item#0", fillHtml(fid, "item#0", "one"))); + expect(swapped).toBe(1); + await quiesce(); + await quiesce(); + frames.sample(); + expect(fr.promise.s).toBe(1); + expect(frames.frames).toEqual(["loading", "one"]); + expect(page.container.querySelector(`template#pl-${frag}`)).toBeNull(); + expect(invocations.length).toBe(1); + expect(fetches).toEqual([]); + expect(page.hy.fr.pending()).toBe(false); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + frames.stop(); + dispose(); + }); + test.fails( "(c2) rejected after adopt: the position shows the server's rendered outcome, never a blank (server half)", async () => { diff --git a/packages/web/test/consistency/c14-dispose-clean.spec.tsx b/packages/web/test/consistency/c14-dispose-clean.spec.tsx index 1daa84063..a4dfecc6e 100644 --- a/packages/web/test/consistency/c14-dispose-clean.spec.tsx +++ b/packages/web/test/consistency/c14-dispose-clean.spec.tsx @@ -13,9 +13,12 @@ * (unregister first, `#recordRefresh` cleared, slot cleanups, record * hygiene), `createFrameHost.unregister`, frames/src/client.ts * `adoptBoundary`'s `onCleanup` (live applier removed, `fr` unsubscribed, - * fragment claims released, `frame.dispose()`), `documentBoundary`'s - * `boundaryWaiters` cleanup, `followAddress.drop`, `slotsFor`'s - * per-occurrence fill owners (`ctx.onCleanup`). + * `frame.dispose()`), `documentBoundary`'s `live` latch over the shared + * arrival wait, `followAddress.drop`, `slotsFor`'s per-occurrence fill + * owners (`ctx.onCleanup`). Fragment ownership needs no release: it is + * geometry (`_$HY.fa` reads the placeholder's enclosing `data-fid` element + * in the live document), so a disposed boundary's element leaving the + * document is what retires it — arm (e) pins the attached case. * * Each arm disposes at a different hold, then delivers EVERYTHING the * disposed mount was waiting for and asserts nothing moved: the fill's @@ -71,6 +74,15 @@ function makeFill(log: { invocations: number; seen: string[]; cleanups: number } } const freshLog = () => ({ invocations: 0, seen: [] as string[], cleanups: 0 }); +/** Latch global hydration done the way a completed root pass does (see + * c02): the post-done arms need the ledger's held-swap policy in force. */ +function completeHydrationPass() { + const other = document.createElement("div"); + document.body.appendChild(other); + hydrate(() => null, other)(); + other.remove(); +} + /** A stream-face site over `getX()` with the instrumented fill under `item`. */ function mountSite(getX: () => unknown, log: ReturnType) { const Site = dynamic(() => getX() as any); @@ -262,6 +274,86 @@ describe("C14 — disposal leaves nothing", () => { expect(site.applied).toEqual([]); }); + // Arm (e): document face, adopted, disposed IN PLACE, then a post-done + // reveal into the dead element. The adopted element is the component's + // return value, so a root disposed without detaching it leaves the + // `` standing in the document with a server ``'s + // placeholder still inside it. By geometry alone that placeholder reads + // as the frame's content (frames A5′, `_$HY.fa`: inside a `data-fid` + // element) and the swap would land — server markup nobody drives, + // visible and inert. The disposal must disown it: `$df` holds (0), the + // fallback stands, the record never invokes the fill. A region element + // between the placeholder and the boundary (the nested-region shape) must + // not hide the disposed boundary from the predicate. + test("(e) dispose in place, then a post-done reveal into the dead element: held, nothing lands", async () => { + const fid = freshFid("c14e"); + const frag = "c14e"; + const nested = "c14e-nested"; + page = bootPage( + frameHtml( + fid, + `
      ${placeholderHtml(frag, "loading")}` + + `` + + `${placeholderHtml(nested, "inner")}
    ` + ) + ); + completeHydrationPass(); + page.declareFragment(frag); + page.declareFragment(nested); + const Comp = (globalThis as any)._$SC.r(fid); + const log = freshLog(); + const dispose = createRoot(d => { + ; + return d; + }); + await quiesce(); + const frameEl = page.container.querySelector("solid-frame")!; + expect(frameEl).not.toBeNull(); + expect(page.container.textContent).toBe("loadinginner"); + dispose(); + // Disposed, but still in the document — the shape under test. + expect(frameEl.isConnected).toBe(true); + expect(page.container.querySelector("solid-frame")).toBe(frameEl); + const html = frameEl.innerHTML; + page.slotRecord(fid, "item#0", { text: "one" }); + expect(page.revealFragment(frag, slotRange("item#0", fillHtml(fid, "item#0", "one")))).toBe(0); + expect(page.revealFragment(nested, "late")).toBe(0); + await quiesce(); + await quiesce(); + expect(frameEl.innerHTML).toBe(html); + expect(page.container.textContent).toBe("loadinginner"); + expect(log.invocations).toBe(0); + expect(page.errors).toEqual([]); + }); + + // Control for (e): the same page NOT disposed — the placeholder inside the + // live adopted element is the frame's content by rendering, the swap + // lands (1) and the revealed occurrence mounts. + test("(e-control) undisposed, the same post-done reveal lands and the fill runs", async () => { + const fid = freshFid("c14e-control"); + const frag = "c14e-control"; + page = bootPage(frameHtml(fid, `
      ${placeholderHtml(frag, "loading")}
    `)); + completeHydrationPass(); + page.declareFragment(frag); + const Comp = (globalThis as any)._$SC.r(fid); + const log = freshLog(); + const dispose = createRoot(d => { + ; + return d; + }); + disposers.push(dispose); + await quiesce(); + expect(page.container.textContent).toBe("loading"); + page.slotRecord(fid, "item#0", { text: "one" }); + expect(page.revealFragment(frag, slotRange("item#0", fillHtml(fid, "item#0", "one")))).toBe(1); + await quiesce(); + await quiesce(); + expect(page.container.textContent).toBe("one"); + expect(log.invocations).toBe(1); + expect(log.seen).toEqual(["one"]); + expect(page.errors).toEqual([]); + }); + // Control: undisposed, the same late chunks land — the hole morphs, the // changed record pushes into the live fill (no re-call), the root morphs. test("(control) undisposed, the same late chunks land: the fill runs once and the root morphs", async () => { diff --git a/packages/web/test/frames-late-boundary-client.spec.tsx b/packages/web/test/frames-late-boundary-client.spec.tsx index 0c2e03be3..17d86bbc7 100644 --- a/packages/web/test/frames-late-boundary-client.spec.tsx +++ b/packages/web/test/frames-late-boundary-client.spec.tsx @@ -53,10 +53,14 @@ function declareFragment(id: string) { (window as any)._$HY.r[`${id}_fr`] = { then() {} }; } -/** The `$df` swap, reduced to what matters here: retire the fragment's - * placeholder, put the server's boundary element in the live document, - * record the reveal in the ledger (`_$HY.v`, what the real $dfr marks; - * seroval settles the `_fr` ref in the same batch), then announce it. */ +/** The `$df` swap, reduced to what matters here, in the PRODUCER'S order: + * retire the fragment's placeholder, put the server's boundary element in + * the live document, record the reveal in the ledger (`_$HY.v`, what the + * real $dfr marks), announce it (`_$HY.fe`) — and only THEN settle the + * `_fr` ref, the way seroval's resolution script runs after the swap + * script in the same task batch. Anything the announcement's subscribers + * read must therefore classify the revealing fragment from `_$HY.v`, not + * from its `_fr` stamp (the exhaustion arms below pin this). */ function swapIn(parent: HTMLElement, html: string) { document.getElementById("pl-1902")?.remove(); const tpl = document.createElement("template"); @@ -64,8 +68,8 @@ function swapIn(parent: HTMLElement, html: string) { parent.appendChild(tpl.content); const hy = (window as any)._$HY; (hy.v = hy.v || {})["1902"] = 1; - if (hy.r["1902_fr"]) hy.r["1902_fr"].s = 1; hy.fe && hy.fe("1902", parent); + if (hy.r["1902_fr"]) hy.r["1902_fr"].s = 1; } /** A one-shot frame stream, the shape a navigation's response arrives in. */ @@ -163,9 +167,10 @@ describe("boundary that arrives after the shell flush", () => { // The same "not in the page yet" moment, one step later in the document's // life: global hydration has already completed. Under the held-swap policy // (#2964) that no longer means the page is finished — a fragment settling - // post-done keeps its placeholder, fallback and template in place until its - // boundary claims it, and the replay that follows is what delivers this - // element. A boundary rendering in that window (a frames slot fill or lazy + // post-done keeps its placeholder, fallback and template in place until a + // claimant is on record for it (a client registering, or a frame + // element enclosing it — ownership by rendering), and the swap that follows + // is what delivers this element. A boundary rendering in that window (a frames slot fill or lazy // route module running after the root pass) that reads `done` as "never" // mounts a fresh frame and orphans the markup: the region goes inert AND — // because the id is never claimed — every later call for this function @@ -234,7 +239,11 @@ describe("boundary that arrives after the shell flush", () => { // The mirror case: nothing is left to reveal, so waiting would strand the // region on its fallback forever. A reveal that exhausts the page's deferred - // fragments releases the waiter to mount fresh. + // fragments releases the waiter to mount fresh — and the exhausting reveal + // is the LAST one, whose own `_fr` is still unstamped while the hook runs + // (see swapIn): the ledger must count it as delivered from `_$HY.v`, or + // the page's final reveal never reads as exhaustion and the waiter waits + // forever. test("gives up waiting once the page has no deferred fragment left", async () => { document.body.innerHTML = '
    fallback
    ';