From e2a416d9fec726c7c60d4a5708cff109011348e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:33:17 +0000 Subject: [PATCH] fix: fail the build when React reports a render error During `vite build`, the RSC render, the SSR prerender and deferred payload renders now get an `onError` handler that logs the error with its source (entry path or defer name) and records it. React recovers from these errors in the output (an `E` row in the RSC payload, a client-rendered Suspense boundary in the HTML), so previously the build wrote the broken page and exited 0. `buildApp` now fails with an error listing every collected error before writing the affected output. Add a `failOnRenderError` option (default `true`); set it to `false` to only log the errors and emit the output as before. Closes #194 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FMU5vtU9xKNCD7XzUNDftA --- packages/docs/src/pages/advanced/SSR.mdx | 2 +- .../docs/src/pages/api/FunstackStatic.mdx | 19 +++ .../e2e/fixture-render-error/package.json | 14 +++ .../fixture-render-error/src/ClientPart.tsx | 12 ++ .../e2e/fixture-render-error/src/root.tsx | 11 ++ .../src/scenarios/defer-error.tsx | 16 +++ .../fixture-render-error/src/scenarios/ok.tsx | 15 +++ .../src/scenarios/rsc-error.tsx | 15 +++ .../src/scenarios/ssr-error.tsx | 12 ++ .../e2e/fixture-render-error/vite.config.ts | 29 +++++ packages/static/e2e/playwright.config.ts | 5 + .../static/e2e/tests/render-error.spec.ts | 119 ++++++++++++++++++ packages/static/src/build/buildApp.ts | 19 ++- .../static/src/build/renderErrors.test.ts | 46 +++++++ packages/static/src/build/renderErrors.ts | 62 +++++++++ packages/static/src/plugin/index.ts | 17 ++- packages/static/src/rsc/defer.tsx | 23 +++- packages/static/src/rsc/deferRegistry.ts | 4 +- packages/static/src/rsc/entry.tsx | 69 +++++++--- packages/static/src/ssr/entry.tsx | 6 + pnpm-lock.yaml | 24 ++++ pnpm-workspace.yaml | 1 + 22 files changed, 518 insertions(+), 22 deletions(-) create mode 100644 packages/static/e2e/fixture-render-error/package.json create mode 100644 packages/static/e2e/fixture-render-error/src/ClientPart.tsx create mode 100644 packages/static/e2e/fixture-render-error/src/root.tsx create mode 100644 packages/static/e2e/fixture-render-error/src/scenarios/defer-error.tsx create mode 100644 packages/static/e2e/fixture-render-error/src/scenarios/ok.tsx create mode 100644 packages/static/e2e/fixture-render-error/src/scenarios/rsc-error.tsx create mode 100644 packages/static/e2e/fixture-render-error/src/scenarios/ssr-error.tsx create mode 100644 packages/static/e2e/fixture-render-error/vite.config.ts create mode 100644 packages/static/e2e/tests/render-error.spec.ts create mode 100644 packages/static/src/build/renderErrors.test.ts create mode 100644 packages/static/src/build/renderErrors.ts diff --git a/packages/docs/src/pages/advanced/SSR.mdx b/packages/docs/src/pages/advanced/SSR.mdx index 5f00eb6..e8f7c70 100644 --- a/packages/docs/src/pages/advanced/SSR.mdx +++ b/packages/docs/src/pages/advanced/SSR.mdx @@ -40,7 +40,7 @@ Without SSR, `{children}` must be the only content of its parent element in the When SSR is enabled, your client components run during the build process to generate HTML. This means they must work in a server-like environment where browser APIs are not available. -Components that directly access browser-only APIs will cause build errors: +Components that directly access browser-only APIs will cause build errors. This includes errors inside a `` boundary, which React would otherwise recover from by rendering that part on the client (see [`failOnRenderError`](/api/funstack-static#failonrendererror-optional)): ```tsx "use client"; diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index 80e15f0..4c3852e 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -323,6 +323,25 @@ funstackStatic({ }); ``` +### failOnRenderError (optional) + +**Type:** `boolean` +**Default:** `true` + +Whether `vite build` fails when React reports an error while rendering a page. Examples include a Server Component that throws, a function passed as a prop to a Client Component, a [deferred](/api/defer) component that throws, or a Client Component that throws during [SSR](/advanced/ssr). + +React recovers from these errors without stopping the render. It puts an error into the RSC payload, or with SSR, leaves the affected `` boundary to be rendered on the client. A server could try the render again on a later request, but a static build has no later request, so the broken page would be deployed as is. That's why the build fails by default: the errors are logged, the build stops before writing the affected pages, and it ends with an error that lists each failure and where it happened. + +Set this to `false` to log the errors and write the output anyway. This was the behavior before this option existed. + +```typescript +funstackStatic({ + root: "./src/root.tsx", + app: "./src/App.tsx", + failOnRenderError: false, +}); +``` + ## Full Example ### Single-Entry diff --git a/packages/static/e2e/fixture-render-error/package.json b/packages/static/e2e/fixture-render-error/package.json new file mode 100644 index 0000000..3535587 --- /dev/null +++ b/packages/static/e2e/fixture-render-error/package.json @@ -0,0 +1,14 @@ +{ + "name": "e2e-fixture-render-error", + "private": true, + "type": "module", + "devDependencies": { + "@funstack/static": "workspace:*", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/static/e2e/fixture-render-error/src/ClientPart.tsx b/packages/static/e2e/fixture-render-error/src/ClientPart.tsx new file mode 100644 index 0000000..6187953 --- /dev/null +++ b/packages/static/e2e/fixture-render-error/src/ClientPart.tsx @@ -0,0 +1,12 @@ +"use client"; + +export function ClientPart({ children }: { children?: unknown }) { + return

{typeof children === "function" ? children() : "client part"}

; +} + +export function ThrowOnServer() { + if (typeof window === "undefined") { + throw new Error("ThrowOnServer: error thrown during SSR"); + } + return

client only

; +} diff --git a/packages/static/e2e/fixture-render-error/src/root.tsx b/packages/static/e2e/fixture-render-error/src/root.tsx new file mode 100644 index 0000000..a8ab964 --- /dev/null +++ b/packages/static/e2e/fixture-render-error/src/root.tsx @@ -0,0 +1,11 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + + Render Error Test + + {children} + + ); +} diff --git a/packages/static/e2e/fixture-render-error/src/scenarios/defer-error.tsx b/packages/static/e2e/fixture-render-error/src/scenarios/defer-error.tsx new file mode 100644 index 0000000..ea55461 --- /dev/null +++ b/packages/static/e2e/fixture-render-error/src/scenarios/defer-error.tsx @@ -0,0 +1,16 @@ +import { Suspense } from "react"; +import { defer } from "@funstack/static/server"; + +function Throws(): React.ReactNode { + throw new Error("Throws: error thrown in a deferred component"); +} + +export default function App() { + return ( +
+ loading

}> + {defer(, { name: "Throws" })} +
+
+ ); +} diff --git a/packages/static/e2e/fixture-render-error/src/scenarios/ok.tsx b/packages/static/e2e/fixture-render-error/src/scenarios/ok.tsx new file mode 100644 index 0000000..a94abaf --- /dev/null +++ b/packages/static/e2e/fixture-render-error/src/scenarios/ok.tsx @@ -0,0 +1,15 @@ +import { defer } from "@funstack/static/server"; +import { ClientPart } from "../ClientPart"; + +function Deferred() { + return

deferred

; +} + +export default function App() { + return ( +
+ + {defer(, { name: "Deferred" })} +
+ ); +} diff --git a/packages/static/e2e/fixture-render-error/src/scenarios/rsc-error.tsx b/packages/static/e2e/fixture-render-error/src/scenarios/rsc-error.tsx new file mode 100644 index 0000000..cb3da26 --- /dev/null +++ b/packages/static/e2e/fixture-render-error/src/scenarios/rsc-error.tsx @@ -0,0 +1,15 @@ +import { Suspense } from "react"; +import { ClientPart } from "../ClientPart"; + +export default function App() { + return ( +
+ {/* With SSR, the boundary lets prerendering recover from the error by + client-rendering this part, so only onError reports it. */} + loading

}> + {/* Functions cannot be passed to Client Components (#194) */} + {() => "x"} +
+
+ ); +} diff --git a/packages/static/e2e/fixture-render-error/src/scenarios/ssr-error.tsx b/packages/static/e2e/fixture-render-error/src/scenarios/ssr-error.tsx new file mode 100644 index 0000000..99f7c8d --- /dev/null +++ b/packages/static/e2e/fixture-render-error/src/scenarios/ssr-error.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import { ThrowOnServer } from "../ClientPart"; + +export default function App() { + return ( +
+ loading

}> + +
+
+ ); +} diff --git a/packages/static/e2e/fixture-render-error/vite.config.ts b/packages/static/e2e/fixture-render-error/vite.config.ts new file mode 100644 index 0000000..ef11143 --- /dev/null +++ b/packages/static/e2e/fixture-render-error/vite.config.ts @@ -0,0 +1,29 @@ +import funstackStatic from "@funstack/static"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// Which App to build and the plugin options are selected by environment +// variables, so that e2e/tests/render-error.spec.ts can build each case. +const scenario = process.env.RENDER_ERROR_SCENARIO ?? "ok"; +const ssr = process.env.RENDER_ERROR_SSR === "true"; +const failOnRenderError = process.env.RENDER_ERROR_FAIL !== "false"; + +// Separate output per variant so that builds can run in parallel. +const outDir = `dist/${scenario}-${ssr ? "ssr" : "csr"}${failOnRenderError ? "" : "-nofail"}`; + +export default defineConfig({ + plugins: [ + funstackStatic({ + root: "./src/root.tsx", + app: `./src/scenarios/${scenario}.tsx`, + ssr, + failOnRenderError, + publicOutDir: `${outDir}/public`, + }), + react(), + ], + environments: { + rsc: { build: { outDir: `${outDir}/rsc` } }, + ssr: { build: { outDir: `${outDir}/ssr` } }, + }, +}); diff --git a/packages/static/e2e/playwright.config.ts b/packages/static/e2e/playwright.config.ts index ee58daa..9dd528d 100644 --- a/packages/static/e2e/playwright.config.ts +++ b/packages/static/e2e/playwright.config.ts @@ -45,6 +45,11 @@ export default defineConfig({ }, testMatch: /\/fs-routing\.spec\.ts$/, }, + { + // Runs `vite build` itself; needs no web server + name: "render-error", + testMatch: /\/render-error\.spec\.ts$/, + }, ], webServer: [ diff --git a/packages/static/e2e/tests/render-error.spec.ts b/packages/static/e2e/tests/render-error.spec.ts new file mode 100644 index 0000000..fb5c234 --- /dev/null +++ b/packages/static/e2e/tests/render-error.spec.ts @@ -0,0 +1,119 @@ +import { execFile } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "@playwright/test"; + +const execFileAsync = promisify(execFile); + +const fixtureDir = path.resolve(import.meta.dirname, "../fixture-render-error"); + +type Scenario = "ok" | "rsc-error" | "defer-error" | "ssr-error"; + +/** + * Runs `vite build` in the render-error fixture and returns its exit code, + * combined output and public output directory. + */ +async function build( + scenario: Scenario, + options: { ssr: boolean; failOnRenderError?: boolean }, +) { + const failOnRenderError = options.failOnRenderError ?? true; + const outDir = path.join( + fixtureDir, + "dist", + `${scenario}-${options.ssr ? "ssr" : "csr"}${failOnRenderError ? "" : "-nofail"}`, + "public", + ); + try { + const { stdout, stderr } = await execFileAsync("pnpm", ["vite", "build"], { + cwd: fixtureDir, + env: { + ...process.env, + RENDER_ERROR_SCENARIO: scenario, + RENDER_ERROR_SSR: String(options.ssr), + RENDER_ERROR_FAIL: String(failOnRenderError), + }, + }); + return { exitCode: 0, output: stdout + stderr, outDir }; + } catch (error) { + const { code, stdout, stderr } = error as { + code: number; + stdout: string; + stderr: string; + }; + return { exitCode: code, output: stdout + stderr, outDir }; + } +} + +async function readRscPayloads(outDir: string): Promise { + const dir = path.join(outDir, "funstack__", "fun__rsc-payload"); + const files = await readdir(dir); + return Promise.all( + files.map((file) => readFile(path.join(dir, file), "utf8")), + ); +} + +test.describe("Build fails on render errors (#194)", () => { + // Each build takes a few seconds. + test.setTimeout(60000); + + for (const ssr of [false, true]) { + test.describe(ssr ? "SSR on" : "SSR off", () => { + test("succeeds without render errors", async () => { + const result = await build("ok", { ssr }); + expect(result.output).not.toContain("Error while rendering"); + expect(result.exitCode).toBe(0); + }); + + test("fails when a Server Component render errors", async () => { + const result = await build("rsc-error", { ssr }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain( + 'RSC payload of "index.html": Functions cannot be passed directly to Client Components', + ); + // Nothing is written for the broken page + await expect( + readFile(path.join(result.outDir, "index.html")), + ).rejects.toThrow(); + }); + + test("fails when a deferred component errors", async () => { + const result = await build("defer-error", { ssr }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain( + 'deferred component "Throws": Throws: error thrown in a deferred component', + ); + }); + + test("emits the output with failOnRenderError: false", async () => { + const result = await build("rsc-error", { + ssr, + failOnRenderError: false, + }); + expect(result.exitCode).toBe(0); + // The error is still logged + expect(result.output).toContain("Error while rendering"); + const payloads = await readRscPayloads(result.outDir); + expect(payloads.some((payload) => /^\w+:E\{/m.test(payload))).toBe( + true, + ); + if (ssr) { + const html = await readFile( + path.join(result.outDir, "index.html"), + "utf8", + ); + expect(html).toContain(""); + } + }); + }); + } + + test("fails when prerendering a Client Component errors", async () => { + const result = await build("ssr-error", { ssr: true }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain( + 'HTML of "index.html": ThrowOnServer: error thrown during SSR', + ); + }); +}); diff --git a/packages/static/src/build/buildApp.ts b/packages/static/src/build/buildApp.ts index 6c27ebc..8d766c4 100644 --- a/packages/static/src/build/buildApp.ts +++ b/packages/static/src/build/buildApp.ts @@ -9,12 +9,13 @@ import { replaceIdsInContent } from "./idReplacement"; import { computeContentHash } from "./contentHash"; import { drainStream } from "../util/drainStream"; import { validateEntryPath, checkDuplicatePaths } from "./validateEntryPath"; +import { createRenderErrorsError } from "./renderErrors"; import type { EntryBuildResult } from "../rsc/entry"; export async function buildApp( builder: ViteBuilder, context: MinimalPluginContextWithoutEnvironment, - options: { rscPayloadDir: string }, + options: { rscPayloadDir: string; failOnRenderError: boolean }, ) { const { config } = builder; // import server entry @@ -35,7 +36,17 @@ export async function buildApp( const base = normalizeBase(config.base); async function doBuild() { - const { entries, deferRegistry } = await entry.build(); + const { entries, deferRegistry, renderErrors } = await entry.build(); + + // React reports render errors through `onError` and recovers in the + // output (an error row in the RSC payload, a client-rendered boundary in + // the HTML), so they are collected as streams drain. Check before each + // write so broken output is not emitted. + const checkRenderErrors = () => { + if (options.failOnRenderError && renderErrors.length > 0) { + throw createRenderErrorsError(renderErrors); + } + }; // Validate all entry paths const paths: string[] = []; @@ -57,6 +68,7 @@ export async function buildApp( options.rscPayloadDir, context, ); + checkRenderErrors(); // Write each entry's HTML and RSC payload for (const result of entries) { @@ -67,6 +79,7 @@ export async function buildApp( base, options.rscPayloadDir, context, + checkRenderErrors, ); } @@ -99,6 +112,7 @@ async function buildSingleEntry( base: string, rscPayloadDir: string, context: MinimalPluginContextWithoutEnvironment, + checkRenderErrors: () => void, ) { const { path: entryPath, html, appRsc } = result; @@ -108,6 +122,7 @@ async function buildSingleEntry( // Drain and process RSC payload: replace temp IDs with final hashed IDs const rawAppRscContent = await drainStream(appRsc); const appRscContent = replaceIdsInContent(rawAppRscContent, idMapping); + checkRenderErrors(); // Compute hash for this entry's RSC payload const mainPayloadHash = await computeContentHash(appRscContent); diff --git a/packages/static/src/build/renderErrors.test.ts b/packages/static/src/build/renderErrors.test.ts new file mode 100644 index 0000000..1ec5920 --- /dev/null +++ b/packages/static/src/build/renderErrors.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createRenderErrorHandler, + createRenderErrorsError, + type RenderError, +} from "./renderErrors"; + +describe("createRenderErrorHandler", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("logs and records errors with their source", () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const errors: RenderError[] = []; + const onError = createRenderErrorHandler(errors, 'HTML of "index.html"'); + const error = new Error("boom"); + + onError(error); + + expect(errors).toEqual([{ source: 'HTML of "index.html"', error }]); + expect(consoleError).toHaveBeenCalledWith( + '[funstack] Error while rendering HTML of "index.html":', + error, + ); + }); +}); + +describe("createRenderErrorsError", () => { + it("lists every error with its source", () => { + const first = new Error("first"); + const error = createRenderErrorsError([ + { source: 'RSC payload of "index.html"', error: first }, + { source: "a deferred component", error: "not an Error" }, + ]); + + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([first, "not an Error"]); + expect(error.message).toContain("2 error(s) occurred"); + expect(error.message).toContain(' - RSC payload of "index.html": first'); + expect(error.message).toContain(" - a deferred component: not an Error"); + expect(error.message).toContain("failOnRenderError: false"); + }); +}); diff --git a/packages/static/src/build/renderErrors.ts b/packages/static/src/build/renderErrors.ts new file mode 100644 index 0000000..55784cc --- /dev/null +++ b/packages/static/src/build/renderErrors.ts @@ -0,0 +1,62 @@ +/** + * An error reported by React (via `onError`) while rendering during a build. + * React recovers from these by emitting an error row into the RSC payload or + * a client-rendered Suspense boundary into the HTML, so the render itself + * does not reject. + */ +export interface RenderError { + /** + * Human-readable description of what was being rendered + * (e.g. `RSC payload of "index.html"`). + */ + source: string; + error: unknown; +} + +/** + * Logs a render error and records it in `errors`. + */ +export function reportRenderError( + errors: RenderError[], + source: string, + error: unknown, +): void { + console.error(`[funstack] Error while rendering ${source}:`, error); + errors.push({ source, error }); +} + +/** + * Creates an `onError` handler for React renderers that reports errors with + * `reportRenderError`. + */ +export function createRenderErrorHandler( + errors: RenderError[], + source: string, +): (error: unknown) => void { + return (error) => reportRenderError(errors, source, error); +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +/** + * Creates the error thrown to fail the build when render errors were + * collected. + */ +export function createRenderErrorsError(errors: RenderError[]): Error { + const lines = errors.map( + ({ source, error }) => ` - ${source}: ${errorMessage(error)}`, + ); + return new AggregateError( + errors.map(({ error }) => error), + `[funstack] ${errors.length} error(s) occurred while rendering during the build:\n` + + lines.join("\n") + + "\nA static build has no server to recover these pages at request time, " + + "so the build was aborted. Set `failOnRenderError: false` to emit the " + + "output anyway.", + ); +} diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index 5b64c89..6a91896 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -54,6 +54,20 @@ interface FunstackStaticBaseOptions { * The module runs in the RSC environment. */ build?: string; + /** + * Fail the production build when React reports an error while rendering + * (a Server Component throws, an invalid prop is passed to a Client + * Component, SSR of a component fails, etc.). + * + * React recovers from such errors by embedding them in the output (an + * error in the RSC payload, a client-rendered Suspense boundary in the + * HTML). A static build has no server to retry the render later, so by + * default the build fails instead of emitting the broken output. + * Set to `false` to only log these errors and emit the output anyway. + * + * @default true + */ + failOnRenderError?: boolean; } interface SingleEntryOptions { @@ -152,6 +166,7 @@ export default function funstackStatic( ssr = false, clientInit, rscPayloadDir = defaultRscPayloadDir, + failOnRenderError = true, } = options; // Validate rscPayloadDir to prevent path traversal or invalid segments @@ -393,7 +408,7 @@ export default function funstackStatic( { name: "@funstack/static:build", async buildApp(builder) { - await buildApp(builder, this, { rscPayloadDir }); + await buildApp(builder, this, { rscPayloadDir, failOnRenderError }); }, }, ]; diff --git a/packages/static/src/rsc/defer.tsx b/packages/static/src/rsc/defer.tsx index f255a68..e61dc2e 100644 --- a/packages/static/src/rsc/defer.tsx +++ b/packages/static/src/rsc/defer.tsx @@ -29,9 +29,26 @@ function sanitizeName(name: string): string { .slice(0, 50); } -export const deferRegistry = new DeferRegistry((element) => - renderToReadableStream(element), -); +let deferRenderErrorHandler: + ((error: unknown, name: string | undefined) => void) | undefined; + +/** + * Sets the handler for errors reported while rendering deferred payloads. + * Used by the build to collect render errors; when unset, React's default + * (`console.error`) applies. + */ +export function setDeferRenderErrorHandler( + handler: ((error: unknown, name: string | undefined) => void) | undefined, +) { + deferRenderErrorHandler = handler; +} + +export const deferRegistry = new DeferRegistry((element, name) => { + const handler = deferRenderErrorHandler; + return renderToReadableStream(element, { + onError: handler && ((error: unknown) => handler(error, name)), + }); +}); /** * Registers a Server Component element as a separate RSC payload in the diff --git a/packages/static/src/rsc/deferRegistry.ts b/packages/static/src/rsc/deferRegistry.ts index 46e0a87..b2cf897 100644 --- a/packages/static/src/rsc/deferRegistry.ts +++ b/packages/static/src/rsc/deferRegistry.ts @@ -37,9 +37,11 @@ type DeferEntryState = /** * Renders a React element to an RSC payload stream. * Injected so the registry does not depend on the RSC runtime directly. + * `name` is the debug name given to `defer()`, if any. */ export type RenderToStream = ( element: ReactElement, + name: string | undefined, ) => ReadableStream; export interface EvictStaleOptions { @@ -99,7 +101,7 @@ export class DeferRegistry { const { state } = entry; switch (state.state) { case "pending": { - const stream = this.#render(state.element); + const stream = this.#render(state.element, entry.name); const [stream1, stream2] = stream.tee(); entry.state = { state: "streaming", stream: stream1 }; const drainPromise = drainStream(stream2); diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx index 85d08de..1796cba 100644 --- a/packages/static/src/rsc/entry.tsx +++ b/packages/static/src/rsc/entry.tsx @@ -2,13 +2,18 @@ import "./defer"; import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc"; import { devMainRscPath } from "./request"; import { generateAppMarker } from "./marker"; -import { deferRegistry } from "./defer"; +import { deferRegistry, setDeferRenderErrorHandler } from "./defer"; import { devDeferEvictionOptions } from "./deferRegistry"; import { extractIDFromModulePath } from "./rscModule"; import { stripBasePath } from "../util/basePath"; import { urlPathToFileCandidates } from "../util/urlPath"; import { resolveRoot, resolveApp } from "./resolveEntry"; import type { EntryDefinition, GetEntriesResult } from "../entryDefinition"; +import { + createRenderErrorHandler, + reportRenderError, + type RenderError, +} from "../build/renderErrors"; export type RscPayload = { root: React.ReactNode; @@ -272,10 +277,26 @@ export async function serveRSC(request: Request): Promise { /** * Build handler — iterates over all entries and returns per-entry results * along with the shared defer registry. + * + * `renderErrors` collects errors React reports while rendering (RSC, SSR + * prerender and deferred payloads). React recovers from them in the output, + * so they do not reject; the array keeps filling while the returned streams + * and deferred payloads are drained. */ export async function build() { const getEntries = (await import("virtual:funstack/entries")).default; + const renderErrors: RenderError[] = []; + setDeferRenderErrorHandler((error, name) => + reportRenderError( + renderErrors, + name === undefined + ? "a deferred component" + : `deferred component "${name}"`, + error, + ), + ); + const ssrEntryModule = await import.meta.viteRsc.loadModule< typeof import("../ssr/entry") >("ssr"); @@ -290,25 +311,40 @@ export async function build() { let rootRscStream: ReadableStream; let appRscStream: ReadableStream; + const rscOnError = createRenderErrorHandler( + renderErrors, + `RSC payload of "${entry.path}"`, + ); + if (ssrEnabled) { // SSR on: render the full tree once and tee the stream — one branch // for SSR HTML, one for the app RSC payload. Rendering twice would // execute every server component (and defer()) twice per entry (#147). - [rootRscStream, appRscStream] = renderToReadableStream({ - root: {appNode}, - }).tee(); + [rootRscStream, appRscStream] = renderToReadableStream( + { root: {appNode} }, + { onError: rscOnError }, + ).tee(); } else { // SSR off: root stream has shell, app stream has App only - rootRscStream = renderToReadableStream({ - root: ( - - - - ), - }); - appRscStream = renderToReadableStream({ - root: appNode, - }); + rootRscStream = renderToReadableStream( + { + root: ( + + + + ), + }, + { + onError: createRenderErrorHandler( + renderErrors, + `RSC shell of "${entry.path}"`, + ), + }, + ); + appRscStream = renderToReadableStream( + { root: appNode }, + { onError: rscOnError }, + ); } const ssrResult = await ssrEntryModule.renderHTML(rootRscStream, { @@ -316,6 +352,10 @@ export async function build() { build: true, ssr: ssrEnabled, deferRegistry, + onError: createRenderErrorHandler( + renderErrors, + `HTML of "${entry.path}"`, + ), }); results.push({ @@ -328,6 +368,7 @@ export async function build() { return { entries: results, deferRegistry, + renderErrors, }; } diff --git a/packages/static/src/ssr/entry.tsx b/packages/static/src/ssr/entry.tsx index 3d51d1e..960e433 100644 --- a/packages/static/src/ssr/entry.tsx +++ b/packages/static/src/ssr/entry.tsx @@ -19,6 +19,11 @@ export async function renderHTML( nonce?: string; deferRegistry?: DeferRegistry; clientRscStream?: ReadableStream; + /** + * Receives errors reported while prerendering (build only). Without it, + * React logs them with `console.error`. + */ + onError?: (error: unknown) => void; }, ): Promise<{ stream: ReadableStream; status?: number }> { // Tee only when the second branch is actually consumed (dev without a @@ -77,6 +82,7 @@ export async function renderHTML( if (options.build) { const { prelude, postponed } = await prerender(, { bootstrapScriptContent, + onError: options.onError, }); if (postponed !== null) { throw new Error("Unexpected postponed state during prerendering"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c5865b..e8cc7ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,30 @@ importers: specifier: 'catalog:' version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.47.1)(tsx@4.21.0) + packages/static/e2e/fixture-render-error: + devDependencies: + '@funstack/static': + specifier: workspace:* + version: link:../.. + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.1.2(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.47.1)(tsx@4.21.0)) + react: + specifier: 'catalog:' + version: 19.3.1 + react-dom: + specifier: 'catalog:' + version: 19.3.1(react@19.3.1) + vite: + specifier: 'catalog:' + version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.47.1)(tsx@4.21.0) + packages/static/e2e/fixture-ssr-defer: devDependencies: '@funstack/static': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ccc2f34..3dc8dc0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - packages/static/e2e/fixture-multi-entry - packages/static/e2e/fixture-ssr-defer - packages/static/e2e/fixture-fs-routing + - packages/static/e2e/fixture-render-error catalog: "@types/node": ^26.6.5