From 09819874c3a69356b7cd465de9edbfcf2d19d1de Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 23 Apr 2025 18:37:21 -0400 Subject: [PATCH 1/9] Add config option to disable lazy route discovery --- .changeset/angry-students-pay.md | 9 + integration/fog-of-war-test.ts | 174 ++++++++++++++++++ integration/helpers/vite.ts | 3 + packages/react-router-dev/config/config.ts | 40 +++- packages/react-router-dev/vite/plugin.ts | 3 + .../lib/dom-export/hydrated-router.tsx | 3 + packages/react-router/lib/dom/global.ts | 18 +- .../react-router/lib/dom/ssr/components.tsx | 6 +- packages/react-router/lib/dom/ssr/entry.ts | 2 + .../react-router/lib/dom/ssr/fog-of-war.ts | 13 +- .../lib/dom/ssr/routes-test-stub.tsx | 1 + packages/react-router/lib/dom/ssr/server.tsx | 1 + .../react-router/lib/server-runtime/build.ts | 1 + .../react-router/lib/server-runtime/server.ts | 22 +-- .../lib/server-runtime/serverHandoff.ts | 26 +-- 15 files changed, 267 insertions(+), 55 deletions(-) create mode 100644 .changeset/angry-students-pay.md diff --git a/.changeset/angry-students-pay.md b/.changeset/angry-students-pay.md new file mode 100644 index 0000000000..40c3d60636 --- /dev/null +++ b/.changeset/angry-students-pay.md @@ -0,0 +1,9 @@ +--- +"@react-router/dev": minor +"react-router": minor +--- + +Add new `routeDiscovery` `react-router.config.ts` option to disable Lazy Route Discovery + +- The default value is `routeDiscovery: "lazy"` +- Setting `routeDiscovery: "initial"` will disable Lazy Route Discovery and send up all routes in the manifest on initial document load diff --git a/integration/fog-of-war-test.ts b/integration/fog-of-war-test.ts index 9dda94a4c1..4f9bfab1b1 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.describe("routeDiscovery=initial", () => { + test("loads full manifest on initial load", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "react-router.config.ts": reactRouterConfig({ + routeDiscovery: "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":"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: "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` 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..ce7bb09446 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?: "initial" | "lazy"; }) => { let config: Config = { ssr, basename, prerender, appDirectory, + routeDiscovery, future: { unstable_splitRouteModules: splitRouteModules, unstable_viteEnvironmentApi: viteEnvironmentApi, diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 999e19eec3..0684d7d495 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -158,6 +158,13 @@ export type ReactRouterConfig = { * other platforms and tools. */ presets?: Array; + /** + * Control the "Lazy Route Discovery" behavior. 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?: "lazy" | "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 +212,13 @@ export type ResolvedReactRouterConfig = Readonly<{ * function returning an array to dynamically generate URLs. */ prerender: ReactRouterConfig["prerender"]; + /** + * Control the "Lazy Route Discovery" behavior. 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: ReactRouterConfig["routeDiscovery"]; /** * An object of all available routes, keyed by route id. */ @@ -383,24 +397,31 @@ async function resolveConfig({ let defaults = { basename: "/", buildDirectory: "build", + routeDiscovery: "lazy", serverBuildFile: "index.js", serverModuleFormat: "esm", ssr: true, } as const satisfies Partial; + let userAndPresetConfigs = mergeReactRouterConfig( + ...presets, + reactRouterUserConfig + ); + let { appDirectory: userAppDirectory, basename, buildDirectory: userBuildDirectory, buildEnd, prerender, + routeDiscovery, 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 +441,20 @@ async function resolveConfig({ ); } + if (routeDiscovery === "lazy" && !ssr) { + if (userAndPresetConfigs.routeDiscovery === "lazy") { + // If the user set "lazy" and `ssr:false`, then it's an invalid config + // and we want to fail the build + return err( + 'The `routeDiscovery` config cannot be set to "lazy" when setting `ssr:false`' + ); + } else { + // But if the user didn't specify, then we want to default to "initial" + // when SSR is disabled + routeDiscovery = "initial"; + } + } + let appDirectory = path.resolve(root, userAppDirectory || "app"); let buildDirectory = path.resolve(root, userBuildDirectory); @@ -512,11 +547,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/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/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 40a70add34..e4faad01b8 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -185,6 +185,7 @@ function createHydratedRouter({ ssrInfo.manifest, ssrInfo.routeModules, ssrInfo.context.ssr, + ssrInfo.context.routeDiscovery, ssrInfo.context.isSpaMode, ssrInfo.context.basename ), @@ -270,6 +271,7 @@ export function HydratedRouter(props: HydratedRouterProps) { ssrInfo.manifest, ssrInfo.routeModules, ssrInfo.context.ssr, + ssrInfo.context.routeDiscovery, ssrInfo.context.isSpaMode ); @@ -289,6 +291,7 @@ export function HydratedRouter(props: HydratedRouterProps) { criticalCss, ssr: ssrInfo.context.ssr, isSpaMode: ssrInfo.context.isSpaMode, + routeDiscovery: ssrInfo.context.routeDiscovery, }} > diff --git a/packages/react-router/lib/dom/global.ts b/packages/react-router/lib/dom/global.ts index c6e3f5b58b..d781dce03d 100644 --- a/packages/react-router/lib/dom/global.ts +++ b/packages/react-router/lib/dom/global.ts @@ -1,22 +1,12 @@ import type { HydrationState, Router as DataRouter } from "../router/router"; -import type { AssetsManifest, CriticalCss, FutureConfig } from "./ssr/entry"; +import type { ServerHandoff } from "../server-runtime/serverHandoff"; +import type { AssetsManifest } from "./ssr/entry"; import type { RouteModules } from "./ssr/routeModules"; -export type WindowReactRouterContext = { - basename?: string; - state: HydrationState; - criticalCss?: CriticalCss; - future: FutureConfig; - ssr: boolean; - isSpaMode: boolean; +export type WindowReactRouterContext = ServerHandoff & { + state: HydrationState; // Deserialized via the stream stream: ReadableStream | undefined; streamController: ReadableStreamDefaultController; - // The number of active deferred keys rendered on the server - a?: number; - dev?: { - port?: number; - hmrRuntime?: string; - }; }; export interface ViewTransition { diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 0f4e39c7e9..01e4df7cdc 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -29,7 +29,7 @@ import type { import { singleFetchUrl } from "./single-fetch"; import { DataRouterContext, DataRouterStateContext } from "../../context"; import { useLocation } from "../../hooks"; -import { getPartialManifest, isFogOfWarEnabled } from "./fog-of-war"; +import { getPartialManifest } from "./fog-of-war"; import type { PageLinkDescriptor } from "../../router/links"; function useDataRouterContext() { @@ -643,11 +643,11 @@ export type ScriptsProps = Omit< @category Components */ export function Scripts(props: ScriptsProps) { - let { manifest, serverHandoffString, isSpaMode, ssr, renderMeta } = + let { manifest, serverHandoffString, isSpaMode, renderMeta, routeDiscovery } = useFrameworkContext(); let { router, static: isStatic, staticContext } = useDataRouterContext(); let { matches: routerMatches } = useDataRouterStateContext(); - let enableFogOfWar = isFogOfWarEnabled(ssr); + let enableFogOfWar = routeDiscovery === "lazy"; // Let know that we hydrated and we should render the single // fetch streaming scripts diff --git a/packages/react-router/lib/dom/ssr/entry.ts b/packages/react-router/lib/dom/ssr/entry.ts index 995cf50b0d..063729cd63 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -3,6 +3,7 @@ import type { StaticHandlerContext } from "../../router/router"; import type { EntryRoute } from "./routes"; import type { RouteModules } from "./routeModules"; import type { RouteManifest } from "../../router/utils"; +import type { ServerBuild } from "../../server-runtime/build"; type SerializedError = { message: string; @@ -18,6 +19,7 @@ export interface FrameworkContextObject { future: FutureConfig; ssr: boolean; isSpaMode: boolean; + routeDiscovery: ServerBuild["routeDiscovery"]; serializeError?(error: Error): SerializedError; renderMeta?: { didRenderScripts?: boolean; diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index c929bf1c13..5366b10ba7 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -7,6 +7,7 @@ import type { AssetsManifest } from "./entry"; import type { RouteModules } from "./routeModules"; import type { EntryRoute } from "./routes"; import { createClientRoutes } from "./routes"; +import type { ServerBuild } from "../../server-runtime/build"; declare global { interface Navigator { @@ -26,10 +27,6 @@ const discoveredPaths = new Set(); // https://stackoverflow.com/a/417184 const URL_LIMIT = 7680; -export function isFogOfWarEnabled(ssr: boolean) { - return ssr === true; -} - export function getPartialManifest( { sri, ...manifest }: AssetsManifest, router: DataRouter @@ -72,10 +69,11 @@ export function getPatchRoutesOnNavigationFunction( manifest: AssetsManifest, routeModules: RouteModules, ssr: boolean, + routeDiscovery: ServerBuild["routeDiscovery"], isSpaMode: boolean, basename: string | undefined ): PatchRoutesOnNavigationFunction | undefined { - if (!isFogOfWarEnabled(ssr)) { + if (routeDiscovery !== "lazy") { return undefined; } @@ -102,11 +100,12 @@ export function useFogOFWarDiscovery( manifest: AssetsManifest, routeModules: RouteModules, ssr: boolean, + routeDiscovery: ServerBuild["routeDiscovery"], isSpaMode: boolean ) { React.useEffect(() => { // Don't prefetch if not enabled or if the user has `saveData` enabled - if (!isFogOfWarEnabled(ssr) || navigator.connection?.saveData === true) { + if (routeDiscovery !== "lazy" || navigator.connection?.saveData === true) { return; } @@ -181,7 +180,7 @@ export function useFogOFWarDiscovery( }); return () => observer.disconnect(); - }, [ssr, isSpaMode, manifest, routeModules, router]); + }, [ssr, isSpaMode, manifest, routeModules, router, routeDiscovery]); } const MANIFEST_VERSION_STORAGE_KEY = "react-router-manifest-version"; diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index 1ab169f25d..e32e9376a6 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -113,6 +113,7 @@ export function createRoutesStub( routeModules: {}, ssr: false, isSpaMode: false, + routeDiscovery: "lazy", }; // Update the routes to include context in the loader/action and populate diff --git a/packages/react-router/lib/dom/ssr/server.tsx b/packages/react-router/lib/dom/ssr/server.tsx index 7b54e4a37e..befc1b0125 100644 --- a/packages/react-router/lib/dom/ssr/server.tsx +++ b/packages/react-router/lib/dom/ssr/server.tsx @@ -82,6 +82,7 @@ export function ServerRouter({ future: context.future, ssr: context.ssr, isSpaMode: context.isSpaMode, + routeDiscovery: context.routeDiscovery, serializeError: context.serializeError, renderMeta: context.renderMeta, }} diff --git a/packages/react-router/lib/server-runtime/build.ts b/packages/react-router/lib/server-runtime/build.ts index 1fd78abdb7..8ea9b0b87b 100644 --- a/packages/react-router/lib/server-runtime/build.ts +++ b/packages/react-router/lib/server-runtime/build.ts @@ -37,6 +37,7 @@ export interface ServerBuild { */ isSpaMode: boolean; prerender: string[]; + routeDiscovery: "lazy" | "initial"; } export interface HandleDocumentRequestFunction { diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 5cba00bcd4..b2412891b5 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -22,7 +22,7 @@ import type { RouteMatch } from "./routeMatching"; import { matchServerRoutes } from "./routeMatching"; import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; -import { createServerHandoffString } from "./serverHandoff"; +import { ServerHandoff, createServerHandoffString } from "./serverHandoff"; import { getDevServerHooks } from "./dev"; import { encodeViaTurboStream, @@ -474,17 +474,21 @@ async function handleDocumentRequest( actionData: context.actionData, errors: serializeErrors(context.errors, serverMode), }; + let baseServerHandoff: ServerHandoff = { + basename: build.basename, + future: build.future, + routeDiscovery: build.routeDiscovery, + ssr: build.ssr, + isSpaMode, + }; let entryContext: EntryContext = { manifest: build.assets, routeModules: createEntryRouteModules(build.routes), staticHandlerContext: context, criticalCss, serverHandoffString: createServerHandoffString({ - basename: build.basename, + ...baseServerHandoff, criticalCss, - future: build.future, - ssr: build.ssr, - isSpaMode, }), serverHandoffStream: encodeViaTurboStream( state, @@ -495,6 +499,7 @@ async function handleDocumentRequest( renderMeta: {}, future: build.future, ssr: build.ssr, + routeDiscovery: build.routeDiscovery, isSpaMode, serializeError: (err) => serializeError(err, serverMode), }; @@ -554,12 +559,7 @@ async function handleDocumentRequest( entryContext = { ...entryContext, staticHandlerContext: context, - serverHandoffString: createServerHandoffString({ - basename: build.basename, - future: build.future, - ssr: build.ssr, - isSpaMode, - }), + serverHandoffString: createServerHandoffString(baseServerHandoff), serverHandoffStream: encodeViaTurboStream( state, request.signal, diff --git a/packages/react-router/lib/server-runtime/serverHandoff.ts b/packages/react-router/lib/server-runtime/serverHandoff.ts index 203b70bab4..ea24252c81 100644 --- a/packages/react-router/lib/server-runtime/serverHandoff.ts +++ b/packages/react-router/lib/server-runtime/serverHandoff.ts @@ -1,29 +1,19 @@ -import type { HydrationState } from "../router/router"; import type { CriticalCss, FutureConfig } from "../dom/ssr/entry"; import { escapeHtml } from "./markup"; +import type { ServerBuild } from "./build"; -type ValidateShape = - // If it extends T - T extends Shape - ? // and there are no leftover props after removing the base - Exclude extends never - ? // we are good - T - : // otherwise it's either too many or too few props - never - : never; - -// TODO: Remove Promises from serialization -export function createServerHandoffString(serverHandoff: { - // Don't allow StaticHandlerContext to be passed in verbatim, since then - // we'd end up including duplicate info - state?: ValidateShape; +export type ServerHandoff = { criticalCss?: CriticalCss; basename: string | undefined; future: FutureConfig; ssr: boolean; isSpaMode: boolean; -}): string { + routeDiscovery: ServerBuild["routeDiscovery"]; +}; + +export function createServerHandoffString( + serverHandoff: ServerHandoff +): string { // Uses faster alternative of jsesc to escape data returned from the loaders. // This string is inserted directly into the HTML in the `` element. return escapeHtml(JSON.stringify(serverHandoff)); From ccbb6fb6e442e96b6e29a7e89875f667f6693ef5 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 24 Apr 2025 11:04:01 -0400 Subject: [PATCH 2/9] fix lint issue --- packages/react-router/lib/server-runtime/server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index b2412891b5..310222d9ac 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -22,7 +22,8 @@ import type { RouteMatch } from "./routeMatching"; import { matchServerRoutes } from "./routeMatching"; import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; -import { ServerHandoff, createServerHandoffString } from "./serverHandoff"; +import type { ServerHandoff } from "./serverHandoff"; +import { createServerHandoffString } from "./serverHandoff"; import { getDevServerHooks } from "./dev"; import { encodeViaTurboStream, From 4db291c315bf24125cd32268f0608975ea269534 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 24 Apr 2025 11:07:33 -0400 Subject: [PATCH 3/9] Fix e2e tests --- integration/vite-presets-test.ts | 3 ++- packages/react-router-dev/typegen/index.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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/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"]; From 1766db93f0f1fb810af3d1eccb835a28802e2c9b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 24 Apr 2025 11:59:11 -0400 Subject: [PATCH 4/9] Allow configuration of the manifest path --- .changeset/angry-students-pay.md | 2 + integration/fog-of-war-test.ts | 46 +++++++++++ integration/helpers/vite.ts | 9 ++- packages/react-router-dev/config/config.ts | 78 +++++++++++++++---- .../react-router/lib/dom/ssr/components.tsx | 2 +- .../react-router/lib/dom/ssr/fog-of-war.ts | 17 ++-- .../lib/dom/ssr/routes-test-stub.tsx | 2 +- .../react-router/lib/server-runtime/build.ts | 5 +- .../react-router/lib/server-runtime/server.ts | 6 +- 9 files changed, 139 insertions(+), 28 deletions(-) diff --git a/.changeset/angry-students-pay.md b/.changeset/angry-students-pay.md index 40c3d60636..d29009f955 100644 --- a/.changeset/angry-students-pay.md +++ b/.changeset/angry-students-pay.md @@ -7,3 +7,5 @@ Add new `routeDiscovery` `react-router.config.ts` option to disable Lazy Route D - The default value is `routeDiscovery: "lazy"` - Setting `routeDiscovery: "initial"` will disable Lazy Route Discovery and send up all routes in the manifest on initial document load +- There is also an object version of the config which allows you to customize the manifest path when using `lazy` + - `routeDiscovery: { mode: "lazy", manifestPath: "/custom-manifest" }` diff --git a/integration/fog-of-war-test.ts b/integration/fog-of-war-test.ts index 4f9bfab1b1..b1ee7e9fe5 100644 --- a/integration/fog-of-war-test.ts +++ b/integration/fog-of-war-test.ts @@ -1409,6 +1409,52 @@ test.describe("Fog of War", () => { 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: { 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({ diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index ce7bb09446..653f9ac246 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -42,7 +42,14 @@ export const reactRouterConfig = ({ >["unstable_splitRouteModules"]; viteEnvironmentApi?: boolean; middleware?: boolean; - routeDiscovery?: "initial" | "lazy"; + routeDiscovery?: + | "initial" + | "lazy" + | { + mode?: "lazy"; + manifestPath: string; + } + | { mode: "initial" }; }) => { let config: Config = { ssr, diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 0684d7d495..5186283f19 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -159,12 +159,28 @@ export type ReactRouterConfig = { */ presets?: Array; /** - * Control the "Lazy Route Discovery" behavior. 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. + * 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`. + * + * If you do not need to control the manifest, you can specify the mode + * directly on the `routeDiscovery` config. */ - routeDiscovery?: "lazy" | "initial"; + routeDiscovery?: + | "lazy" + | "initial" + | { + mode?: "lazy"; // Can only adjust the manifest path in `lazy` mode + 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. @@ -218,7 +234,10 @@ export type ResolvedReactRouterConfig = Readonly<{ * application. You can set this to `initial` to opt-out of this behavior and * load all routes with the initial HTML document load. */ - routeDiscovery: ReactRouterConfig["routeDiscovery"]; + routeDiscovery: { + mode: "lazy" | "initial"; + manifestPath: string; + }; /** * An object of all available routes, keyed by route id. */ @@ -397,7 +416,6 @@ async function resolveConfig({ let defaults = { basename: "/", buildDirectory: "build", - routeDiscovery: "lazy", serverBuildFile: "index.js", serverModuleFormat: "esm", ssr: true, @@ -414,7 +432,7 @@ async function resolveConfig({ buildDirectory: userBuildDirectory, buildEnd, prerender, - routeDiscovery, + routeDiscovery: userRouteDiscovery, serverBuildFile, serverBundles, serverModuleFormat, @@ -441,17 +459,43 @@ async function resolveConfig({ ); } - if (routeDiscovery === "lazy" && !ssr) { - if (userAndPresetConfigs.routeDiscovery === "lazy") { - // If the user set "lazy" and `ssr:false`, then it's an invalid config - // and we want to fail the build + let routeDiscovery: ResolvedReactRouterConfig["routeDiscovery"] = { + mode: "lazy", + manifestPath: "/__manifest", + }; + + if (userRouteDiscovery == null) { + if (!ssr) { + // No FOW when SSR is disabled + routeDiscovery.mode = "initial"; + } else { + // no-op - use defaults + } + } else if ( + userRouteDiscovery === "initial" || + (typeof userRouteDiscovery === "object" && + userRouteDiscovery.mode === "initial") + ) { + routeDiscovery.mode = "initial"; + } else { + if (!ssr) { return err( - 'The `routeDiscovery` config cannot be set to "lazy" when setting `ssr:false`' + 'The `routeDiscovery` config must be left unset or set to "initial" ' + + "when setting `ssr:false`" ); - } else { - // But if the user didn't specify, then we want to default to "initial" - // when SSR is disabled - routeDiscovery = "initial"; + } + if (typeof userRouteDiscovery === "object") { + if ( + userRouteDiscovery.manifestPath != null && + !userRouteDiscovery.manifestPath.startsWith("/") + ) { + return err( + "The `routeDiscovery.manifestPath` config must be a root-relative " + + 'pathname beginning with a slash (i.e., "/__manifest")' + ); + } + routeDiscovery.manifestPath = + userRouteDiscovery.manifestPath ?? "/__manifest"; } } diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 01e4df7cdc..8fb8c0108e 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -647,7 +647,7 @@ export function Scripts(props: ScriptsProps) { useFrameworkContext(); let { router, static: isStatic, staticContext } = useDataRouterContext(); let { matches: routerMatches } = useDataRouterStateContext(); - let enableFogOfWar = routeDiscovery === "lazy"; + let enableFogOfWar = routeDiscovery.mode === "lazy"; // Let know that we hydrated and we should render the single // fetch streaming scripts diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index 5366b10ba7..08962adec8 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -73,7 +73,7 @@ export function getPatchRoutesOnNavigationFunction( isSpaMode: boolean, basename: string | undefined ): PatchRoutesOnNavigationFunction | undefined { - if (routeDiscovery !== "lazy") { + if (routeDiscovery.mode !== "lazy") { return undefined; } @@ -89,6 +89,7 @@ export function getPatchRoutesOnNavigationFunction( ssr, isSpaMode, basename, + routeDiscovery.manifestPath, patch, signal ); @@ -105,7 +106,10 @@ export function useFogOFWarDiscovery( ) { React.useEffect(() => { // Don't prefetch if not enabled or if the user has `saveData` enabled - if (routeDiscovery !== "lazy" || navigator.connection?.saveData === true) { + if ( + routeDiscovery.mode !== "lazy" || + navigator.connection?.saveData === true + ) { return; } @@ -156,6 +160,7 @@ export function useFogOFWarDiscovery( ssr, isSpaMode, router.basename, + routeDiscovery.manifestPath, router.patchRoutes ); } catch (e) { @@ -193,13 +198,13 @@ export async function fetchAndApplyManifestPatches( ssr: boolean, isSpaMode: boolean, basename: string | undefined, + _manifestPath: string, patchRoutes: DataRouter["patchRoutes"], signal?: AbortSignal ): Promise { - let manifestPath = `${basename != null ? basename : "/"}/__manifest`.replace( - /\/+/g, - "/" - ); + let manifestPath = `${ + basename != null ? basename : "/" + }${_manifestPath}`.replace(/\/+/g, "/"); let url = new URL(manifestPath, window.location.origin); paths.sort().forEach((path) => url.searchParams.append("p", path)); url.searchParams.set("version", manifest.version); diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index e32e9376a6..5307012aa7 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -113,7 +113,7 @@ export function createRoutesStub( routeModules: {}, ssr: false, isSpaMode: false, - routeDiscovery: "lazy", + routeDiscovery: { mode: "lazy", manifestPath: "/__manifest" }, }; // Update the routes to include context in the loader/action and populate diff --git a/packages/react-router/lib/server-runtime/build.ts b/packages/react-router/lib/server-runtime/build.ts index 8ea9b0b87b..c12b4ab60e 100644 --- a/packages/react-router/lib/server-runtime/build.ts +++ b/packages/react-router/lib/server-runtime/build.ts @@ -37,7 +37,10 @@ export interface ServerBuild { */ isSpaMode: boolean; prerender: string[]; - routeDiscovery: "lazy" | "initial"; + routeDiscovery: { + mode: "lazy" | "initial"; + manifestPath: string; + }; } export interface HandleDocumentRequestFunction { diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 310222d9ac..dd39a9e4c4 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -199,7 +199,11 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( } // Manifest request for fog of war - let manifestUrl = `${normalizedBasename}/__manifest`.replace(/\/+/g, "/"); + let manifestUrl = + `${normalizedBasename}${_build.routeDiscovery.manifestPath}`.replace( + /\/+/g, + "/" + ); if (url.pathname === manifestUrl) { try { let res = await handleManifestRequest(_build, routes, url); From 6f82ad8200f039ed3d46c643645bd1f37408602a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 25 Apr 2025 13:50:31 -0400 Subject: [PATCH 5/9] Fix unit tests --- .../__tests__/dom/scroll-restoration-test.tsx | 20 +- .../__tests__/dom/ssr/components-test.tsx | 87 +++--- .../__tests__/server-runtime/data-test.ts | 21 +- .../server-runtime/handle-error-test.ts | 43 ++- .../__tests__/server-runtime/handler-test.ts | 22 +- .../__tests__/server-runtime/server-test.ts | 263 +++++------------- .../__tests__/server-runtime/utils.ts | 58 ++-- .../react-router/__tests__/utils/framework.ts | 71 +++++ 8 files changed, 250 insertions(+), 335 deletions(-) create mode 100644 packages/react-router/__tests__/utils/framework.ts 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