diff --git a/.changeset/fresh-buttons-sit.md b/.changeset/fresh-buttons-sit.md new file mode 100644 index 0000000000..e0f130f916 --- /dev/null +++ b/.changeset/fresh-buttons-sit.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Don't apply Single Fetch revalidation de-optimization when in SPA mode since there is no server HTTP request diff --git a/.changeset/prerender-invalid-exports.md b/.changeset/prerender-invalid-exports.md new file mode 100644 index 0000000000..7cdb6f3e72 --- /dev/null +++ b/.changeset/prerender-invalid-exports.md @@ -0,0 +1,12 @@ +--- +"@react-router/dev": patch +--- + +Enhance invalid export detection when using `ssr:false` + +- `headers`/`action` are prohibited in all routes with `ssr:false` because there will be no runtime server on which to run them +- `loader` functions are more nuanced and depend on whether a given route is prerendered + - When using `ssr:false` without a `prerender` config, only the `root` route can have a `loader` + - This is "SPA mode" which generates a single `index.html` file with the root route `HydrateFallback` so it is capable of hydrating for any path in your application - therefore we can only call a root route `loader` at build time + - When using `ssr:false` with a `prerender` config, you can export a `loader` from routes matched by one of the `prerender` paths because those routes will be server rendered at build time + - Exporting a `loader` from a route that is never matched by a `prerender` path will throw a build time error because there will be no runtime server to ever run the loader diff --git a/.changeset/prerender-spa-fallback.md b/.changeset/prerender-spa-fallback.md new file mode 100644 index 0000000000..da3639a6c5 --- /dev/null +++ b/.changeset/prerender-spa-fallback.md @@ -0,0 +1,13 @@ +--- +"@react-router/dev": minor +--- + +Generate a "SPA fallback" HTML file for scenarios where applications are prerendering the `/` route with `ssr:false` + +- If you specify `ssr:false` without a `prerender` config, this is considered "SPA Mode" and the generated `index.html` file will only render down to the root route and will be able to hydrate for any valid application path +- If you specify `ssr:false` with a `prerender` config but _do not_ include the `/` path (i.e., `prerender: ['/blog/post']`), then we still generate a "SPA Mode" `index.html` file that can hydrate for any path in the application +- However, previously if you specified `ssr:false` and included the `/` path in your `prerender` config, we would prerender the `/` route into `index.html` as a non-SPA page + - The generated HTML would include the root index route which prevented hydration for any other paths + - With this change, we now generate a "SPA Mode" file in `__spa-fallback.html` that will allow you to hydrate for any non-prerendered paths + - You can serve this file from your static file server for any paths that would otherwise 404 if you only want to pre-render _some_ routes in your `ssr:false` app and serve the others as a SPA + - `npx sirv-cli build/client --single __spa-fallback.html` diff --git a/.changeset/spa-mode-root-loader.md b/.changeset/spa-mode-root-loader.md new file mode 100644 index 0000000000..94a7f8a047 --- /dev/null +++ b/.changeset/spa-mode-root-loader.md @@ -0,0 +1,9 @@ +--- +"@react-router/dev": minor +--- + +- Allow a `loader` in the root route in SPA mode because it can be called/server-rendered at build time +- `Route.HydrateFallbackProps` now also receives `loaderData` + - This will be defined so long as the `HydrateFallback` is rendering while _children_ routes are loading + - This will be `undefined` if the `HydrateFallback` is rendering because the route has it's own hydrating `clientLoader` + - In SPA mode, this will allow you to render loader root data into the SPA `index.html` diff --git a/.changeset/stale-ways-ring.md b/.changeset/stale-ways-ring.md new file mode 100644 index 0000000000..c3ca1cfa5c --- /dev/null +++ b/.changeset/stale-ways-ring.md @@ -0,0 +1,9 @@ +--- +"react-router": patch +--- + +Align dev server behavior with static file server behavior when `ssr:false` is set + +- When no `prerender` config exists, only SSR down to the root `HydrateFallback` (SPA Mode) +- When a `prerender` config exists but the current path is not prerendered, only SSR down to the root `HydrateFallback` (SPA Fallback) +- Return a 404 on `.data` requests to non-pre-rendered paths diff --git a/docs/how-to/pre-rendering.md b/docs/how-to/pre-rendering.md index e5f39e7507..2c25fa4cec 100644 --- a/docs/how-to/pre-rendering.md +++ b/docs/how-to/pre-rendering.md @@ -4,16 +4,23 @@ title: Pre-Rendering # Pre-Rendering -Pre-rendering allows you to render pages at build time instead of on a server to speed up pages loads for static content. +Pre-rendering allows you to render pages at build time instead of on a runtime server to speed up page loads for static content. -## Configuration +In some cases, you'll serve these pages _alongside_ a runtime SSR server. If you wish to pre-render pages and deploy them _without_ a runtime SSR server, please see the [Pre-rendering with `ssr:false`](#Pre-rendering-with-ssrfalse) section below. + +## Pre-rendering with ssr:true + +### Configuration Add the `prerender` option to your config, there are three signatures: -```ts filename=react-router.config.ts +```ts filename=react-router.config.ts lines=[7-09,11-12,14-20] import type { Config } from "@react-router/dev/config"; export default { + // Can be omitted - defaults to true + ssr: true, + // all static route paths // (no dynamic segments like "/post/:slug") prerender: true, @@ -22,7 +29,7 @@ export default { prerender: ["/", "/blog", "/blog/popular-post"], // async function for dependencies like a CMS - async prerender({ getStaticPaths }) { + async pre-render({ getStaticPaths }) { let posts = await fakeGetPostsFromCMS(); return ["/", "/blog"].concat( posts.map((post) => post.href) @@ -31,7 +38,7 @@ export default { } satisfies Config; ``` -## Data Loading and Pre-rendering +### Data Loading and Pre-rendering There is no extra application API for pre-rendering. Pre-rendering uses the same route loaders as server rendering: @@ -50,7 +57,7 @@ Instead of a request coming to your route on a deployed server, the build create When server rendering, requests to paths that have not been pre-rendered will be server rendered as usual. -## Static File Output +### Static File Output The rendered result will be written out to your `build/client` directory. You'll notice two files for each path: @@ -74,3 +81,55 @@ Prerender: Generated build/client/blog/my-first-post/index.html ``` During development, pre-rendering doesn't save the rendered results to the public directory, this only happens for `react-router build`. + +## Pre-rendering with `ssr:false` + +The above examples assume you are deploying a runtime server, but are pre-rendering some static pages in order to serve them faster and avoid hitting the server. + +To disable runtime SSR, you can set the `ssr:false` config flag: + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: false, // disable runtime server rendering + prerender: true, // pre-render static routes +} satisfies Config; +``` + +If you specify `ssr:false` without a `prerender` config, React Router refers to that as [SPA Mode](./spa). In SPA Mode, we render a single HTML file that is capable of hydrating for _any_ of your application paths. It can do this because it only renders the `root` route into the HTML file and then determines which child routes to load based on the browser URL during hydration. This means you can use a `loader` on the root route, but not on any other routes because we don't know which routes to load until hydration in the browser. + +If you want to pre-render paths with `ssr:false`, those matched routes _can_ have loaders because we'll pre-render all of the matched routes for those paths, not just the root. Usually, with `prerender:true`, you'll be pre-rendering all of your application routes into a full SSG setup. + +### Pre-rendering with a SPA Fallback + +If you want `ssr:false` but don't want to pre-render _all_ of your routes - that's fine too! You may have some paths where you need the performance/SEO benefits of pre-rendering, but other pages where a SPA would be fine. + +You can do this using the combination of config options as well - just limit your `prerender` config to the paths that you want to pre-render and React Router will also output a "SPA Fallback" HTML file that can be served to hydrate any other paths (using the same approach as [SPA Mode](./spa)). + +This will be written to one of the following paths: + +- `build/client/index.html` - If the `/` path is not pre-rendered +- `build/client/__spa-fallback.html` - If the `/` path is pre-rendered + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: false, + + // SPA fallback will be written to build/client/index.html + prerender: ["/about-us"], + + // SPA fallback will be written to build/client/__spa-fallback.html + prerender: ["/", "/about-us"], +} satisfies Config; +``` + +You can configure your deployment server to serve this file for any path that otherwise would 404. + +Here's an example of how you can do this with the [`sirv-cli`](https://www.npmjs.com/package/sirv-cli#user-content-single-page-applications) tool: + +```sh +sirv-cli build/client --single __spa-fallback.html +``` diff --git a/docs/how-to/spa.md b/docs/how-to/spa.md index 38ee6314f6..6eef43ecaf 100644 --- a/docs/how-to/spa.md +++ b/docs/how-to/spa.md @@ -11,7 +11,7 @@ There are two ways to ship a single page app with React Router ## 1. Disable Server Rendering -Server rendering is enabled by default. Set the ssr flag to false in `react-router.config.ts` to disable it. +Server rendering is enabled by default. Set the `ssr` flag to `false` in `react-router.config.ts` to disable it. ```ts filename=react-router.config.ts lines=[4] import { type Config } from "@react-router/dev/config"; @@ -65,10 +65,11 @@ If you're getting 404s at valid routes for your app, it's likely you need to con Typical Single Pages apps send a mostly blank `index.html` template with little more than an empty `
`. -In contrast `react-router build` (with server rendering disabled) pre-renders your root and index routes. This means you can: +In contrast `react-router build` (with server rendering disabled) pre-renders your root route at build time. This means you can: - Send more than an empty div -- Use React components to generate the initial page users see +- Use a root `loader` to load data for your application shell +- Use React components to generate the initial page users see (root `HydrateFallback`) - Re-enable server rendering later without changing anything about your UI -React Router will still server render your index route to generate that `index.html` file. This is why your project still needs a dependency on `@react-router/node` and your routes need to be SSR-safe. That means you can't call `window` or other browser-only APIs during the initial render, even when server rendering is disabled. +Therefore, setting `ssr:false` only disables _runtime server rendering_. React Router will still server render your index route at _build time_ to generate the `index.html` file. This is why your project still needs a dependency on `@react-router/node` and your routes need to be SSR-safe. That means you can't call `window` or other browser-only APIs during the initial render, even when server rendering is disabled. diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index cc62dd0dbc..672faef6dd 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -94,9 +94,16 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { prerender: init.prerender, requestDocument(href: string) { let file = new URL(href, "test://test").pathname + "/index.html"; - let html = fse.readFileSync( - path.join(projectDir, "build/client" + file) + let mainPath = path.join(projectDir, "build", "client", file); + let fallbackPath = path.join( + projectDir, + "build", + "client", + "__spa-fallback.html" ); + let html = fse.existsSync(mainPath) + ? fse.readFileSync(mainPath) + : fse.readFileSync(fallbackPath); return new Response(html, { headers: { "Content-Type": "text/html", @@ -284,15 +291,18 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { return new Promise(async (accept) => { let port = await getPort(); let app = express(); - app.use(express.static(path.join(fixture.projectDir, "build/client"))); + app.use( + express.static(path.join(fixture.projectDir, "build", "client")) + ); app.get("*", (req, res, next) => { + let dir = path.join(fixture.projectDir, "build", "client"); let file = req.path.endsWith(".data") ? req.path : req.path + "/index.html"; - res.sendFile( - path.join(fixture.projectDir, "build/client", file), - next - ); + if (file.endsWith(".html") && !fse.existsSync(path.join(dir, file))) { + file = "__spa-fallback.html"; + } + res.sendFile(path.join(dir, file), next); }); let server = app.listen(port); accept({ stop: server.close.bind(server), port }); diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index 9bfcbaf0a4..52033b168d 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -10,7 +10,7 @@ import { } from "./helpers/create-fixture.js"; import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -import { reactRouterConfig } from "./helpers/vite.js"; +import { build, createProject, reactRouterConfig } from "./helpers/vite.js"; let files = { "react-router.config.ts": reactRouterConfig({ @@ -29,7 +29,7 @@ let files = { `, "app/root.tsx": js` import * as React from "react"; - import { Form, Link, Links, Meta, Outlet, Scripts, useRouteError } from "react-router"; + import { Link, Links, Meta, Outlet, Scripts, useRouteError } from "react-router"; export function meta({ data }) { return [{ @@ -72,6 +72,10 @@ let files = { error.message; return

{msg}

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

Loading...

; + } `, "app/routes/_index.tsx": js` import * as React from "react"; @@ -150,36 +154,26 @@ function listAllFiles(_dir: string) { test.describe("Prerendering", () => { let fixture: Fixture; let appFixture: AppFixture; - let _consoleError: typeof console.error; - let _consoleWarn: typeof console.warn; - - test.beforeAll(() => { - _consoleError = console.error; - console.error = () => {}; - _consoleWarn = console.warn; - console.warn = () => {}; - }); test.afterAll(() => { - appFixture.close(); - console.error = _consoleError; - console.warn = _consoleWarn; + appFixture?.close(); }); - 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; @@ -188,7 +182,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; @@ -197,7 +191,7 @@ test.describe("Prerendering", () => { return } `, - "app/routes/$.tsx": js` + "app/routes/$.tsx": js` import { Outlet } from 'react-router' export function loader() { return null; @@ -206,63 +200,44 @@ test.describe("Prerendering", () => { 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")) - ); + }, + }); + + 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

'); }); - 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") - ); - - 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

'); - }); - - 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)); @@ -270,7 +245,7 @@ test.describe("Prerendering", () => { }, } `, - "vite.config.ts": js` + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -281,46 +256,46 @@ 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"; @@ -329,7 +304,7 @@ test.describe("Prerendering", () => { plugins: [reactRouter()], }); `, - "app/routes/$slug.tsx": js` + "app/routes/$slug.tsx": js` export function loader() { return null } @@ -337,49 +312,49 @@ 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("Pre-renders resource routes with file extensions", async () => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "app/routes/text[.txt].tsx": js` + test("Pre-renders resource routes with file extensions", async () => { + 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: { @@ -388,58 +363,58 @@ 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", - "json.json", - "json.json.data", - "text.txt", - "text.txt.data", - ]); - - 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({ - root: { - data: null, - }, - "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", + "index.html", + "json.json", + "json.json.data", + "text.txt", + "text.txt.data", + ]); + + 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({ + root: { + data: null, + }, + "routes/json[.json]": { + data: { + hello: "world", + }, + }, + }); + + res = await fixture.requestResource("/text.txt"); + expect(await res.text()).toBe("Hello, world"); - res = await fixture.requestResource("/text.txt"); - expect(await res.text()).toBe("Hello, world"); - - dataRes = await fixture.requestSingleFetchData("/text.txt.data"); - expect(dataRes.data).toEqual({ - root: { - data: null, - }, - "routes/text[.txt]": { - data: "Hello, world", - }, + dataRes = await fixture.requestSingleFetchData("/text.txt.data"); + expect(dataRes.data).toEqual({ + root: { + data: null, + }, + "routes/text[.txt]": { + data: "Hello, world", + }, + }); }); - }); - test("Adds leading slashes if omitted in config", async () => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "react-router.config.ts": js` + 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)); @@ -447,7 +422,7 @@ test.describe("Prerendering", () => { }, } `, - "vite.config.ts": js` + "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; @@ -458,77 +433,47 @@ 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

'); - }); - - test("Hydrates into a navigable app", async ({ page }) => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, - "react-router.config.ts": reactRouterConfig({ - ssr: false, - prerender: true, - }), - }, - }); - appFixture = await createAppFixture(fixture); - - let requests: string[] = []; - page.on("request", (request) => { - let pathname = new URL(request.url()).pathname; - if (pathname.endsWith(".data") || pathname.endsWith("__manifest")) { - requests.push(pathname); - } + }, + }); + 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

'); }); - - 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("Serves the prerendered HTML file alongside runtime routes", async ({ - page, - }) => { - fixture = await createFixture({ - // Even though we are prerendering, we want a running server so we can - // hit the pre-rendered HTML file and a non-prerendered route - prerender: false, - 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"; @@ -537,7 +482,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-" + request.headers.has('X-React-Router-Prerender'); @@ -548,7 +493,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-" + request.headers.has('X-React-Router-Prerender'); @@ -559,33 +504,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({ - // Even thogh we are prerendering, we want a running server so we can - // hit the pre-rendered HTML file and a non-prerendered route - prerender: false, - 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"; @@ -594,7 +538,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 { @@ -615,31 +559,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({ - prerender: false, - 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"; @@ -648,7 +591,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 { @@ -668,7 +611,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 { @@ -688,42 +631,41 @@ test.describe("Prerendering", () => { ); } `, - }, + }, + }); + appFixture = await createAppFixture(fixture); + + 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 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文字" + ); }); - appFixture = await createAppFixture(fixture); - - 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 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({ - prerender: true, - 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"; @@ -732,7 +674,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"; @@ -742,7 +684,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"; @@ -755,7 +697,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"; @@ -765,58 +707,905 @@ 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("Handles 404s on data requests", async ({ page }) => { - fixture = await createFixture({ - prerender: true, - files: { - ...files, + test.describe("ssr: false", () => { + test("Errors on headers/action functions in any route", async () => { + let cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ ssr: false, - prerender: true, + prerender: ["/", "/a"], }), - "app/routes/$slug.tsx": js` - import * as React from "react"; - import { useLoaderData } from "react-router"; - - export async function loader() { - return null; - } + "app/routes/a.tsx": String.raw` + // Invalid exports + export function headers() {} + export function action() {} + + // Valid exports + 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: 2 invalid route export(s) in `routes/a` when prerendering " + + "with `ssr:false`: headers, action. See https://reactrouter.com/how-to/spa for more information." + ); + }); - export default function Component() { - return

Slug

- } + 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` + 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 using `ssr:false` " + + "with `prerender` because the route is never prerendered so the loader " + + "will never be called. See https://reactrouter.com/how-to/spa for more " + + "information." + ); }); - appFixture = await createAppFixture(fixture); - let requests: string[] = []; - page.on("request", (request) => { - let pathname = new URL(request.url()).pathname; - if (pathname.endsWith(".data")) { - requests.push(pathname); - } + 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` + 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")) + ); + }); + + 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` + export function clientLoader() { + return "PAGE DATA" + } + export default function Page({ loaderData }) { + 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...

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

Loading...

"); }); - 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("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: string[] = []; + page.on("request", (request) => { + let pathname = new URL(request.url()).pathname; + if (pathname.endsWith(".data") || pathname.endsWith("__manifest")) { + requests.push(pathname); + } + }); + + 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` + import { Link } from 'react-router'; + export async function clientLoader() { + await new Promise(r => setTimeout(r, 1000)); + return "PAGE DATA" + } + export default function Page({ loaderData }) { + return ( + <> +

{loaderData}

+ Go to page2 + + ); + } + `, + "app/routes/page2.tsx": js` + export function clientLoader() { + return "PAGE2 DATA" + } + export default function Page({ loaderData }) { + 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" + ); + }); + + test("Properly 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"; + + export function loader() { + return "ROOT DATA"; + } + + export function Layout({ children }) { + return ( + + + + {children} + + + + ); + } + + export default function Root({ loaderData }) { + return ( + <> +

{loaderData}

+ + + ); + } + `, + "app/routes/_index.tsx": js` + import { Link } from 'react-router'; + export default function Index() { + return Go to page + } + `, + "app/routes/page.tsx": js` + import { Link, Form } from 'react-router'; + export async function loader() { + return "PAGE DATA" + } + let count = 0; + export function clientAction() { + return "PAGE ACTION " + (++count) + } + export default function Page({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to page2 +
+ +
+
+ +
+ + ); + } + `, + "app/routes/page2.tsx": js` + import { Form } from 'react-router'; + export function clientLoader() { + return "PAGE2 DATA" + } + let count = 0; + export function clientAction() { + return "PAGE2 ACTION " + (++count) + } + export default function Page({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} +
+ +
+
+ +
+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests: string[] = []; + page.on("request", (request) => { + let pathname = new URL(request.url()).pathname; + if (pathname.endsWith(".data") || pathname.endsWith("__manifest")) { + requests.push(pathname); + } + }); + + 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" + ); + + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 1" + ); + + await app.clickLink("/page2"); + await page.waitForSelector("[data-page2]"); + expect(await (await page.$("[data-page2]"))?.innerText()).toBe( + "PAGE2 DATA" + ); + + await app.clickSubmitButton("/page2"); + await page.waitForSelector("[data-page2-action]"); + expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( + "PAGE2 ACTION 1" + ); + + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 2" + ); + + await app.clickSubmitButton("/page2"); + await page.waitForSelector("[data-page2-action]"); + expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( + "PAGE2 ACTION 2" + ); + + // We should only make this call when navigating to the prerendered route + // 3 calls: + // - Initial navigation + // - Revalidation on submission to self + // - Revalidation after submission back from /page + expect(requests).toEqual(["/page.data", "/page.data", "/page.data"]); + }); + + test("Properly 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"; + + export function Layout({ children }) { + return ( + + + + {children} + + + + ); + } + + export default function Root({ loaderData }) { + return + } + `, + "app/routes/_index.tsx": js` + import { Link } from 'react-router'; + export default function Index() { + return Go to page + } + `, + "app/routes/page.tsx": js` + import { Link, Form } from 'react-router'; + export async function loader() { + return "PAGE DATA" + } + let count = 0; + export function clientAction() { + return "PAGE ACTION " + (++count) + } + export default function Page({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to page2 +
+ +
+
+ +
+ + ); + } + `, + "app/routes/page2.tsx": js` + import { Form } from 'react-router'; + export function clientLoader() { + return "PAGE2 DATA" + } + let count = 0; + export function clientAction() { + return "PAGE2 ACTION " + (++count) + } + export default function Page({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} +
+ +
+
+ +
+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests: string[] = []; + page.on("request", (request) => { + let pathname = new URL(request.url()).pathname; + if (pathname.endsWith(".data") || pathname.endsWith("__manifest")) { + requests.push(pathname); + } + }); + + 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" + ); + + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 1" + ); + + await app.clickLink("/page2"); + await page.waitForSelector("[data-page2]"); + expect(await (await page.$("[data-page2]"))?.innerText()).toBe( + "PAGE2 DATA" + ); + + await app.clickSubmitButton("/page2"); + await page.waitForSelector("[data-page2-action]"); + expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( + "PAGE2 ACTION 1" + ); + + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 2" + ); + + await app.clickSubmitButton("/page2"); + await page.waitForSelector("[data-page2-action]"); + expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( + "PAGE2 ACTION 2" + ); + + // We should only make this call when navigating to the prerendered route + // 3 calls: + // - Initial navigation + // - Revalidation on submission to self + // - Revalidation after submission back from /page + expect(requests).toEqual(["/page.data", "/page.data", "/page.data"]); + }); + + test("Properly 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"; + + export function Layout({ children }) { + return ( + + + + {children} + + + + ); + } + + export default function Root({ loaderData }) { + return ; + } + `, + "app/routes/_index.tsx": js` + import { Link } from 'react-router'; + export default function Index() { + return Go to page + } + `, + "app/routes/page.tsx": js` + import { Link, Form } from 'react-router'; + export async function loader() { + return "PAGE DATA" + } + let count = 0; + export function clientAction() { + return "PAGE ACTION " + (++count) + } + export default function Page({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to page2 +
+ +
+
+ +
+ + ); + } + `, + "app/routes/page2.tsx": js` + import { Form } from 'react-router'; + export function clientLoader() { + return "PAGE2 DATA" + } + let count = 0; + export function clientAction() { + return "PAGE2 ACTION " + (++count) + } + export default function Page({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} +
+ +
+
+ +
+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests: string[] = []; + page.on("request", (request) => { + let pathname = new URL(request.url()).pathname; + if (pathname.endsWith(".data") || pathname.endsWith("__manifest")) { + requests.push(pathname); + } + }); + + 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" + ); + + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 1" + ); + + await app.clickLink("/page2"); + await page.waitForSelector("[data-page2]"); + expect(await (await page.$("[data-page2]"))?.innerText()).toBe( + "PAGE2 DATA" + ); + + await app.clickSubmitButton("/page2"); + await page.waitForSelector("[data-page2-action]"); + expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( + "PAGE2 ACTION 1" + ); + + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 2" + ); + + await app.clickSubmitButton("/page2"); + await page.waitForSelector("[data-page2-action]"); + expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( + "PAGE2 ACTION 2" + ); + + // We should only make this call when navigating to the prerendered route + // 3 calls: + // - Initial navigation + // - Revalidation on submission to self + // - Revalidation after submission back from /page + expect(requests).toEqual(["/page.data", "/page.data", "/page.data"]); + }); + + test("Properly 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"; + + export function loader() { + return "ROOT DATA"; + } + + export function Layout({ children }) { + return ( + + + + {children} + + + + ); + } + + export default function Root({ loaderData }) { + return ( + <> +

{loaderData}

+ + + ); + } + `, + "app/routes/_index.tsx": js` + import { Link } from 'react-router'; + export default function Index() { + return Go to page + } + `, + "app/routes/page.tsx": js` + import { Link, Form } from 'react-router'; + export async function loader() { + return "PAGE DATA" + } + let count = 0; + export function clientAction() { + return "PAGE ACTION " + (++count) + } + export default function Page({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} + Go to page2 +
+ +
+
+ +
+ + ); + } + `, + "app/routes/page2.tsx": js` + import { Form } from 'react-router'; + export function clientLoader() { + return "PAGE2 DATA" + } + let count = 0; + export function clientAction() { + return "PAGE2 ACTION " + (++count) + } + export default function Page({ loaderData, actionData }) { + return ( + <> +

{loaderData}

+ {actionData ?

{actionData}

: null} +
+ +
+
+ +
+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests: string[] = []; + page.on("request", (request) => { + let pathname = new URL(request.url()).pathname; + if (pathname.endsWith(".data") || pathname.endsWith("__manifest")) { + requests.push(pathname); + } + }); + + 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" + ); + + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 1" + ); + + await app.clickLink("/page2"); + await page.waitForSelector("[data-page2]"); + expect(await (await page.$("[data-page2]"))?.innerText()).toBe( + "PAGE2 DATA" + ); + + await app.clickSubmitButton("/page2"); + await page.waitForSelector("[data-page2-action]"); + expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( + "PAGE2 ACTION 1" + ); + + await app.clickSubmitButton("/page"); + await page.waitForSelector("[data-page-action]"); + expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( + "PAGE ACTION 2" + ); + + await app.clickSubmitButton("/page2"); + await page.waitForSelector("[data-page2-action]"); + expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( + "PAGE2 ACTION 2" + ); + + // We should only make this call when navigating to the prerendered route + // 3 calls: + // - Initial navigation + // - Revalidation on submission to self + // - Revalidation after submission back from /page + expect(requests).toEqual(["/page.data", "/page.data", "/page.data"]); + }); + + 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"; + + export async function loader() { + return null; + } + + export default function Component() { + return

Slug

+ } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests: string[] = []; + page.on("request", (request) => { + let pathname = new URL(request.url()).pathname; + if (pathname.endsWith(".data")) { + requests.push(pathname); + } + }); + + 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"]); + }); }); }); diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index c47a890290..8ca8eb7c48 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -42,11 +42,43 @@ test.describe("SPA Mode", () => { let stderr = result.stderr.toString("utf8"); expect(stderr).toMatch( "SPA Mode: 3 invalid route export(s) in `routes/invalid-exports.tsx`: " + - "`headers`, `loader`, `action`. See https://remix.run/guides/spa-mode " + + "`headers`, `loader`, `action`. See https://reactrouter.com/how-to/spa " + "for more information." ); }); + test("allows loader in root route", async () => { + let cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: false, + splitRouteModules, + }), + "app/root.tsx": String.raw` + // Invalid exports + export function headers() {} + export function action() {} + + // Valid exports + export function loader() {} + export function clientLoader() {} + export function clientAction() {} + export default function Component() {} + `, + "app/routes/_index.tsx": String.raw` + // Valid exports + export function clientLoader() {} + export function clientAction() {} + export default function Component() {} + `, + }); + let result = build({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "SPA Mode: 2 invalid route export(s) in `root.tsx`: `headers`, `action`. " + + "See https://reactrouter.com/how-to/spa for more information." + ); + }); + test("errors on HydrateFallback export from non-root route", async () => { let cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ @@ -68,7 +100,7 @@ test.describe("SPA Mode", () => { expect(stderr).toMatch( "SPA Mode: Invalid `HydrateFallback` export found in `routes/invalid-exports.tsx`. " + "`HydrateFallback` is only permitted on the root route in SPA Mode. " + - "See https://remix.run/guides/spa-mode for more information." + "See https://reactrouter.com/how-to/spa for more information." ); }); @@ -135,7 +167,7 @@ test.describe("SPA Mode", () => { let stderr = result.stderr.toString("utf8"); expect(stderr).toMatch( "SPA Mode: Received a 500 status code from `entry.server.tsx` while " + - "prerendering the `/` path." + "prerendering your `index.html` file." ); expect(stderr).toMatch("

Loading...

"); }); @@ -155,8 +187,8 @@ test.describe("SPA Mode", () => { let result = build({ cwd }); let stderr = result.stderr.toString("utf8"); expect(stderr).toMatch( - "SPA Mode: Did you forget to include in your root route? " + - "Your pre-rendered HTML files cannot hydrate without ``." + "SPA Mode: Did you forget to include `` in your root route? " + + "Your pre-rendered HTML cannot hydrate without ``." ); }); }); @@ -637,7 +669,7 @@ test.describe("SPA Mode", () => { `, "app/root.tsx": js` import * as React from "react"; - import { Form, Link, Links, Meta, Outlet, Scripts } from "react-router"; + import { Form, Link, Links, Meta, Outlet, Scripts, useLoaderData } from "react-router"; export function meta({ data }) { return [{ @@ -652,6 +684,10 @@ test.describe("SPA Mode", () => { }]; } + export function loader() { + return { message: "Root Loader Data" }; + } + export default function Root() { let id = React.useId(); return ( @@ -693,6 +729,7 @@ test.describe("SPA Mode", () => { export function HydrateFallback() { const id = React.useId(); + const loaderData = useLoaderData(); const [hydrated, setHydrated] = React.useState(false); React.useEffect(() => setHydrated(true), []); @@ -704,6 +741,7 @@ test.describe("SPA Mode", () => {

Loading SPA...

+

{loaderData?.message}

{id}
{hydrated ?

Hydrated

: null} @@ -731,7 +769,7 @@ test.describe("SPA Mode", () => { export async function clientLoader({ request }) { if (new URL(request.url).searchParams.has('slow')) { - await new Promise(r => setTimeout(r, 500)); + await new Promise(r => setTimeout(r, 1000)); } return "Index Loader Data"; } @@ -812,10 +850,13 @@ test.describe("SPA Mode", () => { appFixture.close(); }); - test("renders the root HydrateFallback initially", async ({ page }) => { + test("renders the root HydrateFallback initially with access to the root loader data", async ({}) => { let res = await fixture.requestDocument("/"); let html = await res.text(); expect(html).toMatch('

Loading SPA...

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

Root Loader Data

' + ); }); test("does not include Meta/Links from routes below the root", async ({ @@ -862,20 +903,22 @@ test.describe("SPA Mode", () => { }); test("hydrates a proper useId value", async ({ page }) => { - // SSR'd useId value we can assert against pre- and post-hydration - let USE_ID_VALUE = ":R5:"; - // Ensure we SSR a proper useId value let res = await fixture.requestDocument("/"); let html = await res.text(); - expect(html).toMatch(`
${USE_ID_VALUE}
`); + expect(html).toMatch(/
(:[a-zA-Z]\d:)<\/pre>/);
+          let matches = /
(:[a-zA-Z]\d:)<\/pre>/.exec(
+            html
+          );
+          expect(matches?.length).toBe(2);
+          let useIdValue = matches?.[1];
 
           // We should hydrate the same useId value in HydrateFallback
           let app = new PlaywrightFixture(appFixture, page);
           await app.goto("/?slow");
           await page.waitForSelector("[data-hydrated]");
           expect(await page.locator("[data-use-id]").textContent()).toBe(
-            USE_ID_VALUE
+            useIdValue
           );
 
           // Once hydrated, we should get a different useId value from the root Component
@@ -884,7 +927,7 @@ test.describe("SPA Mode", () => {
             "Index"
           );
           expect(await page.locator("[data-use-id]").textContent()).not.toBe(
-            USE_ID_VALUE
+            useIdValue
           );
         });
 
diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts
index 1a70fa7f0a..a261c53d86 100644
--- a/packages/react-router-dev/vite/plugin.ts
+++ b/packages/react-router-dev/vite/plugin.ts
@@ -591,6 +591,12 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
       : // Otherwise, all routes are imported as usual
         ctx.reactRouterConfig.routes;
 
+    let prerenderPaths = await getPrerenderPaths(
+      ctx.reactRouterConfig.prerender,
+      ctx.reactRouterConfig.ssr,
+      routes
+    );
+
     return `
     import * as entryServer from ${JSON.stringify(
       resolveFileUrl(ctx, ctx.entryServerFilePath)
@@ -621,6 +627,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
       export const future = ${JSON.stringify(ctx.reactRouterConfig.future)};
       export const ssr = ${ctx.reactRouterConfig.ssr};
       export const isSpaMode = ${isSpaModeEnabled(ctx.reactRouterConfig)};
+      export const prerender = ${JSON.stringify(prerenderPaths)};
       export const publicPath = ${JSON.stringify(ctx.publicPath)};
       export const entry = { module: entryServer };
       export const routes = {
@@ -1385,10 +1392,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
             );
           }
 
-          if (
-            ctx.reactRouterConfig.prerender != null &&
-            ctx.reactRouterConfig.prerender !== false
-          ) {
+          if (isPrerenderingEnabled(ctx.reactRouterConfig)) {
             // If we have prerender routes, that takes precedence over SPA mode
             // which is ssr:false and only the rot route being rendered
             await handlePrerender(
@@ -1400,7 +1404,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
             );
           }
 
-          // If we are in SPA mode, *always* generate the `index.html` that can be served at any location for hydration.
+          // 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) {
             await handleSpaMode(
               viteConfig,
@@ -1633,6 +1639,17 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
                   ).reactRouterServerManifest
                 : await getReactRouterManifestForDev();
 
+            // Check for invalid APIs when SSR is disabled
+            if (!ctx.reactRouterConfig.ssr) {
+              invariant(viteConfig);
+              validateSsrFalsePrerenderExports(
+                viteConfig,
+                ctx,
+                reactRouterManifest,
+                viteChildCompiler
+              );
+            }
+
             return `export default ${jsesc(reactRouterManifest, {
               es6: true,
             })};`;
@@ -1755,14 +1772,19 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
 
         if (!options?.ssr && isSpaModeEnabled(ctx.reactRouterConfig)) {
           let exportNames = getExportNames(code);
-          let serverOnlyExports = exportNames.filter((exp) =>
-            SERVER_ONLY_ROUTE_EXPORTS.includes(exp)
-          );
+          let serverOnlyExports = exportNames.filter((exp) => {
+            // Root route can have a loader in SPA mode
+            if (route.id === "root" && exp === "loader") {
+              return false;
+            }
+            return SERVER_ONLY_ROUTE_EXPORTS.includes(exp);
+          });
+
           if (serverOnlyExports.length > 0) {
             let str = serverOnlyExports.map((e) => `\`${e}\``).join(", ");
             let message =
               `SPA Mode: ${serverOnlyExports.length} invalid route export(s) in ` +
-              `\`${route.file}\`: ${str}. See https://remix.run/guides/spa-mode ` +
+              `\`${route.file}\`: ${str}. See https://reactrouter.com/how-to/spa ` +
               `for more information.`;
             throw Error(message);
           }
@@ -1775,7 +1797,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
               let message =
                 `SPA Mode: Invalid \`HydrateFallback\` export found in ` +
                 `\`${route.file}\`. \`HydrateFallback\` is only permitted on ` +
-                `the root route in SPA Mode. See https://remix.run/guides/spa-mode ` +
+                `the root route in SPA Mode. See https://reactrouter.com/how-to/spa ` +
                 `for more information.`;
               throw Error(message);
             }
@@ -2160,16 +2182,34 @@ async function getRouteMetadata(
   return info;
 }
 
+function isPrerenderingEnabled(
+  reactRouterConfig: ReactRouterPluginContext["reactRouterConfig"]
+) {
+  return (
+    reactRouterConfig.prerender != null && reactRouterConfig.prerender !== false
+  );
+}
+
 function isSpaModeEnabled(
   reactRouterConfig: ReactRouterPluginContext["reactRouterConfig"]
 ) {
+  // "SPA Mode" is possible in 2 ways:
+  //  - `ssr:false` and no `prerender` config (undefined or null)
+  //  - `ssr:false` and `prerender: false`
+  //    - not an expected config but since we support `prerender:true` we allow it
+  //
+  // "SPA Mode" means we will only prerender a *single* `index.html` file which
+  // prerenders only to the root route and thus can hydrate for _any_ path and
+  // the proper routes below the root will be loaded via `route.lazy` during
+  // hydration.
+  //
+  // If `ssr:false` is specified and the user provided a `prerender` config -
+  // then it's no longer a "SPA" because we are generating multiple HTML pages.
+  // It's now a MPA and we can prerender down past the root, which unlocks the
+  // ability to use loaders on any routes and prerender the UI with build-time
+  // loaderData
   return (
-    reactRouterConfig.ssr === false &&
-    (reactRouterConfig.prerender == null ||
-      reactRouterConfig.prerender === false ||
-      (Array.isArray(reactRouterConfig.prerender) &&
-        reactRouterConfig.prerender.length === 1 &&
-        reactRouterConfig.prerender[0] === "/"))
+    reactRouterConfig.ssr === false && !isPrerenderingEnabled(reactRouterConfig)
   );
 }
 
@@ -2194,26 +2234,61 @@ async function handleSpaMode(
   serverBuildFile: string,
   clientBuildDirectory: string
 ) {
-  let { handler } = await getPrerenderBuildAndHandler(
+  let { build, handler } = await getPrerenderBuildAndHandler(
     viteConfig,
     serverBuildDirectory,
     serverBuildFile
   );
-  let request = new Request(`http://localhost${reactRouterConfig.basename}`);
+  let 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",
+    },
+  });
   let response = await handler(request);
   let html = await response.text();
 
-  validatePrerenderedResponse(response, html, "SPA Mode", "/");
-  validatePrerenderedHtml(html, "SPA Mode");
+  // If the user prerendered `/`, then we write this out to a separate file
+  // they can serve.  Otherwise it can be the main entry point.
+  let isPrerenderSpaFallback = build.prerender.includes("/");
+  let filename = isPrerenderSpaFallback ? "__spa-fallback.html" : "index.html";
+  if (response.status !== 200) {
+    if (isPrerenderSpaFallback) {
+      throw new Error(
+        `Prerender: Received a ${response.status} status code from ` +
+          `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` +
+          html
+      );
+    } else {
+      throw new Error(
+        `SPA Mode: Received a ${response.status} status code from ` +
+          `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` +
+          html
+      );
+    }
+  }
 
-  // Write out the index.html file for the SPA
-  await fse.writeFile(path.join(clientBuildDirectory, "index.html"), 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 ``."
+    );
+  }
 
-  viteConfig.logger.info(
-    "SPA Mode: index.html has been written to your " +
-      colors.bold(path.relative(process.cwd(), clientBuildDirectory)) +
-      " directory"
-  );
+  // Write out the HTML file for the SPA
+  await fse.writeFile(path.join(clientBuildDirectory, filename), html);
+  let prettyDir = path.relative(process.cwd(), clientBuildDirectory);
+  let prettyPath = path.join(prettyDir, filename);
+  if (build.prerender.length > 0) {
+    viteConfig.logger.info(
+      `Prerender (html): SPA Fallback -> ${colors.bold(prettyPath)}`
+    );
+  } else {
+    viteConfig.logger.info(`SPA Mode: Generated ${colors.bold(prettyPath)}`);
+  }
 }
 
 async function handlePrerender(
@@ -2230,31 +2305,21 @@ async function handlePrerender(
   );
 
   let routes = createPrerenderRoutes(build.routes);
-  let routesToPrerender: string[];
-  if (typeof reactRouterConfig.prerender === "boolean") {
-    invariant(reactRouterConfig.prerender, "Expected prerender:true");
-    routesToPrerender = determineStaticPrerenderRoutes(
-      routes,
-      viteConfig,
-      true
-    );
-  } else if (typeof reactRouterConfig.prerender === "function") {
-    routesToPrerender = await reactRouterConfig.prerender({
-      getStaticPaths: () =>
-        determineStaticPrerenderRoutes(routes, viteConfig, false),
-    });
-  } else {
-    routesToPrerender = reactRouterConfig.prerender || ["/"];
-  }
   let headers = {
     // Header that can be used in the loader to know if you're running at
     // build time or runtime
     "X-React-Router-Prerender": "yes",
   };
-  for (let path of routesToPrerender) {
+  for (let path of build.prerender) {
     // Ensure we have a leading slash for matching
     let matches = matchRoutes(routes, `/${path}/`.replace(/^\/\/+/, "/"));
-    let hasLoaders = matches?.some((m) => m.route.loader);
+    invariant(
+      matches,
+      `Unable to prerender path because it does not match any routes: ${path}`
+    );
+    let hasLoaders = matches.some(
+      (m) => build.assets.routes[m.route.id]?.hasLoader
+    );
     let data: string | undefined;
     if (hasLoaders) {
       data = await prerenderData(
@@ -2309,11 +2374,7 @@ async function handlePrerender(
   }
 }
 
-function determineStaticPrerenderRoutes(
-  routes: DataRouteObject[],
-  viteConfig: Vite.ResolvedConfig,
-  isBooleanUsage = false
-): string[] {
+function getStaticPrerenderPaths(routes: DataRouteObject[]) {
   // Always start with the root/index route included
   let paths: string[] = ["/"];
   let paramRoutes: string[] = [];
@@ -2337,18 +2398,11 @@ function determineStaticPrerenderRoutes(
   }
   recurse(routes);
 
-  if (isBooleanUsage && paramRoutes.length > 0) {
-    viteConfig.logger.warn(
-      [
-        "⚠️ 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:",
-        ...paramRoutes.map((p) => "  - " + p),
-      ].join("\n")
-    );
-  }
-
   // Clean double slashes and remove trailing slashes
-  return paths.map((p) => p.replace(/\/\/+/g, "/").replace(/(.+)\/$/, "$1"));
+  return {
+    paths: paths.map((p) => p.replace(/\/\/+/g, "/").replace(/(.+)\/$/, "$1")),
+    paramRoutes,
+  };
 }
 
 async function prerenderData(
@@ -2368,14 +2422,22 @@ async function prerenderData(
   let response = await handler(request);
   let data = await response.text();
 
-  validatePrerenderedResponse(response, data, "Prerender", normalizedPath);
+  if (response.status !== 200) {
+    throw new Error(
+      `Prerender (data): Received a ${response.status} status code from ` +
+        `\`entry.server.tsx\` while prerendering the \`${path}\` ` +
+        `path.\n${normalizedPath}`
+    );
+  }
 
   // Write out the .data file
   let outdir = path.relative(process.cwd(), clientBuildDirectory);
   let outfile = path.join(outdir, ...normalizedPath.split("/"));
   await fse.ensureDir(path.dirname(outfile));
   await fse.outputFile(outfile, data);
-  viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`);
+  viteConfig.logger.info(
+    `Prerender (data): ${prerenderPath} -> ${colors.bold(outfile)}`
+  );
   return data;
 }
 
@@ -2395,10 +2457,12 @@ async function prerenderRoute(
   let response = await handler(request);
   let html = await response.text();
 
-  validatePrerenderedResponse(response, html, "Prerender", normalizedPath);
-
-  if (!reactRouterConfig.ssr) {
-    validatePrerenderedHtml(html, "Prerender");
+  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}`
+    );
   }
 
   // Write out the HTML file
@@ -2406,7 +2470,9 @@ async function prerenderRoute(
   let outfile = path.join(outdir, ...normalizedPath.split("/"), "index.html");
   await fse.ensureDir(path.dirname(outfile));
   await fse.outputFile(outfile, html);
-  viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`);
+  viteConfig.logger.info(
+    `Prerender (html): ${prerenderPath} -> ${colors.bold(outfile)}`
+  );
 }
 
 async function prerenderResourceRoute(
@@ -2424,50 +2490,67 @@ async function prerenderResourceRoute(
   let response = await handler(request);
   let text = await response.text();
 
-  validatePrerenderedResponse(response, text, "Prerender", normalizedPath);
+  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${text}`
+    );
+  }
 
   // Write out the resource route file
   let outdir = path.relative(process.cwd(), clientBuildDirectory);
   let outfile = path.join(outdir, ...normalizedPath.split("/"));
   await fse.ensureDir(path.dirname(outfile));
   await fse.outputFile(outfile, text);
-  viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`);
+  viteConfig.logger.info(
+    `Prerender (resource): ${prerenderPath} -> ${colors.bold(outfile)}`
+  );
 }
 
-function validatePrerenderedResponse(
-  response: Response,
-  html: string,
-  prefix: string,
-  path: string
-) {
-  if (response.status !== 200) {
-    throw new Error(
-      `${prefix}: Received a ${response.status} status code from ` +
-        `\`entry.server.tsx\` while prerendering the \`${path}\` ` +
-        `path.\n${html}`
-    );
-  }
+// Allows us to use both the RouteManifest and the ServerRouteManifest from the build
+export interface GenericRouteManifest {
+  [routeId: string]: Omit | undefined;
 }
 
-function validatePrerenderedHtml(html: string, prefix: string) {
-  if (
-    !html.includes("window.__reactRouterContext =") ||
-    !html.includes("window.__reactRouterRouteModules =")
-  ) {
-    throw new Error(
-      `${prefix}: Did you forget to include  in your root route? ` +
-        "Your pre-rendered HTML files cannot hydrate without ``."
-    );
+export async function getPrerenderPaths(
+  prerender: ResolvedReactRouterConfig["prerender"],
+  ssr: ResolvedReactRouterConfig["ssr"],
+  routes: GenericRouteManifest,
+  logWarning = false
+): Promise {
+  let prerenderPaths: string[] = [];
+  if (prerender != null && prerender !== false) {
+    let prerenderRoutes = createPrerenderRoutes(routes);
+    if (prerender === true) {
+      let { paths, paramRoutes } = getStaticPrerenderPaths(prerenderRoutes);
+      if (logWarning && !ssr && paramRoutes.length > 0) {
+        console.warn(
+          colors.yellow(
+            [
+              "⚠️ 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:",
+              ...paramRoutes.map((p) => "  - " + p),
+            ].join("\n")
+          )
+        );
+      }
+      prerenderPaths = paths;
+    } else if (typeof prerender === "function") {
+      prerenderPaths = await prerender({
+        getStaticPaths: () => getStaticPrerenderPaths(prerenderRoutes).paths,
+      });
+    } else {
+      prerenderPaths = prerender || ["/"];
+    }
   }
+  return prerenderPaths;
 }
 
-type ServerRoute = ServerBuild["routes"][string] & {
-  children: ServerRoute[];
-};
-
 // Note: Duplicated from react-router/lib/server-runtime
-function groupRoutesByParentId(manifest: ServerBuild["routes"]) {
-  let routes: Record[]> = {};
+function groupRoutesByParentId(manifest: GenericRouteManifest) {
+  let routes: Record[]> = {};
 
   Object.values(manifest).forEach((route) => {
     if (route) {
@@ -2482,40 +2565,106 @@ function groupRoutesByParentId(manifest: ServerBuild["routes"]) {
   return routes;
 }
 
-// Note: Duplicated from react-router/lib/server-runtime
+// Create a skeleton route tree of paths
 function createPrerenderRoutes(
-  manifest: ServerBuild["routes"],
+  manifest: GenericRouteManifest,
   parentId: string = "",
-  routesByParentId: Record<
-    string,
-    Omit[]
-  > = groupRoutesByParentId(manifest)
+  routesByParentId = groupRoutesByParentId(manifest)
 ): DataRouteObject[] {
   return (routesByParentId[parentId] || []).map((route) => {
     let commonRoute = {
-      // Always include root due to default boundaries
-      hasErrorBoundary:
-        route.id === "root" || route.module.ErrorBoundary != null,
       id: route.id,
       path: route.path,
-      loader: route.module.loader ? () => null : undefined,
-      action: undefined,
-      handle: route.module.handle,
     };
 
-    return route.index
-      ? {
-          index: true,
-          ...commonRoute,
-        }
-      : {
-          caseSensitive: route.caseSensitive,
-          children: createPrerenderRoutes(manifest, route.id, routesByParentId),
-          ...commonRoute,
-        };
+    if (route.index) {
+      return {
+        index: true,
+        ...commonRoute,
+      };
+    }
+
+    return {
+      children: createPrerenderRoutes(manifest, route.id, routesByParentId),
+      ...commonRoute,
+    };
   });
 }
 
+async function validateSsrFalsePrerenderExports(
+  viteConfig: Vite.ResolvedConfig,
+  ctx: ReactRouterPluginContext,
+  manifest: ReactRouterManifest,
+  viteChildCompiler: Vite.ViteDevServer | null
+) {
+  let prerenderPaths = await getPrerenderPaths(
+    ctx.reactRouterConfig.prerender,
+    ctx.reactRouterConfig.ssr,
+    manifest.routes,
+    true
+  );
+
+  if (prerenderPaths.length === 0) {
+    return;
+  }
+
+  // Identify all routes used by a prerender path
+  let prerenderRoutes = createPrerenderRoutes(manifest.routes);
+  let prerenderedRoutes = new Set();
+  for (let path of prerenderPaths) {
+    // Ensure we have a leading slash for matching
+    let matches = matchRoutes(
+      prerenderRoutes,
+      `/${path}/`.replace(/^\/\/+/, "/")
+    );
+    invariant(
+      matches,
+      `Unable to prerender path because it does not match any routes: ${path}`
+    );
+    matches.forEach((m) => prerenderedRoutes.add(m.route.id));
+  }
+
+  // Identify invalid exports
+  let errors: string[] = [];
+  let routeExports = await getRouteManifestModuleExports(
+    viteChildCompiler,
+    ctx
+  );
+  for (let [routeId, route] of Object.entries(manifest.routes)) {
+    let invalidApis: string[] = [];
+    invariant(route, "Expected a route object in validateSsrFalseExports");
+    let exports = routeExports[route.id];
+
+    // `headers`/`action` are never valid without SSR
+    if (exports.includes("headers")) invalidApis.push("headers");
+    if (exports.includes("action")) invalidApis.push("action");
+    if (invalidApis.length > 0) {
+      errors.push(
+        `Prerender: ${invalidApis.length} invalid route export(s) in ` +
+          `\`${route.id}\` when prerendering with \`ssr:false\`: ` +
+          `${invalidApis.join(", ")}.  ` +
+          "See https://reactrouter.com/how-to/spa for more information."
+      );
+    }
+
+    // `loader` is only valid if the route is matched by a `prerender` path
+    if (exports.includes("loader") && !prerenderedRoutes.has(routeId)) {
+      errors.push(
+        `Prerender: 1 invalid route export in \`${route.id}\` when ` +
+          "using `ssr:false` with `prerender` because the route is never " +
+          "prerendered so the loader will never be called.  " +
+          "See https://reactrouter.com/how-to/spa for more information."
+      );
+    }
+  }
+  if (errors.length > 0) {
+    viteConfig.logger.error(colors.red(errors.join("\n")));
+    throw new Error(
+      "Invalid route exports found when prerendering with `ssr:false`"
+    );
+  }
+}
+
 function getAddressableRoutes(routes: RouteManifest): RouteManifestEntry[] {
   let nonAddressableIds = new Set();
 
diff --git a/packages/react-router-dev/vite/static/refresh-utils.cjs b/packages/react-router-dev/vite/static/refresh-utils.cjs
index 02b0f8358f..9bdb6014a2 100644
--- a/packages/react-router-dev/vite/static/refresh-utils.cjs
+++ b/packages/react-router-dev/vite/static/refresh-utils.cjs
@@ -53,7 +53,7 @@ const enqueueUpdate = debounce(async () => {
       needsRevalidation,
       manifest.routes,
       window.__reactRouterRouteModules,
-      window.__reactRouterContext.future,
+      window.__reactRouterContext.ssr,
       window.__reactRouterContext.isSpaMode
     );
     __reactRouterDataRouter._internalSetRoutes(routes);
diff --git a/packages/react-router-dev/vite/with-props.ts b/packages/react-router-dev/vite/with-props.ts
index cdc247b35a..f4a8c0f8ff 100644
--- a/packages/react-router-dev/vite/with-props.ts
+++ b/packages/react-router-dev/vite/with-props.ts
@@ -37,6 +37,8 @@ export const plugin: Plugin = {
         return function Wrapped() {
           const props = {
             params: useParams(),
+            loaderData: useLoaderData(),
+            actionData: useActionData(),
           };
           return h(HydrateFallback, props);
         };
diff --git a/packages/react-router/__tests__/server-runtime/data-test.ts b/packages/react-router/__tests__/server-runtime/data-test.ts
index 4e29ad03e0..ba0cbe11f3 100644
--- a/packages/react-router/__tests__/server-runtime/data-test.ts
+++ b/packages/react-router/__tests__/server-runtime/data-test.ts
@@ -22,6 +22,7 @@ describe("loaders", () => {
         },
       },
       entry: { module: {} },
+      prerender: [],
     } as unknown as ServerBuild;
 
     let handler = createRequestHandler(build);
diff --git a/packages/react-router/__tests__/server-runtime/handle-error-test.ts b/packages/react-router/__tests__/server-runtime/handle-error-test.ts
index b7d0f0f8cf..4061633265 100644
--- a/packages/react-router/__tests__/server-runtime/handle-error-test.ts
+++ b/packages/react-router/__tests__/server-runtime/handle-error-test.ts
@@ -24,6 +24,7 @@ function getHandler(routeModule = {}, entryServerModule = {}) {
       },
     },
     future: {},
+    prerender: [],
   } as unknown as ServerBuild;
 
   return {
diff --git a/packages/react-router/__tests__/server-runtime/handler-test.ts b/packages/react-router/__tests__/server-runtime/handler-test.ts
index 17f56aa69a..efd433b7d1 100644
--- a/packages/react-router/__tests__/server-runtime/handler-test.ts
+++ b/packages/react-router/__tests__/server-runtime/handler-test.ts
@@ -17,6 +17,7 @@ describe("createRequestHandler", () => {
       entry: { module: {} as any },
       // @ts-expect-error
       future: {},
+      prerender: [],
     });
 
     let response = await handler(
diff --git a/packages/react-router/__tests__/server-runtime/server-test.ts b/packages/react-router/__tests__/server-runtime/server-test.ts
index ca5bd2f875..4b9290e30b 100644
--- a/packages/react-router/__tests__/server-runtime/server-test.ts
+++ b/packages/react-router/__tests__/server-runtime/server-test.ts
@@ -60,6 +60,7 @@ describe.skip("server", () => {
       },
     },
     future: {},
+    prerender: [],
   } as unknown as ServerBuild;
 
   describe("createRequestHandler", () => {
diff --git a/packages/react-router/__tests__/server-runtime/utils.ts b/packages/react-router/__tests__/server-runtime/utils.ts
index cac6802bf5..e38e73ca66 100644
--- a/packages/react-router/__tests__/server-runtime/utils.ts
+++ b/packages/react-router/__tests__/server-runtime/utils.ts
@@ -36,6 +36,7 @@ export function mockServerBuild(
     future: {
       ...opts.future,
     },
+    prerender: [],
     assets: {
       entry: {
         imports: [""],
diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx
index b6026ebdf3..e7f4d7a264 100644
--- a/packages/react-router/lib/dom-export/hydrated-router.tsx
+++ b/packages/react-router/lib/dom-export/hydrated-router.tsx
@@ -102,11 +102,17 @@ function createHydratedRouter(): DataRouter {
     ssrInfo.manifest.routes,
     ssrInfo.routeModules,
     ssrInfo.context.state,
+    ssrInfo.context.ssr,
     ssrInfo.context.isSpaMode
   );
 
   let hydrationData: HydrationState | undefined = undefined;
-  if (!ssrInfo.context.isSpaMode) {
+  let loaderData = ssrInfo.context.state.loaderData;
+  if (ssrInfo.context.isSpaMode) {
+    // In SPA mode we hydrate in any build-time loader data which should be
+    // limited to the root route
+    hydrationData = { loaderData };
+  } else {
     // Create a shallow clone of `loaderData` we can mutate for partial hydration.
     // When a route exports a `clientLoader` and a `HydrateFallback`, the SSR will
     // render the fallback so we need the client to do the same for hydration.
@@ -115,7 +121,7 @@ function createHydratedRouter(): DataRouter {
     // `createBrowserRouter` so it initializes and runs the client loaders.
     hydrationData = {
       ...ssrInfo.context.state,
-      loaderData: { ...ssrInfo.context.state.loaderData },
+      loaderData: { ...loaderData },
     };
     let initialMatches = matchRoutes(
       routes,
@@ -171,6 +177,7 @@ function createHydratedRouter(): DataRouter {
     dataStrategy: getSingleFetchDataStrategy(
       ssrInfo.manifest,
       ssrInfo.routeModules,
+      ssrInfo.context.ssr,
       () => router
     ),
     patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction(
diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts
index 55a469e1c8..ee251dc5c1 100644
--- a/packages/react-router/lib/dom/ssr/fog-of-war.ts
+++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts
@@ -86,6 +86,7 @@ export function getPatchRoutesOnNavigationFunction(
       [path],
       manifest,
       routeModules,
+      ssr,
       isSpaMode,
       basename,
       patch,
@@ -150,6 +151,7 @@ export function useFogOFWarDiscovery(
           lazyPaths,
           manifest,
           routeModules,
+          ssr,
           isSpaMode,
           router.basename,
           router.patchRoutes
@@ -183,6 +185,7 @@ export async function fetchAndApplyManifestPatches(
   paths: string[],
   manifest: AssetsManifest,
   routeModules: RouteModules,
+  ssr: boolean,
   isSpaMode: boolean,
   basename: string | undefined,
   patchRoutes: DataRouter["patchRoutes"],
@@ -244,7 +247,7 @@ export async function fetchAndApplyManifestPatches(
   parentIds.forEach((parentId) =>
     patchRoutes(
       parentId || null,
-      createClientRoutes(patches, routeModules, null, isSpaMode, parentId)
+      createClientRoutes(patches, routeModules, null, ssr, isSpaMode, parentId)
     )
   );
 }
diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx
index f5896cc415..db037143ca 100644
--- a/packages/react-router/lib/dom/ssr/routes.tsx
+++ b/packages/react-router/lib/dom/ssr/routes.tsx
@@ -173,13 +173,14 @@ export function createClientRoutesWithHMRRevalidationOptOut(
   manifest: RouteManifest,
   routeModulesCache: RouteModules,
   initialState: HydrationState,
-  future: FutureConfig,
+  ssr: boolean,
   isSpaMode: boolean
 ) {
   return createClientRoutes(
     manifest,
     routeModulesCache,
     initialState,
+    ssr,
     isSpaMode,
     "",
     groupRoutesByParentId(manifest),
@@ -199,14 +200,14 @@ function preventInvalidServerHandlerCall(
     throw new ErrorResponseImpl(400, "Bad Request", new Error(msg), true);
   }
 
-  let fn = type === "action" ? "serverAction()" : "serverLoader()";
-  let msg =
-    `You are trying to call ${fn} on a route that does not have a server ` +
-    `${type} (routeId: "${route.id}")`;
   if (
     (type === "loader" && !route.hasLoader) ||
     (type === "action" && !route.hasAction)
   ) {
+    let fn = type === "action" ? "serverAction()" : "serverLoader()";
+    let msg =
+      `You are trying to call ${fn} on a route that does not have a server ` +
+      `${type} (routeId: "${route.id}")`;
     console.error(msg);
     throw new ErrorResponseImpl(400, "Bad Request", new Error(msg), true);
   }
@@ -228,6 +229,7 @@ export function createClientRoutes(
   manifest: RouteManifest,
   routeModulesCache: RouteModules,
   initialState: HydrationState | null,
+  ssr: boolean,
   isSpaMode: boolean,
   parentId: string = "",
   routesByParentId: Record<
@@ -315,7 +317,8 @@ export function createClientRoutes(
         handle: routeModule.handle,
         shouldRevalidate: getShouldRevalidateFunction(
           routeModule,
-          route.id,
+          route,
+          ssr,
           needsRevalidation
         ),
       });
@@ -347,7 +350,6 @@ export function createClientRoutes(
               "No `routeModule` available for critical-route loader"
             );
             if (!routeModule.clientLoader) {
-              if (isSpaMode) return null;
               // Call the server when no client loader exists
               return fetchServerLoader(singleFetch);
             }
@@ -424,7 +426,6 @@ export function createClientRoutes(
           singleFetch?: unknown
         ) =>
           prefetchStylesAndCallHandler(() => {
-            if (isSpaMode) return Promise.resolve(null);
             return fetchServerLoader(singleFetch);
           });
       } else if (route.clientLoaderModule) {
@@ -536,7 +537,8 @@ export function createClientRoutes(
           hasErrorBoundary: lazyRoute.hasErrorBoundary,
           shouldRevalidate: getShouldRevalidateFunction(
             lazyRoute,
-            route.id,
+            route,
+            ssr,
             needsRevalidation
           ),
           handle: lazyRoute.handle,
@@ -552,6 +554,7 @@ export function createClientRoutes(
       manifest,
       routeModulesCache,
       initialState,
+      ssr,
       isSpaMode,
       route.id,
       routesByParentId,
@@ -564,21 +567,37 @@ export function createClientRoutes(
 
 function getShouldRevalidateFunction(
   route: Partial,
-  routeId: string,
+  manifestRoute: Omit,
+  ssr: boolean,
   needsRevalidation: Set | undefined
 ) {
   // During HDR we force revalidation for updated routes
   if (needsRevalidation) {
     return wrapShouldRevalidateForHdr(
-      routeId,
+      manifestRoute.id,
       route.shouldRevalidate,
       needsRevalidation
     );
   }
 
+  // When ssr is false and the root route has a `loader` without a
+  // `clientLoader`, the `loader` data is static because it was rendered
+  // at build time so we can just turn off revalidations.  That way when
+  // submitting to a clientAction on a non-pre-rendered path, we don't
+  // try to reach out for a non-existent `.data` file which would have
+  // the "revalidated" root data
+  if (
+    !ssr &&
+    manifestRoute.id === "root" &&
+    manifestRoute.hasLoader &&
+    !manifestRoute.hasClientLoader
+  ) {
+    return () => false;
+  }
+
   // Single fetch revalidates by default, so override the RR default value which
   // matches the multi-fetch behavior with `true`
-  if (route.shouldRevalidate) {
+  if (ssr && route.shouldRevalidate) {
     let fn = route.shouldRevalidate;
     return (opts: ShouldRevalidateFunctionArgs) =>
       fn({ ...opts, defaultShouldRevalidate: true });
diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx
index dfb9227372..0093462ba9 100644
--- a/packages/react-router/lib/dom/ssr/single-fetch.tsx
+++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx
@@ -134,6 +134,7 @@ export function StreamTransfer({
 export function getSingleFetchDataStrategy(
   manifest: AssetsManifest,
   routeModules: RouteModules,
+  ssr: boolean,
   getRouter: () => DataRouter
 ): DataStrategyFunction {
   return async ({ request, matches, fetcherKey }) => {
@@ -142,6 +143,54 @@ export function getSingleFetchDataStrategy(
       return singleFetchActionStrategy(request, matches);
     }
 
+    if (!ssr) {
+      // If this is SPA mode, there won't be any loaders below root and we'll
+      // disable single fetch.  We have to keep the `dataStrategy` defined for
+      // SPA mode because we may load a SPA fallback page but then navigate into
+      // a pre-rendered path and need to fetch the pre-rendered `.data` file.
+      //
+      // If this is `ssr:false` with a `prerender` config, we need to keep single
+      // fetch enabled because we can prerender the `.data` files at build time
+      // and load them from a static file server/CDN at runtime.
+      //
+      // However, with the SPA Fallback logic, we can have SPA routes operating
+      // within a pre-rendered application and even if all the children have
+      // `clientLoaders`, if the root route has a `loader` then the default
+      // behavior would be to make the single fetch `.data` request on
+      // navigation to get the updated root `loader` data.
+      //
+      // We need to detect these scenarios because if it's a non-pre-rendered
+      // route being handled by SPA mode, then the `.data` file won't have been
+      // pre-generated and it'll cause a 404.  Thankfully, we can do this
+      // without knowing the prerender'd paths and can just do loader detection
+      // from the manifest:
+      // - We only allow loaders on pre-rendered routes at build time
+      // - We always let the root route have a loader which will be called at
+      //   build time for _all_ of our pre-rendered pages and the SPA Fallback
+      // - The root loader data will be static so since we already have it in
+      //   the client we never need to revalidate it
+      // - So the only time we need to make the request is if we find a loader
+      //   _below_ the root
+      // - If we find this, we know the route must have been pre-rendered at
+      //   build time since the loader would have errored otherwise
+      // - So it's safe to make the call knowing there will be a .data file on
+      //   the other end
+      let foundLoaderBelowRoot = matches.some(
+        (m) => m.route.id !== "root" && manifest.routes[m.route.id]?.hasLoader
+      );
+      if (!foundLoaderBelowRoot) {
+        // Skip single fetch and just call the loaders in parallel when this is
+        // a SPA mode navigation
+        let matchesToLoad = matches.filter((m) => m.shouldLoad);
+        let results = await Promise.all(matchesToLoad.map((m) => m.resolve()));
+        return results.reduce(
+          (acc, result, i) =>
+            Object.assign(acc, { [matchesToLoad[i].route.id]: result }),
+          {}
+        );
+      }
+    }
+
     // Fetcher loads are singular calls to one loader
     if (fetcherKey) {
       return singleFetchLoaderFetcherStrategy(request, matches);
@@ -151,6 +200,7 @@ export function getSingleFetchDataStrategy(
     return singleFetchLoaderNavigationStrategy(
       manifest,
       routeModules,
+      ssr,
       getRouter(),
       request,
       matches
@@ -200,6 +250,7 @@ async function singleFetchActionStrategy(
 async function singleFetchLoaderNavigationStrategy(
   manifest: AssetsManifest,
   routeModules: RouteModules,
+  ssr: boolean,
   router: DataRouter,
   request: Request,
   matches: DataStrategyFunctionArgs["matches"]
@@ -323,7 +374,7 @@ async function singleFetchLoaderNavigationStrategy(
       // When one or more routes have opted out, we add a _routes param to
       // limit the loaders to those that have a server loader and did not
       // opt out
-      if (foundOptOutRoute && routesParams.size > 0) {
+      if (ssr && foundOptOutRoute && routesParams.size > 0) {
         url.searchParams.set(
           "_routes",
           matches
diff --git a/packages/react-router/lib/server-runtime/build.ts b/packages/react-router/lib/server-runtime/build.ts
index bcbe4f6791..1c7db78a17 100644
--- a/packages/react-router/lib/server-runtime/build.ts
+++ b/packages/react-router/lib/server-runtime/build.ts
@@ -21,7 +21,11 @@ export interface ServerBuild {
   assetsBuildDirectory: string;
   future: FutureConfig;
   ssr: boolean;
+  /**
+   * @deprecated This is now done via a custom header during prerendering
+   */
   isSpaMode: boolean;
+  prerender: string[];
 }
 
 export interface HandleDocumentRequestFunction {
diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts
index 0291cfe7a8..109c5225d3 100644
--- a/packages/react-router/lib/server-runtime/server.ts
+++ b/packages/react-router/lib/server-runtime/server.ts
@@ -1,4 +1,4 @@
-import type { StaticHandler } from "../router/router";
+import type { StaticHandler, StaticHandlerContext } from "../router/router";
 import type { ErrorResponse } from "../router/utils";
 import { isRouteErrorResponse, ErrorResponseImpl } from "../router/utils";
 import {
@@ -107,6 +107,12 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
     }
 
     let url = new URL(request.url);
+    let normalizedPath = url.pathname
+      .replace(/\.data$/, "")
+      .replace(/^\/_root$/, "/");
+    if (normalizedPath !== "/" && normalizedPath.endsWith("/")) {
+      normalizedPath = normalizedPath.slice(0, -1);
+    }
     let params: RouteMatch["params"] = {};
     let handleError = (error: unknown) => {
       if (mode === ServerMode.Development) {
@@ -120,6 +126,41 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
       });
     };
 
+    // When runtime SSR is disabled, make our dev server behave like the deployed
+    // pre-rendered site would
+    if (!_build.ssr) {
+      if (_build.prerender.length === 0) {
+        // Add the header if we're in SPA mode
+        request.headers.set("X-React-Router-SPA-Mode", "yes");
+      } else if (
+        !_build.prerender.includes(normalizedPath) &&
+        !_build.prerender.includes(normalizedPath + "/")
+      ) {
+        if (url.pathname.endsWith(".data")) {
+          // 404 on non-pre-rendered `.data` requests
+          errorHandler(
+            new ErrorResponseImpl(
+              404,
+              "Not Found",
+              `Refusing to SSR the path \`${normalizedPath}\` because \`ssr:false\` is set and the path is not included in the \`prerender\` config, so in production the path will be a 404.`
+            ),
+            {
+              context: loadContext,
+              params,
+              request,
+            }
+          );
+          return new Response("Not Found", {
+            status: 404,
+            statusText: "Not Found",
+          });
+        } else {
+          // Serve a SPA fallback for non-pre-rendered document requests
+          request.headers.set("X-React-Router-SPA-Mode", "yes");
+        }
+      }
+    }
+
     // Manifest request for fog of war
     let manifestUrl = `${_build.basename ?? "/"}/__manifest`.replace(
       /\/+/g,
@@ -143,9 +184,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
     let response: Response;
     if (url.pathname.endsWith(".data")) {
       let handlerUrl = new URL(request.url);
-      handlerUrl.pathname = handlerUrl.pathname
-        .replace(/\.data$/, "")
-        .replace(/^\/_root$/, "/");
+      handlerUrl.pathname = normalizedPath;
 
       let singleFetchMatches = matchServerRoutes(
         routes,
@@ -342,7 +381,8 @@ async function handleDocumentRequest(
   handleError: (err: unknown) => void,
   criticalCss?: string
 ) {
-  let context;
+  let isSpaMode = request.headers.has("X-React-Router-SPA-Mode");
+  let context: Awaited>;
   try {
     context = await staticHandler.query(request, {
       requestContext: loadContext,
@@ -392,7 +432,7 @@ async function handleDocumentRequest(
       criticalCss,
       future: build.future,
       ssr: build.ssr,
-      isSpaMode: build.isSpaMode,
+      isSpaMode,
     }),
     serverHandoffStream: encodeViaTurboStream(
       state,
@@ -403,7 +443,7 @@ async function handleDocumentRequest(
     renderMeta: {},
     future: build.future,
     ssr: build.ssr,
-    isSpaMode: build.isSpaMode,
+    isSpaMode,
     serializeError: (err) => serializeError(err, serverMode),
   };
 
@@ -464,7 +504,7 @@ async function handleDocumentRequest(
         basename: build.basename,
         future: build.future,
         ssr: build.ssr,
-        isSpaMode: build.isSpaMode,
+        isSpaMode,
       }),
       serverHandoffStream: encodeViaTurboStream(
         state,
diff --git a/packages/react-router/lib/types/route-module.ts b/packages/react-router/lib/types/route-module.ts
index 39e59e422c..aefe6c728f 100644
--- a/packages/react-router/lib/types/route-module.ts
+++ b/packages/react-router/lib/types/route-module.ts
@@ -135,6 +135,8 @@ export type CreateClientActionArgs =
 
 export type CreateHydrateFallbackProps = {
   params: T["params"];
+  loaderData?: T["loaderData"];
+  actionData?: T["actionData"];
 };
 
 type Match = Pretty<