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,