diff --git a/.changeset/frames-c12-fragment-error-outcome.md b/.changeset/frames-c12-fragment-error-outcome.md new file mode 100644 index 000000000..2d33fcaad --- /dev/null +++ b/.changeset/frames-c12-fragment-error-outcome.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +A server `` inside a server component that fails after the first flush renders the server's outcome into its fragment instead of a blank (C12 (c), frames-rulings 3.3): the nearest server ``'s fallback for the error, at the ``'s position (asked through the boundary error handler's new `outcome` mode; a `` between passes the question up); with no server `` the error escapes the component — the frame's own `:error` on the stream face (an unkeyed `error` chunk), a frame-addressed `{ type: "error", fid, error }` op on the document face's `sc:live` channel (only the owning adopted boundary applies it) — and the position keeps the boundary's own fallback. `_fr` still rejects and the keyed error chunk still rides (the diagnostics). Outside a server component nothing changes (the blank the client twin renders fresh over). `HydrationContext.registerFragment`'s resolver gains a third argument (`escaped?: { frame?: string }`) and the context an internal `frameId`. diff --git a/.changeset/frames-c13-sweep-ops-chunk.md b/.changeset/frames-c13-sweep-ops-chunk.md new file mode 100644 index 000000000..b1a08c0b4 --- /dev/null +++ b/.changeset/frames-c13-sweep-ops-chunk.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: one server sweep lands as one frame (C13). The sink collects the `hole` / `attr` re-emissions one sweep produces and emits them as one `{ type: "ops", ops: [...] }` chunk on the stream face (one wire line) and one `sc:live` op of the same shape on the document face; a sweep that changes one binding emits that member alone, as before. The client maps the unit to one record map and applies it as one write — one hole pass, one `frame:applied` (the hole pass now announces once per flush, not once per hole). The document op log flattens a unit into its members (last value per hole). Additive wire (`FrameChunk` gains the `ops` member; RFC addendum in `frame-streams-rfc.md`). diff --git a/.changeset/frames-plain-response-bound.md b/.changeset/frames-plain-response-bound.md new file mode 100644 index 000000000..94ad56934 --- /dev/null +++ b/.changeset/frames-plain-response-bound.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: a plain (non-`live`) server component response ends at a streaming bound. A response whose content reads a standing source — a generator memo, a projection — used to stay open until the source settled, which for a source that never returns was never, with none of `live`'s reconnect semantics. The producer now ends it after `maxYields` emitting sweeps past the first flush (default 64) or `maxDurationMs` after the first flush (default 30 000 ms) — and when the request's `signal` aborts after the first flush — emitting `{ type: "complete", bound: "yields" | "time" }` before the body closes, and tears the render down quietly (sources returned, no abandonment finding). Both are new options on `FrameStreamOptions` (`renderServerComponent`, `renderToFrameStream`, `serverComponentResponse`); a `live` response is never bounded. The client stores `:bound` beside `:complete` and, in dev, warns once per cut-off naming `live()` as the declared way past the bound. `createFrameSink` gains an optional fourth `hooks` argument (`onYield`) and its `end(bound?)` takes the bound. Additive wire; RFC addendum in `frame-streams-rfc.md`. diff --git a/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index 0c27bd60b..09e5ff1af 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -518,7 +518,8 @@ Each a yes/no with a recommendation. needed. The cost is deferring the −6.4 KB page saving until Phases A and B are in. **Ruled 2026-10-06: yes** — S1 merges re-based at C3, after Phases A - and B. + and B. **Accepted for now; review after the first size pass** + (maintainer, 2026-10-06). 6. **The adopt-path holds (traces, regions, bind) register as pending boundaries through `initBoundaryResume` (3.1 participants), with the solid-side reach (`sharedConfig.resumeBoundary`, `hydrateWindow`) paid by diff --git a/documentation/server-components/frame-streams-rfc.md b/documentation/server-components/frame-streams-rfc.md index e64eaba83..a512a3960 100644 --- a/documentation/server-components/frame-streams-rfc.md +++ b/documentation/server-components/frame-streams-rfc.md @@ -449,6 +449,93 @@ frame, and that slot/slot chunks follow the same rule. > currently only set flag keys — the readiness model re-derives everything from > the store each flush, so there is nothing to "replay." +### Addenda (2026-10-06 — the frames correctness pass, server half) + +Additive members; a producer may omit them and a consumer that predates them +reads the stream it always read (what an old consumer does with each is +stated). + +**`ops` — one sweep, one unit (C13, frames-rulings §"The server half").** + +```ts +| { + type: "ops"; + id: string; + version: number; + ops: ( + | { type: "hole"; key: string; html: string; digest?: string } + | { type: "attr"; key: string; attrs: string; removed?: string[]; digest?: string } + )[]; + } +``` + +The server's commit unit is the sweep: one pass over every open binding, +coalesced per microtask. Before this member the wire carried a sweep's +re-emissions as N independent `hole` / `attr` chunks with no edge between +them, and the consumer — whose unit of application is the chunk — landed +them one flush apart, so a listener (a `frame:applied` handler, a +`MutationObserver`) could observe one hole of a sweep moved while a sibling +of the same sweep still showed the previous value (contract R7). The `ops` +member is the sweep's edge on the wire: the producer collects the pass's +`hole` / `attr` emissions and ships them as ONE chunk (stream face — one +wire line) or ONE `sc:live` op of the same shape (document face — the op +carries no `id` / `version`, as no document op does). Members ride +unaddressed; the envelope addresses them. A pass that changed one binding +emits that member alone, exactly as before. The consumer maps the unit to +one record map (`chunkToRecords` merges the members') and applies it as one +write — one hole pass, one `frame:applied`. Nothing is buffered, nothing is +correlated, nothing times out: a connection that dies mid-sweep dies before +the unit was written, and the unit is never half-delivered. + +_Old consumer:_ `chunkToRecords` answers an unknown `type` with an empty +record map (its `default` arm), so the write lands nothing and the frame's +flush is a no-op — the sweep's values are **lost on that consumer** until a +later sweep that changes one binding at a time re-ships them (each as a +plain member), or a reconnect / refetch re-ships the root. The old consumer +does not crash and does not tear; it under-updates. The frames surface is an +experimental preview (RFC 11's status note): the member is taken as +additive on the producer and the consumer ships with it in the same +release. + +**`complete.bound` — the plain response's streaming bound (savings pass §6 +decision 4, ruled 2026-10-06).** + +```ts +| { type: "complete"; id: string; version: number; bound?: "yields" | "time" } +``` + +A plain (non-`live`) server component whose content reads a standing source +— a generator memo, a projection over an async iterable — keeps its +response open and ships each later commit as holes, with no declaration of +liveness anywhere; its only end was "the source settles", which for a +source that never returns is never. The producer now ends such a response +at a bound and says so: `bound: "yields"` after `maxYields` emitting sweeps +past the first flush (default 64; a sweep that emits nothing — the source +repeating a value — is not a yield), `bound: "time"` `maxDurationMs` after +the first flush (default 30 000) **or when the request's `signal` aborts +after the first flush** (a platform deadline is a time bound the client can +tell from a death). The sink's end-of-response latch runs as for any +completion (the last sweep's values ship before the `complete`), the body +closes, and the render is torn down quietly (sources returned, holds +released; no abandonment finding — the response chose to end). Both +defaults are options on `FrameStreamOptions` (`maxYields`, +`maxDurationMs`); `0` / `Infinity` disable one. A `live` response is never +bounded: liveness IS the declaration that there is no bound, and `live()` +is the documented way past it. A `complete` with no `bound` means what it +always meant. A body that ends without any `complete` stays what it is: the +open frame's `:error` (undeclared death). + +_Consumer:_ `chunkToRecords` stores `:bound` beside `:complete`; the frame +lands as on any `complete` (the covering boundary releases, `landing` +resolves), and a consumer that cares can tell a cut-off from a settled +value by the key. In dev the host warns once per response, naming `live()`. +Not surfaced as an error: the content shown is the server's last value, +which is what the frame says it is. + +_Old consumer:_ reads `complete` as before (the extra field is ignored by +its `chunkToRecords`); it sees a completed frame and never learns it was a +cut-off. Degrades to today's behaviour minus the (new) distinction. + ### Two identity schemes The format uses two deliberately distinct identity schemes: diff --git a/documentation/server-components/frames-rulings.md b/documentation/server-components/frames-rulings.md index 96d05dd72..777e785a9 100644 --- a/documentation/server-components/frames-rulings.md +++ b/documentation/server-components/frames-rulings.md @@ -831,6 +831,53 @@ claimed position shows nothing.** exists to render; the contract's R6 read the absence as the bug. - The frame's `:error` → gate release → enclosing `` (outward): consistent; this is the only client error state a frame has. +- **Refetch after the outcome — ruled by A0 (2026-10-06, the maintainer's + question; pinned with the A6 PR).** Two cases. + - **(a) The frame errored and an enclosing CLIENT `` caught + it.** The non-SC rule: `reset` re-creates the boundary's children, so + an async node under it re-asks by construction. For a frame the content + node is the mount's `landing(address)`, cached per address in the host. + **The rule: an errored landing is not a landing for a fresh consumer — a + re-read after `reset` starts a new flight (a version bump) for the same + address.** _Pin:_ `test/frames-errored-reset-refetch.spec.tsx`, + **`test.fails`**. It fails at its FIRST step: on this branch the client + `` never catches — `landing()` resolves on the `:error` write + (default #1 above: the landing is "the root, the stream's error, or its + completion", all resolving), the covering `` releases over an + empty ``, `frame.error` holds the record and nothing throws + it outward (`call-driven-lifecycle`'s "error/before-html" pins exactly + that reading: "the boundary mounts empty, not stuck on fallback"). So + the rule has two parts to build, neither the one-line `landing` rule: + (1) the outward face — the host rejects the landing on an `:error` + write and `client.ts`'s `landing` memo throws into the enclosing + `` as any async node does (the lifecycle pins re-pin to this + reading); (2) the re-ask — the CALL lives in `dynamic`'s hoisted factory + memo (computed once; a re-created instance reads the same binding), and + the host reads an errored `shown` as warm, so a fresh consumer sees the + error synchronously and nothing fetches. The host must answer an errored + `shown` with a new flight for a fresh consumer: a per-address re-invoke + the handler records at `handle` (it has `ctx.id` / `ctx.args` there — + the address alone is a one-way hash) and the landing calls, bumping the + version. Files: `frame-transport.ts` (the handler), `frame-client.ts` + (`landing`), `client.ts` (`landing`), the server-functions client's + handler context. Left described in A6 (A2b's files; larger than a line). + - **(b) The SERVER `` caught it** — the client sees a successful + frame whose fragment carries static fallback markup; a retry there is + re-asking the whole server component. **What a client component rendered + inside the server component can reach today: nothing frame-specific.** + No context, `useFrame`, `refetch` or `bump` is exposed to slot content + (grep: `frames/src` has none; `bump` is private to the handler). What it + DOES have is full app context — client positions re-enter the zone owner + outside the barrier (`createDocumentSlotProps`) — so an integration's + own revalidation reaches it already (`revalidate(key)` through the + router's context); what it lacks is WHICH call encloses it. **The + smallest addition** (not made): the enclosing call's identity on the + slot callback's context — `SlotContext.address` (the call's + `frameAddress`) beside `adopted` / `call` — one field the client already + builds per occurrence (≈ 15 B); the integration maps the address to its + cache key (one-to-one by construction, DR-1) and refetches through its + own path. A frames-owned `refetch()` on the slot context is the larger + form and needs (a)'s per-address re-invoke first. ### 3.4 The client consumes what the server consumed @@ -1307,6 +1354,68 @@ S7, the three rename sites); store eviction (§3 4). No red touches them. ### The server half — drafts (2026-10-06; design, no wire change shipped) +**Built — 2026-10-06, `fix/frames-a6-server-half` (the A6 PR, against +`wip/frames-pass-integration`, retargets to `next` after #3837).** The three +drafts below were approved by the maintainer the morning of 2026-10-06 and +are coded as three commits on that branch; each draft's text is kept as the +design record, and what landed differs from it only where stated here. + +- **(i) C13** — as drafted: the sink's `sweep()` collects the pass's hole / + attr re-emissions and emits one `{ type: "ops", id, version, ops: [...] + }` chunk (stream face) / one `sc:live` op of the same shape (document + face); members ride unaddressed; a one-binding sweep emits the plain + member. Client: `chunkToRecords` merges the members (one write); the op + log flattens a unit; **and one more line than the draft counted** — + `FrameImpl.#flush`'s hole pass announced `frame:applied` per hole (inside + the loop), so a listener could still read a torn DOM mid-pass; it now + announces once per flush. `c13-sweep-atomic` (a, b) flipped, plus a (log) + arm; `test/server/frame-sweep-ops.spec.tsx` pins the sink on both faces. + RFC addendum in `frame-streams-rfc.md` (an old consumer drops the unit's + write — under-updates, never tears). +- **(ii) the plain-response bound** — as ruled (§6 decision 4): `complete` + gains `bound: "yields" | "time"`; `maxYields` (64) and `maxDurationMs` + (30 000, from the first flush) are `FrameStreamOptions`; the request's + `signal` aborting after the first flush ends a plain response as the time + bound does (before it, or for `live`, the death it was; a body's own + cancel is never a bound). The render is torn down quietly at the cut + (the abort reason carries `quiet: true`; no `SSR_STREAM_ABANDONED`). + Client: `:bound` beside `:complete`; a dev warning once per cut-off, + naming `live()`. A "yield" is an EMITTING sweep after the first flush + (the sink's `onYield` hook — a sweep the equality gate silenced is not + one). `frameTransformResult` takes the defaults (a policy function, no + options object). +- **(iii) C12 (c)** — as drafted, both decisions taken as recommended: (1) + the nearest server ``'s fallback renders at the ``'s + position (the handler chain's new `outcome` mode asks it; a `` + between passes the question up; an `` outside the component — + the app's twin at t = 0 — answers nothing); (2) ids are the component's + own hydration-free scope's; head / asset registrations drop with the + error as before. With no server `` the error **escapes**: the + stream face's unkeyed `error` chunk (`:error`), and — beyond the draft — + the document face's `sc:live` `{ type: "error", fid, error }` op, which + only the owning adopted boundary applies (`applyLiveOp`'s `fid` gate now + covers it); the position keeps the boundary's own fallback, never a + blank. `_fr` still rejects. C12 (c2) flipped; a (c3) escape arm added; + `test/server/frame-fragment-error-outcome.spec.tsx` pins both faces and + the unchanged client-twin case. **Not built:** the client's OUTWARD face + — nothing on the client throws a frame's `:error` into the enclosing + client `` (see 3.3's refetch note); the record lands, the gate + releases, `frame.error` holds it, and that is all the client does with + it today. + +**The maintainer's approvals recorded the same morning (2026-10-06):** + +- **A4 = the declared slot record** (`sc:slot::` written as a + declared pending ref at the marker, settled with the args) — not the + `_$HY.r` write hook. +- **A1b's surface removals approved** — `ServerComponentHandlerOptions. + onStream`, `FrameHostOptions.resolve` / `FrameHost.resolve`, `FrameHost. + preview` / `Frame.preview`, `STAGED_DATA`: "not even beta"; they go. +- **S-adopted is to be built** (`_$HY.fr.adopt / unadopt`, + `claimRegionFragments` deleted; ≈ +80 B solid on hydrating pages). +- **The 30 KB target** (savings pass §4.1) is stated against `page: base` + **without the router**, to be reviewed after the first size pass. + Three items the client pass could not close, each with the server-side shape it needs. None is coded; each is a design note the server PR follows. The savings pass's A6 is this section. @@ -1419,13 +1528,15 @@ lands outside its band is the finding, not a failure to hide. | 7 / A3 — C2 / C4 (2.3, 2.4) | in #3830 | C2 (b), C4 (d), harness C2 ×1 | (in #3830's figure) | the interim form (an empty write at the frame's version from the reveal cascade). Not done: R.reveal's readiness/retry deletion; DR-4 (2c). C2 (a2) is S-record's. | | 4 / A4 — C5 (1.2) | #3832 `fix/frames-c5-data-response-scoped` (on #3830) | C5 (a, b, e) | 43,194 / 13,659 (+71 / +17 vs #3830) | the data path under the store's version guard. Not done: the per-response cell, S-ref (the pending `{$ref}` read), S-record. | | 9 / A5 — C12 (c) client half | #3833 `fix/frames-c12-server-outcome` (on #3830) | C12 (c) → c1 green (dev report), c2 red (server half) | 43,123 / 13,642 (±0) | S-adopted documented, not built (+80 B solid). | -| A6 — server-half drafts | this branch (docs) | — | — | C13 delimiter, the streaming bound, C12 (c)'s template — above. | +| A6 — server-half drafts | this branch (docs); **built** on `fix/frames-a6-server-half` (2026-10-06, three commits — see the drafts section's status block) | C13 (a, b), C12 (c2); new: C12 (c3), the refetch pin (a) as `test.fails` | see the A6 PR's size table | C13's `ops` chunk, `complete.bound` + the `maxYields` / `maxDurationMs` options, C12 (c)'s error outcome on both faces (the escape as the frame's `:error`). Not built: the client's outward `:error` face; the refetch rule's two parts. | Still red after the pass (on #3831 ∪ #3832 ∪ #3833 over #3830): C2 (a2 — S-record), C12 (c2 — server half), C13 (a, b — wire), harness C19 ×2 (3e). Harness, 500 cases, both seeds: only C19 remains (83 / 79 — up from 71 / 68 because cases that ended in C18/C2 now mount and reach the known R10 -shape). +shape). **After A6 (`fix/frames-a6-server-half`):** C12 (c2) and C13 (a, b) +green; still red: C2 (a2 — S-record), harness C19 ×2 (A2b), and the new +refetch pin (a). Expected end state after 1–8: **≈ −565 B min / ≈ −165 B br** on the frames client with the full 1.4 and S1 landed (≈ −475 / −140 if step 8 ports S1's @@ -1488,7 +1599,33 @@ above; `FrameChunk` is unchanged unless the `ops` member is taken. chunk below the store's version is dropped (#3832). - Untouched, pending 1b: `ServerComponentHandlerOptions.onStream`, `FrameHostOptions.resolve` / `FrameHost.resolve`, `FrameHost.preview` / - `Frame.preview`, `STAGED_DATA`. + `Frame.preview`, `STAGED_DATA` — **their removal approved 2026-10-06** + ("not even beta"). + +**Touched by A6 (`fix/frames-a6-server-half`, 2026-10-06), each flagged in +its PR:** + +- **Wire (additive):** `FrameChunk` gains `{ type: "ops", id, version, ops: + (hole | attr member)[] }` and the same shape as an `sc:live` op; + `complete` gains `bound?: "yields" | "time"`; the `sc:live` channel gains + the frame-addressed `{ type: "error", fid, error }` op. RFC addenda in + `frame-streams-rfc.md`. +- `FrameStreamOptions.maxYields?: number` / `maxDurationMs?: number` — + **new** (`renderServerComponent`, `renderToFrameStream`, + `serverComponentResponse`). +- `createFrameSink(emit, frame, have, hooks?)` — a fourth optional + parameter (`{ onYield }`); the sink's `end(bound?)` takes the bound. +- Behaviour: `frame:applied` fires once per flush for the hole pass (was + once per hole); a plain response's body ends with `complete.bound: "time"` + on the request's abort after the first flush (was a bare close); the + `SSR_STREAM_ABANDONED` finding is suppressed for an abort whose reason + carries `quiet: true` (`renderToStream`). +- **solid (server, `@internal`):** `HydrationContext.registerFragment`'s + resolver gains `escaped?: { frame?: string }`; `HydrationContext.frameId`; + the module-internal `ErrorContext` handler takes `(err, outcome?: true)` + and may return the rendered outcome (`BoundaryErrorHandler` — not on the + package surface; `ErrorContext` never was). +- Store keys: `:bound` beside `:complete`. --- diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 11bf03277..08062f345 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -13,7 +13,8 @@ import { reportServerError, throwerOf, ownerId, - onCleanup + onCleanup, + inServerComponentScope } from "./signals.js"; import { OBSERVE } from "@solidjs/signals"; import { sharedConfig, NoHydrateContext, callerRenderContext } from "./shared.js"; @@ -115,7 +116,7 @@ function ssrLoadingBoundary( const flattenId = id + (hasOn ? "02" : "01"); (o as any).id = contentId; - let done: ((value?: string, error?: any) => boolean) | undefined; + let done: ReturnType | undefined; let handledRenderError: any; let retryPromise: Promise | undefined; @@ -309,12 +310,59 @@ function ssrLoadingBoundary( if (modules) ctx.serialize(id + "_assets", { ...modules }); } + // What the fragment carries for a failure once it is registered (its + // channel owns the routing — see runLoadingPhase). Outside a server + // component: nothing — the blank the client twin renders fresh over, as + // always (`hydratedCreateLoadingBoundary`'s rejected arm). Inside one + // (frames-rulings 3.3, A0 corollary 4 inward — the position shows what the + // SERVER rendered for the outcome, never a blank, never a client-invented + // state; there is no twin): the nearest server ``'s fallback for + // the error, rendered at this boundary's position (asked through the + // handler chain's `outcome` mode; a `` between passes the + // question up); with none, the error ESCAPES the component — the frame as + // one async value errors (the outward face; the renderer's sink carries + // it) and the position keeps the boundary's own markup, its fallback. + // Hydration ids inside the rendered fallback are the component's own + // hydration-free scope's; its head and asset registrations drop with the + // error, as the error path drops them today. + function errorOutcome(err: any): { value?: string; escaped?: { frame?: string } } | undefined { + if (!inServerComponentScope(o as any)) return undefined; + const rendered = parentHandler ? parentHandler(err, true) : undefined; + if (typeof rendered === "string") return { value: rendered }; + return { value: plainFallback(), escaped: { frame: ctx.frameId } }; + } + // Settle the fragment with the failure. The server error hook hears of it + // first (the `_fr` rejection and a transport sink's error chunk read the + // verdict it decides) — as `handling: "client"` when the client is where + // it goes, unless a server `` just reported it as its own + // (`"fallback"`, once per error); `report` is false where the caller + // defers that to the parent handler (the pre-flush path). + // Whether the last failure's outcome was rendered by a server + // (its own finding names it; the "client re-renders" one would be wrong). + let outcomeRendered = false; + function failFragment(err: any, report: boolean): boolean { + const outcome = errorOutcome(err); + outcomeRendered = !!outcome && outcome.value !== undefined && !outcome.escaped; + if (report && !outcomeRendered) { + reportServerError( + err, + { kind: "render", handling: "client", boundary: id }, + o, + ctx.errorPolicy + ); + } + return done!(outcome && outcome.value, err, outcome && outcome.escaped); + } + function runLoadingPhase(render: () => T): T { handledRenderError = undefined; return runWithBoundaryErrorContext( o, render, - (err: any, parentHandler) => { + (err: any, handler, outcome) => { + // The outcome question (see errorOutcome) is a ``'s to pass + // up: it owns no fallback for an error. + if (outcome) return handler ? handler(err, true) : undefined; handledRenderError = err; if (done) { // Once the fragment is registered, its channel owns error routing: @@ -326,18 +374,11 @@ function ssrLoadingBoundary( // so its only lasting effect is serializing the error at the // Errored id, which makes the hydrating client render the error // fallback expecting server DOM that was never emitted, derailing - // hydration before the fragment channel can engage. - reportRouted(err, "client"); - // The server error hook hears of it here, before the channel - // carries it (the `_fr` rejection, a transport sink's error chunk - // read the verdict the hook decides). - reportServerError( - err, - { kind: "render", handling: "client", boundary: id }, - o, - ctx.errorPolicy - ); - streamedOnError = done(undefined, err); + // hydration before the fragment channel can engage. (Inside a + // server component the nearest server `` IS asked — for + // its rendered outcome, not to route; see errorOutcome.) + streamedOnError = failFragment(err, true); + if (!outcomeRendered) reportRouted(err, "client"); throw err; } // Synchronous discovery (no fragment yet): the enclosing Errored's @@ -379,15 +420,8 @@ function ssrLoadingBoundary( // when it delivers) and the failure is met next by the parent handler // — an rendering its fallback — or fails the request below. const streamed = ctx.flushed !== undefined && ctx.flushed(); - if (streamed) - reportServerError( - err, - { kind: "render", handling: "client", boundary: id }, - o, - ctx.errorPolicy - ); - if (done(undefined, err)) { - reportRouted(err, "client"); + if (failFragment(err, streamed)) { + if (!outcomeRendered) reportRouted(err, "client"); record("error", true, err); return; } diff --git a/packages/solid/src/server/shared.ts b/packages/solid/src/server/shared.ts index f99de3997..02aeceb4e 100644 --- a/packages/solid/src/server/shared.ts +++ b/packages/solid/src/server/shared.ts @@ -36,10 +36,24 @@ export type HydrationContext = { escape(value: any): string; replace: (id: string, replacement: () => any) => void; block: (p: Promise) => void; + /** + * Register a deferred fragment; the resolver settles it: `v` its markup, + * `err` the failure it settled with, `escaped` that the failure escaped a + * server component (no server `` rendered an outcome for it) — + * the renderer surfaces it as the frame's own error, the outward face; + * `escaped.frame` names the component's frame where the channel needs it + * (the document face's `sc:live` op). + */ registerFragment: ( v: string, options?: { revealGroup?: string } - ) => (v?: string, err?: any) => boolean; + ) => (v?: string, err?: any, escaped?: { frame?: string }) => boolean; + /** + * @internal The frame id of the server component this context renders + * inside, on the document face (set by the frames server runtime on the + * component's render context; inherited by every clone below it). + */ + frameId?: string; revealFragments?: (groupOrKeys: string | string[]) => void; revealFallbacks?: (groupOrKeys: string | string[]) => void; /** Register a client-side asset discovered during SSR (e.g. from lazy()). */ diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index fd27c51f7..7ef273700 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -3211,7 +3211,20 @@ export function repeat( // === Boundary primitives === -const ErrorContext: Context<((err: any) => void) | null> = { +/** + * The error handler a boundary installs for its subtree. Called with the + * error alone it routes it (an `` renders its fallback and throws; + * a `` channels it). Called with `outcome: true` it is asked for + * the SERVER's rendered outcome for a post-flush failure inside a server + * component (frames-rulings 3.3 — the position shows what the server + * rendered, never a blank): the nearest server `` answers with + * its fallback as markup; a `` passes the question up; a handler + * that belongs to no server `` answers `undefined` — the error + * escapes the component. + */ +export type BoundaryErrorHandler = (err: any, outcome?: true) => string | undefined | void; + +const ErrorContext: Context = { id: Symbol("ErrorContext"), defaultValue: null }; @@ -3258,7 +3271,11 @@ export const RevealGroupContext: Context = { export function runWithBoundaryErrorContext( owner: Owner, render: () => T, - onError: (err: any, parentHandler: ((err: any) => void) | null) => void, + onError: ( + err: any, + parentHandler: BoundaryErrorHandler | null, + outcome?: true + ) => string | undefined | void, context?: NonNullable, boundaryId?: string ): T { @@ -3284,7 +3301,7 @@ export function runWithBoundaryErrorContext( try { return runWithOwner(owner, () => { const parentHandler = getContext(ErrorContext); - setContext(ErrorContext, err => onError(err, parentHandler)); + setContext(ErrorContext, (err, outcome) => onError(err, parentHandler, outcome)); return render(); }) as T; } finally { @@ -3677,6 +3694,36 @@ export function createErrorBoundary( serializeError(wire); return renderFallback(wire); }; + // The server's rendered OUTCOME for a post-flush failure inside a server + // component (frames-rulings 3.3, A0 corollary 4 inward; asked through the + // handler's `outcome` mode by the `` whose fragment failed): this + // boundary's fallback for the error, as finished markup, rendered at the + // asking boundary's position — this boundary's own subtree is already in + // the shell, so its fallback replacing the placeholder is the one layout + // the fragment can express. Only a SERVER `` answers — one inside + // the component's scope, whose ids the client never claims (the scope is + // hydration-free) and whose record nothing adopts; an `` outside + // the component (the app's, at t = 0 — a client twin) answers nothing, and + // the error escapes the component as the frame's own error (the outward + // face). A fallback still resolving (an async hole in it) has no finished + // markup to answer with and escapes the same way. + const renderOutcome = (err: any): string | undefined => { + if (!ctx || !inServerComponentScope(owner as unknown as SSROwner)) return undefined; + // Rendered from a resume loop, where the render context the compiled + // template reads has long moved past this boundary's: restore it. + const prevCtx = sharedConfig.context; + sharedConfig.context = ctx; + try { + const resolved: any = ctx.resolve(ctx.escape(handleError(err))); + if (!resolved || (resolved.h && resolved.h.length)) return undefined; + const t = resolved.t; + return Array.isArray(t) ? t[0] : t; + } catch { + return undefined; + } finally { + sharedConfig.context = prevCtx; + } + }; // `$lhSkip`: boundary machinery owns this position (see ssrLoadingBoundary) // — a live binding over the boundary's output would re-run resolve(), // which re-creates owners and re-enters retry plumbing per sweep. @@ -3689,8 +3736,9 @@ export function createErrorBoundary( if (ctx && !pending) disposeOwner(owner, false); try { result = ctx - ? runWithBoundaryErrorContext(owner, resolve, err => { + ? runWithBoundaryErrorContext(owner, resolve, (err, _parent, outcome) => { if (err instanceof NotReadyError) throw err; + if (outcome) return renderOutcome(err); handled = true; result = handleError(err); throw err; diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index df1755987..8d432ef1a 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -1051,7 +1051,11 @@ function pumpLiveChannel() { reader.read().then((r: { done: boolean; value: any }) => { if (r.done) return; const op = r.value; - liveOps.set(`${op.type}:${op.fid || ""}:${op.key || ""}`, op); + // A sweep's `ops` unit is applied whole (one write per boundary) but + // logged by its members: the log is last-value-wins per target, and + // a member's target is the key, not the unit it rode in. + for (const m of op.type === "ops" ? op.ops : [op]) + liveOps.set(`${m.type}:${m.fid || ""}:${m.key || ""}`, m); for (const apply of liveAppliers) apply(op); return pump(); }); @@ -1387,9 +1391,11 @@ function adoptBoundary( // store-keyed — two boundaries can share an occurrence name — so they // carry the producing frame's id and only the owning boundary applies // (the stray `fid` field rides into the apply; records are built from - // key/args, so it is ignored). + // key/args, so it is ignored). So does a frame-addressed ERROR op — a + // failure that escaped the server component (its `:error`, the outward + // face; frames-rulings 3.3); hole-keyed errors stay geometry-routed. const applyLiveOp = (op: any) => { - if (op.type === "slot" && op.fid !== id) return; + if (op.fid && op.fid !== id) return; host.apply({ ...op, id: address, version: 0 }); }; liveAppliers.add(applyLiveOp); diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 1831cfb2f..7345543e3 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -94,7 +94,36 @@ export type FrameChunk = preloads?: { href?: string; attrs: Record }[]; } | { type: "slot"; id: string; version: number; key: string; args: Record } - | { type: "complete"; id: string; version: number } + | { + /** + * One server sweep's re-emissions as one unit (RFC 11 addendum, C13): + * the `hole` / `attr` members a sweep produced, unaddressed (the + * envelope addresses them), applied as one write — one flush, one + * `frame:applied`. A sweep that changed one binding is emitted as + * that member alone. + */ + type: "ops"; + id: string; + version: number; + ops: ( + | { type: "hole"; key: string; html: string; digest?: string } + | { type: "attr"; key: string; attrs: string; removed?: string[]; digest?: string } + )[]; + } + | { + type: "complete"; + id: string; + version: number; + /** + * Present when the producer ended a plain (non-`live`) response at + * its streaming bound rather than at its sources' settling (RFC 11 + * addendum): `"yields"` — the later-yield count; `"time"` — the + * wall-clock bound after the first flush, or the request's abort + * after it. The content shown is a cut-off, not a settled value; + * `live()` is the declared way past the bound. + */ + bound?: "yields" | "time"; + } | { type: "error"; id: string; version: number; key?: string; error: unknown }; /** @@ -639,8 +668,16 @@ export function chunkToRecords(chunk) { digest: chunk.digest } }; + case "ops": + // One sweep's members as one write: the records merge into one map + // and the frame flushes once over all of them (C13). + return Object.assign({}, ...chunk.ops.map(chunkToRecords)); case "complete": - return { ":complete": true }; + // `:bound` beside `:complete` — the producer's streaming bound when + // it cut the response there (`undefined` for a settled one, as + // `holes` is for a root without them): a consumer can tell a cut-off + // from a settled value; the frame landed either way. + return { ":complete": true, ":bound": chunk.bound }; case "error": // Keyed errors scope to what the key names: a hole key (`lh:N`) is a // failed live-hole sweep — terminal for the hole, whose range latched @@ -827,6 +864,18 @@ export function createFrameHost(options = {}) { const records = chunkToRecords(chunk); const store = storeFor(chunk.id); if (!write(store, chunk.version, records)) return; + // The producer cut a plain response at its streaming bound: what the + // address shows is a cut-off, not a settled value. `live()` is the + // declared way past the bound; say so once per response, in dev. + if ("_SOLID_DEV_" && chunk.type === "complete" && chunk.bound) { + console.warn( + `Server component "${chunk.id}" kept streaming past the server's ${chunk.bound} ` + + `bound and was cut off (complete.bound: "${chunk.bound}"); its content is the last ` + + `value the server sent, not a settled one. A source meant to keep streaming is ` + + `declared with live(): wrap the server function (live(fn)) so the client holds a ` + + `standing connection instead.` + ); + } let r = records; // The address as a source: `start` opens a flight; the write that // lands it makes the version the one SHOWN and answers whoever awaited @@ -1233,7 +1282,12 @@ class FrameImpl { // mount's empty map replays the warm store). A hole error is terminal // server-side — the range latched at its last markup, and unlike a // rejected arg ref there is no client read to throw into, so it - // surfaces as a one-time diagnostic. + // surfaces as a one-time diagnostic. The pass is announced ONCE after + // every applicable record landed (C13: one write is one frame — a + // sweep's `ops` unit arrives as one write, and a listener on + // `frame:applied` must never read the DOM with one of its holes moved + // and a sibling still showing the previous value). + let morphed = false; for (const key in this.#store) { const record = this.#store[key]; if (!record || this.#appliedHoles.get(key) === record) continue; @@ -1245,16 +1299,17 @@ class FrameImpl { } else if (this.#applyHole(key.slice(5), record.value)) { this.#appliedHoles.set(key, record); this.#recordHave(key.slice(5), record); - this.#applied(version, "morph"); + morphed = true; } } else if (key.startsWith("attr:")) { if (this.#applyAttrs(key.slice(5), record.value, record.removed)) { this.#appliedHoles.set(key, record); this.#recordHave("lha:" + key.slice(5), record); - this.#applied(version, "morph"); + morphed = true; } } } + if (morphed) this.#applied(version, "morph"); // Root asset records reuse a store key, so consume them by identity. // Styles remain owned by the reveal gate. diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 5ba77b14a..77a20843f 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -81,6 +81,28 @@ export interface FrameStreamOptions { * event stream. The chunk protocol is unchanged; only the framing is. */ live?: boolean; + /** + * The plain response's streaming bound, in later yields (frames savings + * pass §6 decision 4). A plain (non-`live`) server component whose + * content reads a standing source — a generator, a projection — keeps + * its response open and ships each later commit as holes; with no + * declaration of liveness anywhere, that response ends here: after this + * many emitting sweeps past the first flush the producer emits + * `{ type: "complete", bound: "yields" }` and closes. `live()` is the + * declared way past the bound; a `live` response is never bounded. + * Default 64. `0` / `Infinity` disables the count bound. + */ + maxYields?: number; + /** + * The plain response's streaming bound, in wall-clock milliseconds after + * the first flush (the same decision): `{ type: "complete", bound: + * "time" }` then the body closes. The request's `signal` aborting after + * the first flush ends a plain response the same way — a platform's + * deadline is a time bound the client can tell from a death. Default + * 30 000. `0` / `Infinity` disables the timer (the `signal` still ends + * it). + */ + maxDurationMs?: number; /** * A RESUME (RFC 11 §9.5): the have-list the reconnecting client sent — * the digests it holds for this address, keyed as the chunks carry them @@ -354,12 +376,22 @@ function withHoles(chunk, holes) { * have-list (see `FrameStreamOptions.resume`): present, the sink emits * conditionally against it. * + * `hooks.onYield` is called after every sweep that emitted something — the + * visible effect of one commit (a source yielding, a promise settling): the + * producer's bound on a plain response counts these (see `frameStream`). + * * @param {(chunk: object) => void} emit * @param {{ id: string, version: number }} frame * @param {Record} [have] + * @param {{ onYield?: () => void }} [hooks] */ -export function createFrameSink(emit, frame, have) { +export function createFrameSink(write, frame, have, hooks) { const { id, version } = frame; + // Every emission passes here, so a sweep knows whether it produced one. + const emit = chunk => { + if (swept) swept.emitted = true; + write(chunk); + }; // Conditional emission (Stage 8 B4, RFC 11 §9.5 Server face 2). `have` // is the client's ledger for this address; `conditional` arms once the // shell decides the client's structure stands (skeleton digests equal) @@ -430,18 +462,44 @@ export function createFrameSink(emit, frame, have) { // sweep computes once; a memo pulled across commits recomputes — the // client contract applied to the server, without a subscriber graph). let epoch = 0; + // The sweep is one unit on the wire (C13, frames-rulings §"the server + // half": one sweep, one frame). The hole / attr re-emissions a pass + // produces are collected here and leave as ONE chunk — `{ type: "ops", + // ops: [...] }` when the pass changed more than one binding, the member + // itself when it changed one — so the client, whose unit of application + // is the chunk, lands the server's flush as one flush of its own. The + // chunk's edge is the delimiter: no sweep-end marker, no buffering on + // the client, nothing to time out if a connection dies mid-sweep. + let swept = null; const sweep = () => { epoch++; - for (const b of [...bindings.values()]) { - try { - b.sweep(); - } catch (_) { - // A sweep failure (a serializer already closed at the end-of-response - // latch) must not take the stream down: the binding's last emitted - // value stands. + const pass = (swept = { ops: [], emitted: false }); + try { + for (const b of [...bindings.values()]) { + try { + b.sweep(); + } catch (_) { + // A sweep failure (a serializer already closed at the end-of-response + // latch) must not take the stream down: the binding's last emitted + // value stands. + } } + emitOps(pass.ops); + // A pass that emitted is one visible commit — one "yield" to the + // plain-response bound (`frameStream`). After the pass's unit left, + // so a cut taken here follows it on the wire; still inside the pass, + // so `end` knows nothing is owed to the latch. + if (pass.emitted && hooks && hooks.onYield) hooks.onYield(); + } finally { + swept = null; } }; + // Members ride unaddressed (the envelope addresses them); a lone member + // is addressed and emitted as the plain chunk it always was. + const emitOps = ops => { + if (ops.length === 1) emit(Object.assign({ type: ops[0].type, id, version }, ops[0])); + else if (ops.length) emit({ type: "ops", id, version, ops }); + }; const scheduleSweep = () => { if (closed || sweepScheduled || !bindings.size) return; sweepScheduled = true; @@ -639,14 +697,24 @@ export function createFrameSink(emit, frame, have) { emit({ type: "assets", id, version, key: "", preloads: [wirePreload(value)] }); } }, - end() { + /** + * End the response: `complete`, with `bound` when the producer cut a + * plain response at its streaming bound (`"yields"` | `"time"`; see + * `frameStream`) rather than its sources settling. Idempotent — a cut + * and the render's own end may both reach here. + */ + end(bound) { + if (closed) return; // The end-of-response latch: one final synchronous sweep so a commit // that landed in the last flush still ships before `complete` (the // scheduled microtask would lose that race). Completion latches every - // binding's last value as final. - if (bindings.size) sweep(); + // binding's last value as final. A cut taken from inside a sweep's + // yield hook has just swept; nothing is owed. + if (bindings.size && !swept) sweep(); closed = true; - emit({ type: "complete", id, version }); + const chunk = { type: "complete", id, version }; + if (bound) chunk.bound = bound; + emit(chunk); }, error(errorId, error) { emit({ type: "error", id, version, key: errorId, error }); @@ -665,17 +733,19 @@ export function createFrameSink(emit, frame, have) { }, // A live-hole re-emission (Stage 3): the hole's re-resolved HTML, keyed // by its marker id — the consumer morphs the marked range in place. + // Produced by a sweep, so it joins the sweep's unit (see `sweep`). hole(key, html) { - emit({ type: "hole", id, version, key, html, digest: textDigest(html) }); + const op = { type: "hole", key, html, digest: textDigest(html) }; + swept ? swept.ops.push(op) : emitOps([op]); }, // A live attr-hole re-emission: the addressed element's rebuilt // attribute text, plus the names that vanished since the last emission // (the server holds the previous text — the client never tracks name // history). attr(key, attrs, removed) { - const chunk = { type: "attr", id, version, key, attrs, digest: textDigest(attrs) }; - if (removed && removed.length) chunk.removed = removed; - emit(chunk); + const op = { type: "attr", key, attrs, digest: textDigest(attrs) }; + if (removed && removed.length) op.removed = removed; + swept ? swept.ops.push(op) : emitOps([op]); }, // An attr hole's first-render text, keyed by its address — the digest // source for root/fragment `holes` maps and the resume compare. @@ -824,23 +894,101 @@ export function renderServerComponent(component, options = {}) { // emission, `complete` + end on the stream settling. `makeCode` builds the // render thunk with access to the sink/frame (the slot-props proxy needs // both); no document text is ever written. +// The plain response's defaults (frames savings pass §6 decision 4, ruled +// 2026-10-06): 64 later yields, or 30 s after the first flush. +const DEFAULT_MAX_YIELDS = 64; +const DEFAULT_MAX_DURATION_MS = 30_000; +// The abort reason a response's own `cancel` tears its render down with: +// the reader left, so the stream must not dress the end as a bound. +const DISCONNECTED = Symbol("solid.frames.disconnected"); + function frameStream(makeCode, options) { const { id = "", version = 1 } = options.frame || {}; const frame = { id, version }; + // The plain-response streaming bound. A plain server component reading + // a standing source would otherwise hold its response open for as long + // as a `live` one does, with none of `live`'s reconnect semantics; the + // bound ends it, detectably: `complete` carries `bound`, so the client + // can tell a cut-off from a settled value. A `live` response is never + // bounded — liveness is the declaration that there is no bound. + const bounded = !options.live; + const maxYields = bounded ? (options.maxYields ?? DEFAULT_MAX_YIELDS) : 0; + const maxDurationMs = bounded ? (options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS) : 0; function stream(w) { // Observe tier: the server half of the `"frame"` record // (`OBSERVE.records`, see `FrameProducedEvent`) — start → complete, with // the chunk census. Nothing is read, not even the clock, without a // listener. const observation = observeFrame(frame); - const emit = observation - ? chunk => { - observation.chunk(chunk); - w.write(chunk); - } - : chunk => w.write(chunk); - const sink = createFrameSink(emit, frame, options.resume && options.resume.have); + // The render's own teardown, chained from the caller's signal: a cut at + // the bound tears the render down (its sources returned, its holds + // released — nothing produces for a response that has ended) without + // touching the caller's signal. + const render = new AbortController(); + const upstream = options.signal; + let ended = false; + let flushed = false; + let yields = 0; + let timer; + function finish() { + if (ended) return; + ended = true; + if (timer) clearTimeout(timer); + if (upstream) upstream.removeEventListener("abort", onSignal); + w.end && w.end(); + } + // End a plain response at a bound: the sink's end (the latch sweep, + // then `complete` with the bound), the body's end, then the render's + // teardown — quiet, so the renderer records no abandonment for a + // response that chose to end. + function cut(bound) { + if (ended) return; + sink.end(bound); + observation && observation.settle("complete"); + finish(); + render.abort({ quiet: true, bound }); + } + // The caller's signal (the request's, through `serverComponentResponse`) + // ends the response. After a plain response's first flush that end is + // its time bound — a platform deadline, a proxy's idle cut — and the + // client is told so (`complete.bound: "time"`); before the first flush, + // for a `live` response, or when the reader itself is gone (the body's + // cancel, flagged on the reason — nobody to tell), the body simply ends + // — the death the consumer already knows (an open frame's `:error`, a + // live loop's reconnect). Either way the render is torn down after the + // body's end, so the `complete` leaves before it. + function onSignal() { + const gone = upstream.reason && upstream.reason[DISCONNECTED]; + if (bounded && flushed && !gone) return cut("time"); + finish(); + render.abort(upstream.reason); + } + const emit = chunk => { + if (observation) observation.chunk(chunk); + w.write(chunk); + // First flush: the root's html. From here the bound counts. + if (bounded && !flushed && chunk.type === "html" && chunk.id === id) { + flushed = true; + if (maxDurationMs > 0 && maxDurationMs !== Infinity) + timer = setTimeout(() => cut("time"), maxDurationMs); + } + }; + const sink = createFrameSink( + emit, + frame, + options.resume && options.resume.have, + bounded + ? { + onYield() { + if (flushed && maxYields > 0 && ++yields >= maxYields) cut("yields"); + } + } + : undefined + ); w.write({ type: "start", id, version }); + // A caller already gone has nobody to render for. + if (upstream && upstream.aborted) return finish(); + if (upstream) upstream.addEventListener("abort", onSignal, { once: true }); const code = makeCode(sink, frame); try { // Frames default to the keyed JSON codec for data records (eval-free @@ -852,6 +1000,7 @@ function frameStream(makeCode, options) { renderToStream(() => serverOwned(code), { serializer: createJSONSerializer, ...options, + signal: render.signal, sink }).pipe({ // Every document emission is intercepted by the frame sink, so no @@ -859,9 +1008,10 @@ function frameStream(makeCode, options) { // signal. write() {}, end() { + if (ended) return; sink.end(); observation && observation.settle("complete"); - w.end && w.end(); + finish(); } }); } catch (err) { @@ -874,7 +1024,7 @@ function frameStream(makeCode, options) { sink.error("", wire instanceof Error ? wire.message : String(wire)); sink.end(); observation && observation.settle("error", err); - w.end && w.end(); + finish(); } } return { @@ -1722,16 +1872,31 @@ function armDocumentLiveHoles(ctx) { let epoch = 0; let sweepScheduled = false; let closed = false; + // The sweep is one op on the channel (C13 — see the stream sink's + // `sweep`): a pass that changed more than one hole / attr binding ships + // them as one `{ type: "ops", ops: [...] }` op, so an adopted boundary + // applies the server's flush as one flush. + let swept = null; const sweep = () => { epoch++; - for (const b of [...bindings.values()]) { - try { - b.sweep(); - } catch (_) { - // A sweep failure must not take the document down: the binding's - // last emitted value stands. + const ops = (swept = []); + try { + for (const b of [...bindings.values()]) { + try { + b.sweep(); + } catch (_) { + // A sweep failure must not take the document down: the binding's + // last emitted value stands. + } } + } finally { + swept = null; } + pushOps(ops); + }; + const pushOps = ops => { + if (ops.length === 1) push(ops[0]); + else if (ops.length) push({ type: "ops", ops }); }; const scheduleSweep = () => { if (closed || sweepScheduled || !bindings.size) return; @@ -1765,12 +1930,13 @@ function armDocumentLiveHoles(ctx) { // hashes) — the document channel's ops included, so a ledger seeded // from the document can follow what the channel later re-emits. hole(key, html) { - push({ type: "hole", key, html, digest: textDigest(html) }); + const op = { type: "hole", key, html, digest: textDigest(html) }; + swept ? swept.push(op) : pushOps([op]); }, attr(key, attrs, removed) { const op = { type: "attr", key, attrs, digest: textDigest(attrs) }; if (removed && removed.length) op.removed = removed; - push(op); + swept ? swept.push(op) : pushOps([op]); }, error(key, error) { push({ type: "error", key, error }); @@ -1806,6 +1972,13 @@ function armDocumentLiveHoles(ctx) { }, commit: scheduleSweep }; + // A failure that escaped a server component on the document face + // (frames-rulings 3.3; web's fragment resolver): the frame as one async + // value errored — an unkeyed error op addressed to its frame, which only + // the owning adopted boundary applies (`:error`). + live.error = (fid, error) => { + push({ type: "error", fid, error }); + }; live.end = () => { if (closed) return; if (bindings.size) sweep(); @@ -1858,6 +2031,9 @@ export function frameTransformDirectResult(value, { id, args }) { // outside the component barrier — neither marks nor warns. const ctx = Object.create(page); ctx.claims = CLAIMS_DOCUMENT; + // The frame this scope renders: a failure escaping a boundary inside + // it is addressed to this frame on the live channel (`live.error`). + ctx.frameId = id; sharedConfig.context = ctx; try { const slotProps = createDocumentSlotProps(props, id); @@ -2426,12 +2602,11 @@ export function serverComponentResponse(component, options = {}, init = {}) { controller.close(); } catch (_) {} }; - // A torn-down render never ends its sink (nobody is listening), so the - // body closes here when the abort came from the request rather than - // from this body's own cancel — including a request gone before the - // body was ever read. + // A request gone before the body was ever read: nothing to render + // for. Once piping, the stream ends the body on the abort itself (and + // after a plain response's first flush, with `complete.bound: "time"` + // ahead of the close — see `frameStream`). if (teardown.signal.aborted) return end(); - teardown.signal.addEventListener("abort", end, { once: true }); // Chaos ends the body as a dying connection would: the render is torn // down first (its sources returned, as on a real disconnect), then // the body errors with the frame still open — a death to the reader. @@ -2457,7 +2632,9 @@ export function serverComponentResponse(component, options = {}, init = {}) { closed = true; disarm(); if (stopLive) stopLive(); - teardown.abort(); + // The reader is gone: a death, never a bound (there is nobody to + // tell) — the render abandons as on any disconnect. + teardown.abort({ [DISCONNECTED]: true }); } }); return new Response(body, { status: init.status || 200, headers }); @@ -2764,7 +2941,9 @@ export function frameFlightResponse({ primary, regions = [], outcome, codec, sig cancel() { closed = true; disarm(); - teardown.abort(); + // The reader is gone: a death for the frame in progress, never a + // bound (see serverComponentResponse's cancel). + teardown.abort({ [DISCONNECTED]: true }); } }); return new Response(body, { status: init.status || 200, headers }); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index b2b9ea334..c4fcf56b3 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -2850,7 +2850,7 @@ export function renderToStream(code, options = {}) { (stubBatch ||= new Map()).set(key + "_fr", p); else serializer.write(key + "_fr", p); } - return (value, error) => { + return (value, error, escaped) => { if (registry.has(key)) { const item = registry.get(key); registry.delete(key); @@ -2860,6 +2860,19 @@ export function renderToStream(code, options = {}) { // `_fr` rejection, a transport sink's error chunk — gets what // the wire policy allows (#3468). if (error) abandonSubtree(key, error); + // A failure that ESCAPED a server component (frames-rulings 3.3: + // no server rendered an outcome for it; `value` is the + // boundary's own markup) is the frame's — one async value errored, + // the outward face: the frame sink's unkeyed error chunk + // (`:error`), or the document face's `sc:live` error op addressed + // to the component's frame. The fragment still settles below (its + // position never blanks; `_fr` still rejects — the diagnostic). + if (error && escaped) { + const wire = ssrSanitizeError(error, null); + const message = wire instanceof Error ? wire.message : String(wire); + if (sink.error) sink.error("", message); + else if (context.live && context.live.error) context.live.error(escaped.frame, message); + } // A settled nested fragment parked its markup here to be spliced // into this fragment's content. On the error path there is no @@ -2907,7 +2920,12 @@ export function renderToStream(code, options = {}) { // (its protocol rejects `_fr` via item.resolve below), but // transport sinks with no resume protocol need the signal. // Post-flush: the boundary told the hook before settling, so - // the verdict the chunk carries is the decided one. + // the verdict the chunk carries is the decided one. On the + // error path `value` is what the boundary rendered for the + // outcome — a server 's fallback or the boundary's own + // markup inside a server component (frames-rulings 3.3) — and + // nothing outside one, where the client twin renders fresh over + // the blank. sink.fragment(key, resolveSSRSelectValues(value !== undefined ? value : " "), { styles, revealGroup, @@ -2959,7 +2977,13 @@ export function renderToStream(code, options = {}) { // registry, the sink and the serializer, all declared above — and disarmed // by the render's final dispose, which every ending runs through. const signal = options.signal; - const onAbort = signal ? () => abandon("signal") : undefined; + // A reason carrying `quiet: true` is a teardown the response chose — a + // frame stream ending a plain response at its streaming bound (see + // frame-sink's `frameStream`) — not a client that left: no abandonment + // finding for it. + const onAbort = signal + ? () => abandon("signal", !!(signal.reason && signal.reason.quiet === true)) + : undefined; let html = root( d => { dispose = () => { diff --git a/packages/web/test/consistency/c12-boundary-parity.spec.tsx b/packages/web/test/consistency/c12-boundary-parity.spec.tsx index 8109fa62a..63467978a 100644 --- a/packages/web/test/consistency/c12-boundary-parity.spec.tsx +++ b/packages/web/test/consistency/c12-boundary-parity.spec.tsx @@ -177,47 +177,97 @@ describe("C12 — boundary parity at claim", () => { dispose(); }); - test.fails( - "(c2) rejected after adopt: the position shows the server's rendered outcome, never a blank (server half)", - async () => { - const fid = freshFid("c12c"); - const frag = "c12c-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 dispose = hydrate( - () =>
  • {p.text}
  • } />, - page.container - ); - await quiesce(); - expect(frames.frames).toEqual(["loading"]); + // The server half (frames-rulings §"The server half" (iii), built): the + // document face's error path renders the boundary's error outcome into the + // fragment template — the nearest SERVER ``'s fallback at the + // ``'s position (test/server/frame-fragment-error-outcome.spec.tsx + // pins the sink on both faces) — and `_fr` rejects as the diagnostic. The + // page below carries that output: the template is the Errored's fallback, + // not the `" "` the server used to write. The client shows it and invents + // nothing (c1). + test("(c2) rejected after adopt: the position shows the server's rendered outcome — the server 's fallback — never a blank", async () => { + const fid = freshFid("c12c"); + const frag = "c12c-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 dispose = hydrate( + () =>
  • {p.text}
  • } />, + page.container + ); + await quiesce(); + expect(frames.frames).toEqual(["loading"]); - // The rejected fragment's chunk: blank template + `$df`, then the - // `_fr` rejection. - const swapped = page.revealFragment(frag, " ", false); - fr.reject(new Error("boom")); - await quiesce(); - await quiesce(); - frames.sample(); - expect(swapped).toBe(1); - expect(fr.promise.s).toBe(2); - expect(page.hy.fr.pending()).toBe(false); - expect(fetches).toEqual([]); - // Observed: the swap lands the blank template the server wrote — the - // frame's text goes "loading" → " " (the fallback is gone, the - // position is empty). Expected: the server's rendered outcome for the - // failure at the position — the nearest server ``'s - // fallback; with none, the error escapes the server component and - // the whole response is the frame's `:error`. The gap is the server - // half's: `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). The client correctly - // invents nothing here (see c1). - expect(page.container.textContent.trim()).not.toBe(""); - frames.stop(); - dispose(); - } - ); + // The rejected fragment's chunk as the server now writes it: the + // Errored's fallback as the template + `$df`, then the `_fr` rejection. + const swapped = page.revealFragment(frag, 'failed: boom', false); + fr.reject(new Error("boom")); + await quiesce(); + await quiesce(); + frames.sample(); + expect(swapped).toBe(1); + expect(fr.promise.s).toBe(2); + expect(page.hy.fr.pending()).toBe(false); + expect(fetches).toEqual([]); + // The server's outcome at the position, in one visible transition. + expect(frames.frames).toEqual(["loading", "failed: boom"]); + expect(page.container.querySelector("em.fail")).not.toBeNull(); + expect(page.container.querySelector("i")).toBeNull(); + // Reported once in dev (c1); no client error state. + expect(page.errors.length).toBe(1); + expect(page.errors[0]).toContain(`fragment "${frag}"`); + expect(page.container.querySelector("li")).toBeNull(); + frames.stop(); + dispose(); + }); + + // The escape arm: no server `` encloses the boundary. The server + // keeps the boundary's own markup at the position (its fallback — never a + // blank) and the error escapes the component: the frame as one async + // value errored, carried on the document face as an `sc:live` error op + // addressed to the frame (`fid`), which only the owning boundary applies + // — the frame's `:error` (the outward face; what the client does with it + // beyond recording it is the client's — today `frame.error`). + test("(c3) rejected after adopt, no server : the position keeps the fallback and the frame records the escaped error", async () => { + const fid = freshFid("c12c3"); + const other = freshFid("c12c3-other"); + const frag = "c12c3-frag"; + page = bootPage(shell(fid, frag) + frameHtml(other, "

    other

    ")); + const fetches = countFetches(); + const fr = page.declareFragment(frag); + const Comp = (globalThis as any)._$SC.r(fid); + const Other = (globalThis as any)._$SC.r(other); + const frames = watchFrames(page.container); + const dispose = hydrate( + () => ( + <> +
  • {p.text}
  • } /> + + + ), + page.container + ); + await quiesce(); + expect(frames.frames).toEqual(["loadingother"]); + + const swapped = page.revealFragment(frag, "loading", false); + fr.reject(new Error("boom")); + page.live.push({ type: "error", fid, error: "boom" }); + await quiesce(); + await quiesce(); + frames.sample(); + expect(swapped).toBe(1); + expect(fr.promise.s).toBe(2); + expect(fetches).toEqual([]); + // The position never blanked: the fallback stands. + expect(frames.frames).toEqual(["loadingother"]); + // The escaped error is the frame's — this frame's, not its neighbour's. + expect((page.host.get(fid) as any).error).toBe("boom"); + expect((page.host.get(other) as any).error).toBeUndefined(); + expect(page.errors.length).toBe(1); + frames.stop(); + dispose(); + }); }); diff --git a/packages/web/test/consistency/c13-sweep-atomic.spec.tsx b/packages/web/test/consistency/c13-sweep-atomic.spec.tsx index 684249da2..b68287330 100644 --- a/packages/web/test/consistency/c13-sweep-atomic.spec.tsx +++ b/packages/web/test/consistency/c13-sweep-atomic.spec.tsx @@ -8,14 +8,17 @@ * visible together: no observable point shows one hole of the sweep updated * while a sibling hole of the same sweep still shows the previous value." * - * Mechanism meant to carry it: frames/src/frame-transport.ts - * `applyFrames.drain` (one `host.apply` per framed chunk, an `await` - * between), frames/src/client.ts `pumpLiveChannel` (the document channel is - * a ReadableStream read one op at a time), frames/src/frame-client.ts - * `FrameImpl.#flush` (the hole pass morphs every applicable hole record of - * the store) and `#applied` (a `frame:applied` event per hole). The wire - * carries no sweep delimiter: the server coalesces per BINDING ("at most - * one emission per binding per flush"), never per sweep. + * Mechanism that carries it (frames-rulings §"The server half", C13 — the + * sweep delimiter): the SINK's `sweep()` collects the pass's hole / attr + * re-emissions and ships them as ONE `{ type: "ops", ops: [...] }` chunk + * (stream face) / `sc:live` op (document face) — the chunk's edge is the + * unit; `chunkToRecords` merges the members into one record map and + * `FrameImpl.apply` flushes once over them (one hole pass, one + * `#applied("morph")`, one `frame:applied`). `applyFrames.drain` and + * `applyLiveOp` pass the unit through unchanged. The server arm — that the + * sink emits the member for a two-binding sweep — is pinned in + * test/server/frame-sweep-ops.spec.tsx; these arms feed the client the + * unit as the sink now emits it. * * Observation points: a `frame:applied` listener (the runtime's own * announcement of a landed morph) and a MutationObserver (a microtask @@ -65,98 +68,142 @@ afterEach(async () => { describe("C13 — one sweep, one frame", () => { // Document face: an adopted boundary; the server's sweep re-emits both - // holes as two `sc:live` ops written in one synchronous span. + // holes as ONE `sc:live` op — `{ type: "ops", ops: [hole, hole] }`, the + // shape frame-sink.ts's document sweep pushes for a two-binding pass. // - // Observed on `next`: applied === ["a1|b0", "a1|b1"] and frames === - // ["a0|b0", "a1|b0", "a1|b1"] — the first hole lands and is announced - // (and is visible at a microtask checkpoint) while the second still - // shows b0. Expected: no "a1|b0" anywhere. Where it goes wrong: the - // document channel is a ReadableStream of ops read one at a time - // (client.ts:pumpLiveChannel — `reader.read().then(op => applyLiveOp(op); - // pump())`), so each op is its own `host.apply` → `FrameImpl.apply` → - // `#flush`, whose hole pass morphs that one hole (`#applyHole`) and fires - // `#applied(version, "morph")` for it; the second op is a microtask later. - // Nothing on the wire says the two ops belong to one sweep (the server - // coalesces per binding, not per sweep), so the client has no unit larger - // than one op to make atomic. - test.fails( - "(a) document face: two `sc:live` ops of one sweep never show one hole updated without the other", - async () => { - const fid = freshFid("c13a"); - page = bootPage(frameHtml(fid, twoHoles("a0", "b0", 0))); - const Comp = (globalThis as any)._$SC.r(fid); - const applied: string[] = []; - page.container.addEventListener("frame:applied", () => applied.push(holes(page!.container))); - const dispose = hydrate(() => , page.container); - disposers.push(dispose); - await quiesce(); - expect(holes(page.container)).toBe("a0|b0"); - applied.length = 0; - const frames = watchFrames(page.container, () => holes(page!.container)); - // The sweep: both re-emissions in one synchronous span. - page.live.push({ type: "hole", key: "lh:0", html: "a1" }); - page.live.push({ type: "hole", key: "lh:1", html: "b1" }); - await quiesce(); - frames.sample(); - frames.stop(); - expect(holes(page.container)).toBe("a1|b1"); - expect(page.errors).toEqual([]); - expect(torn(applied)).toEqual([]); - expect(torn(frames.frames)).toEqual([]); - } - ); + // Was red on `next` (two separate ops): applied === ["a1|b0", "a1|b1"] + // and frames === ["a0|b0", "a1|b0", "a1|b1"] — each op was its own + // `host.apply` → `FrameImpl.apply` → `#flush`, a microtask apart, and + // the wire said nothing about the two belonging together. With the unit + // on the wire the pump hands one op to `applyLiveOp`, one write lands + // both records, and one flush morphs both holes. + test("(a) document face: one sweep's `ops` unit never shows one hole updated without the other", async () => { + const fid = freshFid("c13a"); + page = bootPage(frameHtml(fid, twoHoles("a0", "b0", 0))); + const Comp = (globalThis as any)._$SC.r(fid); + const applied: string[] = []; + page.container.addEventListener("frame:applied", () => applied.push(holes(page!.container))); + const dispose = hydrate(() => , page.container); + disposers.push(dispose); + await quiesce(); + expect(holes(page.container)).toBe("a0|b0"); + applied.length = 0; + const frames = watchFrames(page.container, () => holes(page!.container)); + // The sweep: both re-emissions as one unit. + page.live.push({ + type: "ops", + ops: [ + { type: "hole", key: "lh:0", html: "a1" }, + { type: "hole", key: "lh:1", html: "b1" } + ] + }); + await quiesce(); + frames.sample(); + frames.stop(); + expect(holes(page.container)).toBe("a1|b1"); + expect(page.errors).toEqual([]); + expect(torn(applied)).toEqual([]); + expect(torn(frames.frames)).toEqual([]); + // One flush: one announcement, one frame. + expect(applied).toEqual(["a1|b1"]); + expect(frames.frames).toEqual(["a0|b0", "a1|b1"]); + }); - // Stream face: a mounted call; the sweep's two `hole` chunks are enqueued - // back to back into one body (one network write). + // Stream face: a mounted call; the sweep arrives as ONE `ops` chunk (one + // wire line), the shape frame-sink.ts's stream sweep emits for a + // two-binding pass. // - // Observed on `next`: applied === ["a1|b0", "a1|b1"], frames === ["a0|b0", - // "a1|b0", "a1|b1"]. Expected: no torn pair. Where it goes wrong: - // frame-transport.ts:applyFrames.drain reads one framed chunk per - // `await reader.next()` and calls `host.apply(chunk)` per chunk — each - // `hole` chunk is a separate `FrameImpl.apply` → `#flush` → hole pass → - // `#applied("morph")`, with a microtask between the two; a MutationObserver - // fires in that gap. One body write is not one apply. - test.fails( - "(b) stream face: two hole chunks of one sweep never show one hole updated without the other", - async () => { - const id = freshFid("c13b"); - installServerComponents(makeHost().host); - const { held } = stubHeldFetch([id]); - const getRoom = createServerReference(id); - const Page = dynamic(() => getRoom() as any); - let div!: HTMLDivElement; - const dispose = createRoot(d => { -
    - fallback}> - - -
    ; - document.body.appendChild(div); + // Was red on `next` (two `hole` chunks): applied === ["a1|b0", "a1|b1"], + // frames === ["a0|b0", "a1|b0", "a1|b1"] — applyFrames.drain did one + // `host.apply` per chunk with a microtask between. One chunk is one apply. + test("(b) stream face: one sweep's `ops` chunk never shows one hole updated without the other", async () => { + const id = freshFid("c13b"); + installServerComponents(makeHost().host); + const { held } = stubHeldFetch([id]); + const getRoom = createServerReference(id); + const Page = dynamic(() => getRoom() as any); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
    + fallback}> + + +
    ; + document.body.appendChild(div); + return d; + }); + disposers.push(dispose); + const applied: string[] = []; + div.addEventListener("frame:applied", () => applied.push(holes(div))); + await pump(); + held[0].send({ type: "start", id, version: 1 }); + held[0].send({ type: "html", id, version: 1, html: twoHoles("a0", "b0") }); + await pump(); + expect(holes(div)).toBe("a0|b0"); + applied.length = 0; + const frames = watchFrames(div, () => holes(div)); + // The sweep: both re-emissions as one unit. + held[0].send({ + type: "ops", + id, + version: 1, + ops: [ + { type: "hole", key: "lh:0", html: "a1" }, + { type: "hole", key: "lh:1", html: "b1" } + ] + }); + await pump(); + frames.sample(); + frames.stop(); + expect(holes(div)).toBe("a1|b1"); + expect(torn(applied)).toEqual([]); + expect(torn(frames.frames)).toEqual([]); + expect(applied).toEqual(["a1|b1"]); + expect(frames.frames).toEqual(["a0|b0", "a1|b1"]); + held[0].send({ type: "complete", id, version: 1 }); + held[0].close(); + }); + + // Catch-up: the document op log (`client.ts:liveOps`) is last-value-wins + // per TARGET, so a unit that arrived before a boundary adopted is logged + // by its members — a later single-hole op for one of them supersedes + // that member alone, and the late adopter replays the latest of each. + test("(log) an `ops` unit that arrived before a boundary adopted replays by its members, latest per hole", async () => { + const fidA = freshFid("c13d-a"); + const fidB = freshFid("c13d-b"); + // Two boundaries on the page: A adopts first (its adoption starts the + // channel pump, so the ops below are READ — into the log — before B + // exists); B adopts after and can only see them through the log. + page = bootPage(frameHtml(fidA, "

    x

    ")); + const other = document.createElement("div"); + other.innerHTML = frameHtml(fidB, twoHoles("a0", "b0", 20)); + document.body.appendChild(other); + page.hy.fe("__shell", other); + const CompA = (globalThis as any)._$SC.r(fidA); + const CompB = (globalThis as any)._$SC.r(fidB); + disposers.push(hydrate(() => , page.container)); + await quiesce(); + // A unit, then one member moved again — before B adopts. + page.live.push({ + type: "ops", + ops: [ + { type: "hole", key: "lh:20", html: "a1" }, + { type: "hole", key: "lh:21", html: "b1" } + ] + }); + page.live.push({ type: "hole", key: "lh:21", html: "b2" }); + await quiesce(); + expect(holes(other)).toBe("a0|b0"); + disposers.push( + createRoot(d => { + ; return d; - }); - disposers.push(dispose); - const applied: string[] = []; - div.addEventListener("frame:applied", () => applied.push(holes(div))); - await pump(); - held[0].send({ type: "start", id, version: 1 }); - held[0].send({ type: "html", id, version: 1, html: twoHoles("a0", "b0") }); - await pump(); - expect(holes(div)).toBe("a0|b0"); - applied.length = 0; - const frames = watchFrames(div, () => holes(div)); - // The sweep: both re-emissions in one burst. - held[0].send({ type: "hole", id, version: 1, key: "lh:0", html: "a1" }); - held[0].send({ type: "hole", id, version: 1, key: "lh:1", html: "b1" }); - await pump(); - frames.sample(); - frames.stop(); - expect(holes(div)).toBe("a1|b1"); - expect(torn(applied)).toEqual([]); - expect(torn(frames.frames)).toEqual([]); - held[0].send({ type: "complete", id, version: 1 }); - held[0].close(); - } - ); + }) + ); + await quiesce(); + expect(holes(other)).toBe("a1|b2"); + expect(page.errors).toEqual([]); + }); // Control: a sweep that touches ONE hole is trivially atomic — the single // `frame:applied` and the single frame both show the new value, and the diff --git a/packages/web/test/frames-errored-reset-refetch.spec.tsx b/packages/web/test/frames-errored-reset-refetch.spec.tsx new file mode 100644 index 000000000..91adb5904 --- /dev/null +++ b/packages/web/test/frames-errored-reset-refetch.spec.tsx @@ -0,0 +1,122 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// Can the client refetch a server component whose response errored? (The +// maintainer's question, 2026-10-06; frames-rulings 3.3.) The non-SC rule: +// `reset` re-creates an ``'s children, so an async node under it +// re-asks by construction. For a frame the content node is the mount's +// `landing(address)` — the frame as one async value outward (A0, corollary +// 4) — cached per address in the host. The rule under A0: an errored +// landing is not a landing for a fresh consumer — a re-read after `reset` +// starts a new flight (a version bump) for the same address. +// +// Pinned RED (2026-10-06, A6). It fails at its first step: the client +// never catches. What it would take, in order: +// +// 1. The outward face — the frame's `:error` must REJECT the landing: +// `createFrameHost.apply` settles the address's landing on an `:error` +// write by rejecting it (today it resolves: "the root, the stream's +// error, or its completion" all resolve, default #1), and `client.ts`'s +// `landing()` memo then throws into the enclosing as any async +// node does. The `call-driven/error-record` pins ("the boundary mounts +// EMPTY, not stuck on fallback") assert today's reading and would +// re-pin to the ruling's (an un-boundaried frame error surfaces). +// 2. The re-ask — `reset` re-creates the children, but the CALL lives in +// `dynamic`'s hoisted factory memo (computed once; the re-created +// instance reads the same binding), so no request is made by +// construction; and `host.landing(address)` reads the errored store as +// warm (`shown` set) — a fresh consumer sees the error synchronously. +// The rule needs the host to answer an errored `shown` with a NEW +// flight for a fresh consumer: a per-address re-invoke the handler +// records at `handle` (it has `ctx.id` / `ctx.args` there; the address +// alone is a one-way hash) and the landing calls, bumping the version. +// That is `frame-transport.ts` (the handler), `frame-client.ts` (the +// host's `landing`), `client.ts` (`landing`) and the server-functions +// client's handler context — not the one-line `landing` rule, so it is +// left described, not built, in A6. +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createRoot, Errored, Loading } from "solid-js"; +import { dynamic } from "../src/index.js"; +import { installServerComponents } from "../frames/src/client.js"; +import { createServerReference } from "../server-functions/src/client.js"; +import { frameAddress } from "../server-functions/src/shared.js"; +import { makeHost, frameResponse, pump } from "./lifecycle-matrix/harness.js"; + +const html = (text: string) => `

    ${text}

    `; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("refetch after a client caught the frame's error", () => { + test.fails( + "(a) the frame errors → the client catches → reset() → a new request, the new content shows", + async () => { + const { host } = makeHost(); + installServerComponents(host); + let call = 0; + vi.stubGlobal("fetch", async () => { + call++; + return call === 1 + ? frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "error", id: "srv", version: 1, error: { message: "boom" } } + ]) + : frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "html", id: "srv", version: 1, html: html("recovered") }, + { type: "complete", id: "srv", version: 1 } + ]); + }); + const getStory = createServerReference("frames-reset/story"); + const Page = dynamic(() => getStory() as any); + let resetFn: (() => void) | undefined; + const container = document.createElement("div"); + document.body.appendChild(container); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
    + { + resetFn = reset; + return failed: {(err() as any)?.message}; + }} + > + shell-fallback}> + + + +
    ; + container.appendChild(div); + return d; + }); + await pump(); + + // STEP 1 — the frame's `:error` is the enclosing client 's + // to catch (the frame as one errored async value, 3.3's outward + // face). Observed on this branch: it is NOT — `landing()` resolves + // on the error write (frames-rulings default #1: the landing is "the + // root, the stream's error, or its completion"), the covering + // releases over an EMPTY , `frame.error` holds + // the record and nothing throws it outward (call-driven-lifecycle's + // "error/before-html" pins exactly this: "the boundary mounts empty"). + const frame: any = host.get(frameAddress("frames-reset/story")); + expect(frame.error).toEqual({ message: "boom" }); + expect(call).toBe(1); + expect(div.querySelector(".err")).not.toBeNull(); + expect(div.querySelector(".err")!.textContent).toBe("failed: boom"); + + // STEP 2 — `reset` re-creates the children: the frame's node re-asks. + resetFn!(); + await pump(); + expect(call).toBe(2); + expect(div.querySelector("p")!.textContent).toBe("recovered"); + expect(div.querySelector(".err")).toBeNull(); + + dispose(); + container.remove(); + } + ); +}); diff --git a/packages/web/test/frames-plain-bound.spec.tsx b/packages/web/test/frames-plain-bound.spec.tsx new file mode 100644 index 000000000..b4c92acd2 --- /dev/null +++ b/packages/web/test/frames-plain-bound.spec.tsx @@ -0,0 +1,121 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// The plain-response streaming bound, client half (frames savings pass §6 +// decision 4). A `complete` carrying `bound` is a landing like any other +// `complete` — the frame settles, the covering boundary releases — and the +// store keeps `:bound` beside `:complete`, so a consumer can tell a cut-off +// from a settled value. In dev the cut-off is named once per response, with +// `live()` as the declared way past the bound. +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createRoot, Loading } from "solid-js"; +import { dynamic } from "../src/index.js"; +import { installServerComponents } from "../frames/src/client.js"; +import { createServerReference } from "../server-functions/src/client.js"; +import { frameAddress } from "../server-functions/src/shared.js"; +import { makeHost, frameResponse, pump } from "./lifecycle-matrix/harness.js"; + +const html = (text: string) => `

    ${text}

    `; + +function mountUnderLoading(Comp: any) { + const container = document.createElement("div"); + document.body.appendChild(container); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
    + shell-fallback}> + + +
    ; + container.appendChild(div); + return d; + }); + return { + div, + cleanup() { + dispose(); + container.remove(); + } + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("plain-response bound — the client", () => { + test("`complete.bound` lands the frame, stores `:bound` beside `:complete`, and dev names live() once", async () => { + const { host } = makeHost(); + installServerComponents(host); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", async () => + frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "html", id: "srv", version: 1, html: html("v3") }, + { type: "complete", id: "srv", version: 1, bound: "yields" } + ]) + ); + const getFeed = createServerReference("frames-bound/yields"); + const Page = dynamic(() => getFeed() as any); + const m = mountUnderLoading(Page); + await pump(); + + // Landed: the content shows, the covering boundary released. + expect(m.div.querySelector("p")!.textContent).toBe("v3"); + expect(m.div.textContent).not.toContain("shell-fallback"); + const frame: any = host.get(frameAddress("frames-bound/yields")); + expect(frame.store[":complete"]).toBe(true); + expect(frame.store[":bound"]).toBe("yields"); + expect(frame.error).toBeUndefined(); + // Named once, with the way past it. + const named = warn.mock.calls.filter(c => String(c[0]).includes("bound")); + expect(named).toHaveLength(1); + expect(String(named[0][0])).toContain('complete.bound: "yields"'); + expect(String(named[0][0])).toContain("live("); + m.cleanup(); + }); + + test("a `complete` without `bound` stores no `:bound` and warns nothing", async () => { + const { host } = makeHost(); + installServerComponents(host); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", async () => + frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "html", id: "srv", version: 1, html: html("settled") }, + { type: "complete", id: "srv", version: 1 } + ]) + ); + const getFeed = createServerReference("frames-bound/settled"); + const Page = dynamic(() => getFeed() as any); + const m = mountUnderLoading(Page); + await pump(); + const frame: any = host.get(frameAddress("frames-bound/settled")); + expect(frame.store[":complete"]).toBe(true); + expect(frame.store[":bound"]).toBeUndefined(); + expect(warn.mock.calls.filter(c => String(c[0]).includes("bound"))).toHaveLength(0); + m.cleanup(); + }); + + test("a time bound is stored as such", async () => { + const { host } = makeHost(); + installServerComponents(host); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", async () => + frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "html", id: "srv", version: 1, html: html("t") }, + { type: "complete", id: "srv", version: 1, bound: "time" } + ]) + ); + const getFeed = createServerReference("frames-bound/time"); + const Page = dynamic(() => getFeed() as any); + const m = mountUnderLoading(Page); + await pump(); + const frame: any = host.get(frameAddress("frames-bound/time")); + expect(frame.store[":bound"]).toBe("time"); + m.cleanup(); + }); +}); diff --git a/packages/web/test/server/frame-fragment-error-outcome.spec.tsx b/packages/web/test/server/frame-fragment-error-outcome.spec.tsx new file mode 100644 index 000000000..93161003a --- /dev/null +++ b/packages/web/test/server/frame-fragment-error-outcome.spec.tsx @@ -0,0 +1,266 @@ +/** + * @jsxImportSource @solidjs/web + * + * C12 (c), the server half (frames-rulings 3.3; A0 corollary 4 inward): a + * server `` inside a SERVER COMPONENT that rejects after the first + * flush has no client twin to render over its position, so the fragment + * carries what the SERVER rendered for the outcome — never a blank: + * + * - the nearest server ``'s fallback for the error, rendered at + * the ``'s position (the Errored's own subtree is already in + * the shell; its fallback replacing the placeholder is the one layout + * the fragment can express) — the rule: "a post-flush error inside a + * server component's shows the nearest 's fallback + * at the boundary's position"; + * - with no server ``, the error ESCAPES the component: the frame + * as one async value errors (the stream face's unkeyed `error` chunk — + * `:error`; the document face's frame-addressed `sc:live` error op) and + * the position keeps the boundary's own markup, its fallback. + * + * `_fr` still rejects (the client's dev diagnostic, c1), the keyed error + * chunk still rides. Outside a server component nothing changes: the blank + * the client twin renders fresh over. + */ +import { describe, expect, it } from "vitest"; +import vm from "node:vm"; +import { createMemo } from "solid-js"; +import { Errored, Loading, renderToStream } from "@solidjs/web"; +import { + frameTransformDirectResult, + renderServerComponent, + ServerComponentPlugin +} from "../../frames/src/frame-sink.js"; + +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); + +/** An async memo that rejects after the shell flushed. */ +function lateReject(message: string) { + return createMemo(async () => { + await delay(10); + throw new Error(message); + }); +} + +const collectStream = (stream: any) => + new Promise(resolve => { + const chunks: any[] = []; + stream.pipe({ write: (c: any) => chunks.push(c), end: () => resolve(chunks) }); + }); + +const collectDocument = (code: () => any) => + new Promise(resolve => { + const out: string[] = []; + renderToStream(code, { plugins: [ServerComponentPlugin], onError() {} } as any).pipe({ + write: (c: string) => out.push(c), + end: () => resolve(out.join("")) + }); + }); + +/** Every `` of a document, by key. */ +function templates(html: string) { + const out: Record = {}; + for (const m of html.matchAll(/