Skip to content

Commit 90e07f1

Browse files
authored
feat(rsc): throw data() and Response from server component render phase (#14632)
gate external redirect tests for firefox
1 parent 2f9ffa5 commit 90e07f1

File tree

7 files changed

+188
-18
lines changed

7 files changed

+188
-18
lines changed

.changeset/empty-rabbits-live.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+
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.

integration/rsc/rsc-nojs-test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,13 @@ implementations.forEach((implementation) => {
223223
});
224224

225225
test("Suppport throwing external redirect Response from render", async ({
226+
browserName,
226227
page,
227228
}) => {
229+
test.skip(
230+
browserName === "firefox",
231+
"Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.",
232+
);
228233
await page.goto(`http://localhost:${port}/render-redirect`);
229234
await expect(page.getByText("home")).toBeAttached();
230235
await page.getByText("External").click();
@@ -248,7 +253,7 @@ implementations.forEach((implementation) => {
248253
}) => {
249254
test.skip(
250255
browserName === "firefox",
251-
"Playwright doesn't like external meta redirects for tests. It times out waiting for the URL even though it navigates.",
256+
"Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.",
252257
);
253258
await page.goto(`http://localhost:${port}/render-redirect/lazy/external`);
254259
await page.waitForURL(`https://example.com/`);

integration/rsc/rsc-test.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,11 @@ implementations.forEach((implementation) => {
545545
path: "/render-redirect/:id?",
546546
lazy: () => import("./routes/render-redirect/home"),
547547
},
548+
{
549+
id: "render-route-error-response",
550+
path: "render-route-error-response/:id?",
551+
lazy: () => import("./routes/render-route-error-response/home"),
552+
}
548553
],
549554
},
550555
] satisfies RSCRouteConfig;
@@ -1524,6 +1529,30 @@ implementations.forEach((implementation) => {
15241529
);
15251530
}
15261531
`,
1532+
1533+
"src/routes/render-route-error-response/home.tsx": js`
1534+
import { data } from "react-router";
1535+
1536+
export { ErrorBoundary } from "./home.client";
1537+
1538+
export default function RenderRouteErrorResponse({ params: { id } }) {
1539+
if (!id) throw new Response(null, { status: 400, statusText: "Oh no!" });
1540+
1541+
throw data({ message: id }, { status: 400, statusText: "Oh no!" });
1542+
}
1543+
`,
1544+
"src/routes/render-route-error-response/home.client.tsx": js`
1545+
"use client";
1546+
import { useRouteError, isRouteErrorResponse } from "react-router";
1547+
1548+
export function ErrorBoundary() {
1549+
const error = useRouteError();
1550+
if (isRouteErrorResponse(error)) {
1551+
return <p>{error.status} {error.statusText} {error.data?.message || "no"}</p>;
1552+
}
1553+
return <p>Oh no D:</p>;
1554+
}
1555+
`,
15271556
},
15281557
});
15291558
});
@@ -1816,8 +1845,13 @@ implementations.forEach((implementation) => {
18161845
});
18171846

18181847
test("Suppport throwing external redirect Response from render", async ({
1848+
browserName,
18191849
page,
18201850
}) => {
1851+
test.skip(
1852+
browserName === "firefox",
1853+
"Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.",
1854+
);
18211855
await page.goto(`http://localhost:${port}/render-redirect`);
18221856
await expect(page.getByText("home")).toBeAttached();
18231857
await page.getByText("External").click();
@@ -1838,14 +1872,35 @@ implementations.forEach((implementation) => {
18381872
});
18391873

18401874
test("Suppport throwing external redirect Response from suspended render", async ({
1875+
browserName,
18411876
page,
18421877
}) => {
1878+
test.skip(
1879+
browserName === "firefox",
1880+
"Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.",
1881+
);
18431882
await page.goto(`http://localhost:${port}/render-redirect/lazy`);
18441883
await expect(page.getByText("home")).toBeAttached();
18451884
await page.getByText("External").click();
18461885
await page.waitForURL(`https://example.com/`);
18471886
await expect(page.getByText("Example Domain")).toBeAttached();
18481887
});
1888+
1889+
test("Support throwing Responses", async ({ page }) => {
1890+
await page.goto(
1891+
`http://localhost:${port}/render-route-error-response`,
1892+
);
1893+
await expect(page.getByText("400 Oh no! no")).toBeAttached();
1894+
});
1895+
1896+
test("Support throwing data() responses with data", async ({
1897+
page,
1898+
}) => {
1899+
await page.goto(
1900+
`http://localhost:${port}/render-route-error-response/Test`,
1901+
);
1902+
await expect(page.getByText("400 Oh no! Test")).toBeAttached();
1903+
});
18491904
});
18501905

18511906
test.describe("Server Actions", () => {
@@ -1945,9 +2000,6 @@ implementations.forEach((implementation) => {
19452000
test("Supports React Server Functions thrown external redirects", async ({
19462001
page,
19472002
}) => {
1948-
// Test is expected to fail currently — skip running it
1949-
// test.skip(true, "Known failing test for external redirect behavior");
1950-
19512003
await page.goto(
19522004
`http://localhost:${port}/throw-external-redirect-server-action/`,
19532005
);

packages/react-router/lib/errors.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
const ERROR_DIGEST_BASE = "REACT_ROUTER_ERROR";
2-
const ERROR_DIGEST_REDIRECT = "REDIRECT";
1+
import { isDataWithResponseInit } from "./router/router";
2+
import { ErrorResponseImpl } from "./router/utils";
3+
import type { DataWithResponseInit } from "./router/utils";
4+
5+
const ERROR_DIGEST_BASE = "REACT_ROUTER_ERROR"; // 18
6+
const ERROR_DIGEST_REDIRECT = "REDIRECT"; // 8
7+
const ERROR_DIGEST_ROUTE_ERROR_RESPONSE = "ROUTE_ERROR_RESPONSE"; // 20
38

49
export function createRedirectErrorDigest(response: Response) {
510
return `${ERROR_DIGEST_BASE}:${ERROR_DIGEST_REDIRECT}:${JSON.stringify({
@@ -37,3 +42,55 @@ export function decodeRedirectErrorDigest(digest: string):
3742
} catch {}
3843
}
3944
}
45+
46+
export function createRouteErrorResponseDigest(
47+
response: DataWithResponseInit<unknown> | Response,
48+
) {
49+
let status = 500;
50+
let statusText = "";
51+
let data: unknown;
52+
if (isDataWithResponseInit(response)) {
53+
status = response.init?.status ?? status;
54+
statusText = response.init?.statusText ?? statusText;
55+
data = response.data;
56+
} else {
57+
status = response.status;
58+
statusText = response.statusText;
59+
// We can't do async work here to read the response body.
60+
data = undefined;
61+
}
62+
63+
return `${ERROR_DIGEST_BASE}:${ERROR_DIGEST_ROUTE_ERROR_RESPONSE}:${JSON.stringify(
64+
{
65+
status,
66+
statusText,
67+
data,
68+
},
69+
)}`;
70+
}
71+
72+
export function decodeRouteErrorResponseDigest(
73+
digest: string,
74+
): undefined | ErrorResponseImpl {
75+
if (
76+
digest.startsWith(
77+
`${ERROR_DIGEST_BASE}:${ERROR_DIGEST_ROUTE_ERROR_RESPONSE}:{`,
78+
)
79+
) {
80+
try {
81+
let parsed = JSON.parse(digest.slice(40));
82+
if (
83+
typeof parsed === "object" &&
84+
parsed &&
85+
typeof parsed.status === "number" &&
86+
typeof parsed.statusText === "string"
87+
) {
88+
return new ErrorResponseImpl(
89+
parsed.status,
90+
parsed.statusText,
91+
parsed.data,
92+
);
93+
}
94+
} catch {}
95+
}
96+
}

packages/react-router/lib/hooks.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ import type {
6161
} from "./types/route-data";
6262
import type { unstable_ClientOnErrorFunction } from "./components";
6363
import type { RouteModules } from "./types/register";
64-
import { decodeRedirectErrorDigest } from "./errors";
64+
import {
65+
decodeRedirectErrorDigest,
66+
decodeRouteErrorResponseDigest,
67+
} from "./errors";
6568

6669
/**
6770
* Resolves a URL against the current {@link Location}.
@@ -1068,11 +1071,24 @@ export class RenderErrorBoundary extends React.Component<
10681071
}
10691072

10701073
render() {
1074+
let error = this.state.error;
1075+
1076+
if (
1077+
this.context &&
1078+
typeof error === "object" &&
1079+
error &&
1080+
"digest" in error &&
1081+
typeof error.digest === "string"
1082+
) {
1083+
const decoded = decodeRouteErrorResponseDigest(error.digest);
1084+
if (decoded) error = decoded;
1085+
}
1086+
10711087
let result =
1072-
this.state.error !== undefined ? (
1088+
error !== undefined ? (
10731089
<RouteContext.Provider value={this.props.routeContext}>
10741090
<RouteErrorContext.Provider
1075-
value={this.state.error}
1091+
value={error}
10761092
children={this.props.component}
10771093
/>
10781094
</RouteContext.Provider>
@@ -1081,9 +1097,7 @@ export class RenderErrorBoundary extends React.Component<
10811097
);
10821098

10831099
if (this.context) {
1084-
return (
1085-
<RSCErrorHandler error={this.state.error}>{result}</RSCErrorHandler>
1086-
);
1100+
return <RSCErrorHandler error={error}>{result}</RSCErrorHandler>;
10871101
}
10881102

10891103
return result;

packages/react-router/lib/rsc/server.rsc.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import { type Location } from "../router/history";
1313
import {
1414
createStaticHandler,
15+
isDataWithResponseInit,
1516
isMutationMethod,
1617
isResponse,
1718
isRedirectResponse,
@@ -61,7 +62,10 @@ import type {
6162
HydrateFallbackProps,
6263
} from "../components";
6364

64-
import { createRedirectErrorDigest } from "../errors";
65+
import {
66+
createRedirectErrorDigest,
67+
createRouteErrorResponseDigest,
68+
} from "../errors";
6569

6670
const Outlet: typeof OutletType = UNTYPED_Outlet;
6771
const WithComponentProps: typeof WithComponentPropsType =
@@ -1356,6 +1360,9 @@ function defaultOnError(error: unknown) {
13561360
if (isRedirectResponse(error)) {
13571361
return createRedirectErrorDigest(error);
13581362
}
1363+
if (isResponse(error) || isDataWithResponseInit(error)) {
1364+
return createRouteErrorResponseDigest(error);
1365+
}
13591366
}
13601367

13611368
function isClientReference(x: any) {

packages/react-router/lib/rsc/server.ssr.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import { shouldHydrateRouteLoader } from "../dom/ssr/routes";
1010
import type { RSCPayload } from "./server.rsc";
1111
import { createRSCRouteModules } from "./route-modules";
1212
import { isRouteErrorResponse } from "../router/utils";
13-
import { decodeRedirectErrorDigest } from "../errors";
13+
import {
14+
decodeRedirectErrorDigest,
15+
decodeRouteErrorResponseDigest,
16+
} from "../errors";
1417
import { escapeHtml } from "../dom/ssr/markup";
1518

1619
type DecodedPayload = Promise<RSCPayload> & {
@@ -184,6 +187,8 @@ export async function routeRSCServerRequest({
184187
};
185188

186189
let renderRedirect: { status: number; location: string } | undefined;
190+
let renderError: unknown;
191+
187192
try {
188193
if (!detectRedirectResponse.body) {
189194
throw new Error("Failed to clone server response");
@@ -210,6 +215,9 @@ export async function routeRSCServerRequest({
210215
}
211216

212217
let reactHeaders = new Headers();
218+
let status = serverResponse.status;
219+
let statusText = serverResponse.statusText;
220+
213221
let html = await renderHTML(getPayload, {
214222
onError(error: unknown) {
215223
if (
@@ -222,6 +230,13 @@ export async function routeRSCServerRequest({
222230
if (renderRedirect) {
223231
return error.digest;
224232
}
233+
let routeErrorResponse = decodeRouteErrorResponseDigest(error.digest);
234+
if (routeErrorResponse) {
235+
renderError = routeErrorResponse;
236+
status = routeErrorResponse.status;
237+
statusText = routeErrorResponse.statusText;
238+
return error.digest;
239+
}
225240
}
226241
},
227242
onHeaders(headers) {
@@ -259,7 +274,8 @@ export async function routeRSCServerRequest({
259274

260275
if (!hydrate) {
261276
return new Response(html.pipeThrough(redirectTransform), {
262-
status: serverResponse.status,
277+
status,
278+
statusText,
263279
headers,
264280
});
265281
}
@@ -272,7 +288,8 @@ export async function routeRSCServerRequest({
272288
.pipeThrough(injectRSCPayload(serverResponseB.body))
273289
.pipeThrough(redirectTransform);
274290
return new Response(body, {
275-
status: serverResponse.status,
291+
status,
292+
statusText,
276293
headers,
277294
});
278295
} catch (reason) {
@@ -290,7 +307,10 @@ export async function routeRSCServerRequest({
290307
}
291308

292309
try {
293-
const status = isRouteErrorResponse(reason) ? reason.status : 500;
310+
reason = renderError ?? reason;
311+
let [status, statusText] = isRouteErrorResponse(reason)
312+
? [reason.status, reason.statusText]
313+
: [500, ""];
294314

295315
let retryRedirect: { status: number; location: string } | undefined;
296316
let reactHeaders = new Headers();
@@ -341,6 +361,14 @@ export async function routeRSCServerRequest({
341361
if (retryRedirect) {
342362
return error.digest;
343363
}
364+
let routeErrorResponse = decodeRouteErrorResponseDigest(
365+
error.digest,
366+
);
367+
if (routeErrorResponse) {
368+
status = routeErrorResponse.status;
369+
statusText = routeErrorResponse.statusText;
370+
return error.digest;
371+
}
344372
}
345373
},
346374
onHeaders(headers) {
@@ -379,7 +407,8 @@ export async function routeRSCServerRequest({
379407

380408
if (!hydrate) {
381409
return new Response(html.pipeThrough(retryRedirectTransform), {
382-
status: status,
410+
status,
411+
statusText,
383412
headers,
384413
});
385414
}
@@ -393,6 +422,7 @@ export async function routeRSCServerRequest({
393422
.pipeThrough(retryRedirectTransform);
394423
return new Response(body, {
395424
status,
425+
statusText,
396426
headers,
397427
});
398428
} catch {

0 commit comments

Comments
 (0)