Skip to content

Commit c530aa8

Browse files
authored
fix: handle SSR'd client errors and re-try in the browser (#14342)
1 parent 268349a commit c530aa8

File tree

11 files changed

+176
-52
lines changed

11 files changed

+176
-52
lines changed

.changeset/silent-emus-grow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
In RSC Data Mode, handle SSR'd client errors and re-try in the browser

docs/api/rsc/RSCStaticRouter.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ routeRSCServerRequest({
4646
fetchServer,
4747
createFromReadableStream,
4848
async renderHTML(getPayload) {
49-
const payload = await getPayload();
49+
const payload = getPayload();
5050

5151
return await renderHTMLToReadableStream(
5252
<RSCStaticRouter getPayload={getPayload} />,
5353
{
5454
bootstrapScriptContent,
55-
formState: await getFormState(payload),
55+
formState: await payload.formState,
5656
}
5757
);
5858
},

docs/api/rsc/routeRSCServerRequest.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ routeRSCServerRequest({
4848
fetchServer,
4949
createFromReadableStream,
5050
async renderHTML(getPayload) {
51-
const payload = await getPayload();
51+
const payload = getPayload();
5252

5353
return await renderHTMLToReadableStream(
5454
<RSCStaticRouter getPayload={getPayload} />,
5555
{
5656
bootstrapScriptContent,
57-
formState: await getFormState(payload),
57+
formState: await payload.formState,
5858
}
5959
);
6060
},

docs/how-to/react-server-components.md

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -402,17 +402,13 @@ export async function generateHTML(
402402
createFromReadableStream,
403403
// Render the router to HTML.
404404
async renderHTML(getPayload) {
405-
const payload = await getPayload();
406-
const formState =
407-
payload.type === "render"
408-
? await payload.formState
409-
: undefined;
405+
const payload = getPayload();
410406

411407
return await renderHTMLToReadableStream(
412408
<RSCStaticRouter getPayload={getPayload} />,
413409
{
414410
bootstrapScriptContent,
415-
formState,
411+
formState: await payload.formState,
416412
},
417413
);
418414
},
@@ -635,11 +631,7 @@ export async function generateHTML(
635631
createFromReadableStream,
636632
// Render the router to HTML.
637633
async renderHTML(getPayload) {
638-
const payload = await getPayload();
639-
const formState =
640-
payload.type === "render"
641-
? await payload.formState
642-
: undefined;
634+
const payload = getPayload();
643635

644636
const bootstrapScriptContent =
645637
await import.meta.viteRsc.loadBootstrapScriptContent(
@@ -650,7 +642,7 @@ export async function generateHTML(
650642
<RSCStaticRouter getPayload={getPayload} />,
651643
{
652644
bootstrapScriptContent,
653-
formState,
645+
formState: payload.formState,
654646
},
655647
);
656648
},

integration/helpers/rsc-parcel/src/prerender.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,13 @@ export async function prerender(
2121
createFromReadableStream,
2222
// Render the router to HTML.
2323
async renderHTML(getPayload) {
24-
const payload = await getPayload();
25-
const formState =
26-
payload.type === "render" ? await payload.formState : undefined;
27-
24+
const payload = getPayload();
25+
2826
return await renderHTMLToReadableStream(
2927
<RSCStaticRouter getPayload={getPayload} />,
3028
{
3129
bootstrapScriptContent,
32-
formState,
30+
formState: await payload.formState,
3331
},
3432
);
3533
},

integration/helpers/rsc-vite/src/entry.ssr.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,14 @@ export default async function handler(
1717
fetchServer,
1818
createFromReadableStream,
1919
async renderHTML(getPayload) {
20-
const payload = await getPayload();
21-
const formState =
22-
payload.type === "render" ? await payload.formState : undefined;
23-
20+
const payload = getPayload();
21+
2422
return ReactDomServer.renderToReadableStream(
2523
<RSCStaticRouter getPayload={getPayload} />,
2624
{
2725
bootstrapScriptContent,
2826
signal: request.signal,
29-
formState,
27+
formState: await payload.formState,
3028
},
3129
);
3230
},

integration/rsc/rsc-test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,12 @@ implementations.forEach((implementation) => {
502502
lazy: () => import("./routes/await-component/api"),
503503
}
504504
]
505-
}
505+
},
506+
{
507+
id: "ssr-error",
508+
path: "ssr-error",
509+
lazy: () => import("./routes/ssr-error/ssr-error"),
510+
},
506511
],
507512
},
508513
] satisfies RSCRouteConfig;
@@ -1288,6 +1293,27 @@ implementations.forEach((implementation) => {
12881293
);
12891294
}
12901295
`,
1296+
"src/routes/ssr-error/ssr-error.tsx": js`
1297+
"use client";
1298+
import { useState } from "react";
1299+
1300+
export function ErrorBoundary() {
1301+
const [count, setCount] = useState(0);
1302+
1303+
return (
1304+
<div>
1305+
<div data-error-boundary>Client Error Boundary</div>
1306+
<button data-increment onClick={() => setCount(c => c + 1)}>
1307+
Increment {count}
1308+
</button>
1309+
</div>
1310+
);
1311+
}
1312+
1313+
export default function SSRError() {
1314+
throw new Error("Error from SSR component");
1315+
}
1316+
`,
12911317
},
12921318
});
12931319
});
@@ -1788,6 +1814,25 @@ implementations.forEach((implementation) => {
17881814
// Ensure this is using RSC
17891815
validateRSCHtml(await page.content());
17901816
});
1817+
1818+
test("Handles errors thrown in SSR components correctly", async ({
1819+
page,
1820+
}) => {
1821+
test.skip(
1822+
implementation.name === "parcel",
1823+
"Parcel's error overlays are interfering with this test",
1824+
);
1825+
await page.goto(`http://localhost:${port}/ssr-error`);
1826+
1827+
// Verify error boundary is shown
1828+
await page.waitForSelector("[data-error-boundary]");
1829+
expect(
1830+
await page.locator("[data-error-boundary]").textContent(),
1831+
).toBe("Client Error Boundary");
1832+
1833+
// Ensure this is using RSC
1834+
validateRSCHtml(await page.content());
1835+
});
17911836
});
17921837

17931838
test.describe("Route Client Component Props", () => {

packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,14 @@ export default async function handler(
1818
fetchServer,
1919
createFromReadableStream,
2020
async renderHTML(getPayload) {
21-
const payload = await getPayload();
22-
const formState =
23-
payload.type === "render" ? await payload.formState : undefined;
21+
const payload = getPayload();
2422

2523
return ReactDomServer.renderToReadableStream(
2624
<RSCStaticRouter getPayload={getPayload} />,
2725
{
2826
bootstrapScriptContent,
2927
signal: request.signal,
30-
formState,
28+
formState: await payload.formState,
3129
},
3230
);
3331
},

packages/react-router/lib/rsc/server.ssr.tsx

Lines changed: 105 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries";
99
import { shouldHydrateRouteLoader } from "../dom/ssr/routes";
1010
import type { RSCPayload } from "./server.rsc";
1111
import { createRSCRouteModules } from "./route-modules";
12+
import { isRouteErrorResponse } from "../router/utils";
13+
14+
type DecodedPayload = Promise<RSCPayload> & {
15+
_deepestRenderedBoundaryId?: string | null;
16+
formState: Promise<any>;
17+
};
1218

1319
// Safe version of React.use() that will not cause compilation errors against
1420
// React 18 and will result in a runtime error if used (you can't use RSC against
@@ -46,13 +52,13 @@ export type SSRCreateFromReadableStreamFunction = (
4652
* fetchServer,
4753
* createFromReadableStream,
4854
* async renderHTML(getPayload) {
49-
* const payload = await getPayload();
55+
* const payload = getPayload();
5056
*
5157
* return await renderHTMLToReadableStream(
5258
* <RSCStaticRouter getPayload={getPayload} />,
5359
* {
5460
* bootstrapScriptContent,
55-
* formState: await getFormState(payload),
61+
* formState: await payload.formState,
5662
* }
5763
* );
5864
* },
@@ -88,7 +94,7 @@ export async function routeRSCServerRequest({
8894
fetchServer: (request: Request) => Promise<Response>;
8995
createFromReadableStream: SSRCreateFromReadableStreamFunction;
9096
renderHTML: (
91-
getPayload: () => Promise<RSCPayload>,
97+
getPayload: () => DecodedPayload,
9298
) => ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>;
9399
hydrate?: boolean;
94100
}): Promise<Response> {
@@ -150,8 +156,29 @@ export async function routeRSCServerRequest({
150156
});
151157
};
152158

153-
const getPayload = async () => {
154-
return createFromReadableStream(createStream()) as Promise<RSCPayload>;
159+
let deepestRenderedBoundaryId: string | null = null;
160+
const getPayload = (): DecodedPayload => {
161+
const payloadPromise = Promise.resolve(
162+
createFromReadableStream(createStream()),
163+
) as Promise<RSCPayload>;
164+
165+
return Object.defineProperties(payloadPromise, {
166+
_deepestRenderedBoundaryId: {
167+
get() {
168+
return deepestRenderedBoundaryId;
169+
},
170+
set(boundaryId: string | null) {
171+
deepestRenderedBoundaryId = boundaryId;
172+
},
173+
},
174+
formState: {
175+
get() {
176+
return payloadPromise.then((payload) =>
177+
payload.type === "render" ? payload.formState : undefined,
178+
);
179+
},
180+
},
181+
}) as DecodedPayload;
155182
};
156183

157184
try {
@@ -204,11 +231,69 @@ export async function routeRSCServerRequest({
204231
if (reason instanceof Response) {
205232
return reason;
206233
}
234+
235+
try {
236+
const status = isRouteErrorResponse(reason) ? reason.status : 500;
237+
238+
const html = await renderHTML(() => {
239+
const decoded = Promise.resolve(
240+
createFromReadableStream(createStream()),
241+
) as Promise<RSCPayload>;
242+
243+
const payloadPromise = decoded.then((payload) =>
244+
Object.assign(payload, {
245+
status,
246+
errors: deepestRenderedBoundaryId
247+
? {
248+
[deepestRenderedBoundaryId]: reason,
249+
}
250+
: {},
251+
}),
252+
);
253+
254+
return Object.defineProperties(payloadPromise, {
255+
_deepestRenderedBoundaryId: {
256+
get() {
257+
return deepestRenderedBoundaryId;
258+
},
259+
set(boundaryId: string | null) {
260+
deepestRenderedBoundaryId = boundaryId;
261+
},
262+
},
263+
formState: {
264+
get() {
265+
return payloadPromise.then((payload) =>
266+
payload.type === "render" ? payload.formState : undefined,
267+
);
268+
},
269+
},
270+
}) as unknown as DecodedPayload;
271+
});
272+
273+
const headers = new Headers(serverResponse.headers);
274+
headers.set("Content-Type", "text/html");
275+
276+
if (!hydrate) {
277+
return new Response(html, {
278+
status: status,
279+
headers,
280+
});
281+
}
282+
283+
if (!serverResponseB?.body) {
284+
throw new Error("Failed to clone server response");
285+
}
286+
287+
const body = html.pipeThrough(injectRSCPayload(serverResponseB.body));
288+
return new Response(body, {
289+
status,
290+
headers,
291+
});
292+
} catch {
293+
// Throw the original error below
294+
}
295+
207296
throw reason;
208-
// TODO: Track deepest rendered boundary and re-try
209-
// Figure out how / if we need to transport the error,
210-
// or if we can just re-try on the client to reach
211-
// the correct boundary.
212297
}
213298
}
214299

@@ -223,7 +308,7 @@ export interface RSCStaticRouterProps {
223308
* A function that starts decoding of the {@link unstable_RSCPayload}. Usually passed
224309
* through from {@link unstable_routeRSCServerRequest}'s `renderHTML`.
225310
*/
226-
getPayload: () => Promise<RSCPayload>;
311+
getPayload: () => DecodedPayload;
227312
}
228313

229314
/**
@@ -243,13 +328,13 @@ export interface RSCStaticRouterProps {
243328
* fetchServer,
244329
* createFromReadableStream,
245330
* async renderHTML(getPayload) {
246-
* const payload = await getPayload();
331+
* const payload = getPayload();
247332
*
248333
* return await renderHTMLToReadableStream(
249334
* <RSCStaticRouter getPayload={getPayload} />,
250335
* {
251336
* bootstrapScriptContent,
252-
* formState: await getFormState(payload),
337+
* formState: await payload.formState,
253338
* }
254339
* );
255340
* },
@@ -264,8 +349,9 @@ export interface RSCStaticRouterProps {
264349
* @returns A React component that renders the {@link unstable_RSCPayload} as HTML.
265350
*/
266351
export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) {
352+
const decoded = getPayload();
267353
// Can be replaced with React.use when v18 compatibility is no longer required.
268-
const payload = useSafe(getPayload());
354+
const payload = useSafe(decoded);
269355

270356
if (payload.type === "redirect") {
271357
throw new Response(null, {
@@ -298,6 +384,12 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) {
298384
}
299385

300386
const context = {
387+
get _deepestRenderedBoundaryId() {
388+
return decoded._deepestRenderedBoundaryId ?? null;
389+
},
390+
set _deepestRenderedBoundaryId(boundaryId: string | null) {
391+
decoded._deepestRenderedBoundaryId = boundaryId;
392+
},
301393
actionData: payload.actionData,
302394
actionHeaders: {},
303395
basename: payload.basename,

0 commit comments

Comments
 (0)