Skip to content

Commit 795b50c

Browse files
authored
fix: react-server Await component (#14261)
chore: remove compression from integration test server to help streaming tests run smoother
1 parent 14ae816 commit 795b50c

File tree

8 files changed

+191
-7
lines changed

8 files changed

+191
-7
lines changed

.changeset/cool-readers-attack.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+
Add react-server Await component implementation

integration/helpers/rsc-vite/server.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { parseArgs } from "node:util";
22
import { createRequestListener } from "@mjackson/node-fetch-server";
3-
import compression from "compression";
43
import express from "express";
54

65
import rscRequestHandler from "./dist/rsc/index.js";
76

87
const app = express();
98

10-
app.use(compression());
119
app.use(express.static("dist/client"));
1210

1311
app.get("/.well-known/appspecific/com.chrome.devtools.json", (req, res) => {

integration/rsc/rsc-test.ts

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,27 @@ implementations.forEach((implementation) => {
482482
path: "no-revalidate-server-action",
483483
lazy: () => import("./routes/no-revalidate-server-action/home"),
484484
},
485+
{
486+
id: "await-component",
487+
path: "await-component",
488+
children: [
489+
{
490+
id: "await-component.home",
491+
index: true,
492+
lazy: () => import("./routes/await-component/home"),
493+
},
494+
{
495+
id: "await-component.reject",
496+
path: "reject",
497+
lazy: () => import("./routes/await-component/reject"),
498+
},
499+
{
500+
id: "await-component.api",
501+
path: "api",
502+
lazy: () => import("./routes/await-component/api"),
503+
}
504+
]
505+
}
485506
],
486507
},
487508
] satisfies RSCRouteConfig;
@@ -903,7 +924,6 @@ implementations.forEach((implementation) => {
903924
import { Counter } from "./home.client";
904925
905926
export default function HomeRoute(props) {
906-
console.log({props});
907927
return (
908928
<div>
909929
<form action={redirectAction}>
@@ -1155,7 +1175,7 @@ implementations.forEach((implementation) => {
11551175
import ClientHomeRoute from "./home.client";
11561176
11571177
export function loader() {
1158-
console.log("loader");
1178+
console.log("THIS SHOULD NOT BE LOGGED!!!");
11591179
}
11601180
11611181
export default function HomeRoute() {
@@ -1184,6 +1204,90 @@ implementations.forEach((implementation) => {
11841204
);
11851205
}
11861206
`,
1207+
1208+
"src/routes/await-component/events.ts": js`
1209+
import EventEmitter from 'node:events'
1210+
1211+
export const events = new EventEmitter();
1212+
`,
1213+
"src/routes/await-component/api.ts": js`
1214+
import { events } from "./events";
1215+
export async function action({ request }) {
1216+
const event = await request.text()
1217+
events.emit(event);
1218+
return Response.json(event);
1219+
}
1220+
`,
1221+
"src/routes/await-component/client.tsx": js`
1222+
"use client";
1223+
import { useAsyncError, useAsyncValue } from "react-router";
1224+
1225+
export function ClientValue() {
1226+
const value = useAsyncValue();
1227+
return <div data-resolved>{value}</div>;
1228+
}
1229+
1230+
export function ClientError() {
1231+
const error = useAsyncError();
1232+
return <div data-rejected>{error.message}</div>;
1233+
}
1234+
`,
1235+
"src/routes/await-component/home.tsx": js`
1236+
import { Suspense } from "react";
1237+
import { Await } from "react-router";
1238+
1239+
import { ClientValue } from "./client";
1240+
import { events } from "./events";
1241+
1242+
export default function AwaitResolveTest() {
1243+
const promise = new Promise(resolve => {
1244+
events.on("resolve", () => {
1245+
resolve("Async Data");
1246+
});
1247+
});
1248+
1249+
return (
1250+
<>
1251+
<Suspense fallback={<p data-fallback>Loading...</p>}>
1252+
<Await resolve={promise}>
1253+
<ClientValue />
1254+
</Await>
1255+
</Suspense>
1256+
{Array.from({ length: 100 }, (_, i) => (
1257+
<p key={i}>Item {i}</p>
1258+
))}
1259+
</>
1260+
);
1261+
}
1262+
`,
1263+
"src/routes/await-component/reject.tsx": js`
1264+
import { Suspense } from "react";
1265+
import { Await } from "react-router";
1266+
1267+
import { ClientError } from "./client";
1268+
import { events } from "./events";
1269+
1270+
export default function AwaitRejectTest() {
1271+
const promise = new Promise((_, reject) => {
1272+
events.on("reject", () => {
1273+
reject(new Error("Async Error"));
1274+
});
1275+
});
1276+
1277+
return (
1278+
<>
1279+
<Suspense fallback={<p data-fallback>Loading...</p>}>
1280+
<Await resolve={promise} errorElement={<ClientError />}>
1281+
{(data) => (<p data-resolved>{data}</p>)}
1282+
</Await>
1283+
</Suspense>
1284+
{Array.from({ length: 100 }, (_, i) => (
1285+
<p key={i}>Item {i}</p>
1286+
))}
1287+
</>
1288+
);
1289+
}
1290+
`,
11871291
},
11881292
});
11891293
});
@@ -1432,6 +1536,36 @@ implementations.forEach((implementation) => {
14321536
await page.goto(`http://localhost:${port}/resource-error-handling/`);
14331537
validateRSCHtml(await page.content());
14341538
});
1539+
1540+
test("Supports Await component resolve", async ({ page }) => {
1541+
await page.goto(`http://localhost:${port}/await-component`, {
1542+
waitUntil: "commit",
1543+
});
1544+
await page.waitForSelector("[data-fallback]");
1545+
await fetch(`http://localhost:${port}/await-component/api`, {
1546+
method: "POST",
1547+
headers: { "Content-Type": "text/plain" },
1548+
body: "resolve",
1549+
});
1550+
const resolved = await page.waitForSelector("[data-resolved]");
1551+
expect(await resolved.innerText()).toContain("Async Data");
1552+
});
1553+
1554+
test("Supports Await component rejection", async ({ page }) => {
1555+
await page.goto(`http://localhost:${port}/await-component/reject`, {
1556+
waitUntil: "commit",
1557+
});
1558+
await page.waitForSelector("[data-fallback]");
1559+
await fetch(`http://localhost:${port}/await-component/api`, {
1560+
method: "POST",
1561+
headers: { "Content-Type": "text/plain" },
1562+
body: "reject",
1563+
});
1564+
const rejected = await page.waitForSelector("[data-rejected]");
1565+
expect(await rejected.innerText()).toContain(
1566+
"An error occurred in the Server Components render.",
1567+
);
1568+
});
14351569
});
14361570

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

packages/react-router/index-react-server-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

3+
export { AwaitContextProvider as UNSAFE_AwaitContextProvider } from "./lib/context";
34
export {
4-
Await,
55
MemoryRouter,
66
Navigate,
77
Outlet,

packages/react-router/index-react-server.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@ export type {
1717
} from "./lib/rsc/server.rsc";
1818

1919
// RSC implementation of agnostic APIs
20-
export { redirect, redirectDocument, replace } from "./lib/rsc/server.rsc";
20+
export {
21+
Await,
22+
redirect,
23+
redirectDocument,
24+
replace,
25+
} from "./lib/rsc/server.rsc";
2126

2227
// Client references
2328
export {
24-
Await,
2529
BrowserRouter,
2630
Form,
2731
HashRouter,

packages/react-router/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export type {
9191
RouteMatch,
9292
RouteObject,
9393
} from "./lib/context";
94+
export { AwaitContextProvider as UNSAFE_AwaitContextProvider } from "./lib/context";
9495
export type {
9596
AwaitProps,
9697
IndexRouteProps,

packages/react-router/lib/context.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ FetchersContext.displayName = "Fetchers";
137137
export const AwaitContext = React.createContext<TrackedPromise | null>(null);
138138
AwaitContext.displayName = "Await";
139139

140+
export const AwaitContextProvider = (
141+
props: React.ComponentProps<typeof AwaitContext.Provider>,
142+
) => React.createElement(AwaitContext.Provider, props);
143+
140144
export interface NavigateOptions {
141145
/** Replace the current entry in the history stack instead of pushing a new one */
142146
replace?: boolean;

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type Params,
2626
type ShouldRevalidateFunction,
2727
type RouterContextProvider,
28+
type TrackedPromise,
2829
isRouteErrorResponse,
2930
matchRoutes,
3031
prependBasename,
@@ -41,6 +42,7 @@ import invariant from "../server-runtime/invariant";
4142

4243
import {
4344
Outlet as UNTYPED_Outlet,
45+
UNSAFE_AwaitContextProvider,
4446
UNSAFE_WithComponentProps,
4547
UNSAFE_WithHydrateFallbackProps,
4648
UNSAFE_WithErrorBoundaryProps,
@@ -49,6 +51,7 @@ import {
4951
// TSConfig, it breaks the Parcel build within this repo.
5052
} from "react-router/internal/react-server-client";
5153
import type {
54+
Await as AwaitType,
5255
Outlet as OutletType,
5356
WithComponentProps as WithComponentPropsType,
5457
WithErrorBoundaryProps as WithErrorBoundaryPropsType,
@@ -110,6 +113,41 @@ export const replace: typeof baseReplace = (...args) => {
110113
return response;
111114
};
112115

116+
const cachedResolvePromise: <T>(
117+
resolve: T,
118+
) => Promise<PromiseSettledResult<Awaited<T>>> =
119+
// @ts-expect-error - on 18 types, requires 19.
120+
React.cache(async <T>(resolve: T) => {
121+
return Promise.allSettled([resolve]).then((r) => r[0]);
122+
});
123+
124+
export const Await: typeof AwaitType = (async ({
125+
children,
126+
resolve,
127+
errorElement,
128+
}: React.ComponentProps<typeof AwaitType>) => {
129+
let promise = cachedResolvePromise(resolve);
130+
let resolved: Awaited<typeof promise> = await promise;
131+
132+
if (resolved.status === "rejected" && !errorElement) {
133+
throw resolved.reason;
134+
}
135+
if (resolved.status === "rejected") {
136+
return React.createElement(UNSAFE_AwaitContextProvider, {
137+
children: React.createElement(React.Fragment, null, errorElement),
138+
value: { _tracked: true, _error: resolved.reason } as TrackedPromise,
139+
});
140+
}
141+
142+
const toRender =
143+
typeof children === "function" ? children(resolved.value) : children;
144+
145+
return React.createElement(UNSAFE_AwaitContextProvider, {
146+
children: toRender,
147+
value: { _tracked: true, _data: resolved.value } as TrackedPromise,
148+
});
149+
}) as any;
150+
113151
type RSCRouteConfigEntryBase = {
114152
action?: ActionFunction;
115153
clientAction?: ClientActionFunction;

0 commit comments

Comments
 (0)