From ab818cdc4f7d42f94b6bed6d6d4f2eef2c9ea479 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 16 Sep 2025 17:26:53 -0700 Subject: [PATCH 1/3] fix: handle SSR'd client errors and re-try in the browser --- .changeset/silent-emus-grow.md | 5 + integration/rsc/rsc-test.ts | 47 +++++++- packages/react-router/lib/rsc/server.ssr.tsx | 110 +++++++++++++++++-- 3 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 .changeset/silent-emus-grow.md diff --git a/.changeset/silent-emus-grow.md b/.changeset/silent-emus-grow.md new file mode 100644 index 0000000000..7984020573 --- /dev/null +++ b/.changeset/silent-emus-grow.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +handle SSR'd client errors and re-try in the browser diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index be2690ec4b..3c49b46aff 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -502,7 +502,12 @@ implementations.forEach((implementation) => { lazy: () => import("./routes/await-component/api"), } ] - } + }, + { + id: "ssr-error", + path: "ssr-error", + lazy: () => import("./routes/ssr-error/ssr-error"), + }, ], }, ] satisfies RSCRouteConfig; @@ -1288,6 +1293,27 @@ implementations.forEach((implementation) => { ); } `, + "src/routes/ssr-error/ssr-error.tsx": js` + "use client"; + import { useState } from "react"; + + export function ErrorBoundary() { + const [count, setCount] = useState(0); + + return ( +
+
Client Error Boundary
+ +
+ ); + } + + export default function SSRError() { + throw new Error("Error from SSR component"); + } + `, }, }); }); @@ -1788,6 +1814,25 @@ implementations.forEach((implementation) => { // Ensure this is using RSC validateRSCHtml(await page.content()); }); + + test("Handles errors thrown in SSR components correctly", async ({ + page, + }) => { + test.skip( + implementation.name === "parcel", + "Parcel's error overlays are interfering with this test", + ); + await page.goto(`http://localhost:${port}/ssr-error`); + + // Verify error boundary is shown + await page.waitForSelector("[data-error-boundary]"); + expect( + await page.locator("[data-error-boundary]").textContent(), + ).toBe("Client Error Boundary"); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); }); test.describe("Route Client Component Props", () => { diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 93f4377399..0139fc8a41 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -9,6 +9,12 @@ import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries"; import { shouldHydrateRouteLoader } from "../dom/ssr/routes"; import type { RSCPayload } from "./server.rsc"; import { createRSCRouteModules } from "./route-modules"; +import { isRouteErrorResponse } from "../router/utils"; + +type DecodedPayload = Promise & { + _deepestRenderedBoundaryId?: string | null; + formState: Promise; +}; // Safe version of React.use() that will not cause compilation errors against // React 18 and will result in a runtime error if used (you can't use RSC against @@ -88,7 +94,7 @@ export async function routeRSCServerRequest({ fetchServer: (request: Request) => Promise; createFromReadableStream: SSRCreateFromReadableStreamFunction; renderHTML: ( - getPayload: () => Promise, + getPayload: () => DecodedPayload, ) => ReadableStream | Promise>; hydrate?: boolean; }): Promise { @@ -150,8 +156,29 @@ export async function routeRSCServerRequest({ }); }; - const getPayload = async () => { - return createFromReadableStream(createStream()) as Promise; + let deepestRenderedBoundaryId: string | null = null; + const getPayload = (): DecodedPayload => { + const payloadPromise = Promise.resolve( + createFromReadableStream(createStream()), + ) as Promise; + + return Object.defineProperties(payloadPromise, { + _deepestRenderedBoundaryId: { + get() { + return deepestRenderedBoundaryId; + }, + set(boundaryId: string | null) { + deepestRenderedBoundaryId = boundaryId; + }, + }, + formState: { + get() { + return payloadPromise.then((payload) => + payload.type === "render" ? payload.formState : undefined, + ); + }, + }, + }) as DecodedPayload; }; try { @@ -204,11 +231,69 @@ export async function routeRSCServerRequest({ if (reason instanceof Response) { return reason; } + + try { + const status = isRouteErrorResponse(reason) ? reason.status : 500; + + const html = await renderHTML(() => { + const decoded = Promise.resolve( + createFromReadableStream(createStream()), + ) as Promise; + + const payloadPromise = decoded.then((payload) => + Object.assign(payload, { + status, + errors: deepestRenderedBoundaryId + ? { + [deepestRenderedBoundaryId]: reason, + } + : {}, + }), + ); + + return Object.defineProperties(payloadPromise, { + _deepestRenderedBoundaryId: { + get() { + return deepestRenderedBoundaryId; + }, + set(boundaryId: string | null) { + deepestRenderedBoundaryId = boundaryId; + }, + }, + formState: { + get() { + return payloadPromise.then((payload) => + payload.type === "render" ? payload.formState : undefined, + ); + }, + }, + }) as unknown as DecodedPayload; + }); + + const headers = new Headers(serverResponse.headers); + headers.set("Content-Type", "text/html"); + + if (!hydrate) { + return new Response(html, { + status: status, + headers, + }); + } + + if (!serverResponseB?.body) { + throw new Error("Failed to clone server response"); + } + + const body = html.pipeThrough(injectRSCPayload(serverResponseB.body)); + return new Response(body, { + status, + headers, + }); + } catch { + // Throw the original error below + } + throw reason; - // TODO: Track deepest rendered boundary and re-try - // Figure out how / if we need to transport the error, - // or if we can just re-try on the client to reach - // the correct boundary. } } @@ -223,7 +308,7 @@ export interface RSCStaticRouterProps { * A function that starts decoding of the {@link unstable_RSCPayload}. Usually passed * through from {@link unstable_routeRSCServerRequest}'s `renderHTML`. */ - getPayload: () => Promise; + getPayload: () => DecodedPayload; } /** @@ -264,8 +349,9 @@ export interface RSCStaticRouterProps { * @returns A React component that renders the {@link unstable_RSCPayload} as HTML. */ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { + const decoded = getPayload(); // Can be replaced with React.use when v18 compatibility is no longer required. - const payload = useSafe(getPayload()); + const payload = useSafe(decoded); if (payload.type === "redirect") { throw new Response(null, { @@ -298,6 +384,12 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { } const context = { + get _deepestRenderedBoundaryId() { + return decoded._deepestRenderedBoundaryId ?? null; + }, + set _deepestRenderedBoundaryId(boundaryId: string | null) { + decoded._deepestRenderedBoundaryId = boundaryId; + }, actionData: payload.actionData, actionHeaders: {}, basename: payload.basename, From a403fd8bef48fe1d04f9c5cd09333cb37af21093 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 16 Sep 2025 17:36:03 -0700 Subject: [PATCH 2/3] simplify entrypoints and update docs (existing method for formState still works) --- docs/api/rsc/RSCStaticRouter.md | 4 ++-- docs/api/rsc/routeRSCServerRequest.md | 4 ++-- docs/how-to/react-server-components.md | 16 ++++------------ integration/helpers/rsc-parcel/src/prerender.tsx | 8 +++----- integration/helpers/rsc-vite/src/entry.ssr.tsx | 8 +++----- .../config/default-rsc-entries/entry.ssr.tsx | 6 ++---- packages/react-router/lib/rsc/server.ssr.tsx | 8 ++++---- playground/rsc-parcel/src/entry.ssr.tsx | 6 ++---- playground/rsc-vite/src/entry.ssr.tsx | 6 ++---- 9 files changed, 24 insertions(+), 42 deletions(-) diff --git a/docs/api/rsc/RSCStaticRouter.md b/docs/api/rsc/RSCStaticRouter.md index 18a7fdc2f1..5f56c30cdc 100644 --- a/docs/api/rsc/RSCStaticRouter.md +++ b/docs/api/rsc/RSCStaticRouter.md @@ -46,13 +46,13 @@ routeRSCServerRequest({ fetchServer, createFromReadableStream, async renderHTML(getPayload) { - const payload = await getPayload(); + const payload = getPayload(); return await renderHTMLToReadableStream( , { bootstrapScriptContent, - formState: await getFormState(payload), + formState: await payload.formState, } ); }, diff --git a/docs/api/rsc/routeRSCServerRequest.md b/docs/api/rsc/routeRSCServerRequest.md index f22fd9af3a..85f2f39645 100644 --- a/docs/api/rsc/routeRSCServerRequest.md +++ b/docs/api/rsc/routeRSCServerRequest.md @@ -48,13 +48,13 @@ routeRSCServerRequest({ fetchServer, createFromReadableStream, async renderHTML(getPayload) { - const payload = await getPayload(); + const payload = getPayload(); return await renderHTMLToReadableStream( , { bootstrapScriptContent, - formState: await getFormState(payload), + formState: await payload.formState, } ); }, diff --git a/docs/how-to/react-server-components.md b/docs/how-to/react-server-components.md index ab256ce0fd..66f29006b0 100644 --- a/docs/how-to/react-server-components.md +++ b/docs/how-to/react-server-components.md @@ -402,17 +402,13 @@ export async function generateHTML( createFromReadableStream, // Render the router to HTML. async renderHTML(getPayload) { - const payload = await getPayload(); - const formState = - payload.type === "render" - ? await payload.formState - : undefined; + const payload = getPayload(); return await renderHTMLToReadableStream( , { bootstrapScriptContent, - formState, + formState: await payload.formState, }, ); }, @@ -635,11 +631,7 @@ export async function generateHTML( createFromReadableStream, // Render the router to HTML. async renderHTML(getPayload) { - const payload = await getPayload(); - const formState = - payload.type === "render" - ? await payload.formState - : undefined; + const payload = getPayload(); const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent( @@ -650,7 +642,7 @@ export async function generateHTML( , { bootstrapScriptContent, - formState, + formState: payload.formState, }, ); }, diff --git a/integration/helpers/rsc-parcel/src/prerender.tsx b/integration/helpers/rsc-parcel/src/prerender.tsx index a56e426254..6aef528b93 100644 --- a/integration/helpers/rsc-parcel/src/prerender.tsx +++ b/integration/helpers/rsc-parcel/src/prerender.tsx @@ -21,15 +21,13 @@ export async function prerender( createFromReadableStream, // Render the router to HTML. async renderHTML(getPayload) { - const payload = await getPayload(); - const formState = - payload.type === "render" ? await payload.formState : undefined; - + const payload = getPayload(); + return await renderHTMLToReadableStream( , { bootstrapScriptContent, - formState, + formState: await payload.formState, }, ); }, diff --git a/integration/helpers/rsc-vite/src/entry.ssr.tsx b/integration/helpers/rsc-vite/src/entry.ssr.tsx index cc7b7e9441..a2ed6d2a2e 100644 --- a/integration/helpers/rsc-vite/src/entry.ssr.tsx +++ b/integration/helpers/rsc-vite/src/entry.ssr.tsx @@ -17,16 +17,14 @@ export default async function handler( fetchServer, createFromReadableStream, async renderHTML(getPayload) { - const payload = await getPayload(); - const formState = - payload.type === "render" ? await payload.formState : undefined; - + const payload = getPayload(); + return ReactDomServer.renderToReadableStream( , { bootstrapScriptContent, signal: request.signal, - formState, + formState: await payload.formState, }, ); }, diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx index bb5269593e..5b15a77f71 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx @@ -18,16 +18,14 @@ export default async function handler( fetchServer, createFromReadableStream, async renderHTML(getPayload) { - const payload = await getPayload(); - const formState = - payload.type === "render" ? await payload.formState : undefined; + const payload = getPayload(); return ReactDomServer.renderToReadableStream( , { bootstrapScriptContent, signal: request.signal, - formState, + formState: await payload.formState, }, ); }, diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 0139fc8a41..ad127cbd1c 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -52,13 +52,13 @@ export type SSRCreateFromReadableStreamFunction = ( * fetchServer, * createFromReadableStream, * async renderHTML(getPayload) { - * const payload = await getPayload(); + * const payload = getPayload(); * * return await renderHTMLToReadableStream( * , * { * bootstrapScriptContent, - * formState: await getFormState(payload), + * formState: await payload.formState, * } * ); * }, @@ -328,13 +328,13 @@ export interface RSCStaticRouterProps { * fetchServer, * createFromReadableStream, * async renderHTML(getPayload) { - * const payload = await getPayload(); + * const payload = getPayload(); * * return await renderHTMLToReadableStream( * , * { * bootstrapScriptContent, - * formState: await getFormState(payload), + * formState: await payload.formState, * } * ); * }, diff --git a/playground/rsc-parcel/src/entry.ssr.tsx b/playground/rsc-parcel/src/entry.ssr.tsx index c07ec7e771..1f29d629d3 100644 --- a/playground/rsc-parcel/src/entry.ssr.tsx +++ b/playground/rsc-parcel/src/entry.ssr.tsx @@ -22,9 +22,7 @@ app.use( fetchServer, createFromReadableStream, async renderHTML(getPayload) { - const payload = await getPayload(); - const formState = - payload.type === "render" ? await payload.formState : undefined; + const payload = getPayload(); return await renderHTMLToReadableStream( , @@ -32,7 +30,7 @@ app.use( bootstrapScriptContent: ( fetchServer as unknown as { bootstrapScript: string } ).bootstrapScript, - formState, + formState: await payload.formState, }, ); }, diff --git a/playground/rsc-vite/src/entry.ssr.tsx b/playground/rsc-vite/src/entry.ssr.tsx index cc7b7e9441..6f47c70f85 100644 --- a/playground/rsc-vite/src/entry.ssr.tsx +++ b/playground/rsc-vite/src/entry.ssr.tsx @@ -17,16 +17,14 @@ export default async function handler( fetchServer, createFromReadableStream, async renderHTML(getPayload) { - const payload = await getPayload(); - const formState = - payload.type === "render" ? await payload.formState : undefined; + const payload = getPayload(); return ReactDomServer.renderToReadableStream( , { bootstrapScriptContent, signal: request.signal, - formState, + formState: await payload.formState, }, ); }, From 63d33b77c0dbe31a098ae612ae16debbb8109c8e Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 16 Sep 2025 17:57:48 -0700 Subject: [PATCH 3/3] Update .changeset/silent-emus-grow.md Co-authored-by: Mark Dalgleish --- .changeset/silent-emus-grow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/silent-emus-grow.md b/.changeset/silent-emus-grow.md index 7984020573..d9a2c44a93 100644 --- a/.changeset/silent-emus-grow.md +++ b/.changeset/silent-emus-grow.md @@ -2,4 +2,4 @@ "react-router": patch --- -handle SSR'd client errors and re-try in the browser +In RSC Data Mode, handle SSR'd client errors and re-try in the browser