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/empty-rabbits-live.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion integration/rsc/rsc-nojs-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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/`);
Expand Down
58 changes: 55 additions & 3 deletions integration/rsc/rsc-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <p>{error.status} {error.statusText} {error.data?.message || "no"}</p>;
}
return <p>Oh no D:</p>;
}
`,
},
});
});
Expand Down Expand Up @@ -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();
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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/`,
);
Expand Down
61 changes: 59 additions & 2 deletions packages/react-router/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -37,3 +42,55 @@ export function decodeRedirectErrorDigest(digest: string):
} catch {}
}
}

export function createRouteErrorResponseDigest(
response: DataWithResponseInit<unknown> | 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 {}
}
}
26 changes: 20 additions & 6 deletions packages/react-router/lib/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -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 ? (
<RouteContext.Provider value={this.props.routeContext}>
<RouteErrorContext.Provider
value={this.state.error}
value={error}
children={this.props.component}
/>
</RouteContext.Provider>
Expand All @@ -1081,9 +1097,7 @@ export class RenderErrorBoundary extends React.Component<
);

if (this.context) {
return (
<RSCErrorHandler error={this.state.error}>{result}</RSCErrorHandler>
);
return <RSCErrorHandler error={error}>{result}</RSCErrorHandler>;
}

return result;
Expand Down
9 changes: 8 additions & 1 deletion packages/react-router/lib/rsc/server.rsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import { type Location } from "../router/history";
import {
createStaticHandler,
isDataWithResponseInit,
isMutationMethod,
isResponse,
isRedirectResponse,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 35 additions & 5 deletions packages/react-router/lib/rsc/server.ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RSCPayload> & {
Expand Down Expand Up @@ -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");
Expand All @@ -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 (
Expand All @@ -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) {
Expand Down Expand Up @@ -259,7 +274,8 @@ export async function routeRSCServerRequest({

if (!hydrate) {
return new Response(html.pipeThrough(redirectTransform), {
status: serverResponse.status,
status,
statusText,
headers,
});
}
Expand All @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -379,7 +407,8 @@ export async function routeRSCServerRequest({

if (!hydrate) {
return new Response(html.pipeThrough(retryRedirectTransform), {
status: status,
status,
statusText,
headers,
});
}
Expand All @@ -393,6 +422,7 @@ export async function routeRSCServerRequest({
.pipeThrough(retryRedirectTransform);
return new Response(body, {
status,
statusText,
headers,
});
} catch {
Expand Down