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,