Skip to content

Commit 44ceb6d

Browse files
authored
Use runtime-specific default server entries (#15290)
* Use runtime-specific default server entries Assisted-By: devx/e4d8ffad-97c4-40b6-9373-8114dbad5591 * Add readable stream future flag for Node defaults * Split Node readable stream future flag change file * Mark Node readable stream flag change as unstable * Clarify Node readable stream future flag docs * Update Assisted-By: devx/e4d8ffad-97c4-40b6-9373-8114dbad5591 * Update Node readable stream change note * Fix e2e tests Assisted-By: devx/e4d8ffad-97c4-40b6-9373-8114dbad5591
1 parent 053fd6d commit 44ceb6d

11 files changed

Lines changed: 169 additions & 68 deletions

File tree

docs/upgrading/future.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,39 @@ _No known planned breaking changes yet_
4646

4747
We document some [unstable] flags here as a reference for folks contributing to the project via beta testing, but they are not generally recommended for production use and may have breaking changes in patch or minor releases - adopt with caution!
4848

49+
### `future.unstable_enableNodeReadableStream`
50+
51+
[MODES: framework]
52+
53+
<br/>
54+
<br/>
55+
56+
**Background**
57+
58+
Now that the Web Streams API is [stable](https://nodejs.org/docs/latest-v22.x/api/webstreams.html) in Node 22+, it's viable for React Router to use React's [`renderToReadableStream`](https://react.dev/reference/react-dom/server/renderToReadableStream) in the server entry.
59+
60+
When no `entry.server.tsx` file is present, React Router defaults to [`renderToPipeableStream`](https://react.dev/reference/react-dom/server/renderToPipeableStream) when a Node runtime is detected, and `renderToReadableStream` otherwise.
61+
62+
With this flag enabled, React Router will default to `renderToReadableStream` on all runtimes, including Node. You can continue to use `renderToPipeableStream` via a custom `entry.server.tsx` file if needed.
63+
64+
<docs-info>Enabling this flag might even provide slight performance gains because we are already using Web Streams internally, so this flag removes some unnecessary transforms between Web and Node streams.</docs-info>
65+
66+
👉 **Enable the Flag**
67+
68+
```ts filename=react-router.config.ts
69+
import type { Config } from "@react-router/dev/config";
70+
71+
export default {
72+
future: {
73+
unstable_enableNodeReadableStream: true,
74+
},
75+
} satisfies Config;
76+
```
77+
78+
**Update your Code**
79+
80+
No code changes are required. If your app has a custom `entry.server.tsx`, this flag will not change your runtime behavior.
81+
4982
### `future.unstable_optimizeDeps`
5083

5184
[MODES: framework]

integration/cli-test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
existsSync,
55
mkdirSync,
66
mkdtempSync,
7+
readFileSync,
78
rmSync,
89
writeFileSync,
910
} from "node:fs";
@@ -15,7 +16,7 @@ import { expect, test } from "@playwright/test";
1516
import dedent from "dedent";
1617
import semver from "semver";
1718

18-
import { createProject } from "./helpers/vite";
19+
import { build, createProject, reactRouterConfig } from "./helpers/vite";
1920

2021
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2122
const rootDirectory = path.resolve(__dirname, "..");
@@ -198,6 +199,51 @@ test.describe("cli", () => {
198199

199200
expect(existsSync(entryServerFile)).toBeTruthy();
200201
expect(existsSync(entryClientFile)).toBeTruthy();
202+
expect(readFileSync(entryServerFile, "utf-8")).toContain(
203+
"renderToPipeableStream",
204+
);
205+
});
206+
207+
test("generates a web server entry for non-Node projects", async () => {
208+
const cwd = await createProject();
209+
let packageJsonPath = path.join(cwd, "package.json");
210+
let pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
211+
delete pkg.dependencies["@react-router/express"];
212+
delete pkg.dependencies["@react-router/node"];
213+
delete pkg.dependencies["@react-router/serve"];
214+
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2));
215+
216+
let entryServerFile = path.join(cwd, "app", "entry.server.tsx");
217+
218+
expect(existsSync(entryServerFile)).toBeFalsy();
219+
220+
run(["reveal", "entry.server"], { cwd });
221+
222+
expect(existsSync(entryServerFile)).toBeTruthy();
223+
expect(readFileSync(entryServerFile, "utf-8")).toContain(
224+
"renderToReadableStream",
225+
);
226+
});
227+
228+
test("generates a web server entry for Node projects with the readable stream future flag", async () => {
229+
const cwd = await createProject({
230+
"react-router.config.ts": reactRouterConfig({
231+
future: {
232+
unstable_enableNodeReadableStream: true,
233+
},
234+
}),
235+
});
236+
237+
let entryServerFile = path.join(cwd, "app", "entry.server.tsx");
238+
239+
expect(existsSync(entryServerFile)).toBeFalsy();
240+
241+
run(["reveal", "entry.server"], { cwd });
242+
243+
expect(existsSync(entryServerFile)).toBeTruthy();
244+
expect(readFileSync(entryServerFile, "utf-8")).toContain(
245+
"renderToReadableStream",
246+
);
201247
});
202248

203249
test("rsc generates entry.{ssr,rsc,client}.tsx in the app directory", async () => {
@@ -250,4 +296,18 @@ test.describe("cli", () => {
250296
expect(existsSync(entryClientFile)).toBeTruthy();
251297
});
252298
});
299+
300+
test("builds a Node project with the readable stream future flag and default server entry", async () => {
301+
const cwd = await createProject({
302+
"react-router.config.ts": reactRouterConfig({
303+
future: {
304+
unstable_enableNodeReadableStream: true,
305+
},
306+
}),
307+
});
308+
309+
const buildResult = build({ cwd });
310+
311+
expect(buildResult.status).toBe(0);
312+
});
253313
});

integration/vite-plugin-cloudflare-test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { expect } from "@playwright/test";
22
import dedent from "dedent";
33
import getPort from "get-port";
44

5-
import { type Files, test, viteConfig } from "./helpers/vite.js";
5+
import {
6+
build,
7+
createProject,
8+
type Files,
9+
test,
10+
viteConfig,
11+
} from "./helpers/vite.js";
612

713
const tsx = dedent;
814
const css = dedent;
@@ -177,4 +183,16 @@ test.describe("vite-plugin-cloudflare", () => {
177183
"20px",
178184
);
179185
});
186+
187+
test("builds project with default server entry", async () => {
188+
const files = defineFiles();
189+
const cwd = await createProject(
190+
await files({ port: 0 }),
191+
"vite-plugin-cloudflare-template",
192+
);
193+
194+
const buildResult = build({ cwd });
195+
196+
expect(buildResult.status).toBe(0);
197+
});
180198
});

integration/vite-presets-test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ test.describe("Vite / presets", async () => {
246246

247247
// Ensure future flags from presets are properly merged
248248
expect(buildEndArgsMeta.futureFlags).toEqual({
249+
unstable_enableNodeReadableStream: false,
249250
unstable_optimizeDeps: true,
250251
});
251252
expect(buildEndArgsMeta.splitRouteModules).toBe(true);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Add a web streams default server entry for non-Node Framework mode apps
2+
3+
- Apps using `@react-router/node`, `@react-router/express`, or `@react-router/serve` continue to use the `renderToPipeableStream` default server entry
4+
- Apps without those Node server adapter dependencies use a `renderToReadableStream` default server entry
5+
- Non-Node apps with their own `entry.server.tsx` may be able to remove it in favor of the default if it is not doing anything custom
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add a new [`future.unstable_enableNodeReadableStream`](https://reactrouter.com/upgrading/future#futureunstable_enablenodereadablestream) flag to opt Node Framework mode apps into using `renderToReadableStream` instead of `renderToPipeableStream`
2+
3+
- This flag has no effect if you have your own `entry.server.tsx`

packages/react-router-dev/cli/commands.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import "react-router";
1010

1111
import type { ViteDevOptions } from "../vite/dev";
1212
import type { ViteBuildOptions } from "../vite/build";
13-
import { loadConfig } from "../config/config";
13+
import { hasNodeDependency, loadConfig } from "../config/config";
1414
import { formatRoutes } from "../config/format";
1515
import type { RoutesFormat } from "../config/format";
1616
import { transpile as convertFileToJS } from "./useJavascript";
@@ -171,12 +171,6 @@ export async function generateEntry(
171171
await copyFile(defaultEntry, outputFile);
172172
} else {
173173
let pkgJson = await readPackageJSON(rootDirectory);
174-
let deps = pkgJson.dependencies ?? {};
175-
176-
if (!deps["@react-router/node"]) {
177-
console.error(colors.red(`No default server entry detected.`));
178-
return;
179-
}
180174

181175
let defaultEntryClient = path.resolve(
182176
defaultsDirectory,
@@ -185,7 +179,10 @@ export async function generateEntry(
185179

186180
let defaultEntryServer = path.resolve(
187181
defaultsDirectory,
188-
`entry.server.node.tsx`,
182+
hasNodeDependency(pkgJson.dependencies) &&
183+
!configResult.value.future.unstable_enableNodeReadableStream
184+
? `entry.server.node.tsx`
185+
: `entry.server.web.tsx`,
189186
);
190187

191188
let isServerEntry = entry === "entry.server";

packages/react-router-dev/config/config.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import chokidar, {
88
type FSWatcher,
99
type EmitArgs as ChokidarEmitArgs,
1010
} from "chokidar";
11-
import { readPackageJSON, sortPackage, updatePackage } from "pkg-types";
11+
import {
12+
readPackageJSON,
13+
sortPackage,
14+
updatePackage,
15+
type PackageJson,
16+
} from "pkg-types";
1217
import colors from "picocolors";
1318
import pick from "lodash/pick.js";
1419
import omit from "lodash/omit.js";
@@ -89,6 +94,7 @@ type ServerModuleFormat = "esm" | "cjs";
8994
type ValidateConfigFunction = (config: ReactRouterConfig) => string | void;
9095

9196
interface FutureConfig {
97+
unstable_enableNodeReadableStream: boolean;
9298
unstable_optimizeDeps: boolean;
9399
}
94100

@@ -738,6 +744,8 @@ async function resolveConfig({
738744
}
739745

740746
let future: FutureConfig = {
747+
unstable_enableNodeReadableStream:
748+
userAndPresetConfigs.future?.unstable_enableNodeReadableStream ?? false,
741749
unstable_optimizeDeps:
742750
userAndPresetConfigs.future?.unstable_optimizeDeps ?? false,
743751
};
@@ -1045,12 +1053,6 @@ export async function resolveEntryFiles({
10451053
let pkgJson = await readPackageJSON(packageJsonDirectory);
10461054
let deps = pkgJson.dependencies ?? {};
10471055

1048-
if (!deps["@react-router/node"]) {
1049-
throw new Error(
1050-
`Could not determine server runtime. Please install @react-router/node, or provide a custom entry.server.tsx/jsx file in your app directory.`,
1051-
);
1052-
}
1053-
10541056
if (!deps["isbot"]) {
10551057
console.log(
10561058
"adding `isbot@5` to your package.json, you should commit this change",
@@ -1070,7 +1072,11 @@ export async function resolveEntryFiles({
10701072
});
10711073
}
10721074

1073-
entryServerFile = `entry.server.node.tsx`;
1075+
entryServerFile =
1076+
hasNodeDependency(deps) &&
1077+
!reactRouterConfig.future.unstable_enableNodeReadableStream
1078+
? `entry.server.node.tsx`
1079+
: `entry.server.web.tsx`;
10741080
}
10751081

10761082
let entryClientFilePath = userEntryClientFile
@@ -1127,6 +1133,19 @@ function omitRoutes(
11271133

11281134
const entryExts = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".mts"];
11291135

1136+
export function hasNodeDependency(deps: PackageJson["dependencies"]) {
1137+
// Match the server condition check in the Vite plugin: missing dependencies
1138+
// imply Node for backwards compatibility.
1139+
return (
1140+
!deps ||
1141+
Boolean(
1142+
deps["@react-router/node"] ||
1143+
deps["@react-router/express"] ||
1144+
deps["@react-router/serve"],
1145+
)
1146+
);
1147+
}
1148+
11301149
function isEntryFile(entryBasename: string, filename: string) {
11311150
return entryExts.some((ext) => filename === `${entryBasename}${ext}`);
11321151
}

integration/helpers/vite-plugin-cloudflare-template/app/entry.server.tsx renamed to packages/react-router-dev/config/defaults/entry.server.web.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,33 @@ import { ServerRouter } from "react-router";
33
import { isbot } from "isbot";
44
import { renderToReadableStream } from "react-dom/server";
55

6+
export const streamTimeout = 5_000;
7+
68
export default async function handleRequest(
79
request: Request,
810
responseStatusCode: number,
911
responseHeaders: Headers,
1012
routerContext: EntryContext,
1113
_loadContext: RouterContextProvider,
1214
) {
15+
// https://httpwg.org/specs/rfc9110.html#HEAD
16+
if (request.method.toUpperCase() === "HEAD") {
17+
return new Response(null, {
18+
status: responseStatusCode,
19+
headers: responseHeaders,
20+
});
21+
}
22+
1323
let shellRendered = false;
14-
const userAgent = request.headers.get("user-agent");
24+
let userAgent = request.headers.get("user-agent");
1525

1626
const body = await renderToReadableStream(
1727
<ServerRouter context={routerContext} url={request.url} />,
1828
{
29+
signal: AbortSignal.timeout(streamTimeout + 1000),
1930
onError(error: unknown) {
2031
responseStatusCode = 500;
21-
// Log streaming rendering errors from inside the shell. Don't log
32+
// Log streaming rendering errors from inside the shell. Don't log
2233
// errors encountered during initial shell rendering since they'll
2334
// reject and get logged in handleDocumentRequest.
2435
if (shellRendered) {

packages/react-router-dev/vite/plugin.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import {
8484
type BuildManifest,
8585
type ConfigLoader,
8686
createConfigLoader,
87+
hasNodeDependency,
8788
resolveEntryFiles,
8889
configRouteToBranchRoute,
8990
type PrerenderPaths,
@@ -3577,11 +3578,7 @@ export async function getEnvironmentOptionsResolvers(
35773578
let defaultExternalConditions = vite.defaultExternalConditions ?? ["node"];
35783579

35793580
// If we couldn't find the package.json, we assume node for backwards compatibility
3580-
let isNode =
3581-
!pkgJson.dependencies ||
3582-
pkgJson.dependencies["@react-router/node"] ||
3583-
pkgJson.dependencies["@react-router/express"] ||
3584-
pkgJson.dependencies["@react-router/serve"];
3581+
let isNode = hasNodeDependency(pkgJson.dependencies);
35853582

35863583
if (!isNode) {
35873584
maybeDefaultServerConditions = maybeDefaultServerConditions.filter(

0 commit comments

Comments
 (0)