diff --git a/.changeset/silent-emus-grow.md b/.changeset/silent-emus-grow.md
new file mode 100644
index 0000000000..d9a2c44a93
--- /dev/null
+++ b/.changeset/silent-emus-grow.md
@@ -0,0 +1,5 @@
+---
+"react-router": patch
+---
+
+In RSC Data Mode, handle SSR'd client errors and re-try in the browser
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/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-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 93f4377399..ad127cbd1c 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
@@ -46,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,
* }
* );
* },
@@ -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;
}
/**
@@ -243,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,
* }
* );
* },
@@ -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,
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,
},
);
},