From 4ce8ff72737bcf43afc7e9f5705c9214a19ec9f6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 10 Jun 2026 09:57:49 -0400 Subject: [PATCH] =?UTF-8?q?Fix=20prerendering=20pathname=20issue=20with=20?= =?UTF-8?q?trailingSlashAwareDataRequests=20e=E2=80=A6=20(#15173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- integration/vite-prerender-test.ts | 575 ++++++++++++++++++ .../.changes/patch.fix-bug-prerender.md | 1 + .../react-router/lib/server-runtime/server.ts | 4 +- 3 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 packages/react-router/.changes/patch.fix-bug-prerender.md diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index 5efe5f1a33..6e3905bfa3 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -3066,6 +3066,581 @@ for (let previewServerPrerendering of [false, true]) { ); expect(requests).toEqual([]); }); + + test.describe("future.trailingSlashAwareDataRequests=true", () => { + test("Navigates between prerendered parent and child SPA route", async ({ + page, + }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/", "/parent"], + future: { + v8_trailingSlashAwareDataRequests: true, + }, + }), + "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/parent.tsx": js` + import { Link, Form, Outlet } from 'react-router'; + export async function loader() { + return "PARENT DATA" + } + export async function clientLoader() { + return "PARENT CLIENT DATA" + } + export function clientAction() { + return "PARENT ACTION" + } + export default function Parent({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to child +
+ +
+
+ +
+ + + ); + } + `, + "app/routes/parent.child.tsx": js` + import { Link, Form } from 'react-router'; + export function clientLoader() { + return "CHILD DATA" + } + export function clientAction() { + return "CHILD ACTION" + } + export default function Child({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to parent +
+ +
+
+ +
+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent", true); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + + await app.clickLink("/parent/child"); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + // Submit to self + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.goBack(); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // Submit across routes + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + // Submit to self + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + // Submit across routes + await app.clickSubmitButton("/parent"); + await expect(page.getByText("PARENT ACTION")).toBeVisible(); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // Submit to self + await app.clickSubmitButton("/parent"); + await expect(page.getByText("PARENT ACTION")).toBeVisible(); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // We should never make this call because we started on this route and it never unmounts + expect(requests).toEqual([]); + }); + + test("Navigates between SPA parent and prerendered child route", async ({ + page, + }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/", "/parent/child"], + future: { + v8_trailingSlashAwareDataRequests: true, + }, + }), + "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/parent.tsx": js` + import { Link, Form, Outlet } from 'react-router'; + export async function clientLoader() { + return "PARENT DATA" + } + export function clientAction() { + return "PARENT ACTION" + } + export default function Parent({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to child +
+ +
+
+ +
+ + + ); + } + `, + "app/routes/parent.child.tsx": js` + import { Link, Form } from 'react-router'; + export function loader() { + return "CHILD DATA" + } + export function clientAction() { + return "CHILD ACTION" + } + export default function Child({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to parent +
+ +
+
+ +
+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent", true); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + + await app.clickLink("/parent/child"); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.goBack(); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.clickSubmitButton("/parent"); + await expect(page.getByText("PARENT ACTION")).toBeVisible(); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // Initial navigation and submission from /parent + expect(requests).toEqual([ + "/parent/child.data", + "/parent/child.data", + ]); + while (requests.length) requests.pop(); + + await app.goto("/parent/child", true); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.clickLink("/parent"); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.clickSubmitButton("/parent"); + await expect(page.getByText("PARENT ACTION")).toBeVisible(); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // Submission from /parent + expect(requests).toEqual(["/parent/child.data"]); + }); + + test("Navigates between prerendered parent and child SPA route (with a root loader)", async ({ + page, + }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/", "/parent"], + future: { + v8_trailingSlashAwareDataRequests: true, + }, + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` + import * as React from "react"; + import { Outlet, Scripts } from "react-router"; + + export function loader() { + return "ROOT DATA" + } + + export function Layout({ children }) { + return ( + + + + {children} + + + + ); + } + + export default function Root({ loaderData }) { + return ( + <> +

{loaderData}

+ + + ); + } + + export function HydrateFallback() { + return

Loading...

; + } + `, + "app/routes/parent.tsx": js` + import { Link, Form, Outlet } from 'react-router'; + export async function loader() { + return "PARENT DATA" + } + export async function clientLoader() { + return "PARENT CLIENT DATA" + } + export function clientAction() { + return "PARENT ACTION" + } + export default function Parent({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to child +
+ +
+
+ +
+ + + ); + } + `, + "app/routes/parent.child.tsx": js` + import { Link, Form } from 'react-router'; + export function clientLoader() { + return "CHILD DATA" + } + export function clientAction() { + return "CHILD ACTION" + } + export default function Child({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to parent +
+ +
+
+ +
+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent", true); + await expect(page.getByText("ROOT DATA")).toBeVisible(); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + + await app.clickLink("/parent/child"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + // Submit to self + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.goBack(); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // Submit across routes + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + // Submit to self + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + // Submit across routes + await app.clickSubmitButton("/parent"); + await expect(page.getByText("PARENT ACTION")).toBeVisible(); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // Submit to self + await app.clickSubmitButton("/parent"); + await expect(page.getByText("PARENT ACTION")).toBeVisible(); + await expect(page.getByText("PARENT CLIENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // We should never make this call because we started on this route and it never unmounts + expect(requests).toEqual([]); + }); + + test("Navigates between SPA parent and prerendered child route (with a root loader)", async ({ + page, + }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/", "/parent/child"], + future: { + v8_trailingSlashAwareDataRequests: true, + }, + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` + import * as React from "react"; + import { Outlet, Scripts } from "react-router"; + + export function loader() { + return "ROOT DATA" + } + + export function Layout({ children }) { + return ( + + + + {children} + + + + ); + } + + + export default function Root({ loaderData }) { + return ( + <> +

{loaderData}

+ + + ); + } `, + "app/routes/parent.tsx": js` + import { Link, Form, Outlet } from 'react-router'; + export async function clientLoader() { + return "PARENT DATA" + } + export function clientAction() { + return "PARENT ACTION" + } + export default function Parent({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to child +
+ +
+
+ +
+ + + ); + } + `, + "app/routes/parent.child.tsx": js` + import { Link, Form } from 'react-router'; + export function loader() { + return "CHILD DATA" + } + export function clientAction() { + return "CHILD ACTION" + } + export default function Child({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to parent +
+ +
+
+ +
+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent", true); + await expect(page.getByText("ROOT DATA")).toBeVisible(); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + + await app.clickLink("/parent/child"); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.goBack(); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.clickSubmitButton("/parent"); + await expect(page.getByText("PARENT ACTION")).toBeVisible(); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // Initial navigation and submission from /parent + expect(requests).toEqual([ + "/parent/child.data", + "/parent/child.data", + ]); + while (requests.length) requests.pop(); + + await app.goto("/parent/child", true); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.clickLink("/parent"); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + await app.clickSubmitButton("/parent/child"); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD ACTION")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).toBeVisible(); + + await app.clickSubmitButton("/parent"); + await expect(page.getByText("PARENT ACTION")).toBeVisible(); + await expect(page.getByText("PARENT DATA")).toBeVisible(); + await expect(page.getByText("CHILD DATA")).not.toBeVisible(); + + // Submission from /parent + expect(requests).toEqual(["/parent/child.data"]); + }); + }); }); }); } diff --git a/packages/react-router/.changes/patch.fix-bug-prerender.md b/packages/react-router/.changes/patch.fix-bug-prerender.md new file mode 100644 index 0000000000..6f503fb22d --- /dev/null +++ b/packages/react-router/.changes/patch.fix-bug-prerender.md @@ -0,0 +1 @@ +Fix server handler prerender responses when using `ssr: false` and `future.v8_trailingSlashAwareDataRequests: true`. Avoids false positive "SPA Mode" detection when serving prerendered paths diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 9c756c5260..dcc15f5869 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -158,8 +158,8 @@ function derive(build: ServerBuild, mode?: string) { // ssr:false and no prerender config indicates "SPA Mode" isSpaMode = true; } else if ( - !build.prerender.includes(decodedPath) && - !build.prerender.includes(decodedPath + "/") + !build.prerender.includes(decodedPath.replace(/\/$/, "")) && + !build.prerender.includes(decodedPath.replace(/[^/]$/, "/")) ) { if (requestUrl.pathname.endsWith(".data")) { // 404 on non-pre-rendered `.data` requests