Skip to content

Commit ab818cd

Browse files
committed
fix: handle SSR'd client errors and re-try in the browser
1 parent 8233930 commit ab818cd

File tree

3 files changed

+152
-10
lines changed

3 files changed

+152
-10
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+
handle SSR'd client errors and re-try in the browser

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/lib/rsc/server.ssr.tsx

Lines changed: 101 additions & 9 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
@@ -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
/**
@@ -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)