From f81fb44469bd9d90640576a2aa92bf7caf18a6b4 Mon Sep 17 00:00:00 2001
From: johan sh
Date: Wed, 11 Jun 2025 17:01:01 +0200
Subject: [PATCH 1/4] fix: allow prerendering with a basename set to work
alongside ssr:false
the normalized (including basename) was compared with the prerender-entry, which does not have a basename
---
contributors.yml | 1 +
.../react-router/lib/server-runtime/server.ts | 15 +++++++++++++--
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/contributors.yml b/contributors.yml
index a603914de0..aa43af6c97 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -401,3 +401,4 @@
- zeromask1337
- zheng-chuang
- zxTomw
+- skrhlm
diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts
index 5bbe24e801..3a0c11cc14 100644
--- a/packages/react-router/lib/server-runtime/server.ts
+++ b/packages/react-router/lib/server-runtime/server.ts
@@ -166,6 +166,16 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
normalizedPath = normalizedPath.slice(0, -1);
}
+ let pathForPrerenderCheck = stripBasename(
+ normalizedPath,
+ normalizedBasename
+ );
+
+ // handle root path
+ if (pathForPrerenderCheck === "/") {
+ pathForPrerenderCheck = "";
+ }
+
let isSpaMode =
getBuildTimeHeader(request, "X-React-Router-SPA-Mode") === "yes";
@@ -178,8 +188,9 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
// ssr:false and no prerender config indicates "SPA Mode"
isSpaMode = true;
} else if (
- !_build.prerender.includes(normalizedPath) &&
- !_build.prerender.includes(normalizedPath + "/")
+ pathForPrerenderCheck !== null &&
+ !_build.prerender.includes(pathForPrerenderCheck) &&
+ !_build.prerender.includes(pathForPrerenderCheck + "/")
) {
if (url.pathname.endsWith(".data")) {
// 404 on non-pre-rendered `.data` requests
From 56a6e8a1f9d9374546bb89b88d83a5a535156268 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Wed, 6 Aug 2025 16:27:50 -0400
Subject: [PATCH 2/4] Add tests and update approach
---
integration/helpers/create-fixture.ts | 32 +-
integration/vite-prerender-test.ts | 285 ++++++++++++++++++
.../react-router/lib/server-runtime/server.ts | 35 ++-
3 files changed, 329 insertions(+), 23 deletions(-)
diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts
index c1283c2572..27bd68b892 100644
--- a/integration/helpers/create-fixture.ts
+++ b/integration/helpers/create-fixture.ts
@@ -158,16 +158,15 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
prerender: init.prerender,
requestDocument(href: string) {
let file = new URL(href, "test://test").pathname + "/index.html";
- let mainPath = path.join(projectDir, "build", "client", file);
- let fallbackPath = path.join(
- projectDir,
- "build",
- "client",
- "__spa-fallback.html",
- );
+ let clientDir = path.join(projectDir, "build", "client");
+ let mainPath = path.join(clientDir, file);
+ let fallbackPath = path.join(clientDir, "__spa-fallback.html");
+ let fallbackPath2 = path.join(clientDir, "index.html");
let html = existsSync(mainPath)
? readFileSync(mainPath)
- : readFileSync(fallbackPath);
+ : existsSync(fallbackPath)
+ ? readFileSync(fallbackPath)
+ : readFileSync(fallbackPath2);
return new Response(html, {
headers: {
"Content-Type": "text/html",
@@ -344,11 +343,18 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
);
app.get("*", (req, res, next) => {
let dir = path.join(fixture.projectDir, "build", "client");
- let file = req.path.endsWith(".data")
- ? req.path
- : req.path + "/index.html";
- if (file.endsWith(".html") && !existsSync(path.join(dir, file))) {
- file = "__spa-fallback.html";
+ let file;
+ if (req.path.endsWith(".data")) {
+ file = req.path;
+ } else {
+ let mainPath = req.path + "/index.html";
+ let fallbackPath = "__spa-fallback.html";
+ let fallbackPath2 = "index.html";
+ file = existsSync(mainPath)
+ ? mainPath
+ : existsSync(fallbackPath)
+ ? fallbackPath
+ : fallbackPath2;
}
let filePath = path.join(dir, file);
if (existsSync(filePath)) {
diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts
index 8ef67929f5..ebbf8f62f9 100644
--- a/integration/vite-prerender-test.ts
+++ b/integration/vite-prerender-test.ts
@@ -2673,5 +2673,290 @@ test.describe("Prerendering", () => {
await page.waitForSelector("#target");
expect(requests).toEqual(["/redirect.data"]);
});
+
+ test("Navigates across SPA/prerender pages when starting from a SPA page (w/basename)", async ({
+ page,
+ }) => {
+ fixture = await createFixture({
+ prerender: true,
+ files: {
+ "react-router.config.ts": reactRouterConfig({
+ ssr: false, // turn off fog of war since we're serving with a static server
+ prerender: ["/page"],
+ basename: "/base",
+ }),
+ "vite.config.ts": files["vite.config.ts"],
+ "app/root.tsx": js`
+ import * as React from "react";
+ import { Outlet, Scripts } from "react-router";
+
+ export function Layout({ children }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+
+ export default function Root({ loaderData }) {
+ return
+ }
+ `,
+ "app/routes/_index.tsx": js`
+ import { Link } from 'react-router';
+ export default function Index() {
+ return Go to page
+ }
+ `,
+ "app/routes/page.tsx": js`
+ import { Link, Form } from 'react-router';
+ export async function loader() {
+ return "PAGE DATA"
+ }
+ let count = 0;
+ export function clientAction() {
+ return "PAGE ACTION " + (++count)
+ }
+ export default function Page({ loaderData, actionData }) {
+ return (
+ <>
+ {loaderData}
+ {actionData ? {actionData}
: null}
+ Go to page2
+
+
+ >
+ );
+ }
+ `,
+ "app/routes/page2.tsx": js`
+ import { Form } from 'react-router';
+ export function clientLoader() {
+ return "PAGE2 DATA"
+ }
+ let count = 0;
+ export function clientAction() {
+ return "PAGE2 ACTION " + (++count)
+ }
+ export default function Page({ loaderData, actionData }) {
+ return (
+ <>
+ {loaderData}
+ {actionData ? {actionData}
: null}
+
+
+ >
+ );
+ }
+ `,
+ },
+ });
+ appFixture = await createAppFixture(fixture);
+
+ let requests = captureRequests(page);
+ let app = new PlaywrightFixture(appFixture, page);
+
+ await app.goto("/base", true);
+ await page.waitForSelector('a[href="/base/page"]');
+
+ await app.clickLink("/base/page");
+ await page.waitForSelector("[data-page]");
+ expect(await (await page.$("[data-page]"))?.innerText()).toBe(
+ "PAGE DATA",
+ );
+ expect(requests).toEqual(["/base/page.data"]);
+ clearRequests(requests);
+
+ await app.clickSubmitButton("/base/page");
+ await page.waitForSelector("[data-page-action]");
+ expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
+ "PAGE ACTION 1",
+ );
+ // No revalidation after submission to self
+ expect(requests).toEqual([]);
+
+ await app.clickLink("/base/page2");
+ await page.waitForSelector("[data-page2]");
+ expect(await (await page.$("[data-page2]"))?.innerText()).toBe(
+ "PAGE2 DATA",
+ );
+ expect(requests).toEqual([]);
+
+ await app.clickSubmitButton("/base/page2");
+ await page.waitForSelector("[data-page2-action]");
+ expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
+ "PAGE2 ACTION 1",
+ );
+ expect(requests).toEqual([]);
+
+ await app.clickSubmitButton("/base/page");
+ await page.waitForSelector("[data-page-action]");
+ expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
+ "PAGE ACTION 2",
+ );
+ expect(requests).toEqual(["/base/page.data"]);
+ clearRequests(requests);
+
+ await app.clickSubmitButton("/base/page2");
+ await page.waitForSelector("[data-page2-action]");
+ expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
+ "PAGE2 ACTION 2",
+ );
+ expect(requests).toEqual([]);
+ });
+
+ test("Navigates across SPA/prerender pages when starting from a prerendered page (w/basename)", async ({
+ page,
+ }) => {
+ fixture = await createFixture({
+ prerender: true,
+ files: {
+ "react-router.config.ts": reactRouterConfig({
+ ssr: false, // turn off fog of war since we're serving with a static server
+ prerender: ["/", "/page"],
+ basename: "/base",
+ }),
+ "vite.config.ts": files["vite.config.ts"],
+ "app/root.tsx": js`
+ import * as React from "react";
+ import { Outlet, Scripts } from "react-router";
+
+ export function Layout({ children }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+
+ export default function Root({ loaderData }) {
+ return ;
+ }
+ `,
+ "app/routes/_index.tsx": js`
+ import { Link } from 'react-router';
+ export default function Index() {
+ return Go to page
+ }
+ `,
+ "app/routes/page.tsx": js`
+ import { Link, Form } from 'react-router';
+ export async function loader() {
+ return "PAGE DATA"
+ }
+ let count = 0;
+ export function clientAction() {
+ return "PAGE ACTION " + (++count)
+ }
+ export default function Page({ loaderData, actionData }) {
+ return (
+ <>
+ {loaderData}
+ {actionData ? {actionData}
: null}
+ Go to page2
+
+
+ >
+ );
+ }
+ `,
+ "app/routes/page2.tsx": js`
+ import { Form } from 'react-router';
+ export function clientLoader() {
+ return "PAGE2 DATA"
+ }
+ let count = 0;
+ export function clientAction() {
+ return "PAGE2 ACTION " + (++count)
+ }
+ export default function Page({ loaderData, actionData }) {
+ return (
+ <>
+ {loaderData}
+ {actionData ? {actionData}
: null}
+
+
+ >
+ );
+ }
+ `,
+ },
+ });
+ appFixture = await createAppFixture(fixture);
+
+ let requests = captureRequests(page);
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/base", true);
+ await page.waitForSelector('a[href="/base/page"]');
+
+ await app.clickLink("/base/page");
+ await page.waitForSelector("[data-page]");
+ expect(await (await page.$("[data-page]"))?.innerText()).toBe(
+ "PAGE DATA",
+ );
+ expect(requests).toEqual(["/base/page.data"]);
+ clearRequests(requests);
+
+ await app.clickSubmitButton("/base/page");
+ await page.waitForSelector("[data-page-action]");
+ expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
+ "PAGE ACTION 1",
+ );
+ // No revalidation after submission to self
+ expect(requests).toEqual([]);
+
+ await app.clickLink("/base/page2");
+ await page.waitForSelector("[data-page2]");
+ expect(await (await page.$("[data-page2]"))?.innerText()).toBe(
+ "PAGE2 DATA",
+ );
+ expect(requests).toEqual([]);
+
+ await app.clickSubmitButton("/base/page2");
+ await page.waitForSelector("[data-page2-action]");
+ expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
+ "PAGE2 ACTION 1",
+ );
+ expect(requests).toEqual([]);
+
+ await app.clickSubmitButton("/base/page");
+ await page.waitForSelector("[data-page-action]");
+ expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
+ "PAGE ACTION 2",
+ );
+ expect(requests).toEqual(["/base/page.data"]);
+ clearRequests(requests);
+
+ await app.clickSubmitButton("/base/page2");
+ await page.waitForSelector("[data-page2-action]");
+ expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
+ "PAGE2 ACTION 2",
+ );
+ expect(requests).toEqual([]);
+ });
});
});
diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts
index a26f15afc2..63c0ef7524 100644
--- a/packages/react-router/lib/server-runtime/server.ts
+++ b/packages/react-router/lib/server-runtime/server.ts
@@ -169,14 +169,30 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
if (!_build.ssr) {
// Decode the URL path before checking against the prerender config
let decodedPath = decodeURI(normalizedPath);
- let decodedPathWithoutBasename = stripBasename(
- normalizedPath,
- normalizedBasename,
- );
- // handle root path
- if (decodedPathWithoutBasename === "/") {
- decodedPathWithoutBasename = "";
+ if (normalizedBasename !== "/") {
+ let strippedPath = stripBasename(decodedPath, normalizedBasename);
+ if (strippedPath == null) {
+ // 404 on non-pre-rendered `.data` requests
+ errorHandler(
+ new ErrorResponseImpl(
+ 404,
+ "Not Found",
+ `Refusing to prerender the \`${decodedPath}\` path because it does ` +
+ `not start with the basename \`${normalizedBasename}\``,
+ ),
+ {
+ context: loadContext,
+ params,
+ request,
+ },
+ );
+ return new Response("Not Found", {
+ status: 404,
+ statusText: "Not Found",
+ });
+ }
+ decodedPath = strippedPath;
}
// When SSR is disabled this, file can only ever run during dev because we
@@ -185,9 +201,8 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
// ssr:false and no prerender config indicates "SPA Mode"
isSpaMode = true;
} else if (
- decodedPathWithoutBasename !== null &&
- !_build.prerender.includes(decodedPathWithoutBasename) &&
- !_build.prerender.includes(decodedPathWithoutBasename + "/")
+ !_build.prerender.includes(decodedPath) &&
+ !_build.prerender.includes(decodedPath + "/")
) {
if (url.pathname.endsWith(".data")) {
// 404 on non-pre-rendered `.data` requests
From e1744935be7a267deaba6f9e709f490633aabcf2 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Wed, 6 Aug 2025 16:28:50 -0400
Subject: [PATCH 3/4] Add changeset
---
.changeset/fresh-brooms-trade.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/fresh-brooms-trade.md
diff --git a/.changeset/fresh-brooms-trade.md b/.changeset/fresh-brooms-trade.md
new file mode 100644
index 0000000000..e61cb523e8
--- /dev/null
+++ b/.changeset/fresh-brooms-trade.md
@@ -0,0 +1,5 @@
+---
+"react-router": patch
+---
+
+Fix prerendering when a `basename` is set with `ssr:false`
From 811fea84854151e12abf089eb75c2256a23328b0 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Wed, 6 Aug 2025 16:31:58 -0400
Subject: [PATCH 4/4] Update packages/react-router/lib/server-runtime/server.ts
---
packages/react-router/lib/server-runtime/server.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts
index 63c0ef7524..3a19718e47 100644
--- a/packages/react-router/lib/server-runtime/server.ts
+++ b/packages/react-router/lib/server-runtime/server.ts
@@ -173,7 +173,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
if (normalizedBasename !== "/") {
let strippedPath = stripBasename(decodedPath, normalizedBasename);
if (strippedPath == null) {
- // 404 on non-pre-rendered `.data` requests
errorHandler(
new ErrorResponseImpl(
404,