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-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 cfdb45474e..ff8364d1cb 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/:id?", + lazy: () => import("./routes/render-route-error-response/home"), + } ], }, ] satisfies RSCRouteConfig; @@ -1524,6 +1529,30 @@ 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({ 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, isRouteErrorResponse } from "react-router"; + + export function ErrorBoundary() { + const error = useRouteError(); + if (isRouteErrorResponse(error)) { + return

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

; + } + return

Oh no D:

; + } + `, }, }); }); @@ -1816,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(); @@ -1838,14 +1872,35 @@ 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(); await page.waitForURL(`https://example.com/`); await expect(page.getByText("Example Domain")).toBeAttached(); }); + + 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("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(); + }); }); test.describe("Server Actions", () => { @@ -1945,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/`, ); 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/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 {