From 4ce8ff72737bcf43afc7e9f5705c9214a19ec9f6 Mon Sep 17 00:00:00 2001
From: Matt Brophy
{loaderData}
+ {actionData ?{actionData}
: null} + Go to child + + +{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} +{loaderData}
+ {actionData ?{actionData}
: null} + Go to child + + +{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} +{loaderData}
+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 + + +{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} +{loaderData}
+{loaderData}
+ {actionData ?{actionData}
: null} + Go to child + + +{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