Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/json-serializer-failed-write.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions .changeset/render-local-error-state.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/stream-completion-containment.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions documentation/solid-2.0/12-ssr-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Client (`@solidjs/web` in the browser):
Server (`@solidjs/web` under the `node`/`deno`/`worker` conditions):

- `renderToString(() => <App />)` — synchronous; async boundaries render their fallbacks.
- `renderToStream(() => <App />, 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 `<Loading>` 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(() => <App />, 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 `<Loading>` 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 `<Errored>` 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 `<HydrationScript />` renders it in JSX documents.

Expand Down Expand Up @@ -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 `<Errored>` 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 `<Errored>` 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 `<Loading>` 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 `<Errored>` 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 `<Errored>` 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 `<Loading>` 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 `<Errored>` that contains it; the boundary reuses the verdict — the same replacement in the fallback and the record — and does not report again. A `<Loading>` 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 `<Errored>` 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 `<Loading>` 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`.
Expand Down
1 change: 1 addition & 0 deletions packages/solid/src/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/solid/src/server/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3989,6 +3989,7 @@ export function createErrorBoundary<T, U>(
// mismatch). See `ssrSanitizeError`.
const handleError = (err: any) => {
if (fallbackSettled) return fallbackResult;
ctx?.recordRenderError?.();
reportContained(err);
const wire = ssrSanitizeError(
err,
Expand Down
81 changes: 61 additions & 20 deletions packages/web/serialization/src/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
Feature,
Serializer,
SerovalParserError,
getCrossReferenceHeader,
toCrossJSONStream,
createPlugin as createPluginImpl,
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/server-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ export function renderToStream<T>(
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
Expand Down
Loading
Loading