diff --git a/.changeset/angry-students-pay.md b/.changeset/angry-students-pay.md
new file mode 100644
index 0000000000..82db27596d
--- /dev/null
+++ b/.changeset/angry-students-pay.md
@@ -0,0 +1,13 @@
+---
+"@react-router/dev": minor
+"react-router": minor
+---
+
+Added a new `react-router.config.ts` `routeDiscovery` option to configure Lazy Route Discovery behavior.
+
+- By default, Lazy Route Discovery is enabled and makes manifest requests to the `/__manifest` path:
+ - `routeDiscovery: { mode: "lazy", manifestPath: "/__manifest" }`
+- You can modify the manifest path used:
+ - `routeDiscovery: { mode: "lazy", manifestPath: "/custom-manifest" }`
+- Or you can disable this feature entirely and include all routes in the manifest on initial document load:
+ - `routeDiscovery: { mode: "initial" }`
diff --git a/integration/fog-of-war-test.ts b/integration/fog-of-war-test.ts
index 9dda94a4c1..e5e9bc8fe1 100644
--- a/integration/fog-of-war-test.ts
+++ b/integration/fog-of-war-test.ts
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
+import { PassThrough } from "node:stream";
import {
createAppFixture,
@@ -6,6 +7,7 @@ import {
js,
} from "./helpers/create-fixture.js";
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import { reactRouterConfig } from "./helpers/vite.js";
function getFiles() {
return {
@@ -118,6 +120,10 @@ test.describe("Fog of War", () => {
let res = await fixture.requestDocument("/");
let html = await res.text();
+ expect(html).toContain("window.__reactRouterManifest = {");
+ expect(html).not.toContain(
+ ' {
await app.clickLink("/a");
await page.waitForSelector("#a-index");
});
+
+ test("allows configuration of the manifest path", async ({ page }) => {
+ let fixture = await createFixture({
+ files: {
+ ...getFiles(),
+ "react-router.config.ts": reactRouterConfig({
+ routeDiscovery: { mode: "lazy", manifestPath: "/custom-manifest" },
+ }),
+ },
+ });
+ let appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+
+ let wrongManifestRequests: string[] = [];
+ let manifestRequests: string[] = [];
+ page.on("request", (req) => {
+ if (req.url().includes("/__manifest")) {
+ wrongManifestRequests.push(req.url());
+ }
+ if (req.url().includes("/custom-manifest")) {
+ manifestRequests.push(req.url());
+ }
+ });
+
+ await app.goto("/", true);
+ expect(
+ await page.evaluate(() =>
+ Object.keys((window as any).__reactRouterManifest.routes)
+ )
+ ).toEqual(["root", "routes/_index", "routes/a"]);
+ expect(manifestRequests).toEqual([
+ expect.stringMatching(/\/custom-manifest\?p=%2F&p=%2Fa&version=/),
+ ]);
+ manifestRequests = [];
+
+ await app.clickLink("/a");
+ await page.waitForSelector("#a");
+ expect(await app.getHtml("#a")).toBe(`
A: A LOADER
`);
+ // Wait for eager discovery to kick off
+ await new Promise((r) => setTimeout(r, 500));
+ expect(manifestRequests).toEqual([
+ expect.stringMatching(/\/custom-manifest\?p=%2Fa%2Fb&version=/),
+ ]);
+
+ expect(wrongManifestRequests).toEqual([]);
+ });
+
+ test.describe("routeDiscovery=initial", () => {
+ test("loads full manifest on initial load", async ({ page }) => {
+ let fixture = await createFixture({
+ files: {
+ ...getFiles(),
+ "react-router.config.ts": reactRouterConfig({
+ routeDiscovery: { mode: "initial" },
+ }),
+ "app/entry.client.tsx": js`
+ import { HydratedRouter } from "react-router/dom";
+ import { startTransition, StrictMode } from "react";
+ import { hydrateRoot } from "react-dom/client";
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+ `,
+ },
+ });
+ let appFixture = await createAppFixture(fixture);
+
+ let manifestRequests: string[] = [];
+ page.on("request", (req) => {
+ if (req.url().includes("/__manifest")) {
+ manifestRequests.push(req.url());
+ }
+ });
+
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await fixture.requestDocument("/");
+ let html = await res.text();
+
+ expect(html).not.toContain("window.__reactRouterManifest = {");
+ expect(html).toContain(
+ '
+ Object.keys((window as any).__reactRouterManifest.routes)
+ )
+ ).toEqual([
+ "root",
+ "routes/_index",
+ "routes/a",
+ "routes/a.b",
+ "routes/a.b.c",
+ ]);
+
+ await app.clickLink("/a");
+ await page.waitForSelector("#a");
+ expect(await app.getHtml("#a")).toBe(`A: A LOADER
`);
+ expect(manifestRequests).toEqual([]);
+ });
+
+ test("defaults to `routeDiscovery=initial` when `ssr:false` is set", async ({
+ page,
+ }) => {
+ let fixture = await createFixture({
+ spaMode: true,
+ files: {
+ "react-router.config.ts": reactRouterConfig({
+ ssr: false,
+ }),
+ "app/root.tsx": js`
+ import * as React from "react";
+ import { Link, Links, Meta, Outlet, Scripts } from "react-router";
+ export default function Root() {
+ let [showLink, setShowLink] = React.useState(false);
+ return (
+
+
+
+
+
+
+ Home
+ /a
+
+
+
+
+ );
+ }
+ `,
+ "app/routes/_index.tsx": js`
+ export default function Index() {
+ return Index
+ }
+ `,
+
+ "app/routes/a.tsx": js`
+ export function clientLoader({ request }) {
+ return { message: "A LOADER" };
+ }
+ export default function Index({ loaderData }) {
+ return A: {loaderData.message}
+ }
+ `,
+ },
+ });
+ let appFixture = await createAppFixture(fixture);
+
+ let manifestRequests: string[] = [];
+ page.on("request", (req) => {
+ if (req.url().includes("/__manifest")) {
+ manifestRequests.push(req.url());
+ }
+ });
+
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await fixture.requestDocument("/");
+ let html = await res.text();
+
+ expect(html).toContain('"routeDiscovery":{"mode":"initial"}');
+
+ await app.goto("/", true);
+ await page.waitForSelector("#index");
+ await app.clickLink("/a");
+ await page.waitForSelector("#a");
+ expect(await app.getHtml("#a")).toBe(`A: A LOADER
`);
+ expect(manifestRequests).toEqual([]);
+ });
+
+ test("Errors if you try to set routeDiscovery=lazy and ssr:false", async () => {
+ let ogConsole = console.error;
+ console.error = () => {};
+ let buildStdio = new PassThrough();
+ let err;
+ try {
+ await createFixture({
+ buildStdio,
+ spaMode: true,
+ files: {
+ ...getFiles(),
+ "react-router.config.ts": reactRouterConfig({
+ ssr: false,
+ routeDiscovery: { mode: "lazy" },
+ }),
+ },
+ });
+ } catch (e) {
+ err = e;
+ }
+
+ let chunks: Buffer[] = [];
+ let buildOutput = await new Promise((resolve, reject) => {
+ buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
+ buildStdio.on("error", (err) => reject(err));
+ buildStdio.on("end", () =>
+ resolve(Buffer.concat(chunks).toString("utf8"))
+ );
+ });
+
+ expect(err).toEqual(new Error("Build failed, check the output above"));
+ expect(buildOutput).toContain(
+ 'Error: The `routeDiscovery.mode` config cannot be set to "lazy" when setting `ssr:false`'
+ );
+ console.error = ogConsole;
+ });
+ });
});
diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts
index 1330c291e1..d590d5cad7 100644
--- a/integration/helpers/vite.ts
+++ b/integration/helpers/vite.ts
@@ -31,6 +31,7 @@ export const reactRouterConfig = ({
splitRouteModules,
viteEnvironmentApi,
middleware,
+ routeDiscovery,
}: {
ssr?: boolean;
basename?: string;
@@ -41,12 +42,14 @@ export const reactRouterConfig = ({
>["unstable_splitRouteModules"];
viteEnvironmentApi?: boolean;
middleware?: boolean;
+ routeDiscovery?: Config["routeDiscovery"];
}) => {
let config: Config = {
ssr,
basename,
prerender,
appDirectory,
+ routeDiscovery,
future: {
unstable_splitRouteModules: splitRouteModules,
unstable_viteEnvironmentApi: viteEnvironmentApi,
diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts
index 6128229996..bc3b9cfab9 100644
--- a/integration/vite-presets-test.ts
+++ b/integration/vite-presets-test.ts
@@ -29,7 +29,7 @@ const files = {
export default {
// Ensure user config takes precedence over preset config
appDirectory: "app",
-
+
presets: [
// Ensure user config is passed to reactRouterConfig hook
{
@@ -221,6 +221,7 @@ test.describe("Vite / presets", async () => {
"future",
"prerender",
"routes",
+ "routeDiscovery",
"serverBuildFile",
"serverBundles",
"serverModuleFormat",
diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts
index 999e19eec3..68e0e810c9 100644
--- a/packages/react-router-dev/config/config.ts
+++ b/packages/react-router-dev/config/config.ts
@@ -158,6 +158,24 @@ export type ReactRouterConfig = {
* other platforms and tools.
*/
presets?: Array;
+ /**
+ * Control the "Lazy Route Discovery" behavior
+ *
+ * - `routeDiscovery.mode`: By default, this resolves to `lazy` which will
+ * lazily discover routes as the user navigates around your application.
+ * You can set this to `initial` to opt-out of this behavior and load all
+ * routes with the initial HTML document load.
+ * - `routeDiscovery.manifestPath`: The path to serve the manifest file from.
+ * Only applies to `mode: "lazy"` and defaults to `/__manifest`.
+ */
+ routeDiscovery?:
+ | {
+ mode: "lazy";
+ manifestPath?: string;
+ }
+ | {
+ mode: "initial";
+ };
/**
* The file name of the server build output. This file
* should end in a `.js` extension and should be deployed to your server.
@@ -205,6 +223,17 @@ export type ResolvedReactRouterConfig = Readonly<{
* function returning an array to dynamically generate URLs.
*/
prerender: ReactRouterConfig["prerender"];
+ /**
+ * Control the "Lazy Route Discovery" behavior
+ *
+ * - `routeDiscovery.mode`: By default, this resolves to `lazy` which will
+ * lazily discover routes as the user navigates around your application.
+ * You can set this to `initial` to opt-out of this behavior and load all
+ * routes with the initial HTML document load.
+ * - `routeDiscovery.manifestPath`: The path to serve the manifest file from.
+ * Only applies to `mode: "lazy"` and defaults to `/__manifest`.
+ */
+ routeDiscovery: ReactRouterConfig["routeDiscovery"];
/**
* An object of all available routes, keyed by route id.
*/
@@ -388,19 +417,25 @@ async function resolveConfig({
ssr: true,
} as const satisfies Partial;
+ let userAndPresetConfigs = mergeReactRouterConfig(
+ ...presets,
+ reactRouterUserConfig
+ );
+
let {
appDirectory: userAppDirectory,
basename,
buildDirectory: userBuildDirectory,
buildEnd,
prerender,
+ routeDiscovery: userRouteDiscovery,
serverBuildFile,
serverBundles,
serverModuleFormat,
ssr,
} = {
...defaults, // Default values should be completely overridden by user/preset config, not merged
- ...mergeReactRouterConfig(...presets, reactRouterUserConfig),
+ ...userAndPresetConfigs,
};
if (!ssr && serverBundles) {
@@ -420,6 +455,36 @@ async function resolveConfig({
);
}
+ let routeDiscovery: ResolvedReactRouterConfig["routeDiscovery"];
+ if (userRouteDiscovery == null) {
+ if (ssr) {
+ routeDiscovery = {
+ mode: "lazy",
+ manifestPath: "/__manifest",
+ };
+ } else {
+ routeDiscovery = { mode: "initial" };
+ }
+ } else if (userRouteDiscovery.mode === "initial") {
+ routeDiscovery = userRouteDiscovery;
+ } else if (userRouteDiscovery.mode === "lazy") {
+ if (!ssr) {
+ return err(
+ 'The `routeDiscovery.mode` config cannot be set to "lazy" when setting `ssr:false`'
+ );
+ }
+
+ let { manifestPath } = userRouteDiscovery;
+ if (manifestPath != null && !manifestPath.startsWith("/")) {
+ return err(
+ "The `routeDiscovery.manifestPath` config must be a root-relative " +
+ 'pathname beginning with a slash (i.e., "/__manifest")'
+ );
+ }
+
+ routeDiscovery = userRouteDiscovery;
+ }
+
let appDirectory = path.resolve(root, userAppDirectory || "app");
let buildDirectory = path.resolve(root, userBuildDirectory);
@@ -512,11 +577,12 @@ async function resolveConfig({
future,
prerender,
routes,
+ routeDiscovery,
serverBuildFile,
serverBundles,
serverModuleFormat,
ssr,
- });
+ } satisfies ResolvedReactRouterConfig);
for (let preset of reactRouterUserConfig.presets ?? []) {
await preset.reactRouterConfigResolved?.({ reactRouterConfig });
diff --git a/packages/react-router-dev/typegen/index.ts b/packages/react-router-dev/typegen/index.ts
index 342f27f808..a046345d31 100644
--- a/packages/react-router-dev/typegen/index.ts
+++ b/packages/react-router-dev/typegen/index.ts
@@ -161,6 +161,7 @@ const virtual = ts`
export const isSpaMode: ServerBuild["isSpaMode"];
export const prerender: ServerBuild["prerender"];
export const publicPath: ServerBuild["publicPath"];
+ export const routeDiscovery: ServerBuild["routeDiscovery"];
export const routes: ServerBuild["routes"];
export const ssr: ServerBuild["ssr"];
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts
index 66c24acfd9..30d026efd5 100644
--- a/packages/react-router-dev/vite/plugin.ts
+++ b/packages/react-router-dev/vite/plugin.ts
@@ -742,6 +742,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
export const ssr = ${ctx.reactRouterConfig.ssr};
export const isSpaMode = ${isSpaMode};
export const prerender = ${JSON.stringify(prerenderPaths)};
+ export const routeDiscovery = ${JSON.stringify(
+ ctx.reactRouterConfig.routeDiscovery
+ )};
export const publicPath = ${JSON.stringify(ctx.publicPath)};
export const entry = { module: entryServer };
export const routes = {
diff --git a/packages/react-router/__tests__/dom/scroll-restoration-test.tsx b/packages/react-router/__tests__/dom/scroll-restoration-test.tsx
index 8576f535e1..9686a49b25 100644
--- a/packages/react-router/__tests__/dom/scroll-restoration-test.tsx
+++ b/packages/react-router/__tests__/dom/scroll-restoration-test.tsx
@@ -11,10 +11,10 @@ import {
ScrollRestoration,
createBrowserRouter,
} from "../../index";
-import type { FrameworkContextObject } from "../../lib/dom/ssr/entry";
import { createMemoryRouter, redirect } from "react-router";
import { FrameworkContext, Scripts } from "../../lib/dom/ssr/components";
import "@testing-library/jest-dom/extend-expect";
+import { mockFrameworkContext } from "../utils/framework";
describe(`ScrollRestoration`, () => {
it("restores the scroll position for a page when re-visited", () => {
@@ -207,23 +207,7 @@ describe(`ScrollRestoration`, () => {
window.scrollTo = scrollTo;
});
- let context: FrameworkContextObject = {
- routeModules: { root: { default: () => null } },
- manifest: {
- routes: {
- root: {
- hasLoader: false,
- hasAction: false,
- hasErrorBoundary: false,
- id: "root",
- module: "root.js",
- },
- },
- entry: { imports: [], module: "" },
- url: "",
- version: "",
- },
- };
+ let context = mockFrameworkContext();
it("should render a