Skip to content

Commit 376ea06

Browse files
committed
feat(rsc): support throwing data() and Response from server component
render phase
1 parent 1954672 commit 376ea06

File tree

7 files changed

+160
-15
lines changed

7 files changed

+160
-15
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-test.ts

Lines changed: 31 additions & 0 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",
551+
lazy: () => import("./routes/render-route-error-response/home"),
552+
}
548553
],
549554
},
550555
] satisfies RSCRouteConfig;
@@ -1524,6 +1529,25 @@ 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() {
1539+
throw data({ message: "Test" }, { status: 400, statusText: "Oh no!" });
1540+
}
1541+
`,
1542+
"src/routes/render-route-error-response/home.client.tsx": js`
1543+
"use client";
1544+
import { useRouteError } from "react-router";
1545+
1546+
export function ErrorBoundary() {
1547+
const error = useRouteError();
1548+
return <p>{error.status} {error.statusText} {error.data.message}</p>
1549+
}
1550+
`,
15271551
},
15281552
});
15291553
});
@@ -1846,6 +1870,13 @@ implementations.forEach((implementation) => {
18461870
await page.waitForURL(`https://example.com/`);
18471871
await expect(page.getByText("Example Domain")).toBeAttached();
18481872
});
1873+
1874+
test("Support throwing data() responses", async ({ page }) => {
1875+
await page.goto(
1876+
`http://localhost:${port}/render-route-error-response`,
1877+
);
1878+
await expect(page.getByText("400 Oh no! Test")).toBeAttached();
1879+
});
18491880
});
18501881

18511882
test.describe("Server Actions", () => {

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/router/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1809,12 +1809,13 @@ export const normalizeSearch = (search: string): string =>
18091809
export const normalizeHash = (hash: string): string =>
18101810
!hash || hash === "#" ? "" : hash.startsWith("#") ? hash : "#" + hash;
18111811

1812-
export class DataWithResponseInit<D> {
1812+
export class DataWithResponseInit<D> extends Error {
18131813
type: string = "DataWithResponseInit";
18141814
data: D;
18151815
init: ResponseInit | null;
18161816

18171817
constructor(data: D, init?: ResponseInit) {
1818+
super("DataWithResponseInit");
18181819
this.data = data;
18191820
this.init = init || null;
18201821
}

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)