diff --git a/.changeset/unlucky-dolphins-chew.md b/.changeset/unlucky-dolphins-chew.md new file mode 100644 index 0000000000..07291cf3b4 --- /dev/null +++ b/.changeset/unlucky-dolphins-chew.md @@ -0,0 +1,9 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +Disable Lazy Route Discovery for all `ssr:false` apps and not just "SPA Mode" because there is no runtime server to serve the search-param-configured `__manifest` requests + +- We previously only disabled this for "SPA Mode" which is `ssr:false` and no `prerender` config but we realized it should apply to all `ssr:false` apps, including those prerendering multiple pages +- In those `prerender` scenarios we would prerender the `/__manifest` file assuming the static file server would serve it but that makes some unneccesary assumptions about the static file server behaviors diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index 36fa6766fd..9bfcbaf0a4 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -232,7 +232,6 @@ test.describe("Prerendering", () => { let clientDir = path.join(fixture.projectDir, "build", "client"); expect(listAllFiles(clientDir).sort()).toEqual([ - "__manifest", "_root.data", "about.data", "about/index.html", @@ -288,7 +287,6 @@ test.describe("Prerendering", () => { let clientDir = path.join(fixture.projectDir, "build", "client"); expect(listAllFiles(clientDir).sort()).toEqual([ - "__manifest", "_root.data", "about.data", "about/index.html", @@ -345,7 +343,6 @@ test.describe("Prerendering", () => { let clientDir = path.join(fixture.projectDir, "build", "client"); expect(listAllFiles(clientDir).sort()).toEqual([ - "__manifest", "_root.data", "a.data", "a/index.html", @@ -397,7 +394,6 @@ test.describe("Prerendering", () => { let clientDir = path.join(fixture.projectDir, "build", "client"); expect(listAllFiles(clientDir).sort()).toEqual([ - "__manifest", "_root.data", "about.data", "about/index.html", @@ -468,7 +464,6 @@ test.describe("Prerendering", () => { let clientDir = path.join(fixture.projectDir, "build", "client"); expect(listAllFiles(clientDir).sort()).toEqual([ - "__manifest", "_root.data", "about.data", "about/index.html", @@ -494,7 +489,13 @@ test.describe("Prerendering", () => { test("Hydrates into a navigable app", async ({ page }) => { fixture = await createFixture({ prerender: true, - files, + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: true, + }), + }, }); appFixture = await createAppFixture(fixture); @@ -511,14 +512,14 @@ test.describe("Prerendering", () => { await page.waitForSelector("[data-mounted]"); await app.clickLink("/about"); await page.waitForSelector("[data-route]:has-text('About')"); - expect(requests).toEqual(["/__manifest", "/about.data"]); + expect(requests).toEqual(["/about.data"]); }); test("Serves the prerendered HTML file alongside runtime routes", async ({ page, }) => { fixture = await createFixture({ - // Even thogh we are prerendering, we want a running server so we can + // Even though we are prerendering, we want a running server so we can // hit the pre-rendered HTML file and a non-prerendered route prerender: false, files: { @@ -783,6 +784,10 @@ test.describe("Prerendering", () => { prerender: true, files: { ...files, + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: true, + }), "app/routes/$slug.tsx": js` import * as React from "react"; import { useLoaderData } from "react-router"; diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 9a78ea39d4..8a6f55eef9 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -557,9 +557,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { )}; export const basename = ${JSON.stringify(ctx.reactRouterConfig.basename)}; export const future = ${JSON.stringify(ctx.reactRouterConfig.future)}; - export const isSpaMode = ${ - !ctx.reactRouterConfig.ssr && ctx.reactRouterConfig.prerender == null - }; + export const ssr = ${ctx.reactRouterConfig.ssr}; + export const isSpaMode = ${isSpaModeEnabled(ctx.reactRouterConfig)}; export const publicPath = ${JSON.stringify(ctx.publicPath)}; export const entry = { module: entryServer }; export const routes = { @@ -1743,7 +1742,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { let route = getRoute(ctx.reactRouterConfig, id); if (!route) return; - if (!options?.ssr && !ctx.reactRouterConfig.ssr) { + if (!options?.ssr && isSpaModeEnabled(ctx.reactRouterConfig)) { let exportNames = getExportNames(code); let serverOnlyExports = exportNames.filter((exp) => SERVER_ONLY_ROUTE_EXPORTS.includes(exp) @@ -2150,6 +2149,19 @@ async function getRouteMetadata( return info; } +function isSpaModeEnabled( + reactRouterConfig: ReactRouterPluginContext["reactRouterConfig"] +) { + return ( + reactRouterConfig.ssr === false && + (reactRouterConfig.prerender == null || + reactRouterConfig.prerender === false || + (Array.isArray(reactRouterConfig.prerender) && + reactRouterConfig.prerender.length === 1 && + reactRouterConfig.prerender[0] === "/")) + ); +} + async function getPrerenderBuildAndHandler( viteConfig: Vite.ResolvedConfig, serverBuildDirectory: string, @@ -2284,13 +2296,6 @@ async function handlePrerender( ); } } - - await prerenderManifest( - build, - clientBuildDirectory, - reactRouterConfig, - viteConfig - ); } function determineStaticPrerenderRoutes( @@ -2418,24 +2423,6 @@ async function prerenderResourceRoute( viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`); } -async function prerenderManifest( - build: ServerBuild, - clientBuildDirectory: string, - reactRouterConfig: ResolvedReactRouterConfig, - viteConfig: Vite.ResolvedConfig -) { - let normalizedPath = `${reactRouterConfig.basename}/__manifest`.replace( - /\/\/+/g, - "/" - ); - let outdir = path.relative(process.cwd(), clientBuildDirectory); - let outfile = path.join(outdir, ...normalizedPath.split("/")); - await fse.ensureDir(path.dirname(outfile)); - let manifestData = JSON.stringify(build.assets.routes); - await fse.outputFile(outfile, manifestData); - viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`); -} - function validatePrerenderedResponse( response: Response, html: string, diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 0bc1086868..b6026ebdf3 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -176,6 +176,7 @@ function createHydratedRouter(): DataRouter { patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( ssrInfo.manifest, ssrInfo.routeModules, + ssrInfo.context.ssr, ssrInfo.context.isSpaMode, ssrInfo.context.basename ), @@ -247,6 +248,7 @@ export function HydratedRouter() { router, ssrInfo.manifest, ssrInfo.routeModules, + ssrInfo.context.ssr, ssrInfo.context.isSpaMode ); @@ -264,6 +266,7 @@ export function HydratedRouter() { routeModules: ssrInfo.routeModules, future: ssrInfo.context.future, criticalCss, + ssr: ssrInfo.context.ssr, isSpaMode: ssrInfo.context.isSpaMode, }} > diff --git a/packages/react-router/lib/dom/global.ts b/packages/react-router/lib/dom/global.ts index 30c54dd0b2..58ec4ef4cf 100644 --- a/packages/react-router/lib/dom/global.ts +++ b/packages/react-router/lib/dom/global.ts @@ -7,6 +7,7 @@ export type WindowReactRouterContext = { state: HydrationState; criticalCss?: string; future: FutureConfig; + ssr: boolean; isSpaMode: boolean; stream: ReadableStream | undefined; streamController: ReadableStreamDefaultController; diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index f69b17594c..47dd5060a4 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -638,11 +638,11 @@ export type ScriptsProps = Omit< @category Components */ export function Scripts(props: ScriptsProps) { - let { manifest, serverHandoffString, isSpaMode, renderMeta } = + let { manifest, serverHandoffString, isSpaMode, ssr, renderMeta } = useFrameworkContext(); let { router, static: isStatic, staticContext } = useDataRouterContext(); let { matches: routerMatches } = useDataRouterStateContext(); - let enableFogOfWar = isFogOfWarEnabled(isSpaMode); + let enableFogOfWar = isFogOfWarEnabled(ssr); // 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 18094245de..6eb700ea2f 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -16,6 +16,7 @@ export interface FrameworkContextObject { criticalCss?: string; serverHandoffString?: string; future: FutureConfig; + ssr: boolean; isSpaMode: boolean; serializeError?(error: Error): SerializedError; renderMeta?: { 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 9993d4a4cf..b33f3410c0 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -26,8 +26,8 @@ const discoveredPaths = new Set(); // https://stackoverflow.com/a/417184 const URL_LIMIT = 7680; -export function isFogOfWarEnabled(isSpaMode: boolean) { - return !isSpaMode; +export function isFogOfWarEnabled(ssr: boolean) { + return ssr === true; } export function getPartialManifest( @@ -70,10 +70,11 @@ export function getPartialManifest( export function getPatchRoutesOnNavigationFunction( manifest: AssetsManifest, routeModules: RouteModules, + ssr: boolean, isSpaMode: boolean, basename: string | undefined ): PatchRoutesOnNavigationFunction | undefined { - if (!isFogOfWarEnabled(isSpaMode)) { + if (!isFogOfWarEnabled(ssr)) { return undefined; } @@ -96,14 +97,12 @@ export function useFogOFWarDiscovery( router: DataRouter, manifest: AssetsManifest, routeModules: RouteModules, + ssr: boolean, isSpaMode: boolean ) { React.useEffect(() => { // Don't prefetch if not enabled or if the user has `saveData` enabled - if ( - !isFogOfWarEnabled(isSpaMode) || - navigator.connection?.saveData === true - ) { + if (!isFogOfWarEnabled(ssr) || navigator.connection?.saveData === true) { return; } @@ -176,7 +175,7 @@ export function useFogOFWarDiscovery( }); return () => observer.disconnect(); - }, [isSpaMode, manifest, routeModules, router]); + }, [ssr, isSpaMode, manifest, routeModules, router]); } export async function fetchAndApplyManifestPatches( 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 3a23d76f12..6711dbcedf 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -112,6 +112,7 @@ export function createRoutesStub( version: "", }, routeModules: {}, + ssr: false, isSpaMode: false, }; diff --git a/packages/react-router/lib/dom/ssr/server.tsx b/packages/react-router/lib/dom/ssr/server.tsx index 92d8597b45..9bf9582e46 100644 --- a/packages/react-router/lib/dom/ssr/server.tsx +++ b/packages/react-router/lib/dom/ssr/server.tsx @@ -75,6 +75,7 @@ export function ServerRouter({ criticalCss, serverHandoffString, future: context.future, + ssr: context.ssr, isSpaMode: context.isSpaMode, 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 b81a326440..bcbe4f6791 100644 --- a/packages/react-router/lib/server-runtime/build.ts +++ b/packages/react-router/lib/server-runtime/build.ts @@ -20,6 +20,7 @@ export interface ServerBuild { publicPath: string; assetsBuildDirectory: string; future: FutureConfig; + ssr: boolean; isSpaMode: boolean; } diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 4810ea371f..0291cfe7a8 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -391,6 +391,7 @@ async function handleDocumentRequest( basename: build.basename, criticalCss, future: build.future, + ssr: build.ssr, isSpaMode: build.isSpaMode, }), serverHandoffStream: encodeViaTurboStream( @@ -401,6 +402,7 @@ async function handleDocumentRequest( ), renderMeta: {}, future: build.future, + ssr: build.ssr, isSpaMode: build.isSpaMode, serializeError: (err) => serializeError(err, serverMode), }; @@ -461,6 +463,7 @@ async function handleDocumentRequest( serverHandoffString: createServerHandoffString({ basename: build.basename, future: build.future, + ssr: build.ssr, isSpaMode: build.isSpaMode, }), serverHandoffStream: encodeViaTurboStream( diff --git a/packages/react-router/lib/server-runtime/serverHandoff.ts b/packages/react-router/lib/server-runtime/serverHandoff.ts index cd0706d4e2..ca1b7bf8a5 100644 --- a/packages/react-router/lib/server-runtime/serverHandoff.ts +++ b/packages/react-router/lib/server-runtime/serverHandoff.ts @@ -21,6 +21,7 @@ export function createServerHandoffString(serverHandoff: { criticalCss?: string; basename: string | undefined; future: FutureConfig; + ssr: boolean; isSpaMode: boolean; }): string { // Uses faster alternative of jsesc to escape data returned from the loaders.