Skip to content

Expose per-render failure state for buffered SSR consumers #4125

Closed

Description

@everton-dgn

Use case

A buffered SSR consumer needs to know whether its HTML contains a render failure before publishing it as a successful document. Our concrete use case is serving Markdown from the same rendered page HTML, without browser hydration.

Awaiting renderToStream() intentionally resolves with the HTML produced, including a render where a Loading fragment rejected. onCompleteAll also runs for that case. The documented onError hook reports/maps each error object once, so it cannot act as a per-render integrity signal:

  • Reusing one Error in two requests only notifies the first request.
  • A server-function error first reported as kind: 'server-function' is not reported again as kind: 'render' when it escapes into Loading.

The distinction matters because a server-function failure caught by application code may still produce valid content. Marking every server-function failure would reject those valid renders.

Reproduction

Verified against solid-js and @solidjs/web 2.0.0-rc.15. Run this as an ES module in Node with those packages installed:

import {configureServerErrors,createRequestEvent,renderToStream,ssr,markSafeError} from '@solidjs/web';
import {provideRequestEvent} from '@solidjs/web/storage';
import {configureServerFunctionsServer,registerServerReference,createServerReference} from '@solidjs/web/server-functions/server';
import {createComponent,createMemo,Loading} from 'solid-js';
const calls=[];
configureServerErrors({onError(error,context){calls.push({kind:context.kind,handling:context.handling,url:context.event?.request.url}); if(context.kind==='render'&&context.event) context.event.locals.hasSsrRenderFailure=true; return markSafeError(new Error('Public failure'));}});
configureServerFunctionsServer({wrapInvocation:async run=>{try{return await run()}catch{return (()=>{throw markSafeError(new Error('Public failure'));})()}}});
async function render(id, run){
const event=createRequestEvent(new Request(`https://example.test/${id}`));
const before=calls.length; let didCompleteAll=false;
const html=await provideRequestEvent(event,()=>renderToStream(()=>ssr(['<main>', '</main>'],createComponent(Loading,{fallback:'pending',get children(){const data=createMemo(run);return ssr(['<p>','</p>'],createMemo(()=>data()))}})), {onCompleteAll(){didCompleteAll=true}}));
return {id,didCompleteAll,hasFailure:event.locals.hasSsrRenderFailure??false,hasResolved:html.includes('resolved'),calls:calls.slice(before)};
}
const shared=new Error('PRIVATE_shared');
const results=[];
results.push(await render('reuse-first',async()=>{throw shared}));
results.push(await render('reuse-second',async()=>{throw shared}));
const sf=createServerReference(registerServerReference('probe-failure',async()=>{throw new Error('PRIVATE_sf')}));
results.push(await render('server-function',()=>sf()));
console.log(JSON.stringify(results,null,2));

Observed output:

[
  {
    "id": "reuse-first",
    "didCompleteAll": true,
    "hasFailure": true,
    "hasResolved": false,
    "calls": [
      {
        "kind": "render",
        "handling": "client",
        "url": "https://example.test/reuse-first"
      }
    ]
  },
  {
    "id": "reuse-second",
    "didCompleteAll": true,
    "hasFailure": false,
    "hasResolved": false,
    "calls": []
  },
  {
    "id": "server-function",
    "didCompleteAll": true,
    "hasFailure": false,
    "hasResolved": false,
    "calls": [
      {
        "kind": "server-function",
        "handling": "thrown",
        "url": "https://example.test/server-function"
      }
    ]
  }
]

All three renders complete with the resolved fragment missing. The second and third renders have no render-hook notification. The existing hook behavior matches its once-per-error-object contract; invoking a mapper again is not the requested solution.

Proposed direction

Expose a read-only, monotonic failure flag on each renderToStream() result. Set it at render containment/failure sites independently of error-reporting deduplication, without retaining or exposing error payloads. Consumers can await the render, check this flag and the request lifecycle, and reject incomplete documents.

A contribution is being prepared against next, with coverage for repeated errors across requests, server-function failures that escape versus those caught by application code, contained fallbacks, serialization, concurrent renders and normal streaming. Existing sanitization and hook invocation counts should remain unchanged.

Activity

  1. ryansolid commented on Oct 18, 2026

    @ryansolid
    Member

    Thanks for the detailed repro. #4130 (merged into next) fixes both cases it shows:

    • The server error hook now dedupes per request (per render when no request is in scope), never per process. A reused Error is heard, and mapped, by each request.
    • Within a request it hears an error once per kind. A server-function failure that escapes into a <Loading> or <Errored> is reported as kind: "server-function", then again as kind: "render", and the wire value stays the first one the hook returned.

    So kind === "render" in a render's onError (or the ambient hook, with context.event) is the per-render failure signal you asked for, with no new API. Your repro is pinned in packages/web/test/server/server-error-hook.spec.tsx. It ships in the next 2.0 RC. Closing as fixed; reopen if it doesn't cover your Markdown case.

    — Claude via Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions