Repository navigation
fix(web/frames): classify an adopted occurrence only after every delivered record has drained (C18) - #3827
fix(web/frames): classify an adopted occurrence only after every delivered record has drained (C18)#3827ryansolid wants to merge 1 commit into
Conversation
…vered record has drained (C18) Frames-rulings 3.5 (proposed): "an occurrence is classified only after every delivered record has drained" — pending is the drain's state, not the parser's. `adoptBoundary.recordsPending` gains a third term: `_$HY.r` holds a slot/region record for the boundary not yet in `appliedRecords`. Before: two records drained after the parser finished classified each other — `drainRecords` applies one record per `host.apply`, each a synchronous `#flush` → `#syncSlots`; the first apply's sync found the second occurrence recordless with `readyState` no longer "loading", classified it direct-insert and evaluated its render prop argless: `TypeError` → REACTIVITY_HALTED. Same window via a live-hole op and the live pump's catch-up read (contract C18 / R9). Pins: harness/replay C18 ×3 flipped from test.fails to test; c18-classify-after-drain.spec.tsx pins the consequence with a real fill (record drain; live op in the window; control). Co-authored-by: Claude via Cursor <noreply@cursor.com>
🦋 Changeset detectedLatest commit: af55363 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Size (brotli, eager entry chunk)
|
Coverage Report for CI Build 37447904115Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage remained the same at 76.058%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
|
… decisions: the defaults accepted, the C3 hold cost accepted (caps raised), #3827 closed as superseded by S-flush Co-authored-by: Cursor <cursoragent@cursor.com>
…port, rulings (#3837) * fix(web/frames): the address is an async source — one landing per bound address, one response per store (S-flush) A0 (SCs are rendered data): a frame is one async value outward. The mount's covering <Loading> now pends on the bound address's FIRST FLUSH — content or error — through `FrameHost.landing(address)`, read per bound address through a fresh node (frames-rulings 1.5, 1.6 (i)): a switch is a new question on the source, the superseded address's late writes release nothing, an unrevealed boundary stays on its fallback and a revealed one holds. The two hand-rolled shell gates (`boundaryComponent`'s arm/release/settle/setGate and `adoptBoundary`'s twin), `followAddress`'s re-arm and frameless waiter, and `onApply`-as-release go. An unstaged response announces itself to the host at its header (`start`), so the address reads "in flight" from the header to the landing. The store is one response's (1.4 full; 2.1/2.2): a version bump or rebind replaces the store wholesale — root, segments, slot records, the error — so a byte-identical root under a new version still applies as the new version's (C7 c), a held record leaves with the response that carried it (C6 a1, b2), and `argsEquivalent`/`clearStreamRecords` delete; the mount's `#slotArgs` value compare is what preserves occurrence state. The host keeps the latest landed version as `shown` so a mount opened mid-flight seeds the committed value (holds-latest) and the landing fans out as the version's whole set. The occurrence's name decides its class: a called occurrence (`prop#n`) found without its record waits for it — never evaluated argless (C18 ×3 flip; the #2968 poll stays as the document face's re-sync trigger, scoped to called occurrences) — and a bare occurrence is direct-insert. A reveal is an apply (2.3, interim): the document face's reveal cascade syncs the adopting frame, so a direct-insert range the fragment carried mounts (C2 b) and a record drained before its range was shown takes effect at the reveal (C4 d). Pins flipped to `test`: C2 (b), C4 (d), C6 (a1, b2), C7 (c), C17 (a — re-pinned: B's first flush, not its `start`, releases), C17 (c), harness C2 ×1, C18 ×3. Re-pinned: frames-binding-slots "orphan record … waits" (was "still mounts"), frames-hn-client zero-data occurrence is the bare prop. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web/frames): hydration-done counts the frame's holds — a waiting adopted occurrence is a pending boundary (C3) Frames-rulings 3.1 (ruled): hydration-done follows non-SC Solid 2 — done is what `checkHydrationComplete` says (the root pass over, `_pendingBoundaries` zero), and the frames client participates through that mechanism, with no accounting of its own. 3.2, the carrier: ONE registration per frame while a sync leaves an adopted occurrence waiting to mount (for its record, for a `{$ref}`'s data), released by the first sync that leaves none or by disposal. solid-js: `sharedConfig.holdBoundary(id)` (internal) — `initBoundaryResume`'s registration for a holder with nothing to resume: the count, the owner's `_hp` mark, the disposal release; returns the release, which checks completion. One assignment in `enableHydration`. web/frames: `FrameOptions.hold()` (adopt path; wired by `adoptBoundary` under the component's owner, only while `isHydrationInProgress()`, keyed `sc:<fid>` so no fragment's bookkeeping is touched); `#syncSlots` tracks whether a full sync left an unmounted occurrence waiting and registers/releases at its end; `dispose` releases. Harness: the 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, as a disposed <Loading>'s is). Verified to hide nothing on `next` (C3 stays 280/500 there). Pins flipped to `test`: C3 (a), harness C3 ×1. Campaign (500 cases): C3 280 → 0 (seed 3289), 268 → 0 (seed 91501); only C19 remains. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web/frames): a data chunk of a superseded response lands nowhere (C5) Frames-rulings 1.2: a `data` chunk lands in its own response's table or nowhere. The integration rotates the address's table at the header and creates it at first use; the transport restamps every chunk with its response's version; but `createFrameHost.apply` routed `data` straight to the data hook with no version read — so a superseded response's late chunk was the first use and filled the table that was now the current response's (R4). The data path is now under the store's version guard like every other chunk: with the response announced to the store at its header (S-flush), a `data` chunk whose version is below the store's is dropped. Pins flipped to `test`: C5 (a), (b), (e). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web/frames): report a rejected server <Loading> fragment inside an adopted frame in dev; the client shows the server's outcome (C12 c, client half) Frames-rulings 3.3 under A0's corollary 4 (inward): a server `<Loading>` inside a server component is the SERVER's boundary; its outcome arrives as markup and the client shows whatever the server rendered for it — never a blank it invents, never a client error fallback for a server state. A rejected one has no client twin to surface its `<key>_fr` rejection (`hydratedCreateLoadingBoundary`'s `s === 2` arm runs only for a boundary registered against it), so the adoption that claims its placeholder observes the rejection and names it in dev (0 B prod). The blank the position shows today is the server half's gap: `server.ts`'s error path hands `sink.fragment` a `" "` template (the client twin, when there is one, renders over it; a server component's boundary has none). Pins: C12 (c) re-pinned per 3.3 — (c1) the rejection is reported in dev, the client invents no error state (test); (c2) the position shows the server's rendered outcome, never a blank (test.fails, the server half). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * web/frames: trim the hold's bytes — one mounted read per occurrence in #syncSlots, the guard reads isHydrationInProgress alone −48 B min on the frames client (43,411 → 43,363); the zombie check now precedes the {$ref} wait, so a zombie held on a ref is unmounted at once rather than when the ref resolves. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * web/frames: the data guard needs no undefined check (n < undefined is false) Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — the three seams the consistency reds and the size audit's duplicates share (proposed) Draft, marked "proposed — maintainer ruling pending"; no engine changes. The frames/hydration consistency contract (spec/frames-consistency-contract) found thirteen reds across C2, C3, C4, C5, C6, C7, C12 and C17; the SC size audit (size/sc-audit §4) listed the layer's duplicated seams. This document tests the hypothesis that they are the same seams seen from two sides, and drafts the rulings — numbered one-sentence statements in the L2 section's voice, each with the mechanism that carries it today, the state it lives in twice, and the reds it decides — grouped by the three questions the reds cluster on: - Response identity (C5, C6, C17): a response owns what it delivered; a `data` chunk lands in its own response's table or nowhere; a record resolves its refs through its own response's table wherever the frame is bound; a bump drops what the previous version never applied; the shell gate is the bound address's; a switch keeps on screen what was on screen. - Applied state per version (C7, C2, C4): the store is the truth and the applied state is a cache of it keyed by version; a bump re-applies a byte-identical root; a reveal is an apply; applied means shown. - Hydration-done accounting (C3, C12, S1's C3b and `.fails`): done counts every occurrence the document delivered; every frames hold is counted once per frame; a claim owns the fragment's outcome; the client consumes the ids the server consumed. For each: the fix shape as one collapsed carrier (response-owned data cells, records carrying their resolver, one shell gate, one applied record, one hold counter), a byte estimate (− collapse / + carrier) read off the audit's function table, the pins that flip, and what touches public surface or the server half. Two-readings items for the maintainer: 1.4 (narrow/full), 1.6 (per-address gate value vs holds-latest), 3.1 (done counts the page vs the root pass — S1's pin and the contract's contradict). What the rulings do not decide (plain bugs, product questions, the wire) and an order of work gated by the contract's pins and scripts/size. Co-authored-by: Claude via Cursor <noreply@cursor.com> * docs(server-components): frames rulings — C13 confirmed, C18 first, C19 under seam 3; gate is #3813 Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — principle: SCs are rendered data; a frame is one async value outward, its inner boundaries are the server's; 3.1 ruled Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): A0 — equivalence: SCs are rendered data, the hold model governs; axioms annotated with the Solid 2 rules they restate Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — prior art comparison Per-reading comparison (1.3, 1.4, 1.6, 2.3, 3.1, 3.3, 3.5, 3.6) and per- product-question comparison (C12 (c), settles-once projections, live containers, slot-trails-html) against RSC/Flight+Fizz, Next.js, React Router/Remix, Turbo/htmx, Phoenix LiveView, Qwik, Astro server islands, Marko, Vue and Solid Router. Adds an addressing section (content-keyed vs tree-shaped vs DOM-addressed vs connection-tree) for the maintainer's A3/A4 model, a summary table, and a list of mechanisms the references have that we don't. Primary-source citations; verified vs inferred marked. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(plans): bring the frames A0 re-attribution and the savings-pass plan onto the rulings branch (from size/frames-a0-reattribution @ 1e0be39) The plans tonight's correctness pass implements (frames-savings-pass.md Phase A: A0–A6), carried with the code per the maintainer's preference. Squashed from the branch's nine commits; content unchanged. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — the overnight pass's defaults, the server-half drafts, the as-landed table What the rulings do NOT decide: the nine readings the 2026-10-06 pass took by default (first flush not start; warm switch-back shows the committed value; the name decides the class on every sync; 2.3's interim with S-flush; 1.2 by the version guard; 3.2 via initBoundaryResume keyed sc:<fid> while in progress; 3.3's dev report; S-adopted documented; the harness's C3 dispose exemption), each with its alternative. The server half: C13's delimiter with the client's one-write status, the plain-response streaming bound (complete.bound), C12 (c)'s error template located in finalizeError/the done closure with the two decisions the server PR must make. Order of work: the as-landed table (#3830–#3833), what each step left undone. Public surface: landing, hold, holdBoundary, the behaviour notes. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — the maintainer's 2026-10-06 decisions: the defaults accepted, the C3 hold cost accepted (caps raised), #3827 closed as superseded by S-flush Co-authored-by: Cursor <cursoragent@cursor.com> * size: raise frames/hydration caps for C3 hold (maintainer Size-Exception) The C3 hold (frames-rulings 3.1, ruled) costs +59 B min on every hydrating page (sharedConfig.holdBoundary in solid-js) and the pass nets +104 B min on the frames client (S-flush −187, the hold and C5's guard +291). Six scenarios are over their cap past the 20 B minified allowance; the maintainer accepted the cost 2026-10-06 ("pay the cost for correctness"). Each cap is set at the 0.01 KB step at or below measured + 10 B, with its recorded minified, from a local measurement that matches CI on this machine: hydrating (no stores) 17.71 -> 17.73 KB, page base 44.84 -> 44.89 KB, page live 48.51 -> 48.60 KB (floor-caps.json, under the PR's Size-Exception:); hydrating + stores 28.87 -> 28.93 KB, compiled hydrating 30.93 -> 31.03 KB, frames eager 13.78 -> 13.79 KB (scenarios.js). The three pre-existing over-cap-within-allowance scenarios (signals floor, isPending, CSR) are at +0 B minified and are not raised. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(server-components): frames rulings — the pass lands as one PR, #3837 Co-authored-by: Cursor <cursoragent@cursor.com> * docs: record 2026-10-06 rulings (3.6 (iii), wire order, §6 decisions, holes eager) and the measured Phase D floor Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Fixes C18 — the only page-halting red in the frames/hydration consistency contract (#3813;
documentation/server-components/frames-consistency-contract.md§C18 / §Red R9).The bug
One adopted boundary, two render-prop occurrences whose records are still owed when the boundary adopts (
document.readyState === "loading", the #2968 defer arms). Both records' data scripts execute, then the parser finishes before the deferredsetTimeoutfires — the document's tail (the frame's data scripts and the end of the response) parses in one go, so this is the common order. Shrunk shape:[item#0 item#1 children] :: H R1 R0.In the code's terms: the timer runs
drainRecords(), which applies one record perhost.apply, each a synchronous#flush→#syncSlots. The first apply's sync mountsitem#0and findsitem#1still recordless — its record is sitting in_$HY.rone loop iteration away — whileadoptBoundary.recordsPending()(readyState === "loading" || fr.pending()) now reads false.#syncSlotsclassifies it direct-insert, and the render prop is evaluated as a zero-arg accessor inside the insert effect:props.text→TypeError→[REACTIVITY_HALTED]. The same window is reached by a live-hole op (applyLiveOp→host.apply) and by the live pump's catch-up read of ops logged before adoption. The existing #2968 pins (adopted-slot-late-record) never saw it: one occurrence,readyStateheld at "loading" through the drain.Three ledgers, two consulted: the document's (
_$HY.r), the store's (where the drain puts a record), the page's. "Recordless" read the store, "pending" read the parser, and nothing read the gap between them.The ruling it implements
Proposed frames-rulings 3.5 (
documentation/server-components/frames-rulings.mdonspec/frames-rulings, pending the maintainer's confirmation):Maintainer's consensus wording: delivered is not drained; the parser's end classifies only the undelivered.
The fix
packages/web/frames/src/client.ts—adoptBoundary.recordsPendinggains a third term:_$HY.rholds a key under the boundary'ssc:slot:<id>:orsc:region:<id>.prefix that is not yet inappliedRecords. "Pending" now means not yet delivered or delivered and not yet drained. The two prefixes are hoisted out ofdrainRecordsand shared; the drain's region branch uses the hoisted prefix instead of the two-step"sc:region:"+id + "."test (same set). The defer arm in#syncSlotsneeds no change:drainRecordsmarks a key applied before itshost.apply, so on the drain's own first apply the other record is still un-applied, that sync defers the occurrence (re-arming the timer — which later fires once into a no-op), and the loop's next apply mounts it with its args. No retry, no extra timer, no drain from inside a sync.packages/web/frames/src/frame-client.ts— comment-only:FrameOptions.recordsPendingand the#syncSlotsdefer arm now state the drain's-end bound.Pins
packages/web/test/consistency/harness/replay.spec.tsx— the three C18 replays flipped fromtest.failstotest, retitled to the behaviour (the first apply defers the second; a live op's sync defers the undrained occurrence; the pump's catch-up read defers), citing 3.5 and C18/R9. Control unchanged.packages/web/test/consistency/c18-classify-after-drain.spec.tsx— the consequence with a real fill (p => <li>{p.text}{tick()}</li>, the props read unguarded): (a) the record drain with two occurrences after the parser's end, (b) a live op's sync in the window; plus a control. Onnextboth arms fail withCannot read properties of undefined (reading 'text'); here every occurrence is invoked once with its args, the server<li>is claimed in place, no console error, and the page is still reactive after thetickbump.test.failsflipped: the hydrate config reports exactly the 19 remainingtest.failsas expected-fail (C3, C19 ×2, C2 replays; c02/c03/c04/c05/c06/c07/c12/c13/c17 pins).Harness campaign (
CONSISTENCY_FUZZ=1, 500 cases)Per-case before/after on the same seeds (same generator, same scenarios; the dump was a temporary spec, not committed):
Nothing new. No law appears after that did not appear before; the only cases whose finding set changed are exactly the 49 / 55 former C18 cases. Of those, 10 / 8 now surface the pre-existing C19 (a trace patch delivered before the claim — R10): the occurrence now really claims instead of being replaced by the oracle's inert stand-in, so the claim-shows-oracle law can see it. The small C2 drop is the same effect from the other side (the inert stand-in used to register as a dead range). C3 is untouched (seam 3.1/3.2, not this PR).
Suites (
packages/web, on the rebased tree)vitest run(dom)--config vite.config.server.mjs--config vite.config.hydrate.mjspnpm run test-types(tsc over tests)pnpm typechecktest/harness/__artifacts__packages/solid/srcandpackages/signals/srcare untouched.Size (local,
scripts/size, head vsorigin/nextbase measured in the same run)The growth is the predicate (the ruling's own estimate was ≈ +50 B min; the
sc:region:prefix term is most of the difference). Under the gate that landed in #3821/#3822, an over-cap scenario passes only within a 20 B minified allowance, sopage: base/page: livefail on real growth, not noise — a region-less predicate would still be ≈ +45 B and fail the same way. No cap or recorded minified is raised here; that is the maintainer's call (cap +capMinifiedbump with a reason, or aSize-Exception:). CI's numbers are authoritative for the brotli side; local brotli on this machine runs a few tens of bytes above CI's on several untouched scenarios.Public API changes
None.
recordsPendingis an internal, spread-cast option on the adopt path; no export, prop, option, signature, default or diagnostic changes.Changeset:
.changeset/frames-c18-classify-after-drain.md("@solidjs/web": patch).