diff --git a/.changeset/json-serializer-failed-write.md b/.changeset/json-serializer-failed-write.md new file mode 100644 index 0000000000..d377d622c5 --- /dev/null +++ b/.changeset/json-serializer-failed-write.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +`createJSONSerializer` settles a write whose value fails to parse, whether the failure is thrown or reported through `onError`, and releases what that write had already parsed, including async iterables and streams it had started reading. `onDone` now fires after `flush()` instead of never, so a frame stream whose client prop cannot be serialized completes, with the other records delivered. The references a failed parse registered are dropped, so a later write that shares a value with it decodes that value instead of `undefined`. A `close()` called from a write's first record also stops that write. diff --git a/.changeset/render-local-error-state.md b/.changeset/render-local-error-state.md new file mode 100644 index 0000000000..881ab3e97b --- /dev/null +++ b/.changeset/render-local-error-state.md @@ -0,0 +1,10 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +Expose a readonly `hasErrors` getter on `renderToStream` results. Hosts that await the full HTML can detect error fallbacks and rejected fragments without replacing the server error hook. The state belongs to each render and does not depend on the hook's once-per-error deduplication, including when a server function reports an error before it escapes into a render. + +The default hydration serializer now reports failures even without a local error hook. A failed parse or serialization marks the render as errored, reports through the local or global server error policy, and disposes the render without calling `onCompleteAll`. Synchronous default-serializer failures are contained rather than thrown to the caller. Buffered and streamed consumers finish instead of waiting indefinitely. A failure met while the shell is being assembled keeps that shell from the consumer, so `createSSRResponse` answers 500 as for a first-pass failure; HTML already sent to a streaming consumer cannot be recalled. + +Custom serializers keep their existing callback contract. Their synchronous write failures and errors reported through a supplied local hook are recorded; errors they suppress internally remain unobservable. A false value does not prove completion: callers must also account for aborted renders. diff --git a/.changeset/stream-completion-containment.md b/.changeset/stream-completion-containment.md new file mode 100644 index 0000000000..ce403dd1d5 --- /dev/null +++ b/.changeset/stream-completion-containment.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +`renderToStream` contains an `onCleanup` that throws while a render completes normally, as it already did when a render was torn down: the awaited result and the piped consumers finish, the failure reaches the server error hook as `handling: "failed"` and `hasErrors` is set, instead of leaving `await` pending behind an unhandled rejection. After a render failure, `pipeTo` settles only once a slow consumer has drained the written output and closed. A custom serializer that calls `onDone` twice completes the render once. diff --git a/documentation/solid-2.0/12-ssr-http.md b/documentation/solid-2.0/12-ssr-http.md index f610017c20..0d82e32dcb 100644 --- a/documentation/solid-2.0/12-ssr-http.md +++ b/documentation/solid-2.0/12-ssr-http.md @@ -24,7 +24,7 @@ Client (`@solidjs/web` in the browser): Server (`@solidjs/web` under the `node`/`deno`/`worker` conditions): - `renderToString(() => )` — synchronous; async boundaries render their fallbacks. -- `renderToStream(() => , options?)` — the streaming renderer: the shell flushes first, and each async boundary streams its resolved fragment plus the activation script that swaps it in. A primitive marked `deferStream: true` (RFC 05) holds the shell flush until its first value resolves instead of letting the enclosing `` fallback into the HTML. The returned stream is also a thenable: `await renderToStream(...)` resolves once the tree settles with the fully-resolved HTML string — the settled-string form of the render (what `renderToStringAsync` was before its removal). +- `renderToStream(() => , options?)` — the streaming renderer: the shell flushes first, and each async boundary streams its resolved fragment plus the activation script that swaps it in. A primitive marked `deferStream: true` (RFC 05) holds the shell flush until its first value resolves instead of letting the enclosing `` fallback into the HTML. The returned stream is also a thenable: `await renderToStream(...)` resolves once the tree settles with the fully-resolved HTML string — the settled-string form of the render (what `renderToStringAsync` was before its removal). Its readonly `hasErrors` records whether this render met an error (an `` fallback, a rejected fragment, a failed serialization), independent of the hook's once-per-error reporting: a host that publishes the awaited HTML whole checks it beside completion, since an aborted render can finish without errors. For hydration, the document needs the hydration script ahead of the app markup: `generateHydrationScript({ nonce?, eventNames? })` returns it as a string for hand-built documents, and `` renders it in JSX documents. @@ -193,9 +193,9 @@ renderToStream(code, { onError }); handleServerFunctionRequest(request, { onError }); ``` -- **Called once per error object per `kind` per request, at first sight** (per render when no request is in scope — never once per process: an error object reused across requests is heard, and mapped, by each), with where the failure was met. A direct server-function call that throws during SSR inside an `` is heard twice, as `server-function` and then as `render`, so `kind: "render"` alone tells you a render failed; the wire value is the first one the hook returned. `kind: "render"` — `fallback` (an `` rendered its fallback; `boundary` is its hydration id and `boundaryPath` its component labels — where the error was **met** — while `ownerPath` is where it was **thrown**, the labels up the owner chain it escaped, when the compiler emitted them), `client` (a `` fragment rejected, the client re-renders the subtree), `failed` (nothing contained it; the request fails — including a synchronous throw out of `renderToString` or `renderToStream`'s first pass, reported before it reaches the caller), `serialize` (a hydration value would not serialize and the render went on without it — reported for a render that passed `onError`, as seroval's own `onError` always was). `kind: "server-function"` — `thrown` (the body threw; `functionId`, and `direct: true` for an in-process call during SSR) or `channel` (a rejection or throw escaping through the result graph, the head already committed). `kind: "request"` is always `failed`: a failure that fails the request outside any render or server function the runtime reports (a middleware throw, a setup step), reported by the framework's request handler through `reportRequestFailure(error, event)`. A render failure, synchronous throws included, is reported as `render`, so a handler that catches one and passes it to `reportRequestFailure` adds nothing. It has no per-request tier, so only the ambient hook hears it. `event` is the request, when the failure happened inside one, and always for `kind: "request"`. +- **Called once per error object per `kind` per request, at first sight** (per render when no request is in scope — never once per process: an error object reused across requests is heard, and mapped, by each), with where the failure was met. A direct server-function call that throws during SSR inside an `` is heard twice, as `server-function` and then as `render`, so `kind: "render"` alone tells you a render failed; the wire value is the first one the hook returned. `kind: "render"` — `fallback` (an `` rendered its fallback; `boundary` is its hydration id and `boundaryPath` its component labels — where the error was **met** — while `ownerPath` is where it was **thrown**, the labels up the owner chain it escaped, when the compiler emitted them), `client` (a `` fragment rejected, the client re-renders the subtree), `failed` (nothing contained it; the request fails — including a synchronous throw out of `renderToString` or `renderToStream`'s first pass, reported before it reaches the caller), `serialize` (a hydration value would not serialize: under `renderToStream`'s default serializer the render ends without `onCompleteAll`, reported to the render's hook or the ambient one; under `renderToString` or a custom serializer it is reported for a render that passed `onError`, as seroval's own `onError` always was, and the render goes on without the value). `kind: "server-function"` — `thrown` (the body threw; `functionId`, and `direct: true` for an in-process call during SSR) or `channel` (a rejection or throw escaping through the result graph, the head already committed). `kind: "request"` is always `failed`: a failure that fails the request outside any render or server function the runtime reports (a middleware throw, a setup step), reported by the framework's request handler through `reportRequestFailure(error, event)`. A render failure, synchronous throws included, is reported as `render`, so a handler that catches one and passes it to `reportRequestFailure` adds nothing. It has no per-request tier, so only the ambient hook hears it. `event` is the request, when the failure happened inside one, and always for `kind: "request"`. - **The return is the wire value**: rendered into the fallback, serialized for hydration, sent as the RPC error. `undefined` leaves the default policy in place (generic outside dev, fidelity in dev). A returned value is the author's intent — like a `wrapInvocation` mapping — and is not sanitized again; `markSafeError` is not needed on it. Ignored for `failed` and `serialize`, which have no wire. One road a mapping does not reach: a rejected async source's serialized rejection is encoded the moment the source rejects, ahead of the boundary, and carries the default policy's value; the hydrating client renders from the boundary's record, which carries the mapping. -- **Once means once across roads.** A direct server-function call that throws during SSR is met first by the invocation (`kind: "server-function"`, `direct: true`) and then by the `` that contains it; the boundary reuses the verdict — the same replacement in the fallback and the record — and does not report again. A `` re-pull recurring the same throw is the same object, the same verdict. The observe tier keeps the multi-event picture (an `"invocation"` record _and_ an `SSR_RENDER_ERROR_CONTAINED` finding) for consumers that want it. +- **Once means once across roads.** A direct server-function call that throws during SSR is met first by the invocation (`kind: "server-function"`, `direct: true`) and then by the `` that contains it; the boundary reports the `render` kind and reuses the first mapped wire value in the fallback and the record. Repeated sightings of the same kind within that request do not report again. A `` re-pull recurring the same throw is the same object, the same verdict. The observe tier keeps the multi-event picture (an `"invocation"` record _and_ an `SSR_RENDER_ERROR_CONTAINED` finding) for consumers that want it. - **Two tiers, as `wrapInvocation` has.** Ambient: `configureServerErrors({ onError })`, once per process, registered on `globalThis` under a registered symbol so a bundled server build and an instrumented `--import`ed module share it — and the only tier that sees direct in-process calls. Per request, overriding it: `onError` on `renderToStream`/`renderToString`, and on `handleServerFunctionRequest` for the call it dispatches. A throwing hook is reported on the console and treated as silent. With no hook anywhere, a failure that fails the request goes to `console.error` — never silent. A synchronous render throw is the exception: it is rethrown to the caller, whose own handling (or `reportRequestFailure`) decides. - **One listener, every failure.** A `renderToStream` `onError` written for the old single-argument shape keeps working; it now hears every handled failure, not only the one that fails the request — filter on `context.handling === "failed"` for the old behaviour. The hook fires in every tier, dev included; only the _default_ mapping differs by tier. - **The client twin** is `configureClientErrors` / `render`'s `onError` ([RFC 03](03-control-flow.md#reporting-what-a-boundary-caught-the-client-error-hook)): a boundary's fallback, no wire to map for. An uncaught client error halts and reaches `reportError`. diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 4b62b2738a..32a06475b2 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -389,6 +389,7 @@ function ssrLoadingBoundary( // (its own finding names it; the "client re-renders" one would be wrong). let outcomeRendered = false; function failFragment(err: any, report: boolean): boolean { + ctx.recordRenderError?.(); const outcome = errorOutcome(err); outcomeRendered = !!outcome && outcome.value !== undefined && !outcome.escaped; if (report && !outcomeRendered) { diff --git a/packages/solid/src/server/shared.ts b/packages/solid/src/server/shared.ts index 91d38ab335..e2fcf00ccd 100644 --- a/packages/solid/src/server/shared.ts +++ b/packages/solid/src/server/shared.ts @@ -118,6 +118,8 @@ export type HydrationContext = { * another request's context by the time an async failure lands. */ errorPolicy?: (error: unknown, context: any) => unknown | void; + /** @internal Records render integrity independently of error mapping and diagnostic deduplication. */ + recordRenderError?: () => void; /** * @internal The scope the server error hook's once-per-error ledger keys * on for this render (see `reportServerError`): the request event it diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 52f7ca0bad..71a2228dbb 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -3989,6 +3989,7 @@ export function createErrorBoundary( // mismatch). See `ssrSanitizeError`. const handleError = (err: any) => { if (fallbackSettled) return fallbackResult; + ctx?.recordRenderError?.(); reportContained(err); const wire = ssrSanitizeError( err, diff --git a/packages/web/serialization/src/serializer.ts b/packages/web/serialization/src/serializer.ts index 229bb09e7f..3380911a89 100644 --- a/packages/web/serialization/src/serializer.ts +++ b/packages/web/serialization/src/serializer.ts @@ -3,6 +3,7 @@ import { Feature, Serializer, + SerovalParserError, getCrossReferenceHeader, toCrossJSONStream, createPlugin as createPluginImpl, @@ -302,7 +303,12 @@ export function createJSONSerializer({ const cancels = new Set(); let pendingWrites = 0; let flushed = false; + let closed = false; let done = false; + // Refs below this count reached onData. Seroval numbers refs by the map's + // size, so the refs a failed parse added past it are ids no record defines: + // dropping them keeps a later write from pointing at them. + let delivered = 0; const maybeDone = () => { if (flushed && pendingWrites === 0 && !done) { done = true; @@ -315,35 +321,70 @@ export function createJSONSerializer({ pendingWrites++; // Sync values settle inside the toCrossJSONStream call, before the // cancel function exists — track with a flag instead of the handle. + // Idempotent: seroval still pops a rejected promise it parsed before + // the write ended, which calls onDone a second time. let settled = false; + let parsed = false; + let starting = true; + let failure = null; let cancel = null; - const stream = toCrossJSONStream(value, { - refs, - plugins: resolved.plugins, - disabledFeatures: - resolved.disabledFeatures | serializeOnlyDisabledFeatures(resolved.serializeErrorStacks), - onParse(node, initial) { - onData({ key, node, initial }); - }, - onError, - onDone() { - settled = true; - if (cancel) cancels.delete(cancel); - pendingWrites--; - maybeDone(); - } - }); - if (!settled) { - cancel = stream; - cancels.add(cancel); + const settle = () => { + if (settled) return; + settled = true; + if (cancel) cancels.delete(cancel); + pendingWrites--; + maybeDone(); + }; + let stream; + try { + stream = toCrossJSONStream(value, { + refs, + plugins: resolved.plugins, + disabledFeatures: + resolved.disabledFeatures | + serializeOnlyDisabledFeatures(resolved.serializeErrorStacks), + onParse(node, initial) { + if (closed || failure) return; + if (initial) parsed = true; + delivered = refs.size; + onData({ key, node, initial }); + }, + onError(err) { + if (refs.size > delivered) + for (const [ref, id] of refs) if (id >= delivered) refs.delete(ref); + if (onError) return onError(err); + // Without onError seroval throws, leaving the parse it started + // with no handle to stop it: hold the error until the write ends it. + const error = err instanceof SerovalParserError ? err : new SerovalParserError(err); + if (!starting) throw error; + failure = error; + }, + onDone: settle + }); + } catch (err) { + // An onError that throws leaves seroval's write unsettled. + settle(); + throw err; } + starting = false; + if (failure) { + stream(); + throw failure; + } + if (settled) return; + // A failed parse reported through onError delivers no initial node and + // never settles either: end the write, releasing what it already parsed. + // A close() from the write's first record ends it the same way. + if (!parsed || closed) return stream(); + cancel = stream; + cancels.add(cancel); }, flush() { flushed = true; maybeDone(); }, close() { - flushed = true; + flushed = closed = true; for (const cancel of cancels) cancel(); cancels.clear(); } diff --git a/packages/web/src/server-mock.ts b/packages/web/src/server-mock.ts index 45a1999a3e..4310891bf1 100644 --- a/packages/web/src/server-mock.ts +++ b/packages/web/src/server-mock.ts @@ -246,6 +246,8 @@ export function renderToStream( signal?: AbortSignal; } ): { + /** Render-local error state. Read after completion; false does not imply completion. */ + readonly hasErrors: boolean; /** * Awaiting the stream resolves with the complete HTML once every boundary * settles — the fully-settled-string form of the render. Render errors diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index f70a448da0..064b73c15a 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -1981,6 +1981,18 @@ export function renderToStream( signal?: AbortSignal; } ): { + /** + * Whether this render observed an error, including an Errored fallback, + * a rejected Loading fragment, or a reported serialization failure. Monotonic + * and local to this render, independent of onError's once-per-error reporting. + * Failures of the default serializer terminate the render and are recorded + * regardless of whether an error hook is installed. Custom serializers must + * throw synchronously or report through their supplied onError callback; + * errors they suppress internally are not observable here. + * Read after completion when deciding whether to publish the whole HTML. + * False does not imply completion: an aborted render may have no errors. + */ + readonly hasErrors: boolean; /** * Awaiting the stream resolves with the complete HTML once every boundary * settles — the fully-settled-string form of the render (`const html = @@ -2032,6 +2044,11 @@ export function renderToStream(code, options = {}) { const errorScope = requestEvent || {}; let dispose; let dead = false; + let hasErrors = false; + let hasSerializationError = false; + const recordRenderError = () => { + hasErrors = true; + }; // The render's `"render"` record (`timeDocument`, once the context is up): // its shell stamped at `doShell`, settled at `onDone` or by the wind-down. let render; @@ -2041,6 +2058,15 @@ export function renderToStream(code, options = {}) { // render starts, by which point it is assigned; the hoist keeps that from // being a temporal-dead-zone assumption. let serializer; + const writeSerialized = (id, value) => { + if (hasSerializationError) return defuse(value); + try { + return serializer.write(id, value); + } catch (err) { + recordRenderError(); + throw err; + } + }; // Client-disconnect teardown. A sink that throws from `write`/`end` (its // transport is gone) or a consumer cancelling the readable view means // nobody is listening anymore: stop touching the sink, mark the render @@ -2127,27 +2153,11 @@ export function renderToStream(code, options = {}) { const sink = writable; buffer = { write() {} }; writable = { end() {} }; - // Contained (#4029): an `onCleanup` that throws here must not escape the - // teardown. On the `signal` path this runs inside an `abort` listener, - // where a throw becomes an uncaught exception that takes the process down; - // on the `failRender` path it would cut the wind-down short. It is the - // render's failure, so it goes to the hook as one (`handling: "failed"`), - // and with no hook anywhere it is never silent. + // The render's dispose contains a throwing `onCleanup` (#4029). if (dispose) { const d = dispose; dispose = () => {}; - try { - d(); - } catch (err) { - reportServerError( - err, - { kind: "render", handling: "failed" }, - null, - options.onError, - errorScope - ); - if (!options.onError && ambientServerErrorHook() === undefined) console.error(err); - } + d(); } // Close the serializer AFTER `dead` is set: seroval's `close()` runs // each pending write's cleanup — a serialized async iterator gets its @@ -2166,6 +2176,7 @@ export function renderToStream(code, options = {}) { try { serializer.close(); } catch (err) { + recordRenderError(); reportServerError( err, { kind: "render", handling: "serialize" }, @@ -2202,6 +2213,7 @@ export function renderToStream(code, options = {}) { // Exposed to the reactive library's boundary resume loop as // `context.failRender` (see the ssrLoadingBoundary finalizeError path). const failRender = err => { + recordRenderError(); // The server error hook is the one channel (`handling: "failed"`); a // boundary that already reported this error with its location leaves // this a no-op (once per error). With no hook anywhere the failure is @@ -2315,12 +2327,12 @@ export function renderToStream(code, options = {}) { stubBatch = null; if (batch.size === 1) { const [id, p] = batch.entries().next().value; - serializer.write(id, p); + writeSerialized(id, p); return; } const obj = {}; for (const [id, p] of batch) obj[id] = p; - serializer.write(STUB_BATCH_KEY, obj); + writeSerialized(STUB_BATCH_KEY, obj); pushTask( `(b=>{for(var k in b)_$HY.r[k]=b[k];delete _$HY.r["${STUB_BATCH_KEY}"]})(_$HY.r["${STUB_BATCH_KEY}"])` ); @@ -2346,8 +2358,9 @@ export function renderToStream(code, options = {}) { // trace `doShell` reads (`traceMetaMarkup(context.trace)`), and the // consumer was completed by the wind-down (`abandon`) — a disconnect has // nobody to complete. Serialized promises still settle after the - // wind-down, so seroval still reaches here; do nothing (#3569). - if (dead) return; + // wind-down, so seroval still reaches here; do nothing (#3569). A custom + // serializer that calls onDone again finds the render already complete. + if (dead || hasSerializationError || completed) return; writeTasks(); // Every blocker has settled by definition here (the render is complete), // so doShell's growth gate has nothing to wait for: align its baseline @@ -2474,16 +2487,39 @@ export function renderToStream(code, options = {}) { // flows through onData is a contract between the serializer and the sink // (hydration scripts for the document sink, keyed codec records for the // frame sink); the core never inspects it. - serializer = (options.serializer || createHydrationSerializer)({ + const customSerializer = options.serializer; + const observeSerializerError = hook => { + if (customSerializer) { + const onError = serializerErrorHook(hook, errorScope); + return onError === undefined + ? undefined + : err => { + recordRenderError(); + onError(err); + }; + } + return err => { + if (dead || hasSerializationError) return; + hasSerializationError = true; + recordRenderError(); + // A synchronous write registers its cleanup only after this callback returns. + queue(abandon); + reportServerError(err, { kind: "render", handling: "serialize" }, null, hook, errorScope); + if (hook === undefined && ambientServerErrorHook() === undefined) console.error(err); + }; + }; + serializer = (customSerializer || createHydrationSerializer)({ scopeId: options.renderId, // Containers (projections) serialize as traces on BOTH faces — the // document's hydration serializer and the frame sink's codec resolve // their plugin sets through the codec defaults, which carry the trace // plugin (inert until the reactive core installs its resolver). plugins: options.plugins, - onData: payload => sink.data(payload), + onData: payload => { + if (!hasSerializationError) sink.data(payload); + }, onDone, - onError: serializerErrorHook(options.onError) + onError: observeSerializerError(options.onError) }); let rootAssetsSerialized = false; const serializeRootAssets = () => { @@ -2868,7 +2904,7 @@ export function renderToStream(code, options = {}) { if (p && typeof p === "object" && typeof p.then === "function") { if (!firstFlushed && deferStream) { blockingPromises.add(p); - p.then(d => serializer.write(id, d)).catch(e => serializer.write(id, e)); + p.then(d => writeSerialized(id, d)).catch(e => writeSerialized(id, e)); return; } // Every pending promise handed to seroval joins the abandonment @@ -2885,7 +2921,7 @@ export function renderToStream(code, options = {}) { return; } } - serializer.write(id, p); + writeSerialized(id, p); }, escape: escape, resolve: resolveSSRNode, @@ -2941,7 +2977,7 @@ export function renderToStream(code, options = {}) { }); if (canBatchStubs && !shellCompleted && !dead) (stubBatch ||= new Map()).set(key + "_fr", p); - else serializer.write(key + "_fr", p); + else writeSerialized(key + "_fr", p); } return (value, error, escaped) => { if (registry.has(key)) { @@ -3057,6 +3093,7 @@ export function renderToStream(code, options = {}) { // post-flush a fragment rejects to the client; pre-flush it inlines or // fails the request. context.errorPolicy = options.onError; + context.recordRenderError = recordRenderError; context._errorScope = errorScope; context.flushed = () => firstFlushed; // The trace this render belongs to (see `getTraceContext`): the request's @@ -3086,7 +3123,26 @@ export function renderToStream(code, options = {}) { // The render is over: no later read finds its trace (see above). context.trace = undefined; if (onAbort) signal.removeEventListener("abort", onAbort); - d(); + // Contained (#4029): an `onCleanup` that throws must not escape the + // teardown. Inside an `abort` listener the throw becomes an uncaught + // exception that takes the process down; in the wind-down it cuts it + // short; at completion the consumer is never completed and the throw + // surfaces as an unhandled rejection. It is the render's failure, so + // it goes to the hook as one (`handling: "failed"`), and with no hook + // anywhere it is never silent. + try { + d(); + } catch (err) { + recordRenderError(); + reportServerError( + err, + { kind: "render", handling: "failed" }, + null, + options.onError, + errorScope + ); + if (!options.onError && ambientServerErrorHook() === undefined) console.error(err); + } }; rootOwner = claimRenderRoot(context); if (requestEvent) claimEventRender(requestEvent, context); @@ -3096,6 +3152,7 @@ export function renderToStream(code, options = {}) { } catch (err) { // The first pass throws out of `renderToStream` to the caller: the // hook hears it first, as for `renderToString`. + recordRenderError(); reportServerError( err, { kind: "render", handling: "failed" }, @@ -3197,6 +3254,9 @@ export function renderToStream(code, options = {}) { // root-hole/head gates above so this runs exactly once, on the attempt // that actually flushes). flushStubBatch(); + // A serialization failure in this attempt already queued the wind-down: + // the consumer completes as a failed render, with no shell. + if (hasSerializationError) return; // Shell head flush: commits every registration not owned by a // still-pending fragment (those flush with their fragment later). const head = renderShellHead( @@ -3304,11 +3364,12 @@ export function renderToStream(code, options = {}) { // settles and the writable — alive; the RENDER died — is closed so a // `Response` built on `readable` completes instead of hanging. Post- // shell the live wrapper ends (flushing what it coalesced, then the - // writer's own close path); pre-shell nothing was written yet. + // writer's own close path, which settles the promise once the writes + // drain); pre-shell nothing was written yet. onFailure(sink => { try { - if (sink) sink.end(); - else w.close().catch(() => {}); + if (sink) return sink.end(); + w.close().catch(() => {}); } catch (_) {} resolve(); }); @@ -3382,6 +3443,9 @@ export function renderToStream(code, options = {}) { return p; }; return { + get hasErrors() { + return hasErrors; + }, // Proper thenable: `await renderToStream(...)` resolves with the full // HTML once every boundary settles — the replacement for the removed // renderToStringAsync. Render errors route through `onError` (the @@ -6073,8 +6137,11 @@ export const RequestContext: unique symbol = Symbol.for("solid.RequestContext") * subtree; `failed`: nothing contained it and the request fails — including * a synchronous throw out of `renderToString` or `renderToStream`'s first * pass, reported before it reaches the caller; - * `serialize`: a hydration value would not serialize and the render went on - * without it (a render that passed `onError`). `kind: "server-function"` — `thrown`: + * `serialize`: a hydration value would not serialize; `renderToStream`'s + * default serializer ends the render, with or without a render hook, while + * under `renderToString` or a custom serializer it is reported for a render + * that passed `onError` and the render goes on without the value. + * `kind: "server-function"` — `thrown`: * the body threw; `channel`: a rejection or throw escaping through the * result graph (a promise, an iterable, a stream) with the head already * committed. `kind: "request"` (always `failed`): a failure that fails the @@ -6169,14 +6236,13 @@ export function reportRequestFailure(error: unknown, event: RequestEvent): void; * value that would not serialize here instead of throwing at the write * (that difference is why this is only wired when a hook was given — as * `onError` always was). The hook hears it as `handling: "serialize"`, - * deduped within the request in scope (what seroval reports is, as a rule, - * its own error, fresh per failure — nothing render-wide to dedupe against). + * deduped within the owning request or render, including async callbacks. */ -function serializerErrorHook(hook) { +function serializerErrorHook(hook, scope) { return hook === undefined ? undefined : err => { - reportServerError(err, { kind: "render", handling: "serialize" }, null, hook); + reportServerError(err, { kind: "render", handling: "serialize" }, null, hook, scope); }; } diff --git a/packages/web/test/server/render-error-state-artifact.spec.tsx b/packages/web/test/server/render-error-state-artifact.spec.tsx new file mode 100644 index 0000000000..2e792ac744 --- /dev/null +++ b/packages/web/test/server/render-error-state-artifact.spec.tsx @@ -0,0 +1,148 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +const cwd = fileURLToPath(new URL("../..", import.meta.url)); +const probe = ` +import assert from 'node:assert/strict'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { createComponent, createMemo, Errored, Loading } from 'solid-js'; +import { configureServerErrors, createRequestEvent, renderToStream, ssr, isDev, RequestContext } from '@solidjs/web'; +import { createServerReference, registerServerReference } from '@solidjs/web/server-functions/server'; +const storage = new AsyncLocalStorage(); +globalThis[RequestContext] = storage; +console.error = () => {}; +const calls = []; +configureServerErrors({onError(error, context) { + calls.push(context.kind); + return new Error('public failure'); +}}); +function page(run) { + return ssr(['
', '
'], createComponent(Loading, { + fallback: 'pending', + get children() { + const value = createMemo(run); + return ssr(['

', '

'], createMemo(() => value())); + } + })); +} +async function render(run) { + const event = createRequestEvent(new Request('https://example.test')); + const stream = storage.run(event, () => renderToStream(() => page(run))); + const html = await stream; + return { html, hasErrors: stream.hasErrors }; +} +const error = new Error('PRIVATE'); +const first = await render(() => Promise.reject(error)); +const second = await render(() => Promise.reject(error)); +assert.equal(first.hasErrors, true); +assert.equal(second.hasErrors, true); +assert.deepEqual(calls, ['render', 'render']); +if (!isDev) assert.ok(!first.html.includes('PRIVATE')); +if (!isDev) assert.ok(!second.html.includes('PRIVATE')); +const load = createServerReference(registerServerReference('artifact/state', () => Promise.reject(new Error('PRIVATE_function')))); +const escaped = await render(() => load()); +assert.equal(escaped.hasErrors, true); +assert.ok(!isDev ? !escaped.html.includes('PRIVATE_function') : true); +const recovered = await render(() => load().catch(() => 'recovered')); +assert.equal(recovered.hasErrors, false); +assert.ok(recovered.html.includes('recovered')); +for (const failure of [undefined, null, false, 0, '']) { + const stream = renderToStream(() => createComponent(Errored, { + fallback: 'fallback', get children() { throw failure; } + })); + await stream; + assert.equal(stream.hasErrors, true); +} +process.stdout.write(JSON.stringify({isDev, passed: true})); +`; + +const serializationProbe = ` +import assert from 'node:assert/strict'; +import { configureServerErrors, renderToStream } from '@solidjs/web'; +import { createPlugin } from '@solidjs/web/serialization'; +import { sharedConfig } from 'solid-js/internal'; +let failure; +let kind; +let logs = 0; +console.error = () => logs++; +class Value {} +function parse() { + if (kind === 'parse') throw failure; + return {}; +} +const plugin = createPlugin({ + tag: 'test/render-error-state', + test: value => value instanceof Value, + parse: { sync: parse, stream: parse }, + serialize() { throw failure; }, + deserialize: () => new Value() +}); +for (kind of ['parse', 'serialize', 'unsupported']) { + for (const mode of ['async', 'sync', 'defer']) { + for (const hook of ['none', 'local', 'global', 'both']) { + logs = 0; + failure = new Error('plugin serialization failure'); + const calls = []; + const onError = (error, context) => { calls.push(['local', error, context.handling]); return new Error('public failure'); }; + const globalError = (error, context) => { calls.push(['global', error, context.handling]); return new Error('public failure'); }; + configureServerErrors(hook === 'global' || hook === 'both' ? { onError: globalError } : {}); + const abort = new AbortController(); + let completed = false; + const value = kind === 'unsupported' ? new WeakMap() : new Value(); + const stream = renderToStream(() => { + sharedConfig.context.serialize('value', mode === 'sync' ? value : Promise.resolve(value), mode === 'defer'); + return '

shell

'; + }, { plugins: [plugin], signal: abort.signal, onCompleteAll() { completed = true; }, ...(hook === 'local' || hook === 'both' ? { onError } : {}) }); + let timer; + try { + const html = await Promise.race([ + Promise.resolve(stream), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('render did not complete')), 1000); }) + ]); + assert.equal(typeof html, 'string'); + assert.ok(!html.includes(failure.message)); + assert.ok(!html.includes('Seroval Error')); + assert.equal(stream.hasErrors, true); + assert.equal(completed, false); + assert.equal(calls.length, hook === 'none' ? 0 : 1); + assert.equal(logs, hook === 'none' ? 1 : 0); + if (calls.length) { + assert.equal(calls[0][0], hook === 'global' ? 'global' : 'local'); + assert.equal(calls[0][2], 'serialize'); + if (kind !== 'unsupported') assert.equal(calls[0][1], failure); + } + } finally { + clearTimeout(timer); + abort.abort(); + } + } + } +} +process.stdout.write('passed'); +`; + +describe("render error state in published artifacts", () => { + test.each(["production", "development", "observe"])("%s", condition => { + const args = condition === "production" ? [] : [`--conditions=${condition}`]; + const output = execFileSync(process.execPath, [...args, "--input-type=module", "-e", probe], { + cwd, + encoding: "utf8", + timeout: 10_000 + }); + expect(JSON.parse(output)).toEqual({ isDev: condition === "development", passed: true }); + }); + + test.each(["production", "development", "observe"])( + "terminates default serializer failures in %s", + condition => { + const args = condition === "production" ? [] : [`--conditions=${condition}`]; + const output = execFileSync( + process.execPath, + [...args, "--input-type=module", "-e", serializationProbe], + { cwd, encoding: "utf8", timeout: 10_000 } + ); + expect(output).toBe("passed"); + } + ); +}); diff --git a/packages/web/test/server/render-error-state.spec.tsx b/packages/web/test/server/render-error-state.spec.tsx new file mode 100644 index 0000000000..15cad8de2d --- /dev/null +++ b/packages/web/test/server/render-error-state.spec.tsx @@ -0,0 +1,833 @@ +/** @jsxImportSource @solidjs/web */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { + Errored, + Loading, + configureServerErrors, + createRequestEvent, + createSSRResponse, + redirect, + respond, + renderToStream, + type JSX, + type RequestEvent, + type ServerErrorHook +} from "@solidjs/web"; +import { SerovalParserError } from "seroval"; +import { NotReadyError, createMemo, onCleanup } from "solid-js"; +import { sharedConfig } from "solid-js/internal"; +import { createPlugin, type HydrationSerializerOptions } from "@solidjs/web/serialization"; +import { + createServerReference, + registerServerReference +} from "@solidjs/web/server-functions/server"; +import { renderServerComponent } from "../../frames/src/frame-sink.js"; +import { createJSONDataTable, createJSONSerializer } from "../../serialization/src/serializer.js"; + +const RequestContext = Symbol.for("solid.RequestContext"); +const storage = new AsyncLocalStorage(); +let previousStorage: unknown; +beforeAll(() => { + previousStorage = (globalThis as any)[RequestContext]; + (globalThis as any)[RequestContext] = storage; +}); +afterAll(() => { + (globalThis as any)[RequestContext] = previousStorage; +}); +beforeEach(() => vi.spyOn(console, "error").mockImplementation(() => {})); +afterEach(() => { + configureServerErrors({}); + vi.restoreAllMocks(); +}); + +function underRequest(run: () => T): T { + return storage.run(createRequestEvent(new Request("https://example.test/page")), run); +} +type PendingProps = { value: Promise }; +type BadProps = { error: unknown }; + +function Pending(props: PendingProps) { + const value = createMemo(() => props.value); + return

{value()}

; +} +function deferredRender() { + const gate = Promise.withResolvers(); + const stream = underRequest(() => + renderToStream(() => ( +
+ pending}> + + +
+ )) + ); + const html = Promise.resolve(stream); + return { gate, stream, html }; +} +function Bad(props: BadProps): JSX.Element { + throw props.error; +} + +function failedFunction(id: string) { + return createServerReference( + registerServerReference(id, () => Promise.reject(new Error("private backend"))) + ); +} + +describe("renderToStream.hasErrors", () => { + test("is a readonly getter and remains false for a successful async render", async () => { + const { gate, stream, html } = deferredRender(); + expect(stream.hasErrors).toBe(false); + expect(Object.getOwnPropertyDescriptor(stream, "hasErrors")?.set).toBeUndefined(); + gate.resolve("ready"); + expect(await html).toContain("ready"); + expect(stream.hasErrors).toBe(false); + }); + + test("tracks interleaved requests and maps a reused error once per request", async () => { + const mapped = vi.fn(() => new Error("public failure")); + configureServerErrors({ onError: mapped }); + const error = new Error("private failure"); + const first = deferredRender(); + const healthy = deferredRender(); + first.gate.reject(error); + await first.html; + const second = deferredRender(); + second.gate.reject(error); + await second.html; + healthy.gate.resolve("healthy"); + await healthy.html; + expect([first.stream.hasErrors, second.stream.hasErrors, healthy.stream.hasErrors]).toEqual([ + true, + true, + false + ]); + expect(mapped).toHaveBeenCalledTimes(2); + }); + + test.each([new Error("private"), undefined, null, false, 0, ""])( + "marks an Errored fallback even for %s", + async error => { + const stream = underRequest(() => + renderToStream(() => ( + fallback

}> + +
+ )) + ); + expect(await stream).toContain("fallback"); + expect(stream.hasErrors).toBe(true); + } + ); + + test.each([undefined, null, false, 0, ""])( + "marks a fragment rejected with a falsy value (%s)", + async error => { + const { gate, stream, html } = deferredRender(); + gate.reject(error); + await html; + expect(stream.hasErrors).toBe(true); + } + ); + + test("keeps separate renders in the same request independent", async () => { + const [failed, healthy] = underRequest(() => [ + renderToStream(() => ( + fallback

}> + +
+ )), + renderToStream(() =>

healthy

) + ]); + await Promise.all([failed, healthy]); + expect([failed.hasErrors, healthy.hasErrors]).toEqual([true, false]); + }); + + test("records each render even when the same request already reported its error", async () => { + const onError = vi.fn(() => new Error("public failure")); + configureServerErrors({ onError }); + const error = new Error("private failure"); + const streams = underRequest(() => + [0, 1].map(() => + renderToStream(() => ( + fallback

}> + +
+ )) + ) + ); + await Promise.all(streams); + expect(streams.map(stream => stream.hasErrors)).toEqual([true, true]); + expect(onError).toHaveBeenCalledTimes(1); + }); + + test("records an asynchronous Errored fallback", async () => { + const gate = Promise.withResolvers(); + const stream = renderToStream(() => ( + fallback

}> + pending}> + + +
+ )); + const html = Promise.resolve(stream); + gate.reject(new Error("private async")); + await html; + expect(stream.hasErrors).toBe(true); + }); + + test("marks a server function failure that was already reported before Loading receives it", async () => { + const onError = vi.fn(() => new Error("public failure")); + configureServerErrors({ onError }); + const load = failedFunction("render-state/escaped"); + const stream = underRequest(() => + renderToStream(() => ( + pending}> + + + )) + ); + await stream; + expect(stream.hasErrors).toBe(true); + expect(onError).toHaveBeenCalledTimes(2); + expect(onError.mock.calls[0]?.[1]).toMatchObject({ kind: "server-function" }); + expect(onError.mock.calls[1]?.[1]).toMatchObject({ kind: "render" }); + }); + + test("does not mark a server function failure caught by the application", async () => { + configureServerErrors({ onError: () => new Error("public failure") }); + const load = failedFunction("render-state/caught"); + const stream = underRequest(() => + renderToStream(() => ( + pending}> + "recovered")} /> + + )) + ); + expect(await stream).toContain("recovered"); + expect(stream.hasErrors).toBe(false); + }); + + test("marks asynchronous serialization loss with the existing local hook policy", async () => { + const onError = vi.fn(() => undefined); + const stream = renderToStream( + () => { + sharedConfig.context!.serialize("unsupported", Promise.resolve(new WeakMap())); + return

shell

; + }, + { onError } + ); + await stream; + expect(stream.hasErrors).toBe(true); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[1]).toMatchObject({ handling: "serialize" }); + }); + + test.each([false, true])( + "terminates a synchronous parse failure without a local hook (global: %s)", + async hasGlobalHook => { + const onError = vi.fn(() => undefined); + const onCompleteAll = vi.fn(); + if (hasGlobalHook) configureServerErrors({ onError }); + const abort = new AbortController(); + let context!: NonNullable; + const stream = renderToStream( + () => { + context = sharedConfig.context!; + return

shell

; + }, + { signal: abort.signal, onCompleteAll } + ); + try { + expect(() => context.serialize("unsupported", new WeakMap())).not.toThrow(); + await stream; + expect(stream.hasErrors).toBe(true); + expect(onCompleteAll).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(hasGlobalHook ? 1 : 0); + expect(console.error).toHaveBeenCalledTimes(hasGlobalHook ? 0 : 1); + } finally { + abort.abort(); + } + } + ); + + test.each([new Error("private parse"), undefined, null, false, 0, ""])( + "cleans up pending sources after a parser throws %s", + async error => { + const token = {}; + const cleanup = vi.fn(); + const returned = vi.fn(async () => ({ done: true as const, value: undefined })); + const source = { + [Symbol.asyncIterator]() { + return { + next: () => new Promise>(() => {}), + return: returned + }; + } + }; + const plugin = createPlugin({ + tag: "test/failing-parse-cleanup", + test: value => value === token, + parse: { + stream(_value, context) { + context.parse(source); + throw error; + } + }, + serialize: () => "({})", + deserialize: () => token + }); + const onCompleteAll = vi.fn(); + const onError = vi.fn(() => new Error("public")); + configureServerErrors({ onError }); + const stream = renderToStream( + () => { + onCleanup(cleanup); + sharedConfig.context!.serialize("bad", token); + return

shell

; + }, + { plugins: [plugin], onCompleteAll } + ); + await stream; + await Promise.resolve(); + expect(stream.hasErrors).toBe(true); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0]).toBe(error); + expect(onError.mock.calls[0]?.[1]).toMatchObject({ handling: "serialize" }); + expect(onCompleteAll).not.toHaveBeenCalled(); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(returned).toHaveBeenCalledTimes(1); + } + ); + + test.each(["pipe", "pipeTo", "readable"] as const)( + "ends %s and disposes pending siblings after a post-shell serialization failure", + async consumer => { + const gate = Promise.withResolvers>(); + const shell = Promise.withResolvers(); + const ended = Promise.withResolvers(); + const cleanup = vi.fn(); + const returned = vi.fn(async () => ({ done: true as const, value: undefined })); + const source = { + [Symbol.asyncIterator]() { + return { + next: () => new Promise>(() => {}), + return: returned + }; + } + }; + const chunks: string[] = []; + const onCompleteAll = vi.fn(); + const onError = vi.fn(() => new Error("public")); + configureServerErrors({ onError }); + const stream = renderToStream( + () => { + onCleanup(cleanup); + sharedConfig.context!.serialize("sibling", source); + sharedConfig.context!.serialize("bad", gate.promise); + return

shell

; + }, + { onCompleteAll, onCompleteShell: () => shell.resolve() } + ); + let result: Promise; + if (consumer === "pipe") { + stream.pipe({ write: chunk => chunks.push(chunk), end: ended.resolve }); + result = ended.promise; + } else if (consumer === "pipeTo") { + result = stream + .pipeTo( + new WritableStream({ + write: chunk => { + chunks.push(new TextDecoder().decode(chunk)); + }, + close: ended.resolve + }) + ) + .then(() => ended.promise); + } else { + result = new Response(stream.readable).text().then(html => chunks.push(html)); + } + await shell.promise; + expect(stream.hasErrors).toBe(false); + gate.resolve(new WeakMap()); + await result; + await Promise.resolve(); + expect(chunks.join("")).toContain("shell"); + expect(chunks.join("")).not.toContain("Seroval Error"); + expect(stream.hasErrors).toBe(true); + expect(onCompleteAll).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(returned).toHaveBeenCalledTimes(1); + const healthy = renderToStream(() =>

healthy

); + expect(await healthy).toContain("healthy"); + expect(healthy.hasErrors).toBe(false); + } + ); + + test.each(["response", "pipe", "pipeTo", "readable"] as const)( + "keeps a shell that met a serialization failure from reaching %s", + async consumer => { + let first = true; + const hole = () => { + if (first) { + first = false; + throw new NotReadyError(Promise.resolve()); + } + sharedConfig.context!.serialize("bad", new WeakMap()); + return "after"; + }; + const onError = vi.fn(() => undefined); + const onCompleteShell = vi.fn(); + configureServerErrors({ onError }); + const stream = renderToStream(() =>
{hole as unknown as JSX.Element}
, { + onCompleteShell + }); + let body: string; + if (consumer === "response") { + const response = await createSSRResponse(stream, undefined); + expect(response.status).toBe(500); + body = await response.text(); + } else if (consumer === "pipe") { + const chunks: string[] = []; + await new Promise(end => stream.pipe({ write: chunk => chunks.push(chunk), end })); + body = chunks.join(""); + } else if (consumer === "pipeTo") { + const chunks: string[] = []; + await stream.pipeTo( + new WritableStream({ + write: chunk => { + chunks.push(new TextDecoder().decode(chunk)); + } + }) + ); + body = chunks.join(""); + } else body = await new Response(stream.readable).text(); + expect(body).toBe(""); + expect(onCompleteShell).not.toHaveBeenCalled(); + expect(stream.hasErrors).toBe(true); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[1]).toMatchObject({ handling: "serialize" }); + } + ); + + test("resolves pipeTo only after a slow consumer drains a failed render", async () => { + const gate = Promise.withResolvers>(); + const events: string[] = []; + configureServerErrors({ onError: () => undefined }); + const stream = renderToStream(() => { + sharedConfig.context!.serialize("bad", gate.promise); + return

shell

; + }); + await stream.pipeTo( + new WritableStream({ + async write() { + events.push("write"); + gate.resolve(new WeakMap()); + await new Promise(resolve => setTimeout(resolve, 20)); + events.push("written"); + }, + close() { + events.push("close"); + } + }) + ); + events.push("resolved"); + expect(events).toEqual(["write", "written", "close", "resolved"]); + expect(stream.hasErrors).toBe(true); + }); + + test.each(["await", "pipe", "pipeTo", "readable"] as const)( + "contains a cleanup that throws as a healthy render completes (%s)", + async consumer => { + const onError = vi.fn(() => undefined); + configureServerErrors({ onError }); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + try { + const stream = renderToStream(() => { + onCleanup(() => { + throw new Error("cleanup"); + }); + return

healthy

; + }); + let html: Promise; + if (consumer === "await") html = Promise.resolve(stream); + else if (consumer === "pipe") { + const chunks: string[] = []; + html = new Promise(resolve => + stream.pipe({ write: chunk => chunks.push(chunk), end: () => resolve(chunks.join("")) }) + ); + } else if (consumer === "pipeTo") { + const chunks: string[] = []; + html = stream + .pipeTo( + new WritableStream({ + write: chunk => { + chunks.push(new TextDecoder().decode(chunk)); + } + }) + ) + .then(() => chunks.join("")); + } else html = new Response(stream.readable).text(); + const timeout = new Promise<"timeout">(resolve => + setTimeout(() => resolve("timeout"), 1000) + ); + expect(await Promise.race([html, timeout])).toContain("healthy"); + await new Promise(resolve => setTimeout(resolve)); + expect(unhandled).not.toHaveBeenCalled(); + expect(stream.hasErrors).toBe(true); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[1]).toMatchObject({ handling: "failed" }); + } finally { + process.off("unhandledRejection", unhandled); + } + } + ); + + test("completes once when a custom serializer calls onDone twice", async () => { + const onCompleteAll = vi.fn(); + let ends = 0; + const options = { + onCompleteAll, + serializer: ({ onDone }: HydrationSerializerOptions) => ({ + write() {}, + flush() { + onDone?.(); + onDone?.(); + } + }) + }; + const stream = renderToStream(() =>

shell

, options); + await new Promise(resolve => + stream.pipe({ + write() {}, + end() { + ends++; + resolve(); + } + }) + ); + await new Promise(resolve => setTimeout(resolve)); + expect(onCompleteAll).toHaveBeenCalledTimes(1); + expect(ends).toBe(1); + }); + + test("still terminates when the global serialization hook throws", async () => { + configureServerErrors({ + onError: () => { + throw new Error("hook failed"); + } + }); + const onCompleteAll = vi.fn(); + const stream = renderToStream( + () => { + sharedConfig.context!.serialize("bad", Promise.resolve(new WeakMap())); + return

shell

; + }, + { onCompleteAll } + ); + await stream; + expect(stream.hasErrors).toBe(true); + expect(onCompleteAll).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(1); + }); + + test.each(["none", "local", "global"])( + "preserves the custom serializer callback contract (%s hook)", + async hook => { + const error = new Error("custom serializer"); + const onError = vi.fn(() => undefined); + if (hook === "global") configureServerErrors({ onError }); + let received: unknown; + const options = { + onError: hook === "local" ? onError : undefined, + serializer({ onError, onDone }: HydrationSerializerOptions) { + received = onError; + return Object.freeze({ + write() {}, + flush() { + onError?.(error); + onError?.(error); + onDone?.(); + } + }); + } + }; + const stream = renderToStream(() =>

shell

, options); + await stream; + expect(typeof received).toBe(hook === "local" ? "function" : "undefined"); + expect(stream.hasErrors).toBe(hook === "local"); + expect(onError).toHaveBeenCalledTimes(hook === "local" ? 1 : 0); + } + ); + + test("reads the serializer factory before capturing its error hook", async () => { + let onError = vi.fn(() => undefined); + let received: unknown; + const options = { + get onError() { + return onError; + }, + get serializer() { + onError = vi.fn(() => undefined); + return ({ onError, onDone }: HydrationSerializerOptions) => { + received = onError; + return { + write() {}, + flush() { + onError?.(new Error("custom serializer")); + onDone?.(); + } + }; + }; + } + }; + const stream = renderToStream(() =>

shell

, options); + await stream; + expect(received).toBeTypeOf("function"); + expect(onError).toHaveBeenCalledTimes(1); + expect(stream.hasErrors).toBe(true); + }); + + test("records a frozen custom serializer's synchronous throw without replacing it", () => { + const error = new Error("custom write"); + const abort = new AbortController(); + let context!: NonNullable; + const serializer = Object.freeze({ + write() { + expect(this).toBe(serializer); + throw error; + }, + flush() {} + }); + const options = { signal: abort.signal, serializer: () => serializer }; + const stream = renderToStream(() => { + context = sharedConfig.context!; + return

shell

; + }, options); + try { + expect(() => context.serialize("custom", 1)).toThrow(error); + expect(stream.hasErrors).toBe(true); + } finally { + abort.abort(); + } + }); + + test("completes a frame stream whose client prop fails to serialize under a local hook", async () => { + const onError = vi.fn(() => undefined); + const View = (props: any) => ( +
    + + +
+ ); + const timeout = new Promise<"timeout">(resolve => setTimeout(() => resolve("timeout"), 1000)); + const chunks: any = await Promise.race([ + renderServerComponent(View, { frame: { id: "unsupported-arg" }, onError }), + timeout + ]); + expect(chunks).not.toBe("timeout"); + expect(chunks.at(-1).type).toBe("complete"); + const table = createJSONDataTable(); + for (const chunk of chunks.filter((c: any) => c.type === "data")) table.apply(chunk); + const slots = chunks.filter((c: any) => c.type === "slot"); + expect(table.resolve(slots[1].args.meta)).toEqual({ healthy: true }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[1]).toMatchObject({ handling: "serialize" }); + }); + + test.each([true, false])( + "settles a JSON serializer write that fails to parse (onError: %s)", + withOnError => { + const keys: string[] = []; + const onDone = vi.fn(); + const onError = vi.fn(); + const serializer = createJSONSerializer({ + onData: (record: { key: string }) => keys.push(record.key), + onDone, + ...(withOnError ? { onError } : {}) + }); + serializer.write("before", { ok: true }); + const fail = () => serializer.write("bad", new WeakMap()); + if (withOnError) fail(); + else expect(fail).toThrow(); + serializer.write("after", { ok: true }); + serializer.flush(); + expect(onDone).toHaveBeenCalledTimes(1); + expect(keys).toEqual(["before", "after"]); + expect(onError).toHaveBeenCalledTimes(withOnError ? 1 : 0); + } + ); + + test.each([true, false])( + "does not complete early when a failed JSON write's parsed rejection settles (onError: %s)", + async withOnError => { + const onDone = vi.fn(); + const pending = Promise.withResolvers(); + const serializer = createJSONSerializer({ + onData() {}, + onDone, + ...(withOnError ? { onError() {} } : {}) + }); + const fail = () => + serializer.write("bad", { rejected: Promise.reject(new Error("x")), value: new WeakMap() }); + if (withOnError) fail(); + else expect(fail).toThrow(); + serializer.write("after", pending.promise); + serializer.flush(); + await new Promise(resolve => setTimeout(resolve)); + expect(onDone).not.toHaveBeenCalled(); + pending.resolve(1); + await new Promise(resolve => setTimeout(resolve)); + expect(onDone).toHaveBeenCalledTimes(1); + } + ); + + test.each([true, false])( + "decodes a value a failed JSON write shared with a later write (onError: %s)", + withOnError => { + const shared = { label: "shared" }; + const table = createJSONDataTable(); + const serializer = createJSONSerializer({ + onData: record => table.apply(record), + ...(withOnError ? { onError() {} } : {}) + }); + const fail = () => serializer.write("bad", { shared, value: new WeakMap() }); + if (withOnError) fail(); + else expect(fail).toThrow(); + serializer.write("after", { shared }); + expect(table.resolve({ $ref: "after" })).toEqual({ shared: { label: "shared" } }); + } + ); + + test.each([true, false])( + "releases the async sources of a JSON write whose parse fails (onError: %s)", + async withOnError => { + const release = vi.fn(); + const items = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return() { + release(); + return Promise.resolve({ done: true as const, value: undefined }); + } + }) + }; + const serializer = createJSONSerializer({ + onData() {}, + ...(withOnError ? { onError() {} } : {}) + }); + const fail = () => serializer.write("bad", { items, value: new WeakMap() }); + if (withOnError) fail(); + else expect(fail).toThrow(SerovalParserError); + await new Promise(resolve => setTimeout(resolve)); + expect(release).toHaveBeenCalledTimes(1); + } + ); + + test("stops a JSON write when close() runs inside its first record", async () => { + const pending = Promise.withResolvers(); + const records: boolean[] = []; + const onDone = vi.fn(); + const serializer: ReturnType = createJSONSerializer({ + onData(record: { initial: boolean }) { + records.push(record.initial); + if (record.initial) serializer.close(); + }, + onDone + }); + serializer.write("value", pending.promise); + pending.resolve(1); + await new Promise(resolve => setTimeout(resolve)); + expect(records).toEqual([true]); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + test("marks a root retry failure while preserving the existing completion policy", async () => { + let first = true; + let didComplete = false; + const retry = () => { + if (first) { + first = false; + throw new NotReadyError(Promise.resolve()); + } + throw new Error("private root"); + }; + const stream = renderToStream(() =>
{retry as unknown as JSX.Element}
, { + onCompleteAll() { + didComplete = true; + } + }); + await stream; + expect(stream.hasErrors).toBe(true); + expect(didComplete).toBe(false); + }); + + test.each([redirect("/login"), respond({ ready: true })])( + "does not classify a native control handled by the application as an error", + async control => { + const load = createServerReference( + registerServerReference("render-state/control", () => { + throw control; + }) + ); + const stream = underRequest(() => + renderToStream(() => { + try { + load(); + } catch { + return

control handled

; + } + return

unexpected success

; + }) + ); + expect(await stream).toContain("control handled"); + expect(stream.hasErrors).toBe(false); + } + ); + + test("does not suppress a Response that actually renders an error fallback", async () => { + const stream = renderToStream(() => ( + fallback

}> + +
+ )); + await stream; + expect(stream.hasErrors).toBe(true); + }); + + test("keeps streamed HTML progressive and records a late rejected fragment", async () => { + const gate = Promise.withResolvers(); + let releaseShell!: () => void; + const shell = new Promise(resolve => { + releaseShell = resolve; + }); + const chunks: string[] = []; + const stream = renderToStream(() => ( +
+ pending}> + + +
+ )); + const ended = new Promise(resolve => + stream.pipe({ + write(chunk) { + chunks.push(chunk); + releaseShell(); + }, + end() { + resolve(); + } + }) + ); + await shell; + expect(chunks.join("")).toContain("pending"); + expect(stream.hasErrors).toBe(false); + gate.reject(new Error("private late")); + await ended; + expect(stream.hasErrors).toBe(true); + }); +});