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