diff --git a/.changeset/frames-residue-3-every-fill-through-insert.md b/.changeset/frames-residue-3-every-fill-through-insert.md new file mode 100644 index 000000000..f9eb960b4 --- /dev/null +++ b/.changeset/frames-residue-3-every-fill-through-insert.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Frames client: every slot fill is placed by `insert` — the fill's output has the core's lifecycle (created under the fill's owner, claimed in place under hydration through `insertExpression`'s claim pass, disposed with the owner) instead of a frame-side copy of it. The Solid binding's static path (`normalizeSlotContent`, `isReactiveContent`, the in-place `settle` test) and the frame runtime's own range writer (`#replaceRange`) are gone; a static fill is one `insert` of a non-function value (no effect created), a reactive one binds the range as before. `createFrame` / `createFrameElement`: the frame element IS the range — the never-used comment-marker range mode of the runtime is removed. **`Slot` (`FrameOptions.slots`, `@experimental`) changes shape:** a callback owns its range — it places or binds its output before `ctx.range.end` over `ctx.existing` — and its return value is no longer read (before: returned nodes were placed by the frame; `undefined` claimed). A slot range missing its end marker (`FRAME_MARKER_CORRUPTED`) is left as the server rendered it instead of being filled at the parent's end. diff --git a/documentation/plans/frames-residue-pass.md b/documentation/plans/frames-residue-pass.md index 5782a97bc..ca5299e81 100644 --- a/documentation/plans/frames-residue-pass.md +++ b/documentation/plans/frames-residue-pass.md @@ -48,7 +48,15 @@ token kept and the carrier built honestly on the edited dist — per-store staging, a lane-correct read, the flight path, nested regions, the commit's landing / L1 semantics the chunk replay got for free — it measures **+1,414 min / +483 br over the −546 ceiling: net −63 br**, against the +242 budget -and the −300 target. §3.1 "As measured" has the attribution; §7 the row._ +and the −300 target. §3.1 "As measured" has the attribution; §7 the row. +**Residue step 3 landed** (`size/frames-residue-3-insert`, on step 2's head): +R.insert — every fill through `insert`, the frame runtime's own range +writer and its never-used comment-marker range mode deleted — **frames +eager 11,654 → 11,367 br (−978 min / −287 br)**, more than the row's −207 +because the dead range mode (−196 / −67 alone) went with it; see §7c for +the measured → built table, the `Slot` contract change and the one pin +re-pinned (C1 (b): the frames rule for a fill that does not claim is now +the core's mismatch rule)._ --- @@ -229,7 +237,7 @@ pattern every tier in #3860 showed). | 2f | the imperative branch of `#revealSegment` | D | −121 / −27 | −65 | −54 | 0 | −27 | **public surface**: `FrameOptions.reveal` is optional on `createFrame` / `createFrameElement` (`@experimental`) — the branch is reachable from a consumer that passes none; a documented "reveal is required" or a default seam | | 2g | `showing`'s `COMPONENT_BINDING` brand | D | −96 / −22 | −3 | −22 | 0 | −22 | **behaviour**: a cache-seeded reader at t = 0 whose site later switches calls would remount instead of delivering through `dynamic`'s equals-gate; the adopted-switch pins (`c17` (b), the notes-search shape) to check | | **2** | **the D list, together** (2a–2g) | D | **−1,004 / −300** | −289 | −305 | 0 … +60 | **−300 … −240** | the sum of the above | -| 3 | **R.insert** — every fill through `insert`: `normalizeSlotContent`, `isReactiveContent`, `settle`, `#replaceRange` go; the reactive branch is the only branch | R.insert | **−713 / −207** | −158 | −193 | ≈ 0 (`insert` is already the reactive branch's call) | **−207** | **public surface**: the marker-less `createFrame` consumer path (`client.ts` "No range handle … degrade to the snapshot") needs an anchor or a documented removal (re-attribution §5.3 item 5); behaviour: a static fill is an `insert` of a non-function value (no effect created — `insertExpression` with no render effect) | +| 3 | **R.insert** — every fill through `insert`: `normalizeSlotContent`, `isReactiveContent`, `settle`, `#replaceRange` go; the reactive branch is the only branch | R.insert | **−713 / −207** → **built −978 / −287** (step 3, §7c: the ceiling re-measured on step 2's head −769 / −230; the dead comment-marker range mode of `FrameImpl` −196 / −67 taken with it) | −158 | −193 | ≈ 0 (`insert` is already the reactive branch's call) — built: +10 min / +14 br (the no-gather flag on the second claim window, the transparent owner) | **−287** (built) | **landed (2026-10-06)**. **Public surface**: `Slot`'s return is no longer read — a callback owns its range through `ctx.range` (§7c); the no-range snapshot fallback is removed (a range without its end marker is left as the server rendered it). **Behaviour**: a static fill is an `insert` of a non-function value (no effect created); a fill that does not claim at t=0 is the core's hydration mismatch (C1 (b) re-pinned), not a frame-side replacement | | 4a | the fallback pass of `#revealSegments` + `#showFallback` | R.reveal | −330 / −79 | −110 | −117 | DR-4 2c (the document fragment as a store write — "its own plan, ≈ 1.3 KB on whichever side goes") | **−79** only under DR-4 | **design**: what `$dfl` does for the document face; C8, C14 (c), the lifecycle matrix's fallback rows | | 4b | the style gate (C5's reveal-readiness term) | F.assets glue | −115 / −25 | −89 | −83 | — | **not deletable** | `tier-assets-ready` ×5 red: the FOUC guard | | 5a | `FRAME_HAVE_HEADER` / `FRAME_HAVE_BUDGET` off the client entry's export list | F.wire | −64 / −28 | 0 | 0 | 0 | −28 | **public surface**: two exported constants leave the eager entry (C2's `lean` keeps + re-exports them at +45 br; the tier would import them from the sf client or carry its own copy). The pages already tree-shake them — the cost is the frames scenario's whole-entry measurement | @@ -537,7 +545,7 @@ still has to pay; the "built" column subtracts it. | 1 | **C6** bind tier (`tight-nopair`, in flight) | −1,243 | **11,840** | 0 (C6's glue is in) | 11,840 | 11,548 | C6's pins; `tier-bind-hold`; the click-replay finding (C6 P1) | | 2 | **the D list** (2a–2g) + the s / v stamp (10) + the nested marking (8a) + `FRAME_HAVE_*` off the entry (5a) | −349 | **11,491** | +10 (`readHydratedValue` export); the mirror's server half is output, not client bytes | 11,501 | 11,209 | 2a / 2b need the server to emit the bootstrap unconditionally; 2f / 5a / 10 are public-surface items; 2d / 2g are behaviour changes | | 3 | **the `preview` pull form** (1c; carrier sketched **and measured in the row**) | −277 → **−63 as designed** (step 2: the sketch's carrier was unsound and half-counted; §3.1 "As measured") | **11,214** (≈ 11,430 with the honest carrier) | 0 | 11,224 (≈ 11,440) | 10,932 (≈ 11,150) | **stopped** (carrier +483 > +300); C15 ×4, optimistic-hold ×3, morph-in-transition ×3 | -| 4 | **R.insert** — every fill through `insert` (3) | −196 | **11,018** | 0 | 11,028 | 10,736 | public surface: the marker-less `createFrame` path | +| 4 | **R.insert** — every fill through `insert` (3) | −196 → **−287 built** (step 3; with the dead range mode) | **11,018** (built: 11,654 → **11,367**, row 3 not taken) | 0 | 11,028 | 10,736 | **landed** (§7c): `Slot` owns its range; C1 (b) re-pinned to the core's rule | | 5 | the fallback pass (4a) | −75 | 10,943 | DR-4 (its own plan) | — | — | **not recommended** (§3.4); kept to show it does not matter — the rows below are measured **without** it where marked | | 6 | **the lean re-ask** (6b) | −78 | **10,865** (10,940 without row 5) | pages +30 | 10,950 | 10,658 | **design** §3.3 (sf client ctx) | | 7 | **S-ref's pending read to the table** (12, ceiling) | −289 | **10,576** (≈ 10,637 without row 5) | **+40** frames; decode chunk +≈ 120 min lazy | ≈ 10,687 | ≈ 10,395 | **design** §3.2; C5 ×3, C6 (a1) | @@ -608,7 +616,13 @@ two seeds clean): cannot go (it is the delivery), the honest carrier is +483 br over the −546 ceiling, net −63; not recommended at that price. 4. **R.insert** (−196) with the marker-less `createFrame` path ruled (an - anchor requirement on `createFrame`, documented). + anchor requirement on `createFrame`, documented). **Landed as residue + step 3 (§7c): −287 built** — the no-range fallback removed (a range + without its end marker is left as rendered), the `Slot` contract made + "the fill owns its range", the runtime's dead comment-marker range mode + deleted with it (the frame element IS the range); C1 (b) re-pinned to + the core's mismatch rule — the one ruling the step took, flagged in the + PR. 5. **S-ref to the table** (§3.2; ≈ −250 built) — a decode-chunk change plus a host simplification; worth it on its own terms (the pending read lives where the keys live). @@ -740,6 +754,93 @@ build, so the residue proper (rows 2–4, 6) stalls ≈ 215 B higher than §4 says; crossing 10.0 still needs C1 and the claims chunk, with ≈ 120 B of margin instead of ≈ 330. The push form stays, as #3844 left it. +## 7c. Landed — residue step 3 (2026-10-06): R.insert, every fill through `insert` + +Branch `size/frames-residue-3-insert` on step 2's head (`f12708386`, frames +eager **35,250 / 11,654**; page base 114,422 / 36,488; live 126,294 / +40,125). §5 row 4. Measured before writing on an edited dist copy through +the harness's bundler (§0's method; `.wt-logs/res3-edit.mjs`, +`res3-measure.mjs`): the ceiling, then the carrier as the step would write +it, in two forms — with the frame runtime's range writer kept for the raw +`Slot` contract, and without it. min / br; the four non-SC scenarios 0 / 0 +on every row. + +| edited dist | frames eager Δ | page base / live Δ br | note | +| --- | ---: | ---: | --- | +| the ceiling (§2 row 3's cut re-applied: `normalizeSlotContent`, `isReactiveContent`, `settle`, the no-range fallback, `#replaceRange` + its two call sites) | **−769 / −230** | −195 / −172 | the row measured −713 / −207 on #3860's head | +| the carrier, `#replaceRange` KEPT (the raw `Slot` "return nodes" contract preserved; one `insert(end.parentNode, value, end, [...existing])`; a range without an end marker left as rendered) | −600 / −160 | −185 / −166 | compatibility costs **69 br** | +| the carrier, `#replaceRange` gone (the `Slot` callback owns its range) | **−791 / −229** | −233 / −210 | | +| the dead comment-marker range mode of `FrameImpl` alone (`#start` / `#end` null on both constructors; `#parent`, `#firstContent`, `#clearContent`, the `end` bounds passed as null) | −196 / −67 | −3 / −99 | not in §2 — found while making "the frame element is the range" literal | +| **the carrier without `#replaceRange` + the range mode, one copy** | **−988 / −301** | −268 / −247 | | +| **built** | **−978 / −287 → 34,272 / 11,367** | **−217 / −221** → 113,443 / 36,271 and 125,315 / 39,904 | +10 min / +14 br over the copy: the no-gather flag on the fill's second claim window and the transparent binding owner (below) | + +**What `insert` now owns.** Every fill of the Solid binding is +`insert(end.parentNode, value, end, [...ctx.existing])` under the fill's +owner (its own for a stream-mounted fill; a transparent owner minted for a +live-render one, registered in `fillScopes` so a re-call disposes the +previous binding before the next renders). A static value is placed once +with no effect (`insertExpression` with no render effect); a reactive one +binds the range as before; a stream re-call reconciles the new output +against the previous (`existing` seeds the tracked array); an adopted fill +places inside a claim window, so `insertExpression`'s claim pass carries +the claim: in-place output moves nothing, and a render whose nodes never +entered the DOM is the core's hydration mismatch — the server's nodes stay, +hydration reports them unclaimed. Deleted: `normalizeSlotContent` (274), +`isReactiveContent` (111), `settle` (the in-place test with its `contains` +lenience), the no-range snapshot fallback, `#replaceRange` (94) and the +returned-nodes path (`#invokeSlot`'s return, the two `#syncSlots` sites, +`regions.bind`'s `start` re-scan), and the runtime's comment-marker range +mode — `FrameImpl(element, options)`; the frame element IS the range. + +**Two things the build found that the edit could not.** (i) The fill's +second claim window (the one around the `insert`) must **not gather**: the +first window's gather is still in the registry, and gathering the prefix +again put every key the evaluation had just claimed back as unclaimed — +20 hydrate files red with "unclaimed server-rendered node" until +`claimRender` took a `bound` flag (the reactive path had the same latent +re-gather; it was masked because its content claims inside the second +window). (ii) The minted binding owner must be **transparent**: a +non-transparent `createOwner()` inside the hydrate pass consumes a child id +from the adopting component's counter, so a keyed sibling after the frame +keyed differently depending on whether a fill mounted at t=0 or after a +hold (`container-trace-hold-id-determinism` — '4' vs '6'). The reactive +path's old `createOwner()` had this consumption too; it is gone for every +fill now. + +**The one pin re-pinned — a ruling for the maintainer.** `c01-claim-once` +(b) asserted that a fill answering the t=0 claim with FRESH nodes (built +outside the claim walk) is replaced into the range by the frame — the +server node leaves the document. That was `settle` + `#replaceRange`'s +rule, and it is exactly what R.insert deletes: under `insert`, a claim pass +moves nothing, so the fresh render is dropped, the server node stays, and +hydration reports it unclaimed — the rule every compiled hole already has. +Arm (b) now pins that (the server node stays, nothing duplicated, one +"unclaimed" warning naming `sc--item#1-`), the contract's C1 text +reads "left as the server rendered it and reported" for the second arm, +and the PR names it first under Pins. Everything else green unchanged: +C1 (a, c), C9 ×3, C10 ×3, C14, the hydrate suite (87 / 446), the lifecycle +matrix, `tier-bind-hold`, `adopted-swap-post-done`, `boundary-arrival`; +web client 129 / 1,194, server 159 / 1,511; harness 500 × {3289, 91501} +SC arm 0, generic arm (`CONSISTENCY_IGNORE=C1,C9,C19,E`) 0. + +**Public surface** (`@experimental`): `Slot` is `(props, ctx) => void` — +the callback places or binds its output before `ctx.range.end` over +`ctx.existing`; a returned node is no longer placed (before: `Node | +Node[] | undefined`, nodes placed by the frame, `undefined` = claim). The +four raw-frame tests (`lifecycle-matrix/call-driven-slots` ×2, +`server/frame-hn` ×2) place through `ctx.range`. `createFrame(boundary: +Element, options?)` / `createFrameElement(options)` signatures unchanged; +the internal constructor is `FrameImpl(element, options)`. +`SlotContext.range` is documented as absent for a range whose end marker +is missing (that range is left as the server rendered it; dev already +reports `FRAME_MARKER_CORRUPTED` at discovery — before, the Solid binding +filled it at the parent's end from a snapshot). + +**Running number: frames eager 11,367 br — 1,367 B above ≤ 10.0 KB.** +§4's row 4 estimated −196; built −287 with the range mode. The residue +proper's remaining rows: S-ref to the table (§3.2, ≈ −250), then C2, C1, +the claims chunk. + --- ## Appendix A — every unit on the head (273 units, 40,000 B; by whole unit) diff --git a/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index 7c663676b..35bf12653 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -375,6 +375,7 @@ cap on the same terms; no frames / page cap moved. | **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). **Landed (2026-10-06, `feat/frames-bind-tier`, measured before written on edited dist copies per re-attribution §7; the maintainer's Phase 2 ruling: the incremental path continues with a frames eager target of ≤ 10 KB br, the replay window closed by option (a)).** The chunk is `@solidjs/web/frames/bind` (frames/src/bind-tier.ts — a NEW `@solidjs/web` export path, built twice: `bind.js` and `bind.dev.js` under the `development` condition so the dev findings — `BINDING_SLOT_POSITION`, the fill-shape / text-shape warnings — ride the dev chunk only): the consumer walk (`text` / `positions` — the `_s:` branch of `collectSlots` dispatches to them), the per-frame consumer sets and rebinders (`sync` / `rebinder` / `unmount` — `#slotConsumers` / `#slotRebinders` became a `WeakMap` the tier keeps), the owned-position arms of the morph (`owned` / `apply` — `ownedPositions` / `morphOwnedClass` / `morphOwnedStyle` / `applyOwned`), and the fill's bind (`bind` — `bindDataOccurrence` with `valuesFor` / `write` / `release` / `writeText`, plus option (a): `completed.add(element)` + `runHydrationEvents()` for an element with handler positions). The module's exports are its appliers (no `install`), reached through `tierLoads.bind.r`. The eager client keeps: the marker detection at the walk (`hasSlotMarker` — a marker met with the tier absent is a note on the found map, and the sync holds under 3.1), `isAsyncValue` (moved to frame-client.ts as the one `@internal` helper the tier shares through the entry), `shapeOf` / `slotShapeFinding` (dev-only, tree-shaken from the production entry), the loader entry — and, **a deviation from the "nopair" variant:** `reconcileChildren`'s text-pair arm stays eager. Deleting it breaks a pin (frames-binding-slots "a refetch re-sends the empty pairs: the morph keeps the client's text (and its node)"): reconciled as ordinary nodes the client's text node is removed and, the start marker being unchanged, the consumer set compares equal — no rebind, text lost; a tier dispatch point there would cost what the arm costs (measured +109 min / +39 br either way). `assign` left the eager frames client (the tier imports it from `@solidjs/web`; the page already carries it). **Rolldown note:** a tier chunk that reaches `solid-js` / `@solidjs/web` without importing the entry chunk makes Rolldown split the shared runtime out of the page's entry (the live page's `action` / `isPending` / `latest` trigger it; the harness then reports a bogus 56 KB entry beside a 71 KB shared chunk); `bind-tier.ts` importing `isAsyncValue` from the entry is the edge that keeps the chunk attached — the regions / trace / assets tiers had it through their own entry imports. | **−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) **Measured: −1,185 / −1,100 / −1,110 / 0** br (−4,005 / −3,969 / −3,963 / 0 min) against the integration base 6b7213d64 (`wip/frames-tiers-integration` @ 5c5991cbd + #3861); vs `next` @ 9d89df731 −1,886 / −8,235 / −8,309 / 0. Measured-before-written on the edited dist (tight-nopair): −1,244 / −1,154 / −1,167; the built head's gap to it is the kept pair arm (+39 br) and the `isAsyncValue` entry edge. Option (a): 0 eager, +106 min / +48 br in the chunk (est. ≈ 40). The pages' delta is smaller than the audit's E.c because `assign` never left the page (B.3 has not run — `dynamic` keeps it). **Frames eager absolute: 11,901 br — 1,901 B above the ≤ 10 KB target.** | `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 **Measured: `bind.js` 4,771 min / 1,844 br** (both pages; `assign` is imported from `@solidjs/web`, not carried — the page has it); `bind.dev.js` 11,065 raw. | `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. **Landed:** `consistency/tier-bind-hold.spec` (6 — §1's bind row lists the arms; the stamp-less mutant pinned, the completion-less mutant fails 4 of 6); `server/frame-binding-slots` gains the stamp pin (event-slot consumers only, document face only, 4 bytes) and 8 document-face expectations carry ` _hk`; `tier-prepare`'s bind arm releases the real module; resident cells warm with `prepareTier("bind")`: `frames-binding-slots`, `hydration/binding-slot-adoption`. Harness 500 × 2 seeds: SC 0, generic (`C1,C9,C19,E` ignored) 0. Artifacts: 1 of 150 re-recorded (`welcome-status-streamed` — C3's drift from `frame-container-plugin.ts`, not the stamp: no artifact carries an `_s:on:` element). Caps lowered (the ratchet): frames eager 13.10 → **11.92 KB**, page base 37.79 → **36.66**, live 41.44 → **40.30**. | B, **A2** (the hold registers — hydration-done waits; **the replay window is the stamp's, not the hold's** — §1); C3 (the `installTier` shape proven on the biggest chunk first) | none public (the `_s:` marker grammar is unchanged; `bindDataOccurrence` was never exported) **Landed:** new export path `@solidjs/web/frames/bind` (`@experimental`; exports `text` / `positions` / `sync` / `rebinder` / `unmount` / `owned` / `apply` / `bind` — the appliers; no `install`); **output-shape change:** a bare ` _hk` on every document-face element with an `_s:on:*` position (4 bytes each; ref-only and attribute-only consumers unstamped; the stream face unstamped); `isAsyncValue` re-exported `@internal` from `@solidjs/web/frames` (the chunk's entry edge); `SLOT_TEXT` / `shapeOf` / `slotShapeFinding` exported `@internal` from frame-client (not on the public entry). **Behaviour:** a data occurrence mounts with the CURRENT record (was: the held one, then the replacement as an args change); a data occurrence's fresh mount now also waits on the regions / trace tiers when its record names them (one predicate for both classes — previously template-only); the walk no longer skips a text pair's interior (walked as ordinary nodes; the pair's own position is the tier's); event-slot consumers' pre-bind events queue and replay (head-of-line: an event on a consumer that never binds — no client fill for its prop — holds the queue until hydration-done drops it). | | **R1** | **Residue step 1** (`documentation/plans/frames-residue-pass.md` §5 rows 2 and 6 + 5a; the first step after the five tiers). **Landed** (`size/frames-residue-1`, on C6's head): the D list without the mirror — `documentAddress` (an adopted mount's address comes with its binding; an unbound placeholder mount binds the function id), the second dispose map folded into `fillScopes`, the zombie heuristic + `#slotNodes` (DR-5: the morph recreates nothing), the imperative branch of `#revealSegment` replaced by a default seam (`revealAtOnce`; `FrameOptions.reveal` stays optional; a placeholder without its closing comment is not revealed), `showing`'s brand on the bare per-function component; `FRAME_HAVE_HEADER` / `FRAME_HAVE_BUDGET` off the client entry's export list (the server entry keeps them); the lean re-ask — the sf client hands its handler the dispatched call as `ctx.retry` / `info.retry`, frames' re-ask is `callFor(address).retry()`, the RPC-seam re-ask and `createServerReference` on the RPC slot deleted. **Not taken:** the capture arm (pinned by `lifecycle-matrix/remount` — away/back over an SSR'd boundary shows the captured interior while the refetch flies; a ruling), the s / v stamp (`readHydratedValue` is module-private — a new `solid-js/internal` export, not a re-export), the `_$SC` mirror (needs the server's unconditional bootstrap). Every item measured on an edited dist before writing (residue doc §7). | **Measured: −247 / −159 / −161 / 0** br (−757 / −665 / −670 / 0 min) against C6's head e7d6e9e34; vs `next` @ 9d89df731 −2,133 / −8,394 / −8,470 / 0. **Frames eager 35,250 min / 11,654 br — 1,654 B above the ≤ 10 KB target.** Singles on the edited dist: D list −721 / −210, `FRAME_HAVE_*` −63 / −25, lean re-ask −260 / −99 (pages −30 / −65 — the sf thunks ≈ +80 min and the pages still shrink). | none (no chunk; the sf client grows ≈ 80 min for the thunks, the pages shrink net) | the nine A7 pins (`frames-errored-reset-refetch`) + `hydration/frames-adopted-error-outward` green unchanged; `adopted-claim-args-address` mounts the binding and asserts the store; web client 129 / 1,194, hydrate + consistency 87 / 445, server 159 / 1,511 green; `test-types` clean; harness 500 × {3289, 91501}: SC arm 0, generic arm (`CONSISTENCY_IGNORE=C1,C9,C19,E`) 0. Size: caps lowered to measured + 10 B — frames eager 11.92 → **11.67**, page base 36.66 → **36.50**, live 40.30 → **40.14**; no cap raised. | C6 (the head it was measured on); nothing of C2 | **removed:** `FRAME_HAVE_HEADER` / `FRAME_HAVE_BUDGET` from `@solidjs/web/frames` (client entry; `@experimental`), `createServerReference(id)` from the client half of `getServerFunctionRPC()` (`@internal`); **changed:** `callFor` (`@internal`) returns `{ id, meta, args, retry }`; `responseHandler.handle`'s ctx and `responseHandler.intercept`'s info (the latter now on the `ServerFunctionsClientConfig` type) carry `retry()`; `handler.showing` no longer brands the component; `FrameOptions.reveal` omitted → the default seam | | **R2** | **Residue step 2 — the `preview` pull form** (residue doc §3.1 / §5 row 3). **Stopped at the carrier budget, nothing built** (`size/frames-residue-2-pull-form`, on R1's head `fce81d2c0`). Measured before writing on an edited dist: the deletion ceiling (`stage` whole out — the buffer, the token, `stagedContent` / `contentAddress`, `FrameImpl#preview` / `host.preview`, the token arms of `handle` / `applyFlightResponse`) is **−1,645 / −546**; the carrier **as the step would have written it** — the token binding kept (the sketch deleted it: `dynamic` delivers a kept resolution only when its address differs, so without the token a same-address refetch never enters the transaction — #3844's gap (1), unsound), the host's per-store staged set (`apply(chunk, stage)`, `staged(id)`, `promote(id, version)` through the store's own `write` + landing tail), the fill's lane-correct read (`ctx.staged()` gated by the mount's address accessor), the flight path's per-root staging, nested regions promoted with the root — is **+1,414 / +483** over the ceiling. The sketch's +242 omitted the token (88 br), the flight path (54), regions (53), the gate (29) and promote's landing / L1 / waits semantics the chunk replay got for free. The step's rule was carrier > +300 → stop. **Pin added:** `c15-staging-atomic` (e), the gap #3844 named — a same-address refetch enters the transaction (the fill derives the new arg in its pass while the DOM, `frame:applied` and the frame's version hold v1) and lands whole at the commit; green on the push form. | **Measured: −231 / −29 / −37 / 0** br as the sound form (−63 frames eager — a fifth of the row's −300, outside its −250 … −350 band); the unsound floor without flight / regions / gate still +980 / +339 over the ceiling. **Built: none; frames eager stays 35,250 / 11,654.** | none | C15 ×5 (the new (e) arm), `frames-optimistic-hold` ×6, `frames-morph-in-transition` ×3 green on the push form (unchanged); no size change | R1 (the head it was measured on) | **none changed**; the form would have removed `FrameHost.preview` / `Frame.preview` / `stagedContent` / `contentAddress` (approved) and ADDED `FrameHost.staged(id)` / `FrameHost.promote(id, version?)` / `FrameHost.apply(chunk, stage?)` / `SlotContext.staged()` (`@experimental`) — named here so the trade is visible if the form is ever taken at −63 | +| **R3** | **Residue step 3 — R.insert, every fill through `insert`** (residue doc §2 row 3 / §5 row 4; §7c). **Landed** (`size/frames-residue-3-insert`, on R2's head `f12708386`). Every fill of the Solid binding is one `insert(end.parentNode, value, end, [...existing])` under the fill's owner — a static value placed once with no effect, a reactive one bound as before, an adopted fill claimed through `insertExpression`'s claim pass, disposal the owner's. Deleted: the binding's static path (`normalizeSlotContent`, `isReactiveContent`, the in-place `settle`, the no-range snapshot fallback — a range without its end marker is left as rendered), the runtime's own range writer (`#replaceRange`, the returned-nodes path through `#syncSlots` / `#invokeSlot` / `regions.bind`'s re-scan), and the runtime's never-used comment-marker range mode (`#start` / `#end`; the frame element IS the range, `FrameImpl(element, options)`). Two things the build found: the fill's second claim window must not re-gather (it put the keys the evaluation had claimed back as unclaimed), and the minted binding owner must be transparent (a non-transparent one consumed an ambient id inside the hydrate pass — keyed siblings after the frame keyed by timing). **One pin re-pinned, a ruling:** `c01-claim-once` (b) — a fill answering the t=0 claim with fresh nodes is the core's hydration mismatch (the server node stays, nothing duplicated, hydration reports it unclaimed), not a frame-side replacement; the contract's C1 text amended. Measured before writing on an edited dist: ceiling −769 / −230; the carrier without `#replaceRange` −791 / −229 (with it kept −600 / −160 — compatibility costs 69 br); the range mode −196 / −67; together −988 / −301. | **Measured: −287 / −217 / −221 / 0** br (−978 / −979 / −979 / 0 min) against R2's head f12708386; vs `next` @ 9d89df731 −2,420 / −8,611 / −8,691 / 0 (the hydrating scenarios' +110 / +142 / +52 br vs `next` are the base's A0 exceptions, 0 vs base). **Frames eager 34,272 min / 11,367 br — 1,367 B above the ≤ 10 KB target.** | none | C1 (a, c), C9 ×3, C10 ×3, C14, `adopted-swap-post-done`, `boundary-arrival`, `tier-bind-hold`, the lifecycle matrix, the hydrate suite (87 / 446) green unchanged; C1 (b) re-pinned (above); the four raw-frame tests place through `ctx.range`; web client 129 / 1,194, server 159 / 1,511, `types` + `test-types` clean; harness 500 × {3289, 91501}: SC arm 0, generic arm (`CONSISTENCY_IGNORE=C1,C9,C19,E`) 0. Size: caps lowered to measured + 10 B — frames eager 11.67 → **11.38**, page base 36.50 → **36.29**, live 40.14 → **39.92**; no cap raised. | R2 (the head) | **changed:** `Slot` (`FrameOptions.slots`, `@experimental`) is `(props, ctx) => void` — the callback owns its range (places or binds before `ctx.range.end` over `ctx.existing`); a returned node is no longer placed (before: `Node \| Node[] \| undefined`, placed by the frame / `undefined` = claim). `SlotContext.range` documented absent for a range missing its end marker (left as rendered; before, filled at the parent's end from a snapshot). `createFrame` / `createFrameElement` signatures unchanged. **Behaviour:** a fill that does not claim at t=0 follows the core's mismatch rule (C1 (b)) | | **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 | — | diff --git a/documentation/server-components/frames-consistency-contract.md b/documentation/server-components/frames-consistency-contract.md index 449521eb1..024164123 100644 --- a/documentation/server-components/frames-consistency-contract.md +++ b/documentation/server-components/frames-consistency-contract.md @@ -65,22 +65,32 @@ named `c-.spec.tsx`; each test title names its arm. Every server-rendered node inside a frame's content is, by quiescence, either claimed exactly once (by the hydrate pass or by the fill that owns its -range) or removed by a deliberate replacement — never claimed by two passes, -never left in the document beside a fresh clone of itself; a boundary element -is adopted by at most one frame. +range) or — when the fill's render did not claim it — left as the server +rendered it and reported as unclaimed (the core's hydration-mismatch rule: +a claim pass moves nothing) — never claimed by two passes, never left in the +document beside a fresh clone of itself; a boundary element is adopted by at +most one frame. _(Until residue step 3 the second arm read "or removed by a +deliberate replacement": the frame placed a fill's output itself and +replaced the range when the output was not in place. Every fill is placed by +`insert` now, so the frames rule IS the core's.)_ - **Mechanism:** `frames/src/client.ts:claimRender` (the claim window — `sharedConfig.hydrateWindow`, the re-entry a streamed boundary's resume takes: the range's keys gathered by the producer prefix into the registry of the root the frame adopted under; A2b replaced the range-scoped - registry handed over from the root registry), `client.ts:slotsFor.settle` - (the in-place check that turns a render into a claim), - `frame-client.ts:FrameImpl.#replaceRange`, `client.ts:adoptBoundary` + + registry handed over from the root registry — one gather per fill: the + window that places the output gathers nothing, so the keys the evaluation + claimed are not put back), `client.ts:slotsFor`'s `insert` of the fill's + output inside that window (`web/src/client.ts:insertExpression`'s claim + pass: in-place output is a claim, a render whose nodes never entered the + DOM leaves the server's in place), `client.ts:adoptBoundary` + `claimedBoundaries` (one adopter per element), `client.ts:documentBoundary` (a second mount goes fresh). - **Pin:** `c01-claim-once.spec.tsx` — arms: (a) two occurrences claim once each with no key miss and node identity preserved; (b) a fill that returns - fresh nodes replaces, leaving no server node of the range behind; (c) a + fresh nodes at the claim is a hydration mismatch — the server node stays, + nothing is duplicated, hydration reports it unclaimed (re-pinned in + residue step 3; it asserted the frame's replacement before); (c) a second mount of the same function while the first adopted mounts fresh and the adopted element is untouched. `c01-claim-window-roots.spec.tsx` (A2b): a claim the frame makes after another `hydrate()` root replaced the live @@ -430,7 +440,7 @@ address's late chunks never release it. | # | invariant | mechanism (carrier) | pin | `next` | | --- | ---------------------------------- | ---------------------------------------------------------------------- | --------------------------------- | ----------------- | -| C1 | claim once / replace / one adopter | `claimRender`, `slotsFor.settle`, `#replaceRange`, `claimedBoundaries` | `c01-claim-once` | holds | +| C1 | claim once / mismatch stays / one adopter | `claimRender`, `slotsFor`'s `insert` (the core's claim pass), `claimedBoundaries` | `c01-claim-once` | holds | | C2 | no inert server content | `#syncSlots`, `adoptBoundary` reveal cascade | `c02-revealed-occurrence-mounts` | **red** (a2, b) | | C3 | done counts every hold | `_pendingBoundaries` vs `#recordRefresh`/`#refsUnresolved` | `c03-hydration-done-counts-holds` | **red** (a) | | C4 | record applies once, any order | `drainRecords`, `write`, `argsEquivalent`, `#appliedHoles` | `c04-record-applies-once` | **red** (d) | diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 1dcb45875..49eec8085 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -373,21 +373,6 @@ export function getFrameHost() { return sharedHost; } -/** Resolve Solid JSX slot content (thunks, arrays, primitives) to nodes. */ -function normalizeSlotContent(value: any): Node | Node[] { - while (typeof value === "function") value = value(); - if (Array.isArray(value)) { - const out: Node[] = []; - for (const v of value) { - const n = normalizeSlotContent(v); - Array.isArray(n) ? out.push(...n) : out.push(n); - } - return out; - } - if (value == null || typeof value === "boolean") return document.createTextNode(""); - return value instanceof Node ? value : document.createTextNode(String(value)); -} - /** * The stable component minted once per boundary. Every mount creates its own * frame instance under the boundary id (mounting the same server component @@ -419,8 +404,19 @@ type ClaimScope = { registry?: Map; gather?: (key: string) => vo * chain, finds its pending `_fr` registration, and resumes into the * swapped content instead of re-rendering over a fragment nobody owns. * Plain render on a page that never hydrated (CSR boot, post-load streams). + * + * `bound`: the second window of one fill — the `insert` of what the first + * evaluated. It claims under the same prefix but gathers nothing: the + * first window's gather is still in the registry, and gathering again + * would put the keys the evaluation already claimed back as unclaimed. */ -function claimRender(prefix: string, existing: Node[], render: () => any, scope?: ClaimScope) { +function claimRender( + prefix: string, + existing: Node[], + render: () => any, + scope?: ClaimScope, + bound?: boolean +) { const sc: any = sharedConfig; // No window, or no registry gathered yet (no `hydrate()` pass has run): // nothing to claim against — render fresh over the markup. @@ -429,7 +425,9 @@ function claimRender(prefix: string, existing: Node[], render: () => any, scope? sc.claimRoots = existing; try { // The claim owner too: the window claims this fill's subtree only. - return runWithOwner(createOwner({ id: prefix }), () => sc.hydrateWindow(prefix, render, scope)); + return runWithOwner(createOwner({ id: prefix }), () => + sc.hydrateWindow(bound ? undefined : prefix, render, scope) + ); } finally { sc.claimRoots = prevRoots; } @@ -573,15 +571,6 @@ function slotArgsProxy(args: () => Record) { ); } -/** Whether a resolved slot value is reactive at the top level. */ -function isReactiveContent(value: any): boolean { - if (typeof value === "function") return true; - if (Array.isArray(value)) { - for (const v of value) if (isReactiveContent(v)) return true; - } - return false; -} - /** * The slot fills of a boundary. `scope` (adopted boundaries): the * registry/gather pair the boundary adopted under, for its occurrences' @@ -594,10 +583,9 @@ function slotsFor(props: Record, scope?: ClaimScope) { // cleanup: a fill's `onCleanup` and effects live and die with the // occurrence — a later response dropping it disposes right there — not // with the covering boundary, which outlives every occurrence it covers. - // A live range binding (the reactive-content path below) is the same - // scope: the `insert` lives under the fill's owner where there is one, - // under an owner of its own otherwise, so one map disposes whichever path - // the previous invocation took. + // The range binding (the `insert` below) is the same scope: it lives + // under the fill's owner where there is one, under an owner of its own + // otherwise, so one map disposes the previous invocation whole. const fillScopes = new Map(); return new Proxy( {}, @@ -609,8 +597,8 @@ function slotsFor(props: Record, scope?: ClaimScope) { // A re-call replaces the invocation wholesale (the frame only // runs slot cleanups at unmount, not between re-calls): the // outgoing fill's scope — its binding included — disposes before - // the incoming one renders, so a static re-call after a reactive - // one never leaves a binding fighting the frame for the range. + // the incoming one renders, so two bindings never fight for the + // range. const prevFill = key !== undefined && fillScopes.get(key); if (prevFill) { fillScopes.delete(key); @@ -656,6 +644,12 @@ function slotsFor(props: Record, scope?: ClaimScope) { }); return undefined; } + // A range occurrence without its end marker has no anchor to bind + // before (the document is corrupted — `FRAME_MARKER_CORRUPTED`, + // reported at discovery): the range is left as the server + // rendered it. + const range = ctx && ctx.range; + if (!range) return undefined; // Stream-mounted fills (no ambient owner at invocation — the frame // called from a chunk microtask) render under a PER-OCCURRENCE // owner whose disposal rides the frame's occurrence-level cleanup: @@ -669,7 +663,8 @@ function slotsFor(props: Record, scope?: ClaimScope) { // computation — already owns the fill with the right lifetime (a // pending fill's nodes are legitimately detached while its // covering boundary shows the fallback; the boundary, not a frame - // cleanup, decides when that render is done with). + // cleanup, decides when that render is done with). Their range + // binding gets an owner of its own below. const fillOwner = streamInvoke ? createOwner() : null; if (fillOwner && key !== undefined && ctx) { fillScopes.set(key, fillOwner); @@ -678,20 +673,6 @@ function slotsFor(props: Record, scope?: ClaimScope) { fillOwner.dispose(); }); } - // A render whose output is already inside the range (hydration - // claims: the nodes ARE the server-rendered DOM) is a CLAIM — - // return undefined per the frame contract so nothing moves. - const settle = (out: Node | Node[]) => { - const existing: Node[] = (ctx && ctx.existing) || []; - if (existing.length) { - const list = Array.isArray(out) ? out : [out]; - const inPlace = list.every(n => - existing.some(e => e === n || (e.nodeType === 1 && (e as Element).contains(n))) - ); - if (inPlace) return undefined; - } - return out; - }; // The prop is read INSIDE the claim scope: compiled component props // are getters, so JSX evaluates lazily at access — deferring the // access into the scoped owner is what makes plain JSX (no thunks) @@ -751,66 +732,58 @@ function slotsFor(props: Record, scope?: ClaimScope) { : adopted ? claimRender(prefix, ctx.existing, evaluate, scope) : evaluate(); - // Static content (the common case: render props returning component - // roots, plain JSX with no top-level control flow): today's - // zero-cost path — claim in place or hand the frame the nodes. No - // effect is created and hydration stays a no-op. - if (!isReactiveContent(value)) { - return settle(normalizeSlotContent(value)); - } - // Reactive content (a boundary accessor, route children): snapshot- - // ting it would freeze ONE state of it into the range, so own the - // range instead — bind the value before the range's end marker with - // insert() (the same primitive compiled JSX uses for `{expr}` - // positions) and return undefined so the frame leaves the interior - // alone. `existing` seeds insert's tracked array: an accessor that - // yields the claimed nodes reconciles to a zero-mutation no-op, one - // that yields new content swaps it in place. + // Every fill is one `insert` before the range's end marker — the + // primitive compiled JSX uses for `{expr}` positions — so the + // fill's output has the core's lifecycle, not a frame-side copy of + // it: a static value (the common case — a component root, plain + // JSX) is placed once with no effect created; a reactive one (a + // boundary accessor, route children) binds the range and follows + // (a snapshot would freeze ONE state of it); an adopted fill + // claims through `insertExpression`'s claim pass — nothing moves, + // and a render whose nodes never entered the DOM is the core's + // hydration mismatch (the server's nodes stay, hydration reports + // them unclaimed; C1) — disposal is the owner's. `existing` seeds + // insert's tracked array: output that IS the claimed nodes is a + // zero-mutation no-op, a stream re-call reconciles its new output + // against the previous one. // - // The claim scope wraps the insert CALL, not the accessor: the - // binding's first evaluation is insert's own render effect computing - // synchronously, so it still creates under the producer's hydration - // keys — boundary-deferred children (route content behind - // ) create on that read — while the reads it makes belong - // to the effect and stay tracked. Claiming inside the accessor - // instead put that first read inside runWithOwner's UNTRACKED window - // (it clears `tracking` along with the owner). Whenever the value it - // returned was not itself an accessor for insert to re-read — a - // answering a still-pending streamed fragment returns its - // fallback NODES — the effect ended up with no dependency at all and - // the range went permanently inert: the boundary's own resume still - // claimed the swapped-in server markup, so the region looked right, - // but nothing downstream (a route change out of it) ever re-rendered - // it again. - if (ctx && ctx.range) { - const source = value; - // The binding's owner: the fill's own (a stream-mounted fill, - // already in `fillScopes` with its cleanup), else one minted - // here and registered the same way. - const owner = fillOwner || createOwner(); - if (!fillOwner) { - fillScopes.set(key, owner); - ctx.onCleanup(() => { - if (fillScopes.get(key) === owner) fillScopes.delete(key); - owner.dispose(); - }); - } - const end = ctx.range.end; - const bind = () => - insert( - end.parentNode as any, - () => (typeof source === "function" ? source() : source), - end, - [...ctx.existing] - ); - runWithOwner(owner, () => - adopted ? claimRender(prefix, ctx.existing, bind, scope) : bind() - ); - return undefined; + // The claim scope wraps the insert CALL, not the accessor: a + // reactive value's first evaluation is insert's own render effect + // computing synchronously, so it still creates under the + // producer's hydration keys — boundary-deferred children (route + // content behind ) create on that read — while the reads + // it makes belong to the effect and stay tracked. Claiming inside + // the accessor instead put that first read inside runWithOwner's + // UNTRACKED window (it clears `tracking` along with the owner). + // Whenever the value it returned was not itself an accessor for + // insert to re-read — a answering a still-pending + // streamed fragment returns its fallback NODES — the effect ended + // up with no dependency at all and the range went permanently + // inert: the boundary's own resume still claimed the swapped-in + // server markup, so the region looked right, but nothing + // downstream (a route change out of it) ever re-rendered it again. + // + // The binding's owner: the fill's own (a stream-mounted fill, + // already in `fillScopes` with its cleanup), else one minted here + // and registered the same way — TRANSPARENT, so a fill mounting + // inside the hydrate pass consumes no id from the adopting + // component's counter (a keyed sibling after the frame keys the + // same whether a fill mounted at t=0 or after a hold; the claim + // window below has its own id). + const owner = fillOwner || createOwner({ transparent: true }); + if (!fillOwner) { + fillScopes.set(key, owner); + ctx.onCleanup(() => { + if (fillScopes.get(key) === owner) fillScopes.delete(key); + owner.dispose(); + }); } - // No range handle (a consumer-constructed frame without markers): - // static placement is the only option — degrade to the snapshot. - return settle(normalizeSlotContent(value)); + const end = range.end; + const bind = () => insert(end.parentNode as any, value, end, [...ctx.existing]); + runWithOwner(owner, () => + adopted ? claimRender(prefix, ctx.existing, bind, scope, true) : bind() + ); + return undefined; }; } } diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index fe0ea42df..1f1334997 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -194,17 +194,17 @@ export interface SlotContext { /** * The range's current interior — server-rendered client content on an * adopted document-SSR boot, or the previous output on a re-call. A - * framework binding hydrates onto it and returns `undefined` to claim it - * in place (zero DOM mutation). + * framework binding hydrates onto it (a claim: zero DOM mutation) or + * replaces it. */ existing: ChildNode[]; /** - * The range's own marker comments, when the occurrence has a placed range. - * A framework binding whose slot content is reactive at the top level (a - * boundary accessor, changing route children) owns the interior instead of - * returning nodes: bind before `end` with the framework's insert primitive - * and return `undefined` — the frame leaves the range alone (server morphs - * already protect slot ranges). + * The range's own marker comments, when the occurrence has a placed range + * — the anchor the fill owns its interior through: bind or place the + * output before `end` (over `existing`) with the framework's insert + * primitive. The frame never touches a range's interior itself (server + * morphs protect slot ranges). Absent for a binding-slot occurrence + * (`positions`) and for a range whose end marker is missing. */ range?: { start: Comment; end: Comment }; } @@ -213,11 +213,13 @@ export interface SlotContext { * Client content for a server-declared slot. Direct-insert occurrences * call it with empty props; render-prop occurrences pass the occurrence's * resolved args (primitives literal, `{$ref}` data resolved through the - * host, `{$frame}` regions as marker-range fragments). Return nodes to fill - * the range, or `undefined` to claim `ctx.existing` untouched. + * host, `{$frame}` regions as frame elements). The fill owns its range: it + * places or binds its output before `ctx.range.end`, over `ctx.existing` + * (claimed in place on hydration), and disposes it through `ctx.onCleanup`. + * The return value is not read. * @experimental */ -export type Slot = (props: Record, ctx: SlotContext) => Node | Node[] | undefined; +export type Slot = (props: Record, ctx: SlotContext) => void; /** @experimental */ export interface Frame { @@ -426,7 +428,8 @@ export interface FrameOptions { * replay, so application is prerequisite-driven and order-independent by * construction: * - * - root HTML apply into a boundary (element or comment-marker range) + * - root HTML apply into a boundary ELEMENT (the frame's range is the + * element's children — `createFrame` / `createFrameElement`) * - version as a stale-guard only ("policy A": a newer version morphs in * place; client slots/regions and their state survive — teardown is * dispose(), never a version bump) @@ -435,7 +438,9 @@ export interface FrameOptions { * slot ranges and fragment placeholders * - the slot model: direct-insert and render-function slots as one callback * primitive, iteration by occurrence id, re-call on args change, slot - * resolution threaded down through nested frames + * resolution threaded down through nested frames; the fill OWNS its + * range (it places or binds its output before the range's end marker — + * the frame discovers ranges and invokes, it never writes an interior) * * Adaptations from the spike: * - Fragment placeholders use the document marker vocabulary emitted by @@ -1215,14 +1220,10 @@ const carriesTrace = value => const needsTrace = args => !tierLoads.trace?.r && carriesTrace(args) && !tierReady("trace"); class FrameImpl { - // A frame renders either into an element (element boundary: #start/#end - // null) or between two comment markers within some parent (range boundary). - // The parent of a range boundary is derived live from the start marker, so - // the range can be moved (e.g. re-placed by a client re-call) without - // rebinding. + // A frame renders INTO an element: the boundary / region element is the + // range (its children are the content), so it moves with the element and + // needs no markers of its own. #element; - #start; - #end; #options; // The frame this one is a region OF (`options.parent`, set by the regions // tier at bind): slot callbacks, records and record removal thread up @@ -1270,8 +1271,8 @@ class FrameImpl { // mount and the mount's rebind callback (`ctx.onRebind`) are the BIND // TIER's (`@solidjs/web/frames/bind`, plan C6), kept by that module per // frame (`sync` / `rebinder` / `unmount` off `tierLoads.bind.r`). - // Nor the mounts' output nodes: the range IS the occurrence's place - // (`#replaceRange` writes between its markers), and nothing reads the + // Nor the mounts' output: the fill owns its range (it places or binds + // before the end marker through `ctx.range`), and nothing reads the // nodes back — "mounted" is `#mountedSlots`, not a check on them. // The release of the frame's hold with the integration while a sync // leaves an occurrence waiting to mount (see #syncSlots' end). @@ -1305,10 +1306,8 @@ class FrameImpl { return scope ? scope(fn) : fn(); } - constructor(element, start, end, options = {}) { + constructor(element, options = {}) { this.#element = element; - this.#start = start; - this.#end = end; this.#options = options; this.#slots = options.slots; this.#outer = options.parent; @@ -1338,15 +1337,10 @@ class FrameImpl { if (options.adopt && this.#version === undefined) this.#syncSlots(); } - /** The node content lives in (element itself, or the range markers' parent). */ - #parent() { - return this.#element ?? this.#start.parentNode; - } - /** Server content landed: caller hook + the bubbling document notification. */ #applied(version, reason) { this.#options.onApply?.({ version, reason }); - const parent = this.#parent(); + const parent = this.#element; // Construct from the element's own realm — a cross-realm CustomEvent // (e.g. Node's global against a JSDOM document) is rejected by dispatch. const Ev = parent && (parent.ownerDocument || parent).defaultView?.CustomEvent; @@ -1360,11 +1354,6 @@ class FrameImpl { } } - /** First content node (or `#end`/null when empty). */ - #firstContent() { - return this.#start ? this.#start.nextSibling : this.#parent().firstChild; - } - get version() { return this.#version; } @@ -1760,11 +1749,11 @@ class FrameImpl { if (!mounted) { // Direct-insert occurrences have no `slot:` record and mount with // empty props; render-function occurrences mount with resolved props. - // Mounting replaces the range interior: on a fresh stream it is - // empty, but an adopted document-SSR range already holds the - // server-rendered client content — a callback that returns nodes - // replaces it (client render), one that returns undefined claims it - // in place (hydration attach; the DOM is untouched). + // The fill owns the range interior (`ctx.range`, `ctx.existing`): on + // a fresh stream it is empty, but an adopted document-SSR range + // already holds the server-rendered client content — the fill + // claims it in place (hydration attach; the DOM is untouched) or + // replaces it (client render). // In an adopt frame, a MOUNT is the hydration attach — whether the // constructor sync or a registration-flush drain (t=0 records // buffered before adoption) triggered it. ctx.adopted lets @@ -1787,26 +1776,15 @@ class FrameImpl { const held = this.#heldRecords.get(occurrence); this.#heldRecords.delete(occurrence); const mountRecord = held || record; - const nodes = this.#invokeSlot( - occurrence, - callback, - mountRecord, - start, - this.#options.adopt - ); - // A data occurrence's mount never returns nodes to place; its - // consumer set is handed to the tier (`sync`), which keeps it per - // frame for the rebind below. + this.#invokeSlot(occurrence, callback, mountRecord, start, this.#options.adopt); + // A data occurrence's consumer set is handed to the tier (`sync`), + // which keeps it per frame for the rebind below. if (consumers) B.sync(this, occurrence, consumers); - else if (nodes) this.#replaceRange(occurrence, start, nodes); this.#mountedSlots.add(occurrence); // Bind the occurrence's regions (the tier): a frame over each region - // element — those #resolveArgs minted or found, plus a re-scan of - // the fill's OUTPUT when it wrote one (`nodes`): a claim on the - // adopt path left the interior untouched, so the pre-invoke - // discovery already saw everything — the repeat walk (per - // occurrence, over a large adopted tree) is skipped. - R?.bind(this, occurrence, nodes && start); + // element #resolveArgs minted or — on the adopt path — discovered in + // the interior before the fill ran. + R?.bind(this, occurrence); if (mountRecord === record || !record || record.kind !== "slot") continue; } // A mounted data occurrence whose CONSUMERS changed — a morph replaced @@ -1854,10 +1832,9 @@ class FrameImpl { continue; } // Args changed (incl. late args): re-call this occurrence only, - // reusing its cached server-content regions. Same contract: an - // undefined return keeps the current interior. - const nodes = this.#invokeSlot(occurrence, callback, record, start); - if (!consumers && nodes) this.#replaceRange(occurrence, start, nodes); + // reusing its cached server-content regions; the fill replaces its + // previous output (`ctx.existing`) over the same range. + this.#invokeSlot(occurrence, callback, record, start); R?.bind(this, occurrence); } } @@ -1901,9 +1878,8 @@ class FrameImpl { * Invoke a slot occurrence's callback with resolved props. `ctx.existing` * carries the range's current interior (server-rendered client content on * an adopted document-SSR boot; the previous output on a re-call) so a - * framework binding can hydrate onto it. Returns the nodes to place, or - * null when the callback returned undefined — "I claimed the existing DOM, - * leave the range alone". + * framework binding can hydrate onto it, `ctx.range` the markers it + * places or binds its output within. The frame never writes an interior. */ #invokeSlot(occurrence, callback, record, start, adopted) { // A (re-)call replaces the occurrence's binding wholesale: drop the old @@ -1912,9 +1888,9 @@ class FrameImpl { this.#slotUpdaters.delete(occurrence); const cleanups = this.#slotCleanups.get(occurrence) ?? []; // One walk yields both the interior and the end marker. The end marker is - // part of the consumer contract (ctx.range): a framework binding that owns - // the range reactively (top-level dynamic slot content) needs an anchor to - // insert before — the markers are the only stable nodes in the range. + // the consumer contract (ctx.range): the fill owns the range and needs + // an anchor to insert before — the markers are the only stable nodes in + // the range. let existing = []; let end = null; // A data occurrence's node is its consumer list: no interior to collect, @@ -1948,15 +1924,15 @@ class FrameImpl { // change. Registration is per-invocation; a real re-call clears it. onUpdate: fn => this.#slotUpdaters.set(occurrence, fn), existing, - // The range's own markers, when it has them: consumers that bind the - // interior reactively insert before `end` and return undefined — the - // frame then never touches the interior (morphs protect slot ranges). + // The range's own markers, when it has them: the fill places or binds + // its output before `end` — the frame never touches the interior + // (morphs protect slot ranges). range: end ? { start, end } : undefined, // Binding slot (§9.2.3): the positions of server markup that read this // occurrence — `[{ element, positions: [{ pos, key, name }] }]` in - // document order. The consumer runs the fill, writes each position - // from its returned object, and returns undefined (there is nothing - // to place). `onRebind` receives the new set when consumers change + // document order. The consumer runs the fill and writes each position + // from its returned object (there is nothing to place). `onRebind` + // receives the new set when consumers change // (a morph replaced an element; a response bound a new position) // without the args changing — the fill's computation survives. The // rebinder is kept by the bind tier (resident: positions exist only @@ -1980,18 +1956,9 @@ class FrameImpl { // adopting render, but stream-driven mounts and re-calls arrive from // microtasks with no owner of their own — without the scope, a render // prop touching context works on boot and throws on the first refresh. - const content = this.#scoped(() => callback(props, ctx)); + this.#scoped(() => callback(props, ctx)); this.#slotArgs.set(occurrence, record); if (cleanups.length) this.#slotCleanups.set(occurrence, cleanups); - if (content == null) return null; - return Array.isArray(content) ? content : [content]; - } - - /** Replace the nodes between a slot range's start marker and its end marker. */ - #replaceRange(key, start, nodes) { - const parent = start.parentNode; - const end = eachInRange(start, key, n => parent.removeChild(n)); - for (const node of nodes) parent.insertBefore(node, end); } #unmountSlot(key) { @@ -2065,18 +2032,14 @@ class FrameImpl { /** Collect this frame's own top-level slot ranges (bounded to its content), * and — for the slot sync — its binding-slot elements into the same map. */ #collectSlots(found, elements) { - collectSlots(this.#firstContent(), this.#end, found, elements); + collectSlots(this.#element.firstChild, null, found, elements); } - /** Find a fragment placeholder `