From 2c04289890968603190e5f6efce3a74138ee10ca Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 3 Dec 2025 20:32:01 -0800 Subject: [PATCH 1/5] feat(rsc): support throwing `data()` and Response from server component render phase --- .changeset/empty-rabbits-live.md | 5 ++ integration/rsc/rsc-test.ts | 31 ++++++++++ packages/react-router/lib/errors.ts | 61 +++++++++++++++++++- packages/react-router/lib/hooks.tsx | 26 +++++++-- packages/react-router/lib/router/utils.ts | 3 +- packages/react-router/lib/rsc/server.rsc.ts | 9 ++- packages/react-router/lib/rsc/server.ssr.tsx | 40 +++++++++++-- 7 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 .changeset/empty-rabbits-live.md diff --git a/.changeset/empty-rabbits-live.md b/.changeset/empty-rabbits-live.md new file mode 100644 index 0000000000..405e005aba --- /dev/null +++ b/.changeset/empty-rabbits-live.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Support for throwing `data()` and Response from server component render phase. Response body is not serialized as async work is not allowed as error encoding phase. If you wish to transmit data to the boundary, throw `data()` instead. diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index cfdb45474e..4e6292bc88 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -545,6 +545,11 @@ implementations.forEach((implementation) => { path: "/render-redirect/:id?", lazy: () => import("./routes/render-redirect/home"), }, + { + id: "render-route-error-response", + path: "render-route-error-response", + lazy: () => import("./routes/render-route-error-response/home"), + } ], }, ] satisfies RSCRouteConfig; @@ -1524,6 +1529,25 @@ implementations.forEach((implementation) => { ); } `, + + "src/routes/render-route-error-response/home.tsx": js` + import { data } from "react-router"; + + export { ErrorBoundary } from "./home.client"; + + export default function RenderRouteErrorResponse() { + throw data({ message: "Test" }, { status: 400, statusText: "Oh no!" }); + } + `, + "src/routes/render-route-error-response/home.client.tsx": js` + "use client"; + import { useRouteError } from "react-router"; + + export function ErrorBoundary() { + const error = useRouteError(); + return

{error.status} {error.statusText} {error.data.message}

+ } + `, }, }); }); @@ -1846,6 +1870,13 @@ implementations.forEach((implementation) => { await page.waitForURL(`https://example.com/`); await expect(page.getByText("Example Domain")).toBeAttached(); }); + + test("Support throwing data() responses", async ({ page }) => { + await page.goto( + `http://localhost:${port}/render-route-error-response`, + ); + await expect(page.getByText("400 Oh no! Test")).toBeAttached(); + }); }); test.describe("Server Actions", () => { diff --git a/packages/react-router/lib/errors.ts b/packages/react-router/lib/errors.ts index aa6436fe14..8887f751a8 100644 --- a/packages/react-router/lib/errors.ts +++ b/packages/react-router/lib/errors.ts @@ -1,5 +1,10 @@ -const ERROR_DIGEST_BASE = "REACT_ROUTER_ERROR"; -const ERROR_DIGEST_REDIRECT = "REDIRECT"; +import { isDataWithResponseInit } from "./router/router"; +import { ErrorResponseImpl } from "./router/utils"; +import type { DataWithResponseInit } from "./router/utils"; + +const ERROR_DIGEST_BASE = "REACT_ROUTER_ERROR"; // 18 +const ERROR_DIGEST_REDIRECT = "REDIRECT"; // 8 +const ERROR_DIGEST_ROUTE_ERROR_RESPONSE = "ROUTE_ERROR_RESPONSE"; // 20 export function createRedirectErrorDigest(response: Response) { return `${ERROR_DIGEST_BASE}:${ERROR_DIGEST_REDIRECT}:${JSON.stringify({ @@ -37,3 +42,55 @@ export function decodeRedirectErrorDigest(digest: string): } catch {} } } + +export function createRouteErrorResponseDigest( + response: DataWithResponseInit | Response, +) { + let status = 500; + let statusText = ""; + let data: unknown; + if (isDataWithResponseInit(response)) { + status = response.init?.status ?? status; + statusText = response.init?.statusText ?? statusText; + data = response.data; + } else { + status = response.status; + statusText = response.statusText; + // We can't do async work here to read the response body. + data = undefined; + } + + return `${ERROR_DIGEST_BASE}:${ERROR_DIGEST_ROUTE_ERROR_RESPONSE}:${JSON.stringify( + { + status, + statusText, + data, + }, + )}`; +} + +export function decodeRouteErrorResponseDigest( + digest: string, +): undefined | ErrorResponseImpl { + if ( + digest.startsWith( + `${ERROR_DIGEST_BASE}:${ERROR_DIGEST_ROUTE_ERROR_RESPONSE}:{`, + ) + ) { + try { + let parsed = JSON.parse(digest.slice(40)); + if ( + typeof parsed === "object" && + parsed && + typeof parsed.status === "number" && + typeof parsed.statusText === "string" + ) { + return new ErrorResponseImpl( + parsed.status, + parsed.statusText, + parsed.data, + ); + } + } catch {} + } +} diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 60708f416f..c30d77796b 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -61,7 +61,10 @@ import type { } from "./types/route-data"; import type { unstable_ClientOnErrorFunction } from "./components"; import type { RouteModules } from "./types/register"; -import { decodeRedirectErrorDigest } from "./errors"; +import { + decodeRedirectErrorDigest, + decodeRouteErrorResponseDigest, +} from "./errors"; /** * Resolves a URL against the current {@link Location}. @@ -1068,11 +1071,24 @@ export class RenderErrorBoundary extends React.Component< } render() { + let error = this.state.error; + + if ( + this.context && + typeof error === "object" && + error && + "digest" in error && + typeof error.digest === "string" + ) { + const decoded = decodeRouteErrorResponseDigest(error.digest); + if (decoded) error = decoded; + } + let result = - this.state.error !== undefined ? ( + error !== undefined ? ( @@ -1081,9 +1097,7 @@ export class RenderErrorBoundary extends React.Component< ); if (this.context) { - return ( - {result} - ); + return {result}; } return result; diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 74046d529c..b72e25a542 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -1809,12 +1809,13 @@ export const normalizeSearch = (search: string): string => export const normalizeHash = (hash: string): string => !hash || hash === "#" ? "" : hash.startsWith("#") ? hash : "#" + hash; -export class DataWithResponseInit { +export class DataWithResponseInit extends Error { type: string = "DataWithResponseInit"; data: D; init: ResponseInit | null; constructor(data: D, init?: ResponseInit) { + super("DataWithResponseInit"); this.data = data; this.init = init || null; } diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 405c0c89b4..e710591c50 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -12,6 +12,7 @@ import type { import { type Location } from "../router/history"; import { createStaticHandler, + isDataWithResponseInit, isMutationMethod, isResponse, isRedirectResponse, @@ -61,7 +62,10 @@ import type { HydrateFallbackProps, } from "../components"; -import { createRedirectErrorDigest } from "../errors"; +import { + createRedirectErrorDigest, + createRouteErrorResponseDigest, +} from "../errors"; const Outlet: typeof OutletType = UNTYPED_Outlet; const WithComponentProps: typeof WithComponentPropsType = @@ -1356,6 +1360,9 @@ function defaultOnError(error: unknown) { if (isRedirectResponse(error)) { return createRedirectErrorDigest(error); } + if (isResponse(error) || isDataWithResponseInit(error)) { + return createRouteErrorResponseDigest(error); + } } function isClientReference(x: any) { diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 230dfe59c8..bf49d14ba9 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -10,7 +10,10 @@ import { shouldHydrateRouteLoader } from "../dom/ssr/routes"; import type { RSCPayload } from "./server.rsc"; import { createRSCRouteModules } from "./route-modules"; import { isRouteErrorResponse } from "../router/utils"; -import { decodeRedirectErrorDigest } from "../errors"; +import { + decodeRedirectErrorDigest, + decodeRouteErrorResponseDigest, +} from "../errors"; import { escapeHtml } from "../dom/ssr/markup"; type DecodedPayload = Promise & { @@ -184,6 +187,8 @@ export async function routeRSCServerRequest({ }; let renderRedirect: { status: number; location: string } | undefined; + let renderError: unknown; + try { if (!detectRedirectResponse.body) { throw new Error("Failed to clone server response"); @@ -210,6 +215,9 @@ export async function routeRSCServerRequest({ } let reactHeaders = new Headers(); + let status = serverResponse.status; + let statusText = serverResponse.statusText; + let html = await renderHTML(getPayload, { onError(error: unknown) { if ( @@ -222,6 +230,13 @@ export async function routeRSCServerRequest({ if (renderRedirect) { return error.digest; } + let routeErrorResponse = decodeRouteErrorResponseDigest(error.digest); + if (routeErrorResponse) { + renderError = routeErrorResponse; + status = routeErrorResponse.status; + statusText = routeErrorResponse.statusText; + return error.digest; + } } }, onHeaders(headers) { @@ -259,7 +274,8 @@ export async function routeRSCServerRequest({ if (!hydrate) { return new Response(html.pipeThrough(redirectTransform), { - status: serverResponse.status, + status, + statusText, headers, }); } @@ -272,7 +288,8 @@ export async function routeRSCServerRequest({ .pipeThrough(injectRSCPayload(serverResponseB.body)) .pipeThrough(redirectTransform); return new Response(body, { - status: serverResponse.status, + status, + statusText, headers, }); } catch (reason) { @@ -290,7 +307,10 @@ export async function routeRSCServerRequest({ } try { - const status = isRouteErrorResponse(reason) ? reason.status : 500; + reason = renderError ?? reason; + let [status, statusText] = isRouteErrorResponse(reason) + ? [reason.status, reason.statusText] + : [500, ""]; let retryRedirect: { status: number; location: string } | undefined; let reactHeaders = new Headers(); @@ -341,6 +361,14 @@ export async function routeRSCServerRequest({ if (retryRedirect) { return error.digest; } + let routeErrorResponse = decodeRouteErrorResponseDigest( + error.digest, + ); + if (routeErrorResponse) { + status = routeErrorResponse.status; + statusText = routeErrorResponse.statusText; + return error.digest; + } } }, onHeaders(headers) { @@ -379,7 +407,8 @@ export async function routeRSCServerRequest({ if (!hydrate) { return new Response(html.pipeThrough(retryRedirectTransform), { - status: status, + status, + statusText, headers, }); } @@ -393,6 +422,7 @@ export async function routeRSCServerRequest({ .pipeThrough(retryRedirectTransform); return new Response(body, { status, + statusText, headers, }); } catch { From 4d9328ffdc563af0c61cae758ec28b3388e40117 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 3 Dec 2025 20:45:50 -0800 Subject: [PATCH 2/5] revert extend error --- packages/react-router/lib/router/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index b72e25a542..74046d529c 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -1809,13 +1809,12 @@ export const normalizeSearch = (search: string): string => export const normalizeHash = (hash: string): string => !hash || hash === "#" ? "" : hash.startsWith("#") ? hash : "#" + hash; -export class DataWithResponseInit extends Error { +export class DataWithResponseInit { type: string = "DataWithResponseInit"; data: D; init: ResponseInit | null; constructor(data: D, init?: ResponseInit) { - super("DataWithResponseInit"); this.data = data; this.init = init || null; } From 468e05b02600db685b85ec981d0c3d92aee61da0 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 3 Dec 2025 21:13:51 -0800 Subject: [PATCH 3/5] add test for throw Response --- integration/rsc/rsc-test.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index 4e6292bc88..1766149cd8 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -547,7 +547,7 @@ implementations.forEach((implementation) => { }, { id: "render-route-error-response", - path: "render-route-error-response", + path: "render-route-error-response/:id?", lazy: () => import("./routes/render-route-error-response/home"), } ], @@ -1535,17 +1535,22 @@ implementations.forEach((implementation) => { export { ErrorBoundary } from "./home.client"; - export default function RenderRouteErrorResponse() { - throw data({ message: "Test" }, { status: 400, statusText: "Oh no!" }); + export default function RenderRouteErrorResponse({ params: { id } }) { + if (!id) throw new Response(null, { status: 400, statusText: "Oh no!" }); + + throw data({ message: id }, { status: 400, statusText: "Oh no!" }); } `, "src/routes/render-route-error-response/home.client.tsx": js` "use client"; - import { useRouteError } from "react-router"; + import { useRouteError, isRouteErrorResponse } from "react-router"; export function ErrorBoundary() { const error = useRouteError(); - return

{error.status} {error.statusText} {error.data.message}

+ if (isRouteErrorResponse(error)) { + return

{error.status} {error.statusText} {error.data?.message || "no"}

; + } + return

Oh no D:

; } `, }, @@ -1871,10 +1876,19 @@ implementations.forEach((implementation) => { await expect(page.getByText("Example Domain")).toBeAttached(); }); - test("Support throwing data() responses", async ({ page }) => { + test.only("Support throwing Responses", async ({ page }) => { await page.goto( `http://localhost:${port}/render-route-error-response`, ); + await expect(page.getByText("400 Oh no! no")).toBeAttached(); + }); + + test.only("Support throwing data() responses with data", async ({ + page, + }) => { + await page.goto( + `http://localhost:${port}/render-route-error-response/Test`, + ); await expect(page.getByText("400 Oh no! Test")).toBeAttached(); }); }); From d4512d39c722394dc5039fe4af338dfb444f6335 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 3 Dec 2025 23:42:04 -0800 Subject: [PATCH 4/5] remove .only --- integration/rsc/rsc-test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index 1766149cd8..43f77c9e45 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -1876,14 +1876,14 @@ implementations.forEach((implementation) => { await expect(page.getByText("Example Domain")).toBeAttached(); }); - test.only("Support throwing Responses", async ({ page }) => { + test("Support throwing Responses", async ({ page }) => { await page.goto( `http://localhost:${port}/render-route-error-response`, ); await expect(page.getByText("400 Oh no! no")).toBeAttached(); }); - test.only("Support throwing data() responses with data", async ({ + test("Support throwing data() responses with data", async ({ page, }) => { await page.goto( From 0d2d99e29bf12dd8664afe76aabd77cad7e6690b Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Fri, 5 Dec 2025 12:10:57 -0800 Subject: [PATCH 5/5] gate external redirect tests for firefox --- integration/rsc/rsc-nojs-test.ts | 7 ++++++- integration/rsc/rsc-test.ts | 13 ++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/integration/rsc/rsc-nojs-test.ts b/integration/rsc/rsc-nojs-test.ts index 5bb15307b9..109cc2dc48 100644 --- a/integration/rsc/rsc-nojs-test.ts +++ b/integration/rsc/rsc-nojs-test.ts @@ -223,8 +223,13 @@ implementations.forEach((implementation) => { }); test("Suppport throwing external redirect Response from render", async ({ + browserName, page, }) => { + test.skip( + browserName === "firefox", + "Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.", + ); await page.goto(`http://localhost:${port}/render-redirect`); await expect(page.getByText("home")).toBeAttached(); await page.getByText("External").click(); @@ -248,7 +253,7 @@ implementations.forEach((implementation) => { }) => { test.skip( browserName === "firefox", - "Playwright doesn't like external meta redirects for tests. It times out waiting for the URL even though it navigates.", + "Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.", ); await page.goto(`http://localhost:${port}/render-redirect/lazy/external`); await page.waitForURL(`https://example.com/`); diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index 43f77c9e45..ff8364d1cb 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -1845,8 +1845,13 @@ implementations.forEach((implementation) => { }); test("Suppport throwing external redirect Response from render", async ({ + browserName, page, }) => { + test.skip( + browserName === "firefox", + "Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.", + ); await page.goto(`http://localhost:${port}/render-redirect`); await expect(page.getByText("home")).toBeAttached(); await page.getByText("External").click(); @@ -1867,8 +1872,13 @@ implementations.forEach((implementation) => { }); test("Suppport throwing external redirect Response from suspended render", async ({ + browserName, page, }) => { + test.skip( + browserName === "firefox", + "Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.", + ); await page.goto(`http://localhost:${port}/render-redirect/lazy`); await expect(page.getByText("home")).toBeAttached(); await page.getByText("External").click(); @@ -1990,9 +2000,6 @@ implementations.forEach((implementation) => { test("Supports React Server Functions thrown external redirects", async ({ page, }) => { - // Test is expected to fail currently — skip running it - // test.skip(true, "Known failing test for external redirect behavior"); - await page.goto( `http://localhost:${port}/throw-external-redirect-server-action/`, );