Skip to content

Commit 0981987

Browse files
committed
Add config option to disable lazy route discovery
1 parent ed77157 commit 0981987

File tree

15 files changed

+267
-55
lines changed

15 files changed

+267
-55
lines changed

.changeset/angry-students-pay.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@react-router/dev": minor
3+
"react-router": minor
4+
---
5+
6+
Add new `routeDiscovery` `react-router.config.ts` option to disable Lazy Route Discovery
7+
8+
- The default value is `routeDiscovery: "lazy"`
9+
- Setting `routeDiscovery: "initial"` will disable Lazy Route Discovery and send up all routes in the manifest on initial document load

integration/fog-of-war-test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { test, expect } from "@playwright/test";
2+
import { PassThrough } from "node:stream";
23

34
import {
45
createAppFixture,
56
createFixture,
67
js,
78
} from "./helpers/create-fixture.js";
89
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
10+
import { reactRouterConfig } from "./helpers/vite.js";
911

1012
function getFiles() {
1113
return {
@@ -118,6 +120,10 @@ test.describe("Fog of War", () => {
118120
let res = await fixture.requestDocument("/");
119121
let html = await res.text();
120122

123+
expect(html).toContain("window.__reactRouterManifest = {");
124+
expect(html).not.toContain(
125+
'<link rel="modulepreload" href="/assets/manifest-'
126+
);
121127
expect(html).toContain('"root": {');
122128
expect(html).toContain('"routes/_index": {');
123129
expect(html).not.toContain('"routes/a"');
@@ -1402,4 +1408,172 @@ test.describe("Fog of War", () => {
14021408
await app.clickLink("/a");
14031409
await page.waitForSelector("#a-index");
14041410
});
1411+
1412+
test.describe("routeDiscovery=initial", () => {
1413+
test("loads full manifest on initial load", async ({ page }) => {
1414+
let fixture = await createFixture({
1415+
files: {
1416+
...getFiles(),
1417+
"react-router.config.ts": reactRouterConfig({
1418+
routeDiscovery: "initial",
1419+
}),
1420+
"app/entry.client.tsx": js`
1421+
import { HydratedRouter } from "react-router/dom";
1422+
import { startTransition, StrictMode } from "react";
1423+
import { hydrateRoot } from "react-dom/client";
1424+
startTransition(() => {
1425+
hydrateRoot(
1426+
document,
1427+
<StrictMode>
1428+
<HydratedRouter discover={"none"} />
1429+
</StrictMode>
1430+
);
1431+
});
1432+
`,
1433+
},
1434+
});
1435+
let appFixture = await createAppFixture(fixture);
1436+
1437+
let manifestRequests: string[] = [];
1438+
page.on("request", (req) => {
1439+
if (req.url().includes("/__manifest")) {
1440+
manifestRequests.push(req.url());
1441+
}
1442+
});
1443+
1444+
let app = new PlaywrightFixture(appFixture, page);
1445+
let res = await fixture.requestDocument("/");
1446+
let html = await res.text();
1447+
1448+
expect(html).not.toContain("window.__reactRouterManifest = {");
1449+
expect(html).toContain(
1450+
'<link rel="modulepreload" href="/assets/manifest-'
1451+
);
1452+
1453+
// Linking to A succeeds
1454+
await app.goto("/", true);
1455+
expect(
1456+
await page.evaluate(() =>
1457+
Object.keys((window as any).__reactRouterManifest.routes)
1458+
)
1459+
).toEqual([
1460+
"root",
1461+
"routes/_index",
1462+
"routes/a",
1463+
"routes/a.b",
1464+
"routes/a.b.c",
1465+
]);
1466+
1467+
await app.clickLink("/a");
1468+
await page.waitForSelector("#a");
1469+
expect(await app.getHtml("#a")).toBe(`<h1 id="a">A: A LOADER</h1>`);
1470+
expect(manifestRequests).toEqual([]);
1471+
});
1472+
1473+
test("defaults to `routeDiscovery=initial` when `ssr:false` is set", async ({
1474+
page,
1475+
}) => {
1476+
let fixture = await createFixture({
1477+
spaMode: true,
1478+
files: {
1479+
"react-router.config.ts": reactRouterConfig({
1480+
ssr: false,
1481+
}),
1482+
"app/root.tsx": js`
1483+
import * as React from "react";
1484+
import { Link, Links, Meta, Outlet, Scripts } from "react-router";
1485+
export default function Root() {
1486+
let [showLink, setShowLink] = React.useState(false);
1487+
return (
1488+
<html lang="en">
1489+
<head>
1490+
<Meta />
1491+
<Links />
1492+
</head>
1493+
<body>
1494+
<Link to="/">Home</Link><br/>
1495+
<Link to="/a">/a</Link><br/>
1496+
<Outlet />
1497+
<Scripts />
1498+
</body>
1499+
</html>
1500+
);
1501+
}
1502+
`,
1503+
"app/routes/_index.tsx": js`
1504+
export default function Index() {
1505+
return <h1 id="index">Index</h1>
1506+
}
1507+
`,
1508+
1509+
"app/routes/a.tsx": js`
1510+
export function clientLoader({ request }) {
1511+
return { message: "A LOADER" };
1512+
}
1513+
export default function Index({ loaderData }) {
1514+
return <h1 id="a">A: {loaderData.message}</h1>
1515+
}
1516+
`,
1517+
},
1518+
});
1519+
let appFixture = await createAppFixture(fixture);
1520+
1521+
let manifestRequests: string[] = [];
1522+
page.on("request", (req) => {
1523+
if (req.url().includes("/__manifest")) {
1524+
manifestRequests.push(req.url());
1525+
}
1526+
});
1527+
1528+
let app = new PlaywrightFixture(appFixture, page);
1529+
let res = await fixture.requestDocument("/");
1530+
let html = await res.text();
1531+
1532+
expect(html).toContain('"routeDiscovery":"initial"');
1533+
1534+
await app.goto("/", true);
1535+
await page.waitForSelector("#index");
1536+
await app.clickLink("/a");
1537+
await page.waitForSelector("#a");
1538+
expect(await app.getHtml("#a")).toBe(`<h1 id="a">A: A LOADER</h1>`);
1539+
expect(manifestRequests).toEqual([]);
1540+
});
1541+
1542+
test("Errors if you try to set routeDiscovery=lazy and ssr:false", async () => {
1543+
let ogConsole = console.error;
1544+
console.error = () => {};
1545+
let buildStdio = new PassThrough();
1546+
let err;
1547+
try {
1548+
await createFixture({
1549+
buildStdio,
1550+
spaMode: true,
1551+
files: {
1552+
...getFiles(),
1553+
"react-router.config.ts": reactRouterConfig({
1554+
ssr: false,
1555+
routeDiscovery: "lazy",
1556+
}),
1557+
},
1558+
});
1559+
} catch (e) {
1560+
err = e;
1561+
}
1562+
1563+
let chunks: Buffer[] = [];
1564+
let buildOutput = await new Promise<string>((resolve, reject) => {
1565+
buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
1566+
buildStdio.on("error", (err) => reject(err));
1567+
buildStdio.on("end", () =>
1568+
resolve(Buffer.concat(chunks).toString("utf8"))
1569+
);
1570+
});
1571+
1572+
expect(err).toEqual(new Error("Build failed, check the output above"));
1573+
expect(buildOutput).toContain(
1574+
'Error: The `routeDiscovery` config cannot be set to "lazy" when setting `ssr:false`'
1575+
);
1576+
console.error = ogConsole;
1577+
});
1578+
});
14051579
});

integration/helpers/vite.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const reactRouterConfig = ({
3131
splitRouteModules,
3232
viteEnvironmentApi,
3333
middleware,
34+
routeDiscovery,
3435
}: {
3536
ssr?: boolean;
3637
basename?: string;
@@ -41,12 +42,14 @@ export const reactRouterConfig = ({
4142
>["unstable_splitRouteModules"];
4243
viteEnvironmentApi?: boolean;
4344
middleware?: boolean;
45+
routeDiscovery?: "initial" | "lazy";
4446
}) => {
4547
let config: Config = {
4648
ssr,
4749
basename,
4850
prerender,
4951
appDirectory,
52+
routeDiscovery,
5053
future: {
5154
unstable_splitRouteModules: splitRouteModules,
5255
unstable_viteEnvironmentApi: viteEnvironmentApi,

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

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ export type ReactRouterConfig = {
158158
* other platforms and tools.
159159
*/
160160
presets?: Array<Preset>;
161+
/**
162+
* Control the "Lazy Route Discovery" behavior. By default, this resolves to
163+
* `lazy` which will lazily discover routes as the user navigates around your
164+
* application. You can set this to `initial` to opt-out of this behavior and
165+
* load all routes with the initial HTML document load.
166+
*/
167+
routeDiscovery?: "lazy" | "initial";
161168
/**
162169
* The file name of the server build output. This file
163170
* should end in a `.js` extension and should be deployed to your server.
@@ -205,6 +212,13 @@ export type ResolvedReactRouterConfig = Readonly<{
205212
* function returning an array to dynamically generate URLs.
206213
*/
207214
prerender: ReactRouterConfig["prerender"];
215+
/**
216+
* Control the "Lazy Route Discovery" behavior. By default, this resolves to
217+
* `lazy` which will lazily discover routes as the user navigates around your
218+
* application. You can set this to `initial` to opt-out of this behavior and
219+
* load all routes with the initial HTML document load.
220+
*/
221+
routeDiscovery: ReactRouterConfig["routeDiscovery"];
208222
/**
209223
* An object of all available routes, keyed by route id.
210224
*/
@@ -383,24 +397,31 @@ async function resolveConfig({
383397
let defaults = {
384398
basename: "/",
385399
buildDirectory: "build",
400+
routeDiscovery: "lazy",
386401
serverBuildFile: "index.js",
387402
serverModuleFormat: "esm",
388403
ssr: true,
389404
} as const satisfies Partial<ReactRouterConfig>;
390405

406+
let userAndPresetConfigs = mergeReactRouterConfig(
407+
...presets,
408+
reactRouterUserConfig
409+
);
410+
391411
let {
392412
appDirectory: userAppDirectory,
393413
basename,
394414
buildDirectory: userBuildDirectory,
395415
buildEnd,
396416
prerender,
417+
routeDiscovery,
397418
serverBuildFile,
398419
serverBundles,
399420
serverModuleFormat,
400421
ssr,
401422
} = {
402423
...defaults, // Default values should be completely overridden by user/preset config, not merged
403-
...mergeReactRouterConfig(...presets, reactRouterUserConfig),
424+
...userAndPresetConfigs,
404425
};
405426

406427
if (!ssr && serverBundles) {
@@ -420,6 +441,20 @@ async function resolveConfig({
420441
);
421442
}
422443

444+
if (routeDiscovery === "lazy" && !ssr) {
445+
if (userAndPresetConfigs.routeDiscovery === "lazy") {
446+
// If the user set "lazy" and `ssr:false`, then it's an invalid config
447+
// and we want to fail the build
448+
return err(
449+
'The `routeDiscovery` config cannot be set to "lazy" when setting `ssr:false`'
450+
);
451+
} else {
452+
// But if the user didn't specify, then we want to default to "initial"
453+
// when SSR is disabled
454+
routeDiscovery = "initial";
455+
}
456+
}
457+
423458
let appDirectory = path.resolve(root, userAppDirectory || "app");
424459
let buildDirectory = path.resolve(root, userBuildDirectory);
425460

@@ -512,11 +547,12 @@ async function resolveConfig({
512547
future,
513548
prerender,
514549
routes,
550+
routeDiscovery,
515551
serverBuildFile,
516552
serverBundles,
517553
serverModuleFormat,
518554
ssr,
519-
});
555+
} satisfies ResolvedReactRouterConfig);
520556

521557
for (let preset of reactRouterUserConfig.presets ?? []) {
522558
await preset.reactRouterConfigResolved?.({ reactRouterConfig });

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
742742
export const ssr = ${ctx.reactRouterConfig.ssr};
743743
export const isSpaMode = ${isSpaMode};
744744
export const prerender = ${JSON.stringify(prerenderPaths)};
745+
export const routeDiscovery = ${JSON.stringify(
746+
ctx.reactRouterConfig.routeDiscovery
747+
)};
745748
export const publicPath = ${JSON.stringify(ctx.publicPath)};
746749
export const entry = { module: entryServer };
747750
export const routes = {

packages/react-router/lib/dom-export/hydrated-router.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ function createHydratedRouter({
185185
ssrInfo.manifest,
186186
ssrInfo.routeModules,
187187
ssrInfo.context.ssr,
188+
ssrInfo.context.routeDiscovery,
188189
ssrInfo.context.isSpaMode,
189190
ssrInfo.context.basename
190191
),
@@ -270,6 +271,7 @@ export function HydratedRouter(props: HydratedRouterProps) {
270271
ssrInfo.manifest,
271272
ssrInfo.routeModules,
272273
ssrInfo.context.ssr,
274+
ssrInfo.context.routeDiscovery,
273275
ssrInfo.context.isSpaMode
274276
);
275277

@@ -289,6 +291,7 @@ export function HydratedRouter(props: HydratedRouterProps) {
289291
criticalCss,
290292
ssr: ssrInfo.context.ssr,
291293
isSpaMode: ssrInfo.context.isSpaMode,
294+
routeDiscovery: ssrInfo.context.routeDiscovery,
292295
}}
293296
>
294297
<RemixErrorBoundary location={location}>

packages/react-router/lib/dom/global.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,12 @@
11
import type { HydrationState, Router as DataRouter } from "../router/router";
2-
import type { AssetsManifest, CriticalCss, FutureConfig } from "./ssr/entry";
2+
import type { ServerHandoff } from "../server-runtime/serverHandoff";
3+
import type { AssetsManifest } from "./ssr/entry";
34
import type { RouteModules } from "./ssr/routeModules";
45

5-
export type WindowReactRouterContext = {
6-
basename?: string;
7-
state: HydrationState;
8-
criticalCss?: CriticalCss;
9-
future: FutureConfig;
10-
ssr: boolean;
11-
isSpaMode: boolean;
6+
export type WindowReactRouterContext = ServerHandoff & {
7+
state: HydrationState; // Deserialized via the stream
128
stream: ReadableStream<Uint8Array> | undefined;
139
streamController: ReadableStreamDefaultController<Uint8Array>;
14-
// The number of active deferred keys rendered on the server
15-
a?: number;
16-
dev?: {
17-
port?: number;
18-
hmrRuntime?: string;
19-
};
2010
};
2111

2212
export interface ViewTransition {

0 commit comments

Comments
 (0)