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() { + returnLoading...
; + } `, "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() { returnIndex Loader Data
'); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + 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 Loader Data
'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - 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 Loader Data
'); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + 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 Loader Data
'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - 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 Loader Data
'); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + 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 Loader Data
'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - 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 Loader Data
'); - - res = await fixture.requestDocument("/about"); - html = await res.text(); - 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 Loader Data
'); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + 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", () => { return24999
' + ); }); - 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}
Child: {data}
Index: {data}
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{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 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 ( + +{loaderData}
+{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 ( + +{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} +{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} +{loaderData}
+{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{loaderData?.message}
{id}{hydrated ?
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<