diff --git a/contributors.yml b/contributors.yml index 64f04cec93..cb2fce8816 100644 --- a/contributors.yml +++ b/contributors.yml @@ -109,6 +109,7 @@ - dokeet - doytch - Drishtantr +- edmundhung - edwin177 - eiffelwong1 - ek9 diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index db770cab39..e6fd645f97 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -31,6 +31,7 @@ export const reactRouterConfig = ({ v8_middleware, v8_splitRouteModules, v8_viteEnvironmentApi, + unstable_previewServerPrerendering, routeDiscovery, }: { ssr?: boolean; @@ -40,6 +41,7 @@ export const reactRouterConfig = ({ v8_middleware?: boolean; v8_splitRouteModules?: NonNullable["v8_splitRouteModules"]; v8_viteEnvironmentApi?: boolean; + unstable_previewServerPrerendering?: boolean; routeDiscovery?: Config["routeDiscovery"]; }) => { let config: Config = { @@ -52,6 +54,7 @@ export const reactRouterConfig = ({ v8_middleware, v8_splitRouteModules, v8_viteEnvironmentApi, + unstable_previewServerPrerendering, }, }; diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index 2cd0c95bbe..ad5039166e 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -13,11 +13,13 @@ import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; import { build, createProject, reactRouterConfig } from "./helpers/vite.js"; -let files = { - "react-router.config.ts": reactRouterConfig({ - prerender: true, - }), - "vite.config.ts": js` +for (let previewServerPrerendering of [false, true]) { + let files = { + "react-router.config.ts": reactRouterConfig({ + prerender: true, + unstable_previewServerPrerendering: previewServerPrerendering, + }), + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -28,7 +30,7 @@ let files = { ], }); `, - "app/root.tsx": js` + "app/root.tsx": js` import * as React from "react"; import { Link, Links, Meta, Outlet, Scripts, useRouteError } from "react-router"; @@ -79,7 +81,7 @@ let files = { return

Loading...

; } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import * as React from "react"; import { useLoaderData } from "react-router"; @@ -104,7 +106,7 @@ let files = { ); } `, - "app/routes/about.tsx": js` + "app/routes/about.tsx": js` import { useActionData, useLoaderData } from "react-router"; export function meta({ data }) { @@ -128,54 +130,54 @@ let files = { ); } `, -}; - -function listAllFiles(_dir: string) { - let files: string[] = []; - - function recurse(dir: string) { - fs.readdirSync(dir).forEach((file) => { - // Join with posix separator for consistency - const absolute = dir + "/" + file; - if (fs.statSync(absolute).isDirectory()) { - if (![".vite", "assets"].includes(file)) { - return recurse(absolute); + }; + + function listAllFiles(_dir: string) { + let files: string[] = []; + + function recurse(dir: string) { + fs.readdirSync(dir).forEach((file) => { + // Join with posix separator for consistency + const absolute = dir + "/" + file; + if (fs.statSync(absolute).isDirectory()) { + if (![".vite", "assets"].includes(file)) { + return recurse(absolute); + } + } else { + return files.push(absolute); } - } else { - return files.push(absolute); - } - }); - } + }); + } - recurse(_dir); + recurse(_dir); - // Normalize *nix/windows paths - return files.map((f) => f.replace(_dir, "").replace(/^\//, "")); -} + // Normalize *nix/windows paths + return files.map((f) => f.replace(_dir, "").replace(/^\//, "")); + } -test.describe("Prerendering", () => { - let fixture: Fixture; - let appFixture: AppFixture; + test.describe(`Prerendering (unstable_previewServerPrerendering: ${JSON.stringify(previewServerPrerendering)})`, () => { + let fixture: Fixture; + let appFixture: AppFixture; - test.afterAll(() => { - appFixture?.close(); - }); + test.afterAll(() => { + appFixture?.close(); + }); - test.describe("prerendered file behavior (agnostic of ssr flag)", () => { - test("Prerenders known static routes when true is specified", async () => { - let buildStdio = new PassThrough(); - fixture = await createFixture({ - buildStdio, - prerender: true, - files: { - ...files, - "app/routes/parent.tsx": js` + test.describe("prerendered file behavior (agnostic of ssr flag)", () => { + test("Prerenders known static routes when true is specified", async () => { + let buildStdio = new PassThrough(); + fixture = await createFixture({ + buildStdio, + prerender: true, + files: { + ...files, + "app/routes/parent.tsx": js` import { Outlet } from 'react-router' export default function Component() { return } `, - "app/routes/parent.child.tsx": js` + "app/routes/parent.child.tsx": js` import { Outlet } from 'react-router' export function loader() { return null; @@ -184,7 +186,7 @@ test.describe("Prerendering", () => { return } `, - "app/routes/$slug.tsx": js` + "app/routes/$slug.tsx": js` import { Outlet } from 'react-router' export function loader() { return null; @@ -193,7 +195,7 @@ test.describe("Prerendering", () => { return } `, - "app/routes/$.tsx": js` + "app/routes/$.tsx": js` import { Outlet } from 'react-router' export function loader() { return null; @@ -202,44 +204,48 @@ test.describe("Prerendering", () => { return } `, - }, - }); + }, + }); + + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "_root.data", + "about.data", + "about/index.html", + "favicon.ico", + "index.html", + "parent/child.data", + "parent/child/index.html", + "parent/index.html", + ]); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Index Title: Index Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

Index

'); + expect(html).toMatch( + '

Index Loader Data

', + ); - appFixture = await createAppFixture(fixture); - - let clientDir = path.join(fixture.projectDir, "build", "client"); - expect(listAllFiles(clientDir).sort()).toEqual([ - "_root.data", - "about.data", - "about/index.html", - "favicon.ico", - "index.html", - "parent/child.data", - "parent/child/index.html", - "parent/index.html", - ]); - - let res = await fixture.requestDocument("/"); - let html = await res.text(); - expect(html).toMatch("Index Title: Index Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

Index

'); - expect(html).toMatch('

Index Loader Data

'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - expect(html).toMatch("About Title: About Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

About

'); - expect(html).toMatch('

About Loader Data

'); - }); + res = await fixture.requestDocument("/about"); + html = await res.text(); + expect(html).toMatch("About Title: About Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

About

'); + expect(html).toMatch( + '

About Loader Data

', + ); + }); - test("Prerenders a static array of routes", async () => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "react-router.config.ts": js` + test("Prerenders a static array of routes", async () => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": js` export default { async prerender() { await new Promise(r => setTimeout(r, 1)); @@ -247,7 +253,7 @@ test.describe("Prerendering", () => { }, } `, - "vite.config.ts": js` + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -258,40 +264,44 @@ test.describe("Prerendering", () => { ], }); `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "_root.data", + "about.data", + "about/index.html", + "favicon.ico", + "index.html", + ]); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Index Title: Index Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

Index

'); + expect(html).toMatch( + '

Index Loader Data

', + ); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + expect(html).toMatch("About Title: About Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

About

'); + expect(html).toMatch( + '

About Loader Data

', + ); }); - appFixture = await createAppFixture(fixture); - - let clientDir = path.join(fixture.projectDir, "build", "client"); - expect(listAllFiles(clientDir).sort()).toEqual([ - "_root.data", - "about.data", - "about/index.html", - "favicon.ico", - "index.html", - ]); - - let res = await fixture.requestDocument("/"); - let html = await res.text(); - expect(html).toMatch("Index Title: Index Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

Index

'); - expect(html).toMatch('

Index Loader Data

'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - expect(html).toMatch("About Title: About Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

About

'); - expect(html).toMatch('

About Loader Data

'); - }); - test("Prerenders a static array of routes with server bundles", async () => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "react-router.config.ts": js` + test("Prerenders a static array of routes with server bundles", async () => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": js` let counter = 1; export default { serverBundles: () => "server" + counter++, @@ -301,7 +311,7 @@ test.describe("Prerendering", () => { }, } `, - "vite.config.ts": js` + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -312,46 +322,50 @@ test.describe("Prerendering", () => { ], }); `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "_root.data", + "about.data", + "about/index.html", + "favicon.ico", + "index.html", + ]); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Index Title: Index Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

Index

'); + expect(html).toMatch( + '

Index Loader Data

', + ); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + expect(html).toMatch("About Title: About Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

About

'); + expect(html).toMatch( + '

About Loader Data

', + ); }); - appFixture = await createAppFixture(fixture); - - let clientDir = path.join(fixture.projectDir, "build", "client"); - expect(listAllFiles(clientDir).sort()).toEqual([ - "_root.data", - "about.data", - "about/index.html", - "favicon.ico", - "index.html", - ]); - - let res = await fixture.requestDocument("/"); - let html = await res.text(); - expect(html).toMatch("Index Title: Index Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

Index

'); - expect(html).toMatch('

Index Loader Data

'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - expect(html).toMatch("About Title: About Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

About

'); - expect(html).toMatch('

About Loader Data

'); - }); - test("Prerenders a dynamic array of routes based on the static routes", async () => { - fixture = await createFixture({ - files: { - ...files, - "react-router.config.ts": js` + test("Prerenders a dynamic array of routes based on the static routes", async () => { + fixture = await createFixture({ + files: { + ...files, + "react-router.config.ts": js` export default { async prerender({ getStaticPaths }) { return [...getStaticPaths(), "/a", "/b"]; }, } `, - "vite.config.ts": js` + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -360,7 +374,7 @@ test.describe("Prerendering", () => { plugins: [reactRouter()], }); `, - "app/routes/$slug.tsx": js` + "app/routes/$slug.tsx": js` export function loader() { return null } @@ -368,87 +382,91 @@ test.describe("Prerendering", () => { return null; } `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "_root.data", + "a.data", + "a/index.html", + "about.data", + "about/index.html", + "b.data", + "b/index.html", + "favicon.ico", + "index.html", + ]); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Index Title: Index Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

Index

'); + expect(html).toMatch( + '

Index Loader Data

', + ); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + expect(html).toMatch("About Title: About Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

About

'); + expect(html).toMatch( + '

About Loader Data

', + ); }); - appFixture = await createAppFixture(fixture); - - let clientDir = path.join(fixture.projectDir, "build", "client"); - expect(listAllFiles(clientDir).sort()).toEqual([ - "_root.data", - "a.data", - "a/index.html", - "about.data", - "about/index.html", - "b.data", - "b/index.html", - "favicon.ico", - "index.html", - ]); - - let res = await fixture.requestDocument("/"); - let html = await res.text(); - expect(html).toMatch("Index Title: Index Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

Index

'); - expect(html).toMatch('

Index Loader Data

'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - expect(html).toMatch("About Title: About Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

About

'); - expect(html).toMatch('

About Loader Data

'); - }); - test("Skips action-only resource routes prerender:true", async () => { - let buildStdio = new PassThrough(); - fixture = await createFixture({ - buildStdio, - files: { - "react-router.config.ts": reactRouterConfig({ - prerender: true, - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": files["app/root.tsx"], - "app/routes/_index.tsx": files["app/routes/_index.tsx"], - "app/routes/action.tsx": js` + test("Skips action-only resource routes prerender:true", async () => { + let buildStdio = new PassThrough(); + fixture = await createFixture({ + buildStdio, + files: { + "react-router.config.ts": reactRouterConfig({ + prerender: true, + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": files["app/root.tsx"], + "app/routes/_index.tsx": files["app/routes/_index.tsx"], + "app/routes/action.tsx": js` export function action() { return null } `, - }, - }); - - let buildOutput: string; - let chunks: Buffer[] = []; - buildOutput = await new Promise((resolve, reject) => { - buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); - buildStdio.on("error", (err) => reject(err)); - buildStdio.on("end", () => - resolve(Buffer.concat(chunks).toString("utf8")), + }, + }); + + let buildOutput: string; + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")), + ); + }); + + expect(buildOutput).toContain( + "⚠️ Skipping prerendering for resource route without a loader: routes/action", ); + // Only logs once + expect(buildOutput.match(/routes\/action/g)?.length).toBe(1); }); - expect(buildOutput).toContain( - "⚠️ Skipping prerendering for resource route without a loader: routes/action", - ); - // Only logs once - expect(buildOutput.match(/routes\/action/g)?.length).toBe(1); - }); - - test("Pre-renders resource routes with file extensions", async () => { - const base64Png = - "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"; - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "app/routes/text[.txt].tsx": js` + test("Pre-renders resource routes with file extensions", async () => { + const base64Png = + "iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAHKADAAQAAAABAAAAHAAAAACXh5mhAAAACXBIWXMAAAsTAAALEwEAmpwYAAACyGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj43MjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6UmVzb2x1dGlvblVuaXQ+MjwvdGlmZjpSZXNvbHV0aW9uVW5pdD4KICAgICAgICAgPHRpZmY6WFJlc29sdXRpb24+NzI8L3RpZmY6WFJlc29sdXRpb24+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj41NjwvZXhpZjpQaXhlbFhEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOkNvbG9yU3BhY2U+MTwvZXhpZjpDb2xvclNwYWNlPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+NTY8L2V4aWY6UGl4ZWxZRGltZW5zaW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KJNwP9wAABj1JREFUSA2FVnlQ1FUc/+yyC7ss96UCAgmYmjIcWjOWjVpoRU46OTbkMTY6muNM1jgjVo6jKZI4palDKVnpZP/lgeSoeEEeeRY2HsSlyNrKobL3we72/T549HNd6zuzfH/vfe/zobJYLH48Bfx+P1QqlaAqvwPZmaYEKSPvpCxjjTxIosRSiaQrlUga8/K9ksZ3T6Mzn5oZGJRMfA5UwncM/+UA05T0QB1M0/SpedKAFJR0iYMpCbxjXmmck81FUavVIghNMGYWeNq90hHJ4/P5WAQhISHCkNfrpW81QkNDCWvAdKfLJQwPRCgkAv4olStJ0gDT2YheryOyCo96ehATHQ2vzw+H0wGjyYg2oxH19deg0miwYHbxvylVKpTfMgJ5Zs81JKiln0arhdvjgdVqQ2tbG46dqoU+3IDMtFRcuXoV53+vR3XVccBmEuK1Z89BFxYGldls9rPiYNHIO46IaxCuD0ePxYKbjY3QUmTt5P2VP+pRunoVQkc/j0+LZyAiwoDIyGgkJiXB09uLWdOnYXVpGUo+XAavtxcqnkNWzBAYEd/zT6/Xw+5w4MxvF/Dlzm8xbvRoFBW+gkFJibCYLUhITEBSYhK8xOt0efCQ7pxuD6r2/YxVO3ag8WQNBsXHUS1prqXBQGMcFadOTZFwFBcuX4HP5cS0N4uQnZ39mHNOtxvGeyYY/zahs7MLZk5zazPWrfoYr898B0MGD8L82e+iICcneEq5VhxVj9mMfQerMOa5URhXUCDSyk3CYLVaYbrfgbvGewJb7XY4nU7xc1A2QElrv9uGyq+3Y/2mL/DB+4t4VvoMsgIZIRszGAxoa2/Hj3t/wpJFC5FI6WpqbkZ8XByiqQs5zexMCNVVqw2Fm2rl9vRSGt2w2hyw2R1wUUOt+WQlElOS8VX5RhiokzlrjzUNX0RFRuJWUzO2VVSgbO0a9JKyw0eO4bWpU5CYEM++DYCLDLS130Pz7bvosVpEBxuoU7VaDXZ/t4tq6kV5aSlioyOpYcgYNacYfPaYu5Aju0T12lC+CXt3VaK7uxvp6ek4dvpXxMfHi8jY2oOHD3GroRF/kWMdXV1wUEROmrs7t1vxgGR44KsP7sfi5SuEDKkfgIHl7XK5caK2FtevXcOend+QR16sWFmCrRU7kJGaAjXtJxakMUJnVzcMkVHIp7qKIact4qGUskx3dxcqt28RBtIpnbqwUPgpcwwiME4jR1Z37jzeKipCbm4ujp84gXlz56Jg3AvIHJaB7MxnhACnpJeMOqjlTR0daGxpJdxJafeK+uio0Sy0bY7X1KDks/VYNH8ewnU67h+RTpZX2e12v8Vmw4bPy5E1fCS0tA185KndZkV6chLGUhRcR5PpPh70mHHuwiVoQ7XQk6KGmzfQ0tSECRMnY3LhFDQ13MSShe+hZM06LF+6hBZFf6OQIQkqMw1+2cZNGJY9AmMoOje1to0cIEfQ0tJEG1KFyKgoLJ4/B3kvTsCE8eNhvNOGSa8WIq9gLPbu/h4VWzfjpZcn4kzdaWzbWYnimW9DR3UUXUnGOJUcHWPNqdo6ODw+5OTmoauzA79UV4mURujCEBsTg9S0NEwqnIryzVswp7hYLOfDR45g5ozpWLrsI1T8sAepOfmIpW1zqu4MxubnimKzMbIyUAppVBPGKfR5EUbFPXhgP+ISYnDxbB2OHq3BgUPVyMzKQhPtznnFs6i9o6j1Q8iRWKGooeEWTh7aj6GpqYigPoiNiYabRoWBI5LAxiRo8imNe2jAL1+6iKFp6Xj0qK+tvX4fjUQGHLQbRw7PQsqQIXBRN7LnGRlpuErjk5KcTHMbIVLF90wXjdGfPqVRNsiG1ZG03cvWrYW/10URRCCP9h2vLaKSMTeiDDq8MbWQjvRi0KPKrT+YXoJRI56FIVxP4+ARTSXrJY1ILFPJBoUz/DzxG6dWh5CwW7zQvCn+vH4DNadrsWDuHOgp3V6KQKkkUJGMQPLwORgMvBYiXE5FPxe/En0H/0C3SQVKY3wXeJZ8wbD4F0OE2l9kWWqeRQmBXv/fWcoFw2r2jiEQS6USS7pSibxjLH9KuvxW8qmlwkAsmSWWdHlmzHesLBgtkE/yPxFhMEblnfxWRiQjYJryW56VvP8AfCpfCs3OlKsAAAAASUVORK5CYII="; + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "app/routes/text[.txt].tsx": js` export function loader() { return new Response("Hello, world"); } `, - "app/routes/json[.json].tsx": js` + "app/routes/json[.json].tsx": js` export function loader() { return new Response(JSON.stringify({ hello: 'world' }), { headers: { @@ -457,7 +475,7 @@ test.describe("Prerendering", () => { }); } `, - "app/routes/image[.png].tsx": js` + "app/routes/image[.png].tsx": js` export function loader() { return new Response( Buffer.from("${base64Png}", 'base64'), @@ -469,69 +487,72 @@ test.describe("Prerendering", () => { ); } `, - }, - }); - appFixture = await createAppFixture(fixture); - - let clientDir = path.join(fixture.projectDir, "build", "client"); - expect(listAllFiles(clientDir).sort()).toEqual([ - "_root.data", - "about.data", - "about/index.html", - "favicon.ico", - "image.png", - "image.png.data", - "index.html", - "json.json", - "json.json.data", - "text.txt", - "text.txt.data", - ]); - - expect( - await fs.promises.readFile(path.join(clientDir, "json.json"), "utf8"), - ).toEqual('{"hello":"world"}'); - expect( - await fs.promises.readFile(path.join(clientDir, "text.txt"), "utf8"), - ).toEqual("Hello, world"); - expect( - await fs.promises.readFile(path.join(clientDir, "image.png"), "base64"), - ).toEqual(base64Png); - - let res = await fixture.requestResource("/json.json"); - expect(await res.json()).toEqual({ hello: "world" }); - - let dataRes = await fixture.requestSingleFetchData("/json.json.data"); - expect(dataRes.data).toEqual({ - "routes/json[.json]": { - data: { - hello: "world", }, - }, - }); + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "_root.data", + "about.data", + "about/index.html", + "favicon.ico", + "image.png", + "image.png.data", + "index.html", + "json.json", + "json.json.data", + "text.txt", + "text.txt.data", + ]); + + expect( + await fs.promises.readFile(path.join(clientDir, "json.json"), "utf8"), + ).toEqual('{"hello":"world"}'); + expect( + await fs.promises.readFile(path.join(clientDir, "text.txt"), "utf8"), + ).toEqual("Hello, world"); + expect( + await fs.promises.readFile( + path.join(clientDir, "image.png"), + "base64", + ), + ).toEqual(base64Png); - res = await fixture.requestResource("/text.txt"); - expect(await res.text()).toBe("Hello, world"); + let res = await fixture.requestResource("/json.json"); + expect(await res.json()).toEqual({ hello: "world" }); - dataRes = await fixture.requestSingleFetchData("/text.txt.data"); - expect(dataRes.data).toEqual({ - "routes/text[.txt]": { - data: "Hello, world", - }, - }); + let dataRes = await fixture.requestSingleFetchData("/json.json.data"); + expect(dataRes.data).toEqual({ + "routes/json[.json]": { + data: { + hello: "world", + }, + }, + }); - res = await fixture.requestResource("/image.png"); - expect(Buffer.from(await res.arrayBuffer()).toString("base64")).toBe( - base64Png, - ); - }); + res = await fixture.requestResource("/text.txt"); + expect(await res.text()).toBe("Hello, world"); - test("Adds leading slashes if omitted in config", async () => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "react-router.config.ts": js` + dataRes = await fixture.requestSingleFetchData("/text.txt.data"); + expect(dataRes.data).toEqual({ + "routes/text[.txt]": { + data: "Hello, world", + }, + }); + + res = await fixture.requestResource("/image.png"); + expect(Buffer.from(await res.arrayBuffer()).toString("base64")).toBe( + base64Png, + ); + }); + + test("Adds leading slashes if omitted in config", async () => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": js` export default { async prerender() { await new Promise(r => setTimeout(r, 1)); @@ -539,7 +560,7 @@ test.describe("Prerendering", () => { }, } `, - "vite.config.ts": js` + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -550,40 +571,44 @@ test.describe("Prerendering", () => { ], }); `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "_root.data", + "about.data", + "about/index.html", + "favicon.ico", + "index.html", + ]); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Index Title: Index Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

Index

'); + expect(html).toMatch( + '

Index Loader Data

', + ); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + expect(html).toMatch("About Title: About Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

About

'); + expect(html).toMatch( + '

About Loader Data

', + ); }); - appFixture = await createAppFixture(fixture); - - let clientDir = path.join(fixture.projectDir, "build", "client"); - expect(listAllFiles(clientDir).sort()).toEqual([ - "_root.data", - "about.data", - "about/index.html", - "favicon.ico", - "index.html", - ]); - - let res = await fixture.requestDocument("/"); - let html = await res.text(); - expect(html).toMatch("Index Title: Index Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

Index

'); - expect(html).toMatch('

Index Loader Data

'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - expect(html).toMatch("About Title: About Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

About

'); - expect(html).toMatch('

About Loader Data

'); - }); - test("Permits a concurrency option", async () => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "react-router.config.ts": js` + test("Permits a concurrency option", async () => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": js` export default { prerender: { paths: ['/', '/about'], @@ -591,7 +616,7 @@ test.describe("Prerendering", () => { }, } `, - "vite.config.ts": js` + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -602,47 +627,51 @@ test.describe("Prerendering", () => { ], }); `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "_root.data", + "about.data", + "about/index.html", + "favicon.ico", + "index.html", + ]); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Index Title: Index Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

Index

'); + expect(html).toMatch( + '

Index Loader Data

', + ); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + expect(html).toMatch("About Title: About Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

About

'); + expect(html).toMatch( + '

About Loader Data

', + ); }); - appFixture = await createAppFixture(fixture); - - let clientDir = path.join(fixture.projectDir, "build", "client"); - expect(listAllFiles(clientDir).sort()).toEqual([ - "_root.data", - "about.data", - "about/index.html", - "favicon.ico", - "index.html", - ]); - - let res = await fixture.requestDocument("/"); - let html = await res.text(); - expect(html).toMatch("Index Title: Index Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

Index

'); - expect(html).toMatch('

Index Loader Data

'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - expect(html).toMatch("About Title: About Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

About

'); - expect(html).toMatch('

About Loader Data

'); }); - }); - test.describe("ssr: true", () => { - test("Serves the prerendered HTML file alongside runtime routes", async ({ - page, - }) => { - fixture = await createFixture({ - files: { - ...files, - "react-router.config.ts": reactRouterConfig({ - // Don't prerender the /not-prerendered route - prerender: ["/", "/about"], - }), - "vite.config.ts": js` + test.describe("ssr: true", () => { + test("Serves the prerendered HTML file alongside runtime routes", async ({ + page, + }) => { + fixture = await createFixture({ + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + // Don't prerender the /not-prerendered route + prerender: ["/", "/about"], + }), + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -651,7 +680,7 @@ test.describe("Prerendering", () => { plugins: [reactRouter()], }); `, - "app/routes/about.tsx": js` + "app/routes/about.tsx": js` import { useLoaderData } from 'react-router'; export function loader({ request }) { return "ABOUT-" + Boolean(process.env.IS_RR_BUILD_REQUEST); @@ -662,7 +691,7 @@ test.describe("Prerendering", () => { return

About: {data}

} `, - "app/routes/not-prerendered.tsx": js` + "app/routes/not-prerendered.tsx": js` import { useLoaderData } from 'react-router'; export function loader({ request }) { return "NOT-PRERENDERED-" + Boolean(process.env.IS_RR_BUILD_REQUEST); @@ -673,32 +702,32 @@ test.describe("Prerendering", () => { return

Not-Prerendered: {data}

} `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/about"); + await page.waitForSelector("[data-mounted]"); + expect(await app.getHtml()).toContain("ABOUT-true"); + + await app.goto("/not-prerendered"); + await page.waitForSelector("[data-mounted]"); + expect(await app.getHtml()).toContain( + "NOT-PRERENDERED-false", + ); }); - appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/about"); - await page.waitForSelector("[data-mounted]"); - expect(await app.getHtml()).toContain("ABOUT-true"); - - await app.goto("/not-prerendered"); - await page.waitForSelector("[data-mounted]"); - expect(await app.getHtml()).toContain( - "NOT-PRERENDERED-false", - ); - }); - test("Does not encounter header limits on large prerendered data", async ({ - page, - }) => { - fixture = await createFixture({ - files: { - ...files, - "react-router.config.ts": reactRouterConfig({ - prerender: ["/", "/about"], - }), - "vite.config.ts": js` + test("Does not encounter header limits on large prerendered data", async ({ + page, + }) => { + fixture = await createFixture({ + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + prerender: ["/", "/about"], + }), + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -707,7 +736,7 @@ test.describe("Prerendering", () => { plugins: [reactRouter()], }); `, - "app/routes/about.tsx": js` + "app/routes/about.tsx": js` import { useLoaderData } from 'react-router'; export function loader({ request }) { return { @@ -728,30 +757,30 @@ test.describe("Prerendering", () => { ); } `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/about"); + await page.waitForSelector("[data-mounted]"); + expect(await app.getHtml("[data-title]")).toContain("Large loader"); + expect(await app.getHtml("[data-prerendered]")).toContain("yes"); + expect(await app.getHtml("[data-length]")).toBe( + '

24999

', + ); }); - appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/about"); - await page.waitForSelector("[data-mounted]"); - expect(await app.getHtml("[data-title]")).toContain("Large loader"); - expect(await app.getHtml("[data-prerendered]")).toContain("yes"); - expect(await app.getHtml("[data-length]")).toBe( - '

24999

', - ); - }); - test("Handles UTF-8 characters in prerendered and non-prerendered routes", async ({ - page, - }) => { - fixture = await createFixture({ - files: { - ...files, - "react-router.config.ts": reactRouterConfig({ - prerender: ["/", "/utf8-prerendered"], - }), - "vite.config.ts": js` + test("Handles UTF-8 characters in prerendered and non-prerendered routes", async ({ + page, + }) => { + fixture = await createFixture({ + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + prerender: ["/", "/utf8-prerendered"], + }), + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -760,7 +789,7 @@ test.describe("Prerendering", () => { plugins: [reactRouter()], }); `, - "app/routes/utf8-prerendered.tsx": js` + "app/routes/utf8-prerendered.tsx": js` import { useLoaderData } from 'react-router'; export function loader({ request }) { return { @@ -780,7 +809,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/utf8-not-prerendered.tsx": js` + "app/routes/utf8-not-prerendered.tsx": js` import { useLoaderData } from 'react-router'; export function loader({ request }) { return { @@ -800,41 +829,43 @@ test.describe("Prerendering", () => { ); } `, - }, - }); - appFixture = await createAppFixture(fixture); + }, + }); + appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); + let app = new PlaywrightFixture(appFixture, page); - // Test prerendered route with UTF-8 characters - await app.goto("/utf8-prerendered"); - await page.waitForSelector("[data-mounted]"); - expect(await app.getHtml("[data-title]")).toContain("UTF-8 Prerendered"); - expect(await app.getHtml("[data-prerendered]")).toContain("yes"); - expect(await app.getHtml("[data-content]")).toContain( - "한글 데이터 - UTF-8 문자", - ); + // Test prerendered route with UTF-8 characters + await app.goto("/utf8-prerendered"); + await page.waitForSelector("[data-mounted]"); + expect(await app.getHtml("[data-title]")).toContain( + "UTF-8 Prerendered", + ); + expect(await app.getHtml("[data-prerendered]")).toContain("yes"); + expect(await app.getHtml("[data-content]")).toContain( + "한글 데이터 - UTF-8 문자", + ); - // Test non-prerendered route with UTF-8 characters - await app.goto("/utf8-not-prerendered"); - await page.waitForSelector("[data-mounted]"); - expect(await app.getHtml("[data-title]")).toContain( - "UTF-8 Not Prerendered", - ); - expect(await app.getHtml("[data-prerendered]")).toContain("no"); - expect(await app.getHtml("[data-content]")).toContain( - "非プリレンダリングデータ - UTF-8文字", - ); - }); + // Test non-prerendered route with UTF-8 characters + await app.goto("/utf8-not-prerendered"); + await page.waitForSelector("[data-mounted]"); + expect(await app.getHtml("[data-title]")).toContain( + "UTF-8 Not Prerendered", + ); + expect(await app.getHtml("[data-prerendered]")).toContain("no"); + expect(await app.getHtml("[data-content]")).toContain( + "非プリレンダリングデータ - UTF-8文字", + ); + }); - test("Renders down to the proper HydrateFallback", async ({ page }) => { - fixture = await createFixture({ - files: { - ...files, - "react-router.config.ts": reactRouterConfig({ - prerender: ["/", "/parent", "/parent/child"], - }), - "vite.config.ts": js` + test("Renders down to the proper HydrateFallback", async ({ page }) => { + fixture = await createFixture({ + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + prerender: ["/", "/parent", "/parent/child"], + }), + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -843,7 +874,7 @@ test.describe("Prerendering", () => { plugins: [reactRouter()], }); `, - "app/routes/parent.tsx": js` + "app/routes/parent.tsx": js` import { Outlet, useLoaderData } from 'react-router'; export function loader() { return "PARENT"; @@ -853,7 +884,7 @@ test.describe("Prerendering", () => { return <>

Parent: {data}

} `, - "app/routes/parent.child.tsx": js` + "app/routes/parent.child.tsx": js` import { Outlet, useLoaderData } from 'react-router'; export function loader() { return "CHILD"; @@ -866,7 +897,7 @@ test.describe("Prerendering", () => { return <>

Child: {data}

} `, - "app/routes/parent.child._index.tsx": js` + "app/routes/parent.child._index.tsx": js` import { Outlet, useLoaderData } from 'react-router'; export function clientLoader() { return "INDEX"; @@ -876,61 +907,63 @@ test.describe("Prerendering", () => { return <>

Index: {data}

} `, - }, - }); - appFixture = await createAppFixture(fixture); + }, + }); + appFixture = await createAppFixture(fixture); - let res = await fixture.requestDocument("/parent/child"); - let html = await res.text(); - expect(html).toContain("

Child loading...

"); + let res = await fixture.requestDocument("/parent/child"); + let html = await res.text(); + expect(html).toContain("

Child loading...

"); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - await page.waitForSelector("[data-mounted]"); - expect(await app.getHtml()).toMatch("Index: INDEX"); - }); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + await page.waitForSelector("[data-mounted]"); + expect(await app.getHtml()).toMatch("Index: INDEX"); + }); - test("Ignores build-time headers at runtime", async () => { - fixture = await createFixture({ files }); - let res = await fixture.requestSingleFetchData("/_root.data", { - headers: { - "X-React-Router-Prerender-Data": encodeURI( - '[{"_1":2},"routes/_index",{"_3":4},"data","Hello World!"]', - ), - }, + test("Ignores build-time headers at runtime", async () => { + fixture = await createFixture({ files }); + let res = await fixture.requestSingleFetchData("/_root.data", { + headers: { + "X-React-Router-Prerender-Data": encodeURI( + '[{"_1":2},"routes/_index",{"_3":4},"data","Hello World!"]', + ), + }, + }); + expect((res.data as any)["routes/_index"].data).toBe( + "Index Loader Data", + ); }); - expect((res.data as any)["routes/_index"].data).toBe("Index Loader Data"); }); - }); - test.describe("ssr: false", () => { - function captureRequests(page: Page) { - let requests: string[] = []; - page.on("request", (request) => { - let url = new URL(request.url()); - if ( - url.pathname.endsWith(".data") || - url.pathname.endsWith("__manifest") - ) { - requests.push(url.pathname + url.search); - } - }); - return requests; - } + test.describe("ssr: false", () => { + function captureRequests(page: Page) { + let requests: string[] = []; + page.on("request", (request) => { + let url = new URL(request.url()); + if ( + url.pathname.endsWith(".data") || + url.pathname.endsWith("__manifest") + ) { + requests.push(url.pathname + url.search); + } + }); + return requests; + } - function clearRequests(requests: string[]) { - while (requests.length) { - requests.pop(); + function clearRequests(requests: string[]) { + while (requests.length) { + requests.pop(); + } } - } - test("Errors on headers/action functions in any route", async () => { - let cwd = await createProject({ - "react-router.config.ts": reactRouterConfig({ - ssr: false, - prerender: ["/", "/a"], - }), - "app/routes/a.tsx": String.raw` + test("Errors on headers/action functions in any route", async () => { + let cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/", "/a"], + }), + "app/routes/a.tsx": String.raw` // Invalid exports export function headers() {} export function action() {} @@ -941,134 +974,136 @@ test.describe("Prerendering", () => { export function clientAction() {} export default function Component() {} `, + }); + let result = build({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "Prerender: 2 invalid route export(s) in `routes/a` when pre-rendering " + + "with `ssr:false`: `headers`, `action`. " + + "See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.", + ); }); - let result = build({ cwd }); - let stderr = result.stderr.toString("utf8"); - expect(stderr).toMatch( - "Prerender: 2 invalid route export(s) in `routes/a` when pre-rendering " + - "with `ssr:false`: `headers`, `action`. " + - "See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.", - ); - }); - test("Errors on loader functions in non-prerendered routes", async () => { - let cwd = await createProject({ - "react-router.config.ts": reactRouterConfig({ - ssr: false, - prerender: ["/", "/a"], - }), - "app/routes/a.tsx": String.raw` + test("Errors on loader functions in non-prerendered routes", async () => { + let cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/", "/a"], + }), + "app/routes/a.tsx": String.raw` export function loader() {} export function clientLoader() {} export function clientAction() {} export default function Component() {} `, - "app/routes/b.tsx": String.raw` + "app/routes/b.tsx": String.raw` export function loader() {} export function clientLoader() {} export function clientAction() {} export default function Component() {} `, + }); + let result = build({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "Prerender: 1 invalid route export in `routes/b` when pre-rendering " + + "with `ssr:false`: `loader`. " + + "See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.", + ); }); - let result = build({ cwd }); - let stderr = result.stderr.toString("utf8"); - expect(stderr).toMatch( - "Prerender: 1 invalid route export in `routes/b` when pre-rendering " + - "with `ssr:false`: `loader`. " + - "See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.", - ); - }); - test("Errors on loader functions in parent routes with non-pre-rendered children", async () => { - let cwd = await createProject({ - "react-router.config.ts": reactRouterConfig({ - ssr: false, - prerender: ["/", "/a"], - }), - "app/routes/a.tsx": String.raw` + test("Errors on loader functions in parent routes with non-pre-rendered children", async () => { + let cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/", "/a"], + }), + "app/routes/a.tsx": String.raw` export function loader() {} export function clientAction() {} export default function Component() {} `, - "app/routes/a.b.tsx": String.raw` + "app/routes/a.b.tsx": String.raw` export function clientLoader() {} export function clientAction() {} export default function Component() {} `, + }); + let result = build({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "Prerender: 1 invalid route export in `routes/a` when pre-rendering " + + "with `ssr:false`: `loader`. " + + "See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.", + ); }); - let result = build({ cwd }); - let stderr = result.stderr.toString("utf8"); - expect(stderr).toMatch( - "Prerender: 1 invalid route export in `routes/a` when pre-rendering " + - "with `ssr:false`: `loader`. " + - "See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.", - ); - }); - test("Warns on parameterized routes with prerender:true + ssr:false", async () => { - let buildStdio = new PassThrough(); - fixture = await createFixture({ - buildStdio, - prerender: true, - files: { - "react-router.config.ts": reactRouterConfig({ - ssr: false, - prerender: true, - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": files["app/root.tsx"], - "app/routes/_index.tsx": files["app/routes/_index.tsx"], - "app/routes/$slug.tsx": js` + test("Warns on parameterized routes with prerender:true + ssr:false", async () => { + let buildStdio = new PassThrough(); + fixture = await createFixture({ + buildStdio, + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: true, + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": files["app/root.tsx"], + "app/routes/_index.tsx": files["app/routes/_index.tsx"], + "app/routes/$slug.tsx": js` import { Outlet } from 'react-router' export default function Component() { return } `, - "app/routes/$.tsx": js` + "app/routes/$.tsx": js` import { Outlet } from 'react-router' export default function Component() { return } `, - }, - }); - - let buildOutput: string; - let chunks: Buffer[] = []; - buildOutput = await new Promise((resolve, reject) => { - buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); - buildStdio.on("error", (err) => reject(err)); - buildStdio.on("end", () => - resolve(Buffer.concat(chunks).toString("utf8")), + }, + }); + + let buildOutput: string; + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")), + ); + }); + + expect(buildOutput).toContain( + [ + "⚠️ Paths with dynamic/splat params cannot be prerendered when using `prerender: true`. " + + "You may want to use the `prerender()` API to prerender the following paths:", + " - :slug", + " - *", + ].join("\n"), + ); + // Only logs once + expect(buildOutput.match(/with dynamic\/splat params/g)?.length).toBe( + 1, ); }); - expect(buildOutput).toContain( - [ - "⚠️ Paths with dynamic/splat params cannot be prerendered when using `prerender: true`. " + - "You may want to use the `prerender()` API to prerender the following paths:", - " - :slug", - " - *", - ].join("\n"), - ); - // Only logs once - expect(buildOutput.match(/with dynamic\/splat params/g)?.length).toBe(1); - }); - - test("Prerenders a spa fallback with prerender:['/'] + ssr:false", async () => { - let buildStdio = new PassThrough(); - fixture = await createFixture({ - buildStdio, - prerender: true, - files: { - "react-router.config.ts": reactRouterConfig({ - ssr: false, - prerender: ["/"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": files["app/root.tsx"], - "app/routes/_index.tsx": files["app/routes/_index.tsx"], - "app/routes/page.tsx": js` + test("Prerenders a spa fallback with prerender:['/'] + ssr:false", async () => { + let buildStdio = new PassThrough(); + fixture = await createFixture({ + buildStdio, + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": files["app/root.tsx"], + "app/routes/_index.tsx": files["app/routes/_index.tsx"], + "app/routes/page.tsx": js` export function clientLoader() { return "PAGE DATA" } @@ -1076,68 +1111,70 @@ test.describe("Prerendering", () => { return

{loaderData}

} `, - }, - }); + }, + }); + + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "__spa-fallback.html", + "_root.data", + "favicon.ico", + "index.html", + ]); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Index Title: Index Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

Index

'); + expect(html).toMatch( + '

Index Loader Data

', + ); + expect(html).not.toMatch("

Loading...

"); - appFixture = await createAppFixture(fixture); - - let clientDir = path.join(fixture.projectDir, "build", "client"); - expect(listAllFiles(clientDir).sort()).toEqual([ - "__spa-fallback.html", - "_root.data", - "favicon.ico", - "index.html", - ]); - - let res = await fixture.requestDocument("/"); - let html = await res.text(); - expect(html).toMatch("Index Title: Index Loader Data"); - expect(html).toMatch("

Root

"); - expect(html).toMatch('

Index

'); - expect(html).toMatch('

Index Loader Data

'); - expect(html).not.toMatch("

Loading...

"); - - res = await fixture.requestDocument("/page"); - html = await res.text(); - expect(html).toMatch("

Loading...

"); - }); + res = await fixture.requestDocument("/page"); + html = await res.text(); + expect(html).toMatch("

Loading...

"); + }); - test("Hydrates into a navigable app", async ({ page }) => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "react-router.config.ts": reactRouterConfig({ - ssr: false, // turn off fog of war since we're serving with a static server - prerender: true, - }), - }, + test("Hydrates into a navigable app", async ({ page }) => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: true, + }), + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("[data-mounted]"); + await app.clickLink("/about"); + await page.waitForSelector("[data-route]:has-text('About')"); + expect(requests).toEqual(["/about.data"]); }); - appFixture = await createAppFixture(fixture); - - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector("[data-mounted]"); - await app.clickLink("/about"); - await page.waitForSelector("[data-route]:has-text('About')"); - expect(requests).toEqual(["/about.data"]); - }); - test("Hydrates into a navigable app from the spa fallback", 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: ["/"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": files["app/root.tsx"], - "app/routes/_index.tsx": files["app/routes/_index.tsx"], - "app/routes/page.tsx": js` + test("Hydrates into a navigable app from the spa fallback", 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: ["/"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": files["app/root.tsx"], + "app/routes/_index.tsx": files["app/routes/_index.tsx"], + "app/routes/page.tsx": js` import { Link } from 'react-router'; export async function clientLoader() { await new Promise(r => setTimeout(r, 1000)); @@ -1152,7 +1189,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/page2.tsx": js` + "app/routes/page2.tsx": js` export function clientLoader() { return "PAGE2 DATA" } @@ -1160,35 +1197,35 @@ test.describe("Prerendering", () => { return

{loaderData}

} `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + // Load a path we didn't prerender, ensure it starts with the root fallback, + // hydrates, and then lets you navigate + await app.goto("/page"); + expect(await page.getByText("Loading...")).toBeVisible(); + await page.waitForSelector("[data-page]"); + await app.clickLink("/page2"); + await page.waitForSelector("[data-page2]"); + expect(await (await page.$("[data-page2]"))?.innerText()).toBe( + "PAGE2 DATA", + ); }); - appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - // Load a path we didn't prerender, ensure it starts with the root fallback, - // hydrates, and then lets you navigate - await app.goto("/page"); - expect(await page.getByText("Loading...")).toBeVisible(); - await page.waitForSelector("[data-page]"); - await app.clickLink("/page2"); - await page.waitForSelector("[data-page2]"); - expect(await (await page.$("[data-page2]"))?.innerText()).toBe( - "PAGE2 DATA", - ); - }); - test("Navigates across SPA/prerender pages when starting from a SPA page", 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"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + test("Navigates across SPA/prerender pages when starting from a SPA page", 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"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Outlet, Scripts } from "react-router"; @@ -1208,13 +1245,13 @@ test.describe("Prerendering", () => { return } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { Link } from 'react-router'; export default function Index() { return Go to page } `, - "app/routes/page.tsx": js` + "app/routes/page.tsx": js` import { Link, Form } from 'react-router'; export async function loader() { return "PAGE DATA" @@ -1239,7 +1276,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/page2.tsx": js` + "app/routes/page2.tsx": js` import { Form } from 'react-router'; export function clientLoader() { return "PAGE2 DATA" @@ -1263,73 +1300,73 @@ test.describe("Prerendering", () => { ); } `, - }, - }); - appFixture = await createAppFixture(fixture); - - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await page.waitForSelector('a[href="/page"]'); - - await app.clickLink("/page"); - await page.waitForSelector("[data-page]"); - expect(await (await page.$("[data-page]"))?.innerText()).toBe( - "PAGE DATA", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector('a[href="/page"]'); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - await app.clickSubmitButton("/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.clickSubmitButton("/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("/page2"); - await page.waitForSelector("[data-page2]"); - expect(await (await page.$("[data-page2]"))?.innerText()).toBe( - "PAGE2 DATA", - ); - expect(requests).toEqual([]); + await app.clickLink("/page2"); + await page.waitForSelector("[data-page2]"); + expect(await (await page.$("[data-page2]"))?.innerText()).toBe( + "PAGE2 DATA", + ); + expect(requests).toEqual([]); - await app.clickSubmitButton("/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("/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("/page"); - await page.waitForSelector("[data-page-action]"); - expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( - "PAGE ACTION 2", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 2", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - await app.clickSubmitButton("/page2"); - await page.waitForSelector("[data-page2-action]"); - expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( - "PAGE2 ACTION 2", - ); - expect(requests).toEqual([]); - }); + await app.clickSubmitButton("/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", 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"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + test("Navigates across SPA/prerender pages when starting from a prerendered page", 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"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Outlet, Scripts } from "react-router"; @@ -1349,13 +1386,13 @@ test.describe("Prerendering", () => { return ; } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { Link } from 'react-router'; export default function Index() { return Go to page } `, - "app/routes/page.tsx": js` + "app/routes/page.tsx": js` import { Link, Form } from 'react-router'; export async function loader() { return "PAGE DATA" @@ -1380,7 +1417,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/page2.tsx": js` + "app/routes/page2.tsx": js` import { Form } from 'react-router'; export function clientLoader() { return "PAGE2 DATA" @@ -1404,73 +1441,73 @@ test.describe("Prerendering", () => { ); } `, - }, - }); - appFixture = await createAppFixture(fixture); - - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await page.waitForSelector('a[href="/page"]'); - - await app.clickLink("/page"); - await page.waitForSelector("[data-page]"); - expect(await (await page.$("[data-page]"))?.innerText()).toBe( - "PAGE DATA", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector('a[href="/page"]'); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - await app.clickSubmitButton("/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.clickSubmitButton("/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("/page2"); - await page.waitForSelector("[data-page2]"); - expect(await (await page.$("[data-page2]"))?.innerText()).toBe( - "PAGE2 DATA", - ); - expect(requests).toEqual([]); + await app.clickLink("/page2"); + await page.waitForSelector("[data-page2]"); + expect(await (await page.$("[data-page2]"))?.innerText()).toBe( + "PAGE2 DATA", + ); + expect(requests).toEqual([]); - await app.clickSubmitButton("/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("/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("/page"); - await page.waitForSelector("[data-page-action]"); - expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( - "PAGE ACTION 2", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 2", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - await app.clickSubmitButton("/page2"); - await page.waitForSelector("[data-page2-action]"); - expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( - "PAGE2 ACTION 2", - ); - expect(requests).toEqual([]); - }); + await app.clickSubmitButton("/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 SPA page and a root loader exists", 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"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + test("Navigates across SPA/prerender pages when starting from a SPA page and a root loader exists", 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"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Outlet, Scripts } from "react-router"; @@ -1499,13 +1536,13 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { Link } from 'react-router'; export default function Index() { return Go to page } `, - "app/routes/page.tsx": js` + "app/routes/page.tsx": js` import { Link, Form } from 'react-router'; export async function loader() { return "PAGE DATA" @@ -1530,7 +1567,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/page2.tsx": js` + "app/routes/page2.tsx": js` import { Form } from 'react-router'; export function clientLoader() { return "PAGE2 DATA" @@ -1554,76 +1591,76 @@ test.describe("Prerendering", () => { ); } `, - }, - }); - appFixture = await createAppFixture(fixture); - - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await page.waitForSelector("[data-root]"); - expect(await (await page.$("[data-root]"))?.innerText()).toBe( - "ROOT DATA", - ); + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-root]"); + expect(await (await page.$("[data-root]"))?.innerText()).toBe( + "ROOT DATA", + ); - await app.clickLink("/page"); - await page.waitForSelector("[data-page]"); - expect(await (await page.$("[data-page]"))?.innerText()).toBe( - "PAGE DATA", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - await app.clickSubmitButton("/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.clickSubmitButton("/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("/page2"); - await page.waitForSelector("[data-page2]"); - expect(await (await page.$("[data-page2]"))?.innerText()).toBe( - "PAGE2 DATA", - ); - expect(requests).toEqual([]); + await app.clickLink("/page2"); + await page.waitForSelector("[data-page2]"); + expect(await (await page.$("[data-page2]"))?.innerText()).toBe( + "PAGE2 DATA", + ); + expect(requests).toEqual([]); - await app.clickSubmitButton("/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("/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("/page"); - await page.waitForSelector("[data-page-action]"); - expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( - "PAGE ACTION 2", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 2", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - await app.clickSubmitButton("/page2"); - await page.waitForSelector("[data-page2-action]"); - expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( - "PAGE2 ACTION 2", - ); - expect(requests).toEqual([]); - }); + await app.clickSubmitButton("/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 and a root loader exists", 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"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + test("Navigates across SPA/prerender pages when starting from a prerendered page and a root loader exists", 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"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Outlet, Scripts } from "react-router"; @@ -1652,13 +1689,13 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { Link } from 'react-router'; export default function Index() { return Go to page } `, - "app/routes/page.tsx": js` + "app/routes/page.tsx": js` import { Link, Form } from 'react-router'; export async function loader() { return "PAGE DATA" @@ -1683,7 +1720,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/page2.tsx": js` + "app/routes/page2.tsx": js` import { Form } from 'react-router'; export function clientLoader() { return "PAGE2 DATA" @@ -1707,76 +1744,76 @@ test.describe("Prerendering", () => { ); } `, - }, - }); - appFixture = await createAppFixture(fixture); - - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await page.waitForSelector("[data-root]"); - expect(await (await page.$("[data-root]"))?.innerText()).toBe( - "ROOT DATA", - ); + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-root]"); + expect(await (await page.$("[data-root]"))?.innerText()).toBe( + "ROOT DATA", + ); - await app.clickLink("/page"); - await page.waitForSelector("[data-page]"); - expect(await (await page.$("[data-page]"))?.innerText()).toBe( - "PAGE DATA", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - await app.clickSubmitButton("/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.clickSubmitButton("/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("/page2"); - await page.waitForSelector("[data-page2]"); - expect(await (await page.$("[data-page2]"))?.innerText()).toBe( - "PAGE2 DATA", - ); - expect(requests).toEqual([]); + await app.clickLink("/page2"); + await page.waitForSelector("[data-page2]"); + expect(await (await page.$("[data-page2]"))?.innerText()).toBe( + "PAGE2 DATA", + ); + expect(requests).toEqual([]); - await app.clickSubmitButton("/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("/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("/page"); - await page.waitForSelector("[data-page-action]"); - expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( - "PAGE ACTION 2", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 2", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - await app.clickSubmitButton("/page2"); - await page.waitForSelector("[data-page2-action]"); - expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( - "PAGE2 ACTION 2", - ); - expect(requests).toEqual([]); - }); + await app.clickSubmitButton("/page2"); + await page.waitForSelector("[data-page2-action]"); + expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( + "PAGE2 ACTION 2", + ); + expect(requests).toEqual([]); + }); - 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"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + 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"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Outlet, Scripts } from "react-router"; @@ -1796,7 +1833,7 @@ test.describe("Prerendering", () => { return } `, - "app/routes/parent.tsx": js` + "app/routes/parent.tsx": js` import { Link, Form, Outlet } from 'react-router'; export async function loader() { return "PARENT DATA" @@ -1824,7 +1861,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/parent.child.tsx": js` + "app/routes/parent.child.tsx": js` import { Link, Form } from 'react-router'; export function clientLoader() { return "CHILD DATA" @@ -1848,68 +1885,68 @@ test.describe("Prerendering", () => { ); } `, - }, + }, + }); + 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([]); }); - 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"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + 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"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Outlet, Scripts } from "react-router"; @@ -1929,7 +1966,7 @@ test.describe("Prerendering", () => { return } `, - "app/routes/parent.tsx": js` + "app/routes/parent.tsx": js` import { Link, Form, Outlet } from 'react-router'; export async function clientLoader() { return "PARENT DATA" @@ -1954,7 +1991,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/parent.child.tsx": js` + "app/routes/parent.child.tsx": js` import { Link, Form } from 'react-router'; export function loader() { return "CHILD DATA" @@ -1978,70 +2015,70 @@ test.describe("Prerendering", () => { ); } `, - }, + }, + }); + 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"]); }); - 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"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + 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"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Outlet, Scripts } from "react-router"; @@ -2074,7 +2111,7 @@ test.describe("Prerendering", () => { return

Loading...

; } `, - "app/routes/parent.tsx": js` + "app/routes/parent.tsx": js` import { Link, Form, Outlet } from 'react-router'; export async function loader() { return "PARENT DATA" @@ -2102,7 +2139,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/parent.child.tsx": js` + "app/routes/parent.child.tsx": js` import { Link, Form } from 'react-router'; export function clientLoader() { return "CHILD DATA" @@ -2126,70 +2163,70 @@ test.describe("Prerendering", () => { ); } `, - }, + }, + }); + 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([]); }); - 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"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + 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"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Outlet, Scripts } from "react-router"; @@ -2218,7 +2255,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/parent.tsx": js` + "app/routes/parent.tsx": js` import { Link, Form, Outlet } from 'react-router'; export async function clientLoader() { return "PARENT DATA" @@ -2243,7 +2280,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/parent.child.tsx": js` + "app/routes/parent.child.tsx": js` import { Link, Form } from 'react-router'; export function loader() { return "CHILD DATA" @@ -2267,69 +2304,69 @@ test.describe("Prerendering", () => { ); } `, - }, + }, + }); + 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"]); }); - 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"]); - }); - test("Navigates prerender pages when params exist", 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", "/param/1", "/param/2"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + test("Navigates prerender pages when params exist", 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", "/param/1", "/param/2"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Link, Outlet, Scripts, useNavigation } from "react-router"; @@ -2356,12 +2393,12 @@ test.describe("Prerendering", () => { return } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` export default function Index() { return

Index

} `, - "app/routes/page.tsx": js` + "app/routes/page.tsx": js` export function loader() { return "PAGE DATA" } @@ -2369,7 +2406,7 @@ test.describe("Prerendering", () => { return

{loaderData}

; } `, - "app/routes/param.$id.tsx": js` + "app/routes/param.$id.tsx": js` export function loader({ params }) { return params.id; } @@ -2377,61 +2414,65 @@ test.describe("Prerendering", () => { return

Param {loaderData}

; } `, - }, - }); - appFixture = await createAppFixture(fixture); + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-index]"); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await page.waitForSelector("[data-index]"); + await app.clickLink("/page"); + await page.waitForSelector("#navigation-idle"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA", + ); + // No revalidation since page.data is static + expect(requests).toEqual([]); - await app.clickLink("/page"); - await page.waitForSelector("[data-page]"); - expect(await (await page.$("[data-page]"))?.innerText()).toBe( - "PAGE DATA", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + await app.clickLink("/param/1"); + await page.waitForSelector('[data-param="1"]'); + expect(await (await page.$("[data-param]"))?.innerText()).toBe( + "Param 1", + ); + expect(requests).toEqual(["/param/1.data"]); + clearRequests(requests); - await app.clickLink("/page"); - await page.waitForSelector("#navigation-idle"); - expect(await (await page.$("[data-page]"))?.innerText()).toBe( - "PAGE DATA", - ); - // No revalidation since page.data is static - expect(requests).toEqual([]); - - await app.clickLink("/param/1"); - await page.waitForSelector('[data-param="1"]'); - expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1"); - expect(requests).toEqual(["/param/1.data"]); - clearRequests(requests); - - await app.clickLink("/param/2"); - await page.waitForSelector('[data-param="2"]'); - expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 2"); - expect(requests).toEqual(["/param/2.data"]); - clearRequests(requests); - - await app.clickLink("/page"); - await page.waitForSelector("[data-page]"); - expect(await (await page.$("[data-page]"))?.innerText()).toBe( - "PAGE DATA", - ); - expect(requests).toEqual(["/page.data"]); - }); + await app.clickLink("/param/2"); + await page.waitForSelector('[data-param="2"]'); + expect(await (await page.$("[data-param]"))?.innerText()).toBe( + "Param 2", + ); + expect(requests).toEqual(["/param/2.data"]); + clearRequests(requests); - test("Navigates prerendered multibyte path routes", 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", "/ページ"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA", + ); + expect(requests).toEqual(["/page.data"]); + }); + + test("Navigates prerendered multibyte path routes", 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", "/ページ"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Link, Outlet, Scripts } from "react-router"; @@ -2455,12 +2496,12 @@ test.describe("Prerendering", () => { return } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` export default function Index() { return

Index

} `, - "app/routes/page.tsx": js` + "app/routes/page.tsx": js` export function loader() { return "PAGE DATA" } @@ -2468,7 +2509,7 @@ test.describe("Prerendering", () => { return

{loaderData}

; } `, - "app/routes/ページ.tsx": js` + "app/routes/ページ.tsx": js` export function loader() { return "ページ データ"; } @@ -2476,44 +2517,44 @@ test.describe("Prerendering", () => { return

{loaderData}

; } `, - }, - }); - appFixture = await createAppFixture(fixture); - - let encodedMultibytePath = encodeURIComponent("ページ"); - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await page.waitForSelector("[data-index]"); - - await app.clickLink("/page"); - await page.waitForSelector("[data-page]"); - expect(await (await page.$("[data-page]"))?.innerText()).toBe( - "PAGE DATA", - ); - expect(requests).toEqual(["/page.data"]); - clearRequests(requests); + }, + }); + appFixture = await createAppFixture(fixture); + + let encodedMultibytePath = encodeURIComponent("ページ"); + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-index]"); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA", + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); - await app.clickLink("/ページ"); - await page.waitForSelector("[data-multibyte-page]"); - expect(await (await page.$("[data-multibyte-page]"))?.innerText()).toBe( - "ページ データ", - ); - expect(requests).toEqual([`/${encodedMultibytePath}.data`]); - }); + await app.clickLink("/ページ"); + await page.waitForSelector("[data-multibyte-page]"); + expect(await (await page.$("[data-multibyte-page]"))?.innerText()).toBe( + "ページ データ", + ); + expect(requests).toEqual([`/${encodedMultibytePath}.data`]); + }); - test("Returns a 404 if navigating to a non-prerendered param value", 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: ["/param/1"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + test("Returns a 404 if navigating to a non-prerendered param value", 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: ["/param/1"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Link, Outlet, Scripts, useNavigation } from "react-router"; @@ -2540,12 +2581,12 @@ test.describe("Prerendering", () => { return } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` export default function Index() { return

Index

} `, - "app/routes/param.$id.tsx": js` + "app/routes/param.$id.tsx": js` export function loader({ params }) { return params.id; } @@ -2557,38 +2598,40 @@ test.describe("Prerendering", () => { return

{error.status}

; } `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-index]"); + + await app.clickLink("/param/1"); + await page.waitForSelector('[data-param="1"]'); + expect(await (await page.$("[data-param]"))?.innerText()).toBe( + "Param 1", + ); + expect(requests).toEqual(["/param/1.data"]); + clearRequests(requests); + + await app.clickLink("/param/404"); + await page.waitForSelector('[data-error="404"]'); + expect(requests).toEqual(["/param/404.data"]); }); - appFixture = await createAppFixture(fixture); - - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await page.waitForSelector("[data-index]"); - - await app.clickLink("/param/1"); - await page.waitForSelector('[data-param="1"]'); - expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1"); - expect(requests).toEqual(["/param/1.data"]); - clearRequests(requests); - - await app.clickLink("/param/404"); - await page.waitForSelector('[data-error="404"]'); - expect(requests).toEqual(["/param/404.data"]); - }); - test("Navigates to prerendered parent with clientLoader calling loader", async ({ - page, - }) => { - fixture = await createFixture({ - prerender: true, - files: { - "react-router.config.ts": reactRouterConfig({ - ssr: false, - prerender: ["/", "/parent"], - }), - "vite.config.ts": files["vite.config.ts"], - "app/root.tsx": js` + test("Navigates to prerendered parent with clientLoader calling loader", async ({ + page, + }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/", "/parent"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` import * as React from "react"; import { Link, Outlet, Scripts } from "react-router"; @@ -2617,7 +2660,7 @@ test.describe("Prerendering", () => { return

Loading...

; } `, - "app/routes/parent.tsx": js` + "app/routes/parent.tsx": js` import { Link, Form, Outlet } from 'react-router'; export async function loader() { return "PARENT DATA" @@ -2633,33 +2676,33 @@ test.describe("Prerendering", () => { return

{loaderData}

; } `, - }, - }); - appFixture = await createAppFixture(fixture); + }, + }); + appFixture = await createAppFixture(fixture); - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await expect(page.getByText("Go to parent")).toBeVisible(); + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await expect(page.getByText("Go to parent")).toBeVisible(); - await app.clickLink("/parent"); - await expect(page.getByText("PARENT DATA - CLIENT")).toBeVisible(); + await app.clickLink("/parent"); + await expect(page.getByText("PARENT DATA - CLIENT")).toBeVisible(); - expect(requests).toEqual(["/parent.data?_routes=routes%2Fparent"]); - }); + expect(requests).toEqual(["/parent.data?_routes=routes%2Fparent"]); + }); - test("Handles 404s on data requests", 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: ["/", "/slug"], - }), - // Just bring in the root instead of all `files` since we can't have - // loaders in non-prerendered routes - "app/root.tsx": files["app/root.tsx"], - "app/routes/$slug.tsx": js` + test("Handles 404s on data requests", 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: ["/", "/slug"], + }), + // Just bring in the root instead of all `files` since we can't have + // loaders in non-prerendered routes + "app/root.tsx": files["app/root.tsx"], + "app/routes/$slug.tsx": js` import * as React from "react"; import { useLoaderData } from "react-router"; @@ -2671,29 +2714,29 @@ test.describe("Prerendering", () => { return

Slug

} `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("[data-mounted]"); + await app.clickLink("/not-found"); + await page.waitForSelector("[data-error]:has-text('404 Not Found')"); + expect(requests).toEqual(["/not-found.data"]); }); - appFixture = await createAppFixture(fixture); - - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector("[data-mounted]"); - await app.clickLink("/not-found"); - await page.waitForSelector("[data-error]:has-text('404 Not Found')"); - expect(requests).toEqual(["/not-found.data"]); - }); - test("Handles redirects in prerendered pages", async ({ page }) => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "react-router.config.ts": reactRouterConfig({ - ssr: false, // turn off fog of war since we're serving with a static server - prerender: true, - }), - "app/routes/redirect.tsx": js` + test("Handles redirects in prerendered pages", async ({ page }) => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: true, + }), + "app/routes/redirect.tsx": js` import { redirect } from "react-router" export function loader() { return redirect('/target', 301); @@ -2702,43 +2745,43 @@ test.describe("Prerendering", () => {

Nope

} `, - "app/routes/target.tsx": js` + "app/routes/target.tsx": js` export default function Component() { return

Target

} `, - }, + }, + }); + + appFixture = await createAppFixture(fixture); + + // Document loads + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + await page.waitForSelector("#target"); + expect(requests).toEqual([]); + + // Client side navigations + await app.goto("/", true); + app.clickLink("/redirect"); + await page.waitForSelector("#target"); + expect(requests).toEqual(["/redirect.data"]); }); - appFixture = await createAppFixture(fixture); - - // Document loads - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/redirect"); - await page.waitForSelector("#target"); - expect(requests).toEqual([]); - - // Client side navigations - await app.goto("/", true); - app.clickLink("/redirect"); - 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` + 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"; @@ -2758,13 +2801,13 @@ test.describe("Prerendering", () => { return } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { Link } from 'react-router'; export default function Index() { return Go to page } `, - "app/routes/page.tsx": js` + "app/routes/page.tsx": js` import { Link, Form } from 'react-router'; export async function loader() { return "PAGE DATA" @@ -2789,7 +2832,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/page2.tsx": js` + "app/routes/page2.tsx": js` import { Form } from 'react-router'; export function clientLoader() { return "PAGE2 DATA" @@ -2813,75 +2856,75 @@ test.describe("Prerendering", () => { ); } `, - }, - }); - appFixture = await createAppFixture(fixture); + }, + }); + appFixture = await createAppFixture(fixture); - let requests = captureRequests(page); - let app = new PlaywrightFixture(appFixture, page); + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); - await app.goto("/base", true); - await page.waitForSelector('a[href="/base/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.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.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.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/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/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([]); - }); + 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` + 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"; @@ -2901,13 +2944,13 @@ test.describe("Prerendering", () => { return ; } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { Link } from 'react-router'; export default function Index() { return Go to page } `, - "app/routes/page.tsx": js` + "app/routes/page.tsx": js` import { Link, Form } from 'react-router'; export async function loader() { return "PAGE DATA" @@ -2932,7 +2975,7 @@ test.describe("Prerendering", () => { ); } `, - "app/routes/page2.tsx": js` + "app/routes/page2.tsx": js` import { Form } from 'react-router'; export function clientLoader() { return "PAGE2 DATA" @@ -2956,59 +2999,60 @@ test.describe("Prerendering", () => { ); } `, - }, - }); - 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); + }, + }); + 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.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.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/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/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([]); + 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/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 00b83a1b5a..d1b418a633 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -245,6 +245,7 @@ test.describe("Vite / presets", async () => { expect(buildEndArgsMeta.futureFlags).toEqual({ unstable_optimizeDeps: true, unstable_subResourceIntegrity: false, + unstable_previewServerPrerendering: false, v8_middleware: true, v8_splitRouteModules: false, v8_viteEnvironmentApi: false, diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index e69c89960f..8d83aaf30e 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -87,6 +87,10 @@ type ValidateConfigFunction = (config: ReactRouterConfig) => string | void; interface FutureConfig { unstable_optimizeDeps: boolean; unstable_subResourceIntegrity: boolean; + /** + * Prerender with Vite Preview server + */ + unstable_previewServerPrerendering?: boolean; /** * Enable route middleware */ @@ -634,6 +638,8 @@ async function resolveConfig({ userAndPresetConfigs.future?.unstable_optimizeDeps ?? false, unstable_subResourceIntegrity: userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? false, + unstable_previewServerPrerendering: + userAndPresetConfigs.future?.unstable_previewServerPrerendering ?? false, v8_middleware: userAndPresetConfigs.future?.v8_middleware ?? false, v8_splitRouteModules: userAndPresetConfigs.future?.v8_splitRouteModules ?? false, diff --git a/packages/react-router-dev/manifest.ts b/packages/react-router-dev/manifest.ts index a4024095c0..92a0558ca8 100644 --- a/packages/react-router-dev/manifest.ts +++ b/packages/react-router-dev/manifest.ts @@ -16,6 +16,7 @@ export type ManifestRoute = { hasClientLoader: boolean; hasClientMiddleware: boolean; hasErrorBoundary: boolean; + hasDefaultExport: boolean; }; export type Manifest = { diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 6a77987977..f22c151a5d 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -86,6 +86,8 @@ import { decorateComponentExportsWithProps } from "./with-props"; import { loadDotenv } from "./load-dotenv"; import { validatePluginOrder } from "./plugins/validate-plugin-order"; import { warnOnClientSourceMaps } from "./plugins/warn-on-client-source-maps"; +import type { PrerenderRequest } from "./plugins/prerender"; +import { prerender } from "./plugins/prerender"; export type LoadCssContents = ( viteDevServer: Vite.ViteDevServer, @@ -252,6 +254,8 @@ type ReactRouterPluginContext = { publicPath: string; reactRouterConfig: ResolvedReactRouterConfig; viteManifestEnabled: boolean; + reactRouterManifest: ReactRouterManifest | null; + prerenderPaths: Set | null; }; let virtualHmrRuntime = VirtualModule.create("hmr-runtime"); @@ -774,6 +778,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { ctx = { environmentBuildContext, + reactRouterManifest: null, + prerenderPaths: null, reactRouterConfig, rootDirectory, entryClientFilePath, @@ -800,6 +806,15 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { routes, ); + if (!ctx.prerenderPaths) { + ctx.prerenderPaths = new Set(); + } + + // Accumulate prerender paths from all bundles + for (let path of prerenderPaths) { + ctx.prerenderPaths.add(path); + } + let isSpaMode = isSpaModeEnabled(ctx.reactRouterConfig); return ` @@ -1020,6 +1035,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { hasClientAction, hasClientLoader, hasClientMiddleware, + hasDefaultExport: sourceExports.includes("default"), hasErrorBoundary: sourceExports.includes("ErrorBoundary"), ...getReactRouterManifestBuildAssets( ctx, @@ -1179,6 +1195,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { hasClientAction, hasClientLoader, hasClientMiddleware, + hasDefaultExport: sourceExports.includes("default"), hasErrorBoundary: sourceExports.includes("ErrorBoundary"), imports: [], }; @@ -1687,37 +1704,80 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { }; }, configurePreviewServer(previewServer) { - return () => { - // Handle SSR requests in preview mode using the built server bundle - previewServer.middlewares.use(async (req, res, next) => { - try { - let serverBuildDirectory = getServerBuildDirectory( - ctx.reactRouterConfig, + // Cache combined handler for all server bundles + let cachedHandler: RequestHandler | null = null; + + async function getHandler(): Promise { + if (cachedHandler) return cachedHandler; + + let serverBuildFiles: string[] = []; + + // Get build manifest to find server bundles + let buildManifest = + ctx.buildManifest ?? + (ctx.reactRouterConfig.serverBundles + ? await getBuildManifest({ + reactRouterConfig: ctx.reactRouterConfig, + rootDirectory: ctx.rootDirectory, + }) + : null); + + if (buildManifest?.serverBundles) { + // Load all server bundle files + for (let bundle of Object.values(buildManifest.serverBundles)) { + serverBuildFiles.push( + path.resolve(ctx.rootDirectory, bundle.file), ); - let serverBuildFile = path.resolve( - serverBuildDirectory, + } + } else { + // Single server build + serverBuildFiles.push( + path.resolve( + getServerBuildDirectory(ctx.reactRouterConfig), "index.js", - ); + ), + ); + } - // Import the built server bundle using dynamic import - // Need to add a cache-busting query parameter to avoid module caching - let build = (await import( - url.pathToFileURL(serverBuildFile).href - )) as ServerBuild; - - let handler = createRequestHandler(build, "production"); - let nodeHandler: NodeRequestHandler = async ( - nodeReq, - nodeRes, - ) => { - let req = fromNodeRequest(nodeReq, nodeRes); - let res = await handler( - req, - await reactRouterDevLoadContext(req), - ); - await sendResponse(nodeRes, res); - }; - await nodeHandler(req, res); + // Import all bundles and create handlers + let handlers: RequestHandler[] = []; + for (let file of serverBuildFiles) { + let build: ServerBuild = await import(url.pathToFileURL(file).href); + handlers.push(createRequestHandler(build, "production")); + } + + // Return a combined handler that tries each bundle until one handles the request. + // A 404 response means "not my route", so we try the next bundle. + cachedHandler = async (request, loadContext) => { + let response: Response | undefined; + + for (let handler of handlers) { + response = await handler(request, loadContext); + + if (response.status !== 404) { + return response; + } + } + + if (response) { + return response; + } + + throw new Error("No handlers were found for the request."); + }; + + return cachedHandler; + } + + return () => { + // Handle SSR requests in preview mode using the built server bundle(s) + previewServer.middlewares.use(async (req, res, next) => { + try { + let handler = await getHandler(); + let request = fromNodeRequest(req, res); + let loadContext = await reactRouterDevLoadContext(request); + let response = await handler(request, loadContext); + await sendResponse(res, response); } catch (error) { next(error); } @@ -1868,6 +1928,11 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { viteConfig.logger.info(""); } + if (future.unstable_previewServerPrerendering) { + // Prerendering is handled by the prerender plugin + return; + } + // Set an environment variable we can look for in the handler to // enable some build-time-only logic process.env.IS_RR_BUILD_REQUEST = "yes"; @@ -2115,6 +2180,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { ).reactRouterServerManifest : await getReactRouterManifestForDev(); + // Cache manifest on context for prerendering later + ctx.reactRouterManifest = reactRouterManifest; + // Check for invalid APIs when SSR is disabled if (!ctx.reactRouterConfig.ssr) { invariant(viteConfig); @@ -2456,6 +2524,293 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { } }, }, + prerender({ + config() { + process.env.IS_RR_BUILD_REQUEST = "yes"; + return { + buildDirectory: getClientBuildDirectory(ctx.reactRouterConfig), + concurrency: getPrerenderConcurrencyConfig(ctx.reactRouterConfig), + }; + }, + async requests() { + invariant(viteConfig); + + let { future } = ctx.reactRouterConfig; + + // Prerender during SSR build only + if ( + future.v8_viteEnvironmentApi + ? this.environment.name === "client" + : !viteConfigEnv.isSsrBuild + ) { + return []; + } + + // Skip prerendering if the future flag is disabled + if (!future.unstable_previewServerPrerendering) { + return []; + } + + let requests: PrerenderRequest[] = []; + + if (isPrerenderingEnabled(ctx.reactRouterConfig)) { + invariant(ctx.prerenderPaths !== null, "Prerender paths missing"); + invariant( + ctx.reactRouterManifest !== null, + "Prerender manifest missing", + ); + + let { reactRouterConfig, reactRouterManifest, prerenderPaths } = ctx; + + assertPrerenderPathsMatchRoutes( + reactRouterConfig, + Array.from(prerenderPaths), + ); + + let buildRoutes = createPrerenderRoutes(reactRouterManifest.routes); + + for (let prerenderPath of prerenderPaths) { + let matches = matchRoutes( + buildRoutes, + `/${prerenderPath}/`.replace(/^\/\/+/, "/"), + ); + + if (!matches) { + continue; + } + + // When prerendering a resource route, we don't want to pass along the + // `.data` file since we want to prerender the raw Response returned from + // the loader. Presumably this is for routes where a file extension is + // already included, such as `app/routes/items[.json].tsx` that will + // render into `/items.json` + let leafRoute = matches[matches.length - 1].route; + let manifestRoute = reactRouterManifest.routes[leafRoute.id]; + let isResourceRoute = + manifestRoute && + !manifestRoute.hasDefaultExport && + !manifestRoute.hasErrorBoundary; + + if (isResourceRoute) { + if (manifestRoute?.hasLoader) { + requests.push( + // Prerender a .data file for turbo-stream consumption + createDataRequest( + prerenderPath, + reactRouterConfig, + [leafRoute.id], + true, + ), + // Prerender a raw file for external consumption + createResourceRouteRequest(prerenderPath, reactRouterConfig), + ); + } else { + viteConfig.logger.warn( + `⚠️ Skipping prerendering for resource route without a loader: ${leafRoute.id}`, + ); + } + } else { + let hasLoaders = matches.some( + (m) => reactRouterManifest.routes[m.route.id]?.hasLoader, + ); + + if (hasLoaders) { + requests.push( + createDataRequest(prerenderPath, reactRouterConfig, null), + ); + } else { + requests.push( + createRouteRequest(prerenderPath, reactRouterConfig), + ); + } + } + } + } + + // When `ssr:false` is set, we always want a SPA HTML they can use + // to serve non-prerendered routes. This file will only SSR the root + // route and can hydrate for any path. + if (!ctx.reactRouterConfig.ssr) { + requests.push(createSpaModeRequest(ctx.reactRouterConfig)); + } + + return requests; + }, + async postProcess(request, response, metadata) { + invariant(metadata); + + // Normalized path for errors/logging and file writing (includes basename from URL) + const normalizedPath = + metadata.type === "spa" ? "/" : new URL(request.url).pathname; + + // Handle loader data responses + if (metadata.type === "data") { + if (response.status !== 200 && response.status !== 202) { + throw new Error( + `Prerender (data): Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${metadata.path}\` ` + + `path.\n${normalizedPath}`, + { cause: response }, + ); + } + + let data = await response.text(); + + return { + files: [ + { + path: normalizedPath, + contents: data, + }, + ], + requests: !metadata.isResourceRoute + ? [createRouteRequest(metadata.path, ctx.reactRouterConfig, data)] + : [], + }; + } + + // Handle resource route responses + if (metadata.type === "resource") { + let contents = new Uint8Array(await response.arrayBuffer()); + if (response.status !== 200) { + throw new Error( + `Prerender (resource): Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + + `path.\n${new TextDecoder().decode(contents)}`, + ); + } + + return [ + { + path: normalizedPath, + contents, + }, + ]; + } + + // Handle document responses (html or spa) + let html = await response.text(); + + if (metadata.type === "spa") { + if (response.status !== 200) { + throw new Error( + `SPA Mode: Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering your SPA Fallback HTML file.\n` + + html, + ); + } + + if ( + !html.includes("window.__reactRouterContext =") || + !html.includes("window.__reactRouterRouteModules =") + ) { + throw new Error( + "SPA Mode: Did you forget to include `` in your root route? " + + "Your pre-rendered HTML cannot hydrate without ``.", + ); + } + } + + if (redirectStatusCodes.has(response.status)) { + // This isn't ideal but gets the job done as a fallback if the user can't + // implement proper redirects via .htaccess or something else. This is the + // approach used by Astro as well, so there's some precedent. + // https://github.com/withastro/roadmap/issues/466 + // https://github.com/withastro/astro/blob/main/packages/astro/src/core/routing/3xx.ts + let location = response.headers.get("Location"); + // A short delay causes Google to interpret the redirect as temporary. + // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh + let delay = response.status === 302 ? 2 : 0; + html = ` + +Redirecting to: ${location} + + + + + + Redirecting from ${normalizedPath} to ${location} + + +`; + } else if (response.status !== 200) { + throw new Error( + `Prerender (html): Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + + `path.\n${html}`, + ); + } + + return [ + { + path: `${normalizedPath}/${metadata.type === "spa" ? "__spa-fallback.html" : "index.html"}`, + contents: html, + }, + ]; + }, + logFile(outputPath, metadata) { + invariant(viteConfig); + invariant(metadata); + // SPA fallback logging is handled in finalize after we know the final filename + if (metadata.type === "spa") { + return; + } + viteConfig.logger.info( + `Prerender (${metadata.type}): ${metadata.path} -> ${colors.bold(outputPath)}`, + ); + }, + async finalize(buildDirectory) { + invariant(viteConfig); + + let { ssr, future } = ctx.reactRouterConfig; + + // if ssr:false is set + if (!ssr) { + let spaFallback = path.join(buildDirectory, "__spa-fallback.html"); + let index = path.join(buildDirectory, "index.html"); + + // If the user didn't prerendered `/`, uses the SPA fallback as the main entry point. + let finalSpaPath: string; + if (existsSync(spaFallback) && !existsSync(index)) { + await rename(spaFallback, index); + finalSpaPath = index; + } else if (existsSync(spaFallback)) { + finalSpaPath = spaFallback; + } + + // Log SPA fallback with the final filename + if (finalSpaPath!) { + let prettyPath = path.relative(viteConfig.root, finalSpaPath); + if (ctx.prerenderPaths && ctx.prerenderPaths.size > 0) { + viteConfig.logger.info( + `Prerender (html): SPA Fallback -> ${colors.bold(prettyPath)}`, + ); + } else { + viteConfig.logger.info( + `SPA Mode: Generated ${colors.bold(prettyPath)}`, + ); + } + } + + let serverBuildDirectory = future.v8_viteEnvironmentApi + ? this.environment.config?.build?.outDir + : (ctx.environmentBuildContext?.options.build?.outDir ?? + getServerBuildDirectory(ctx.reactRouterConfig)); + + // Cleanup - we no longer need the server build assets + viteConfig.logger.info( + [ + "Removing the server build in", + colors.green(serverBuildDirectory), + "due to ssr:false", + ].join(" "), + ); + + // For both SPA mode and prerendering, we can remove the server builds + rmSync(serverBuildDirectory, { force: true, recursive: true }); + } + }, + }), validatePluginOrder(), warnOnClientSourceMaps(), ]; @@ -2630,6 +2985,7 @@ async function getRouteMetadata( hasLoader: sourceExports.includes("loader"), hasClientLoader: sourceExports.includes("clientLoader"), hasClientMiddleware: sourceExports.includes("clientMiddleware"), + hasDefaultExport: sourceExports.includes("default"), hasErrorBoundary: sourceExports.includes("ErrorBoundary"), imports: [], }; @@ -3828,3 +4184,115 @@ async function asyncFlatten( } while (arr.some((v: any) => v?.then)); return arr as unknown[] as AsyncFlatten; } + +type PrerenderMetadata = { + type: "data" | "resource" | "html" | "spa"; + path: string; + isResourceRoute?: boolean; +}; + +function assertPrerenderPathsMatchRoutes( + config: ResolvedReactRouterConfig, + prerenderPaths: string[], +): void { + let routes = createPrerenderRoutes(config.routes); + + for (let path of prerenderPaths) { + let matches = matchRoutes(routes, `/${path}/`.replace(/^\/\/+/, "/")); + if (!matches) { + throw new Error( + `Unable to prerender path because it does not match any routes: ${path}`, + ); + } + } +} + +function getPrerenderConcurrencyConfig( + reactRouterConfig: ResolvedReactRouterConfig, +): number { + let concurrency = 1; + let { prerender } = reactRouterConfig; + if (typeof prerender === "object" && "unstable_concurrency" in prerender) { + concurrency = prerender.unstable_concurrency ?? 1; + } + return concurrency; +} + +function createDataRequest( + prerenderPath: string, + reactRouterConfig: ResolvedReactRouterConfig, + onlyRoutes: string[] | null, + isResourceRoute?: boolean, +): PrerenderRequest { + let normalizedPath = `${reactRouterConfig.basename}${ + prerenderPath === "/" + ? "/_root.data" + : `${prerenderPath.replace(/\/$/, "")}.data` + }`.replace(/\/\/+/g, "/"); + let url = new URL(`http://localhost${normalizedPath}`); + if (onlyRoutes?.length) { + url.searchParams.set("_routes", onlyRoutes.join(",")); + } + + return { + request: new Request(url), + metadata: { type: "data", path: prerenderPath, isResourceRoute }, + }; +} + +function createRouteRequest( + prerenderPath: string, + reactRouterConfig: ResolvedReactRouterConfig, + data?: string, +): PrerenderRequest { + let normalizedPath = `${reactRouterConfig.basename}${prerenderPath}/`.replace( + /\/\/+/g, + "/", + ); + + let headers = new Headers(); + + if (data) { + let encodedData = encodeURI(data); + + // Check if encoded data would exceed HTTP header limits (~8KB threshold) + // Skip header for large data, loaders will run again + if (encodedData.length < 8 * 1024) { + headers.set("X-React-Router-Prerender-Data", encodedData); + } + } + + return { + request: new Request(`http://localhost${normalizedPath}`, { headers }), + metadata: { type: "html", path: prerenderPath }, + }; +} + +function createResourceRouteRequest( + prerenderPath: string, + reactRouterConfig: ResolvedReactRouterConfig, + requestInit?: RequestInit, +): PrerenderRequest { + let normalizedPath = `${reactRouterConfig.basename}${prerenderPath}/` + .replace(/\/\/+/g, "/") + .replace(/\/$/g, ""); + + return { + request: new Request(`http://localhost${normalizedPath}`, requestInit), + metadata: { type: "resource", path: prerenderPath }, + }; +} + +function createSpaModeRequest( + reactRouterConfig: ResolvedReactRouterConfig, +): PrerenderRequest { + return { + request: new Request(`http://localhost${reactRouterConfig.basename}`, { + headers: { + // Enable SPA mode in the server runtime and only render down to the root + "X-React-Router-SPA-Mode": "yes", + }, + }), + metadata: { type: "spa", path: "/" }, + }; +} diff --git a/packages/react-router-dev/vite/plugins/prerender.ts b/packages/react-router-dev/vite/plugins/prerender.ts new file mode 100644 index 0000000000..fdd5bedf87 --- /dev/null +++ b/packages/react-router-dev/vite/plugins/prerender.ts @@ -0,0 +1,494 @@ +import type * as Vite from "vite"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export interface PrerenderFile { + /** + * The filename relative to the build output directory + * e.g., "about.html", "api/data.json", "about/index.html" + * + * Leading slash will be removed if present ("/about.html" -> "about.html") + */ + path: string; + /** + * The file contents to write + */ + contents: string | Uint8Array; +} + +/** + * Request input: string, Request, or object with metadata + */ +export type PrerenderRequest> = + | string + | Request + | { + request: string | Request; + metadata?: Metadata; + }; + +/** + * Result of the `postProcess` option. Return files array, or an object with files and follow-up requests + */ +export type PostProcessResult> = + | PrerenderFile[] + | { + files: PrerenderFile[]; + requests?: PrerenderRequest[]; + }; + +export interface PrerenderConfig { + /** + * Output directory for prerendered files + * @default viteConfig.environments.client.build.outDir + */ + buildDirectory?: string; + /** + * Number of concurrent requests to prerender + * @default 1 + */ + concurrency?: number; + /** + * Number of times to retry failed requests + * + * Retries 5xx errors and timeout errors. Does not retry 4xx client errors. + * + * @default 0 + */ + retryCount?: number; + /** + * Delay in milliseconds between retry attempts + * + * @default 500 + */ + retryDelay?: number; + /** + * Maximum number of redirects to follow + * + * @default 0 + */ + maxRedirects?: number; + /** + * Request timeout in milliseconds + * + * @default 10000 + */ + timeout?: number; +} + +export interface PrerenderPluginOptions< + Metadata extends Record, +> { + /** + * Prerender configuration + */ + config?: + | PrerenderConfig + | (( + this: Vite.Rollup.PluginContext, + ) => PrerenderConfig | Promise); + + /** + * Requests to prerender + * + * Can return simple strings/Requests or objects with metadata. + * Metadata flows through to postProcess and logFile hooks. + * + * If no requests are returned, prerendering is skipped. + * + * @example + * ```ts + * requests() { + * return [ + * "/", + * "/about", + * { request: "/api/data", metadata: { type: "api" } }, + * ]; + * } + * ``` + */ + requests: + | PrerenderRequest[] + | (( + this: Vite.Rollup.PluginContext, + ) => + | PrerenderRequest[] + | Promise[]>); + + /** + * Post-process server responses to generate output files + * + * Can return just files, or files with additional requests to process. + * Follow-up requests go through the same pipeline (retry, redirect, timeout). + * + * @example + * ```ts + * // Simple: just return files + * postProcess(request, response) { + * return [{ path: "/index.html", contents: await response.text() }]; + * } + * + * // With follow-up requests + * postProcess(request, response, metadata) { + * let data = await response.text(); + * return { + * files: [{ path: "/data.json", contents: data }], + * requests: [{ + * request: new Request(htmlUrl, { headers: { "X-Data": data } }), + * metadata: { type: "html" } + * }] + * }; + * } + * ``` + */ + postProcess?: ( + this: Vite.Rollup.PluginContext, + request: Request, + response: Response, + metadata: Metadata | undefined, + ) => + | NoInfer> + | Promise>>; + + /** + * Handle errors during prerendering + * + * If this function does not throw, prerendering continues. + * If it throws, the build fails. + */ + handleError?: ( + this: Vite.Rollup.PluginContext, + request: Request, + error: Error, + metadata: Metadata | undefined, + ) => void; + + /** + * Log when a file is written + * + * Use for custom logging with access to request metadata. + * If not provided, uses default logging. + */ + logFile?: ( + this: Vite.Rollup.PluginContext, + outputPath: string, + metadata: Metadata | undefined, + ) => void; + + /** + * Called after all prerendering is complete + * + * Use for cleanup or post-processing of output files. + */ + finalize?: ( + this: Vite.Rollup.PluginContext, + buildDirectory: string, + ) => void | Promise; +} + +function normalizePrerenderRequest>( + input: PrerenderRequest, +): { + request: string | Request; + metadata: Metadata | undefined; +} { + if (typeof input === "string" || input instanceof Request) { + return { request: input, metadata: undefined }; + } + + return { request: input.request, metadata: input.metadata }; +} + +function normalizePostProcessResult>( + result: PostProcessResult, +): { + files: PrerenderFile[]; + requests: PrerenderRequest[]; +} { + if (Array.isArray(result)) { + return { files: result, requests: [] }; + } + + return { files: result.files, requests: result.requests ?? [] }; +} + +/** + * Vite plugin for prerendering using the preview server + */ +export function prerender>( + options: PrerenderPluginOptions, +): Vite.Plugin { + const { + config, + requests, + postProcess = defaultPostProcess, + handleError = defaultHandleError, + logFile, + finalize, + } = options; + + let viteConfig: Vite.ResolvedConfig; + + return { + name: "prerender", + configResolved(resolvedConfig) { + viteConfig = resolvedConfig; + }, + writeBundle: { + async handler() { + const pluginContext = this; + const rawRequests = + typeof requests === "function" + ? await requests.call(pluginContext) + : requests; + + const prerenderRequests = rawRequests.map(normalizePrerenderRequest); + + if (prerenderRequests.length === 0) { + return; + } + + const prerenderConfig = + typeof config === "function" + ? await config.call(pluginContext) + : config; + const { + buildDirectory = viteConfig.environments.client.build.outDir, + concurrency = 1, + retryCount = 0, + retryDelay = 500, + maxRedirects = 0, + timeout = 10000, + } = prerenderConfig ?? {}; + + const previewServer = await startPreviewServer(viteConfig); + + try { + const baseUrl = getResolvedUrl(previewServer); + + async function prerenderRequest( + input: string | Request, + metadata: Metadata | undefined, + ): Promise> { + let attemptCount = 0; + let redirectCount = 0; + + const request = new Request(input); + const url = new URL(request.url); + + if (url.origin !== baseUrl.origin) { + url.hostname = baseUrl.hostname; + url.protocol = baseUrl.protocol; + url.port = baseUrl.port; + } + + async function attempt( + url: URL, + ): Promise> { + try { + const signal = AbortSignal.timeout(timeout); + const prerenderReq = new Request(url, request); + const response = await fetch(prerenderReq, { + redirect: "manual", + signal, + }); + + if ( + response.status >= 300 && + response.status < 400 && + response.headers.has("location") && + ++redirectCount <= maxRedirects + ) { + const location = response.headers.get("location")!; + const responseURL = new URL(response.url); + const locationUrl = new URL(location, response.url); + + // External redirect: pass to postProcess + if (responseURL.origin !== locationUrl.origin) { + return await postProcess.call( + pluginContext, + request, + response, + metadata, + ); + } + + // Internal redirect within limit: follow it + const redirectUrl = new URL(location, url); + return await attempt(redirectUrl); + } + + if (response.status >= 500 && ++attemptCount <= retryCount) { + await new Promise((resolve) => + setTimeout(resolve, retryDelay), + ); + return attempt(url); + } + + return await postProcess.call( + pluginContext, + request, + response, + metadata, + ); + } catch (error) { + if (++attemptCount <= retryCount) { + await new Promise((resolve) => + setTimeout(resolve, retryDelay), + ); + return attempt(url); + } + + // If handleError does not throw, return empty array and continue + handleError.call( + pluginContext, + request, + error instanceof Error + ? error + : new Error(error?.toString() ?? "Unknown error"), + metadata, + ); + + return []; + } + } + + return attempt(url); + } + + async function prerender( + input: string | Request, + metadata: Metadata | undefined, + ): Promise { + const result = await prerenderRequest(input, metadata); + const { files, requests } = normalizePostProcessResult(result); + + for (const file of files) { + await writePrerenderFile(file, metadata); + } + + for (const followUp of requests) { + const normalized = normalizePrerenderRequest(followUp); + await prerender(normalized.request, normalized.metadata); + } + } + + async function writePrerenderFile( + file: PrerenderFile, + metadata: Metadata | undefined, + ) { + // Removes leading slash if present (e.g. pathname "/about" -> "about") + const normalizedPath = file.path.startsWith("/") + ? file.path.slice(1) + : file.path; + const outputPath = path.join( + buildDirectory, + ...normalizedPath.split("/"), + ); + + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, file.contents); + + const relativePath = path.relative(viteConfig.root, outputPath); + + if (logFile) { + logFile.call(pluginContext, relativePath, metadata); + } + + return relativePath; + } + + const pMap = await import("p-map"); + await pMap.default( + prerenderRequests, + async ({ request, metadata }) => { + await prerender(request, metadata); + }, + { concurrency }, + ); + + if (finalize) { + await finalize.call(pluginContext, buildDirectory); + } + } finally { + await new Promise((resolve, reject) => { + previewServer.httpServer.close((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + }, + }, + }; +} + +async function defaultPostProcess( + request: Request, + response: Response, +): Promise { + const prerenderPath = new URL(request.url).pathname; + + if (!response.ok) { + throw new Error( + `Prerender: Request failed for ${prerenderPath}: ${response.status} ${response.statusText}`, + ); + } + + return [ + { + path: `${prerenderPath}/index.html`, + contents: await response.text(), + }, + ]; +} + +function defaultHandleError(request: Request, error: Error): void { + const prerenderPath = new URL(request.url).pathname; + + if (request.signal?.aborted) { + throw new Error( + `Prerender: Request timed out for ${prerenderPath}: ${error.message}`, + ); + } + + throw new Error( + `Prerender: Request failed for ${prerenderPath}: ${error.message}`, + ); +} + +async function startPreviewServer( + viteConfig: Vite.ResolvedConfig, +): Promise { + const vite = await import("vite"); + + try { + return await vite.preview({ + configFile: viteConfig.configFile, + logLevel: "silent", + preview: { + port: 0, + open: false, + }, + }); + } catch (error) { + throw new Error("Prerender: Failed to start Vite preview server", { + cause: error, + }); + } +} + +function getResolvedUrl(previewServer: Vite.PreviewServer): URL { + const baseUrl = previewServer.resolvedUrls?.local[0]; + + if (!baseUrl) { + throw new Error( + "Prerender: No resolved URL is available from the Vite preview server", + ); + } + + return new URL(baseUrl); +} diff --git a/playground/vite-plugin-cloudflare/app/routes/static.tsx b/playground/vite-plugin-cloudflare/app/routes/static.tsx new file mode 100644 index 0000000000..3c2609ff13 --- /dev/null +++ b/playground/vite-plugin-cloudflare/app/routes/static.tsx @@ -0,0 +1,25 @@ +import type { MetaFunction } from "react-router"; +import { env } from "cloudflare:workers"; +import type { Route } from "./+types/static"; + +export const meta: MetaFunction = () => { + return [ + { title: "New React Router App" }, + { name: "description", content: "Welcome to React Router!" }, + ]; +}; + +export async function loader() { + return { + message: env.VALUE_FROM_CLOUDFLARE, + }; +} + +export default function Static({ loaderData }: Route.ComponentProps) { + return ( +
+

Welcome to React Router

+

{loaderData.message}

+
+ ); +} diff --git a/playground/vite-plugin-cloudflare/react-router.config.ts b/playground/vite-plugin-cloudflare/react-router.config.ts index 99c9123c4b..41a1ed54a2 100644 --- a/playground/vite-plugin-cloudflare/react-router.config.ts +++ b/playground/vite-plugin-cloudflare/react-router.config.ts @@ -3,5 +3,7 @@ import type { Config } from "@react-router/dev/config"; export default { future: { v8_viteEnvironmentApi: true, + unstable_previewServerPrerendering: true, }, + prerender: ["/static"], } satisfies Config;