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` diff --git a/contributors.yml b/contributors.yml index 41d4ad775e..48effc1f67 100644 --- a/contributors.yml +++ b/contributors.yml @@ -432,3 +432,4 @@ - zeromask1337 - zheng-chuang - zxTomw +- skrhlm 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} +{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} +{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 f5a83b49d9..3a19718e47 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -170,6 +170,30 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( // Decode the URL path before checking against the prerender config let decodedPath = decodeURI(normalizedPath); + if (normalizedBasename !== "/") { + let strippedPath = stripBasename(decodedPath, normalizedBasename); + if (strippedPath == null) { + 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 // delete the server build at the end of the build if (_build.prerender.length === 0) {