Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silent-emus-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

In RSC Data Mode, handle SSR'd client errors and re-try in the browser
4 changes: 2 additions & 2 deletions docs/api/rsc/RSCStaticRouter.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@ routeRSCServerRequest({
fetchServer,
createFromReadableStream,
async renderHTML(getPayload) {
const payload = await getPayload();
const payload = getPayload();

return await renderHTMLToReadableStream(
<RSCStaticRouter getPayload={getPayload} />,
{
bootstrapScriptContent,
formState: await getFormState(payload),
formState: await payload.formState,
}
);
},
Expand Down
4 changes: 2 additions & 2 deletions docs/api/rsc/routeRSCServerRequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@ routeRSCServerRequest({
fetchServer,
createFromReadableStream,
async renderHTML(getPayload) {
const payload = await getPayload();
const payload = getPayload();

return await renderHTMLToReadableStream(
<RSCStaticRouter getPayload={getPayload} />,
{
bootstrapScriptContent,
formState: await getFormState(payload),
formState: await payload.formState,
}
);
},
Expand Down
16 changes: 4 additions & 12 deletions docs/how-to/react-server-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RSCStaticRouter getPayload={getPayload} />,
{
bootstrapScriptContent,
formState,
formState: await payload.formState,
},
);
},
Expand Down Expand Up @@ -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(
Expand All @@ -650,7 +642,7 @@ export async function generateHTML(
<RSCStaticRouter getPayload={getPayload} />,
{
bootstrapScriptContent,
formState,
formState: payload.formState,
},
);
},
Expand Down
8 changes: 3 additions & 5 deletions integration/helpers/rsc-parcel/src/prerender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RSCStaticRouter getPayload={getPayload} />,
{
bootstrapScriptContent,
formState,
formState: await payload.formState,
},
);
},
Expand Down
8 changes: 3 additions & 5 deletions integration/helpers/rsc-vite/src/entry.ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RSCStaticRouter getPayload={getPayload} />,
{
bootstrapScriptContent,
signal: request.signal,
formState,
formState: await payload.formState,
},
);
},
Expand Down
47 changes: 46 additions & 1 deletion integration/rsc/rsc-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div>
<div data-error-boundary>Client Error Boundary</div>
<button data-increment onClick={() => setCount(c => c + 1)}>
Increment {count}
</button>
</div>
);
}

export default function SSRError() {
throw new Error("Error from SSR component");
}
`,
},
});
});
Expand Down Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RSCStaticRouter getPayload={getPayload} />,
{
bootstrapScriptContent,
signal: request.signal,
formState,
formState: await payload.formState,
},
);
},
Expand Down
118 changes: 105 additions & 13 deletions packages/react-router/lib/rsc/server.ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RSCPayload> & {
_deepestRenderedBoundaryId?: string | null;
formState: Promise<any>;
};

// 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
Expand Down Expand Up @@ -46,13 +52,13 @@ export type SSRCreateFromReadableStreamFunction = (
* fetchServer,
* createFromReadableStream,
* async renderHTML(getPayload) {
* const payload = await getPayload();
* const payload = getPayload();
*
* return await renderHTMLToReadableStream(
* <RSCStaticRouter getPayload={getPayload} />,
* {
* bootstrapScriptContent,
* formState: await getFormState(payload),
* formState: await payload.formState,
* }
* );
* },
Expand Down Expand Up @@ -88,7 +94,7 @@ export async function routeRSCServerRequest({
fetchServer: (request: Request) => Promise<Response>;
createFromReadableStream: SSRCreateFromReadableStreamFunction;
renderHTML: (
getPayload: () => Promise<RSCPayload>,
getPayload: () => DecodedPayload,
) => ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>;
hydrate?: boolean;
}): Promise<Response> {
Expand Down Expand Up @@ -150,8 +156,29 @@ export async function routeRSCServerRequest({
});
};

const getPayload = async () => {
return createFromReadableStream(createStream()) as Promise<RSCPayload>;
let deepestRenderedBoundaryId: string | null = null;
const getPayload = (): DecodedPayload => {
const payloadPromise = Promise.resolve(
createFromReadableStream(createStream()),
) as Promise<RSCPayload>;

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 {
Expand Down Expand Up @@ -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<RSCPayload>;

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.
}
}

Expand All @@ -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<RSCPayload>;
getPayload: () => DecodedPayload;
}

/**
Expand All @@ -243,13 +328,13 @@ export interface RSCStaticRouterProps {
* fetchServer,
* createFromReadableStream,
* async renderHTML(getPayload) {
* const payload = await getPayload();
* const payload = getPayload();
*
* return await renderHTMLToReadableStream(
* <RSCStaticRouter getPayload={getPayload} />,
* {
* bootstrapScriptContent,
* formState: await getFormState(payload),
* formState: await payload.formState,
* }
* );
* },
Expand All @@ -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, {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading