diff --git a/.changeset/stale-bats-swim.md b/.changeset/stale-bats-swim.md new file mode 100644 index 0000000000..8ee39168bd --- /dev/null +++ b/.changeset/stale-bats-swim.md @@ -0,0 +1,6 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +Adjust approach for Prerendering/SPA Mode via headers diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index 182271a085..73d6bad381 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -602,7 +602,7 @@ test.describe("Prerendering", () => { "app/routes/about.tsx": js` import { useLoaderData } from 'react-router'; export function loader({ request }) { - return "ABOUT-" + request.headers.has('X-React-Router-Prerender'); + return "ABOUT-" + Boolean(process.env.IS_RR_BUILD_REQUEST); } export default function Comp() { @@ -613,7 +613,7 @@ test.describe("Prerendering", () => { "app/routes/not-prerendered.tsx": js` import { useLoaderData } from 'react-router'; export function loader({ request }) { - return "NOT-PRERENDERED-" + request.headers.has('X-React-Router-Prerender'); + return "NOT-PRERENDERED-" + Boolean(process.env.IS_RR_BUILD_REQUEST); } export default function Comp() { @@ -659,7 +659,7 @@ test.describe("Prerendering", () => { import { useLoaderData } from 'react-router'; export function loader({ request }) { return { - prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + prerendered: process.env.IS_RR_BUILD_REQUEST ?? "no", // 24999 characters data: new Array(5000).fill('test').join('-'), }; @@ -712,7 +712,7 @@ test.describe("Prerendering", () => { import { useLoaderData } from 'react-router'; export function loader({ request }) { return { - prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + prerendered: process.env.IS_RR_BUILD_REQUEST ?? "no", data: "한글 데이터 - UTF-8 문자", }; } @@ -732,7 +732,7 @@ test.describe("Prerendering", () => { import { useLoaderData } from 'react-router'; export function loader({ request }) { return { - prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + prerendered: process.env.IS_RR_BUILD_REQUEST ?? "no", data: "非プリレンダリングデータ - UTF-8文字", }; } @@ -837,6 +837,18 @@ test.describe("Prerendering", () => { await page.waitForSelector("[data-mounted]"); expect(await app.getHtml()).toMatch("Index: INDEX"); }); + + test("Ignores build-time headers at runtime", async () => { + fixture = await createFixture({ files }); + let res = await fixture.requestSingleFetchData("/_root.data", { + headers: { + "X-React-Router-Prerender-Data": encodeURI( + '[{"_1":2},"routes/_index",{"_3":4},"data","Hello World!"]' + ), + }, + }); + expect((res.data as any)["routes/_index"].data).toBe("Index Loader Data"); + }); }); test.describe("ssr: false", () => { diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index 848d3bb56c..b8174c6375 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -234,6 +234,37 @@ test.describe("SPA Mode", () => { expect(await res.text()).toMatch(/^/); }); + test("Ignores build-time headers at runtime", async () => { + let fixture = await createFixture({ + files: { + "react-router.config.ts": reactRouterConfig({ + splitRouteModules, + }), + "app/root.tsx": js` + import { Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + +

Root

+ + + + ); + } + `, + }, + }); + let res = await fixture.requestDocument("/", { + headers: { "X-React-Router-SPA-Mode": "yes" }, + }); + let html = await res.text(); + expect(html).toMatch('"isSpaMode":false'); + expect(html).toMatch('

Root

'); + }); + test("works when combined with a basename", async ({ page }) => { fixture = await createFixture({ spaMode: true, diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 66c24acfd9..f93d43a4f5 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -1724,6 +1724,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { ); } + // Set an environment variable we can look for in the handler to + // enable some build-time-only logic + process.env.IS_RR_BUILD_REQUEST = "yes"; + if (isPrerenderingEnabled(ctx.reactRouterConfig)) { // If we have prerender routes, that takes precedence over SPA mode // which is ssr:false and only the root route being rendered @@ -2623,11 +2627,6 @@ async function handlePrerender( } let buildRoutes = createPrerenderRoutes(build.routes); - let headers = { - // Header that can be used in the loader to know if you're running at - // build time or runtime - "X-React-Router-Prerender": "yes", - }; for (let path of build.prerender) { // Ensure we have a leading slash for matching let matches = matchRoutes(buildRoutes, `/${path}/`.replace(/^\/\/+/, "/")); @@ -2655,8 +2654,7 @@ async function handlePrerender( [leafRoute.id], clientBuildDirectory, reactRouterConfig, - viteConfig, - { headers } + viteConfig ); // Prerender a raw file for external consumption await prerenderResourceRoute( @@ -2664,8 +2662,7 @@ async function handlePrerender( path, clientBuildDirectory, reactRouterConfig, - viteConfig, - { headers } + viteConfig ); } else { viteConfig.logger.warn( @@ -2684,8 +2681,7 @@ async function handlePrerender( null, clientBuildDirectory, reactRouterConfig, - viteConfig, - { headers } + viteConfig ); } @@ -2698,11 +2694,10 @@ async function handlePrerender( data ? { headers: { - ...headers, "X-React-Router-Prerender-Data": encodeURI(data), }, } - : { headers } + : undefined ); } } @@ -2746,7 +2741,7 @@ async function prerenderData( clientBuildDirectory: string, reactRouterConfig: ResolvedReactRouterConfig, viteConfig: Vite.ResolvedConfig, - requestInit: RequestInit + requestInit?: RequestInit ) { let normalizedPath = `${reactRouterConfig.basename}${ prerenderPath === "/" @@ -2789,7 +2784,7 @@ async function prerenderRoute( clientBuildDirectory: string, reactRouterConfig: ResolvedReactRouterConfig, viteConfig: Vite.ResolvedConfig, - requestInit: RequestInit + requestInit?: RequestInit ) { let normalizedPath = `${reactRouterConfig.basename}${prerenderPath}/`.replace( /\/\/+/g, @@ -2845,7 +2840,7 @@ async function prerenderResourceRoute( clientBuildDirectory: string, reactRouterConfig: ResolvedReactRouterConfig, viteConfig: Vite.ResolvedConfig, - requestInit: RequestInit + requestInit?: RequestInit ) { let normalizedPath = `${reactRouterConfig.basename}${prerenderPath}/` .replace(/\/\/+/g, "/") diff --git a/packages/react-router/lib/server-runtime/dev.ts b/packages/react-router/lib/server-runtime/dev.ts index 898a016b85..3298d37d78 100644 --- a/packages/react-router/lib/server-runtime/dev.ts +++ b/packages/react-router/lib/server-runtime/dev.ts @@ -14,3 +14,15 @@ export function getDevServerHooks(): DevServerHooks | undefined { // @ts-expect-error return globalThis[globalDevServerHooksKey]; } + +// Guarded access to build-time-only headers +export function getBuildTimeHeader(request: Request, headerName: string) { + if (typeof process !== "undefined") { + try { + if (process.env?.IS_RR_BUILD_REQUEST === "yes") { + return request.headers.get(headerName); + } + } catch (e) {} + } + return null; +} diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index ab22c9c2e5..9cd4812fbb 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -19,6 +19,7 @@ import { } from "../dom/ssr/single-fetch"; import invariant from "./invariant"; import type { ServerRouteModule } from "../dom/ssr/routeModules"; +import { getBuildTimeHeader } from "./dev"; export type ServerRouteManifest = RouteManifest>; @@ -86,10 +87,11 @@ export function createStaticHandlerDataRoutes( ? async (args: RRLoaderFunctionArgs) => { // If we're prerendering, use the data passed in from prerendering // the .data route so we don't call loaders twice - if (args.request.headers.has("X-React-Router-Prerender-Data")) { - const preRenderedData = args.request.headers.get( - "X-React-Router-Prerender-Data" - ); + let preRenderedData = getBuildTimeHeader( + args.request, + "X-React-Router-Prerender-Data" + ); + if (preRenderedData != null) { let encoded = preRenderedData ? decodeURI(preRenderedData) : preRenderedData; diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 1cc006caa2..1bb83d7041 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -23,7 +23,7 @@ import { matchServerRoutes } from "./routeMatching"; import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { createServerHandoffString } from "./serverHandoff"; -import { getDevServerHooks } from "./dev"; +import { getBuildTimeHeader, getDevServerHooks } from "./dev"; import { encodeViaTurboStream, getSingleFetchRedirect, @@ -164,12 +164,17 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( normalizedPath = normalizedPath.slice(0, -1); } + let isSpaMode = + getBuildTimeHeader(request, "X-React-Router-SPA-Mode") === "yes"; + // When runtime SSR is disabled, make our dev server behave like the deployed // pre-rendered site would if (!_build.ssr) { + // When SSR is disabled this, file can only ever run during dev because we + // delete the server build at the end of the build if (_build.prerender.length === 0) { - // Add the header if we're in SPA mode - request.headers.set("X-React-Router-SPA-Mode", "yes"); + // ssr:false and no prerender config indicates "SPA Mode" + isSpaMode = true; } else if ( !_build.prerender.includes(normalizedPath) && !_build.prerender.includes(normalizedPath + "/") @@ -194,7 +199,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( }); } else { // Serve a SPA fallback for non-pre-rendered document requests - request.headers.set("X-React-Router-SPA-Mode", "yes"); + isSpaMode = true; } } } @@ -275,7 +280,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( } } } else if ( - !request.headers.has("X-React-Router-SPA-Mode") && + !isSpaMode && matches && matches[matches.length - 1].route.module.default == null && matches[matches.length - 1].route.module.ErrorBoundary == null @@ -309,6 +314,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( request, loadContext, handleError, + isSpaMode, criticalCss ); } @@ -426,9 +432,9 @@ async function handleDocumentRequest( request: Request, loadContext: AppLoadContext | unstable_RouterContextProvider, handleError: (err: unknown) => void, + isSpaMode: boolean, criticalCss?: CriticalCss ) { - let isSpaMode = request.headers.has("X-React-Router-SPA-Mode"); try { let response = await staticHandler.query(request, { requestContext: loadContext,