Skip to content

Commit be5e97d

Browse files
authored
Add config option to disable lazy route discovery (#13451)
1 parent a1371f4 commit be5e97d

25 files changed

+641
-393
lines changed

.changeset/angry-students-pay.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@react-router/dev": minor
3+
"react-router": minor
4+
---
5+
6+
Added a new `react-router.config.ts` `routeDiscovery` option to configure Lazy Route Discovery behavior.
7+
8+
- By default, Lazy Route Discovery is enabled and makes manifest requests to the `/__manifest` path:
9+
- `routeDiscovery: { mode: "lazy", manifestPath: "/__manifest" }`
10+
- You can modify the manifest path used:
11+
- `routeDiscovery: { mode: "lazy", manifestPath: "/custom-manifest" }`
12+
- Or you can disable this feature entirely and include all routes in the manifest on initial document load:
13+
- `routeDiscovery: { mode: "initial" }`

integration/fog-of-war-test.ts

Lines changed: 220 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,218 @@ test.describe("Fog of War", () => {
14021408
await app.clickLink("/a");
14031409
await page.waitForSelector("#a-index");
14041410
});
1411+
1412+
test("allows configuration of the manifest path", async ({ page }) => {
1413+
let fixture = await createFixture({
1414+
files: {
1415+
...getFiles(),
1416+
"react-router.config.ts": reactRouterConfig({
1417+
routeDiscovery: { mode: "lazy", manifestPath: "/custom-manifest" },
1418+
}),
1419+
},
1420+
});
1421+
let appFixture = await createAppFixture(fixture);
1422+
let app = new PlaywrightFixture(appFixture, page);
1423+
1424+
let wrongManifestRequests: string[] = [];
1425+
let manifestRequests: string[] = [];
1426+
page.on("request", (req) => {
1427+
if (req.url().includes("/__manifest")) {
1428+
wrongManifestRequests.push(req.url());
1429+
}
1430+
if (req.url().includes("/custom-manifest")) {
1431+
manifestRequests.push(req.url());
1432+
}
1433+
});
1434+
1435+
await app.goto("/", true);
1436+
expect(
1437+
await page.evaluate(() =>
1438+
Object.keys((window as any).__reactRouterManifest.routes)
1439+
)
1440+
).toEqual(["root", "routes/_index", "routes/a"]);
1441+
expect(manifestRequests).toEqual([
1442+
expect.stringMatching(/\/custom-manifest\?p=%2F&p=%2Fa&version=/),
1443+
]);
1444+
manifestRequests = [];
1445+
1446+
await app.clickLink("/a");
1447+
await page.waitForSelector("#a");
1448+
expect(await app.getHtml("#a")).toBe(`<h1 id="a">A: A LOADER</h1>`);
1449+
// Wait for eager discovery to kick off
1450+
await new Promise((r) => setTimeout(r, 500));
1451+
expect(manifestRequests).toEqual([
1452+
expect.stringMatching(/\/custom-manifest\?p=%2Fa%2Fb&version=/),
1453+
]);
1454+
1455+
expect(wrongManifestRequests).toEqual([]);
1456+
});
1457+
1458+
test.describe("routeDiscovery=initial", () => {
1459+
test("loads full manifest on initial load", async ({ page }) => {
1460+
let fixture = await createFixture({
1461+
files: {
1462+
...getFiles(),
1463+
"react-router.config.ts": reactRouterConfig({
1464+
routeDiscovery: { mode: "initial" },
1465+
}),
1466+
"app/entry.client.tsx": js`
1467+
import { HydratedRouter } from "react-router/dom";
1468+
import { startTransition, StrictMode } from "react";
1469+
import { hydrateRoot } from "react-dom/client";
1470+
startTransition(() => {
1471+
hydrateRoot(
1472+
document,
1473+
<StrictMode>
1474+
<HydratedRouter discover={"none"} />
1475+
</StrictMode>
1476+
);
1477+
});
1478+
`,
1479+
},
1480+
});
1481+
let appFixture = await createAppFixture(fixture);
1482+
1483+
let manifestRequests: string[] = [];
1484+
page.on("request", (req) => {
1485+
if (req.url().includes("/__manifest")) {
1486+
manifestRequests.push(req.url());
1487+
}
1488+
});
1489+
1490+
let app = new PlaywrightFixture(appFixture, page);
1491+
let res = await fixture.requestDocument("/");
1492+
let html = await res.text();
1493+
1494+
expect(html).not.toContain("window.__reactRouterManifest = {");
1495+
expect(html).toContain(
1496+
'<link rel="modulepreload" href="/assets/manifest-'
1497+
);
1498+
1499+
// Linking to A succeeds
1500+
await app.goto("/", true);
1501+
expect(
1502+
await page.evaluate(() =>
1503+
Object.keys((window as any).__reactRouterManifest.routes)
1504+
)
1505+
).toEqual([
1506+
"root",
1507+
"routes/_index",
1508+
"routes/a",
1509+
"routes/a.b",
1510+
"routes/a.b.c",
1511+
]);
1512+
1513+
await app.clickLink("/a");
1514+
await page.waitForSelector("#a");
1515+
expect(await app.getHtml("#a")).toBe(`<h1 id="a">A: A LOADER</h1>`);
1516+
expect(manifestRequests).toEqual([]);
1517+
});
1518+
1519+
test("defaults to `routeDiscovery=initial` when `ssr:false` is set", async ({
1520+
page,
1521+
}) => {
1522+
let fixture = await createFixture({
1523+
spaMode: true,
1524+
files: {
1525+
"react-router.config.ts": reactRouterConfig({
1526+
ssr: false,
1527+
}),
1528+
"app/root.tsx": js`
1529+
import * as React from "react";
1530+
import { Link, Links, Meta, Outlet, Scripts } from "react-router";
1531+
export default function Root() {
1532+
let [showLink, setShowLink] = React.useState(false);
1533+
return (
1534+
<html lang="en">
1535+
<head>
1536+
<Meta />
1537+
<Links />
1538+
</head>
1539+
<body>
1540+
<Link to="/">Home</Link><br/>
1541+
<Link to="/a">/a</Link><br/>
1542+
<Outlet />
1543+
<Scripts />
1544+
</body>
1545+
</html>
1546+
);
1547+
}
1548+
`,
1549+
"app/routes/_index.tsx": js`
1550+
export default function Index() {
1551+
return <h1 id="index">Index</h1>
1552+
}
1553+
`,
1554+
1555+
"app/routes/a.tsx": js`
1556+
export function clientLoader({ request }) {
1557+
return { message: "A LOADER" };
1558+
}
1559+
export default function Index({ loaderData }) {
1560+
return <h1 id="a">A: {loaderData.message}</h1>
1561+
}
1562+
`,
1563+
},
1564+
});
1565+
let appFixture = await createAppFixture(fixture);
1566+
1567+
let manifestRequests: string[] = [];
1568+
page.on("request", (req) => {
1569+
if (req.url().includes("/__manifest")) {
1570+
manifestRequests.push(req.url());
1571+
}
1572+
});
1573+
1574+
let app = new PlaywrightFixture(appFixture, page);
1575+
let res = await fixture.requestDocument("/");
1576+
let html = await res.text();
1577+
1578+
expect(html).toContain('"routeDiscovery":{"mode":"initial"}');
1579+
1580+
await app.goto("/", true);
1581+
await page.waitForSelector("#index");
1582+
await app.clickLink("/a");
1583+
await page.waitForSelector("#a");
1584+
expect(await app.getHtml("#a")).toBe(`<h1 id="a">A: A LOADER</h1>`);
1585+
expect(manifestRequests).toEqual([]);
1586+
});
1587+
1588+
test("Errors if you try to set routeDiscovery=lazy and ssr:false", async () => {
1589+
let ogConsole = console.error;
1590+
console.error = () => {};
1591+
let buildStdio = new PassThrough();
1592+
let err;
1593+
try {
1594+
await createFixture({
1595+
buildStdio,
1596+
spaMode: true,
1597+
files: {
1598+
...getFiles(),
1599+
"react-router.config.ts": reactRouterConfig({
1600+
ssr: false,
1601+
routeDiscovery: { mode: "lazy" },
1602+
}),
1603+
},
1604+
});
1605+
} catch (e) {
1606+
err = e;
1607+
}
1608+
1609+
let chunks: Buffer[] = [];
1610+
let buildOutput = await new Promise<string>((resolve, reject) => {
1611+
buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
1612+
buildStdio.on("error", (err) => reject(err));
1613+
buildStdio.on("end", () =>
1614+
resolve(Buffer.concat(chunks).toString("utf8"))
1615+
);
1616+
});
1617+
1618+
expect(err).toEqual(new Error("Build failed, check the output above"));
1619+
expect(buildOutput).toContain(
1620+
'Error: The `routeDiscovery.mode` config cannot be set to "lazy" when setting `ssr:false`'
1621+
);
1622+
console.error = ogConsole;
1623+
});
1624+
});
14051625
});

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?: Config["routeDiscovery"];
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,

integration/vite-presets-test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const files = {
2929
export default {
3030
// Ensure user config takes precedence over preset config
3131
appDirectory: "app",
32-
32+
3333
presets: [
3434
// Ensure user config is passed to reactRouterConfig hook
3535
{
@@ -221,6 +221,7 @@ test.describe("Vite / presets", async () => {
221221
"future",
222222
"prerender",
223223
"routes",
224+
"routeDiscovery",
224225
"serverBuildFile",
225226
"serverBundles",
226227
"serverModuleFormat",

0 commit comments

Comments
 (0)