Skip to content

Commit abe8008

Browse files
authored
Improvements to ssr:false + prerender scenarios (#12948)
1 parent f9f4a27 commit abe8008

25 files changed

+1806
-574
lines changed

.changeset/fresh-buttons-sit.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Don't apply Single Fetch revalidation de-optimization when in SPA mode since there is no server HTTP request
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@react-router/dev": patch
3+
---
4+
5+
Enhance invalid export detection when using `ssr:false`
6+
7+
- `headers`/`action` are prohibited in all routes with `ssr:false` because there will be no runtime server on which to run them
8+
- `loader` functions are more nuanced and depend on whether a given route is prerendered
9+
- When using `ssr:false` without a `prerender` config, only the `root` route can have a `loader`
10+
- 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
11+
- 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
12+
- 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

.changeset/prerender-spa-fallback.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@react-router/dev": minor
3+
---
4+
5+
Generate a "SPA fallback" HTML file for scenarios where applications are prerendering the `/` route with `ssr:false`
6+
7+
- 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
8+
- 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
9+
- 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
10+
- The generated HTML would include the root index route which prevented hydration for any other paths
11+
- 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
12+
- 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
13+
- `npx sirv-cli build/client --single __spa-fallback.html`

.changeset/spa-mode-root-loader.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@react-router/dev": minor
3+
---
4+
5+
- Allow a `loader` in the root route in SPA mode because it can be called/server-rendered at build time
6+
- `Route.HydrateFallbackProps` now also receives `loaderData`
7+
- This will be defined so long as the `HydrateFallback` is rendering while _children_ routes are loading
8+
- This will be `undefined` if the `HydrateFallback` is rendering because the route has it's own hydrating `clientLoader`
9+
- In SPA mode, this will allow you to render loader root data into the SPA `index.html`

.changeset/stale-ways-ring.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Align dev server behavior with static file server behavior when `ssr:false` is set
6+
7+
- When no `prerender` config exists, only SSR down to the root `HydrateFallback` (SPA Mode)
8+
- When a `prerender` config exists but the current path is not prerendered, only SSR down to the root `HydrateFallback` (SPA Fallback)
9+
- Return a 404 on `.data` requests to non-pre-rendered paths

docs/how-to/pre-rendering.md

+65-6
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,23 @@ title: Pre-Rendering
44

55
# Pre-Rendering
66

7-
Pre-rendering allows you to render pages at build time instead of on a server to speed up pages loads for static content.
7+
Pre-rendering allows you to render pages at build time instead of on a runtime server to speed up page loads for static content.
88

9-
## Configuration
9+
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.
10+
11+
## Pre-rendering with ssr:true
12+
13+
### Configuration
1014

1115
Add the `prerender` option to your config, there are three signatures:
1216

13-
```ts filename=react-router.config.ts
17+
```ts filename=react-router.config.ts lines=[7-09,11-12,14-20]
1418
import type { Config } from "@react-router/dev/config";
1519

1620
export default {
21+
// Can be omitted - defaults to true
22+
ssr: true,
23+
1724
// all static route paths
1825
// (no dynamic segments like "/post/:slug")
1926
prerender: true,
@@ -22,7 +29,7 @@ export default {
2229
prerender: ["/", "/blog", "/blog/popular-post"],
2330

2431
// async function for dependencies like a CMS
25-
async prerender({ getStaticPaths }) {
32+
async pre-render({ getStaticPaths }) {
2633
let posts = await fakeGetPostsFromCMS();
2734
return ["/", "/blog"].concat(
2835
posts.map((post) => post.href)
@@ -31,7 +38,7 @@ export default {
3138
} satisfies Config;
3239
```
3340

34-
## Data Loading and Pre-rendering
41+
### Data Loading and Pre-rendering
3542

3643
There is no extra application API for pre-rendering. Pre-rendering uses the same route loaders as server rendering:
3744

@@ -50,7 +57,7 @@ Instead of a request coming to your route on a deployed server, the build create
5057

5158
When server rendering, requests to paths that have not been pre-rendered will be server rendered as usual.
5259

53-
## Static File Output
60+
### Static File Output
5461

5562
The rendered result will be written out to your `build/client` directory. You'll notice two files for each path:
5663

@@ -74,3 +81,55 @@ Prerender: Generated build/client/blog/my-first-post/index.html
7481
```
7582

7683
During development, pre-rendering doesn't save the rendered results to the public directory, this only happens for `react-router build`.
84+
85+
## Pre-rendering with `ssr:false`
86+
87+
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.
88+
89+
To disable runtime SSR, you can set the `ssr:false` config flag:
90+
91+
```ts filename=react-router.config.ts
92+
import type { Config } from "@react-router/dev/config";
93+
94+
export default {
95+
ssr: false, // disable runtime server rendering
96+
prerender: true, // pre-render static routes
97+
} satisfies Config;
98+
```
99+
100+
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.
101+
102+
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.
103+
104+
### Pre-rendering with a SPA Fallback
105+
106+
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.
107+
108+
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)).
109+
110+
This will be written to one of the following paths:
111+
112+
- `build/client/index.html` - If the `/` path is not pre-rendered
113+
- `build/client/__spa-fallback.html` - If the `/` path is pre-rendered
114+
115+
```ts filename=react-router.config.ts
116+
import type { Config } from "@react-router/dev/config";
117+
118+
export default {
119+
ssr: false,
120+
121+
// SPA fallback will be written to build/client/index.html
122+
prerender: ["/about-us"],
123+
124+
// SPA fallback will be written to build/client/__spa-fallback.html
125+
prerender: ["/", "/about-us"],
126+
} satisfies Config;
127+
```
128+
129+
You can configure your deployment server to serve this file for any path that otherwise would 404.
130+
131+
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:
132+
133+
```sh
134+
sirv-cli build/client --single __spa-fallback.html
135+
```

docs/how-to/spa.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ There are two ways to ship a single page app with React Router
1111

1212
## 1. Disable Server Rendering
1313

14-
Server rendering is enabled by default. Set the ssr flag to false in `react-router.config.ts` to disable it.
14+
Server rendering is enabled by default. Set the `ssr` flag to `false` in `react-router.config.ts` to disable it.
1515

1616
```ts filename=react-router.config.ts lines=[4]
1717
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
6565

6666
Typical Single Pages apps send a mostly blank `index.html` template with little more than an empty `<div id="root"></div>`.
6767

68-
In contrast `react-router build` (with server rendering disabled) pre-renders your root and index routes. This means you can:
68+
In contrast `react-router build` (with server rendering disabled) pre-renders your root route at build time. This means you can:
6969

7070
- Send more than an empty div
71-
- Use React components to generate the initial page users see
71+
- Use a root `loader` to load data for your application shell
72+
- Use React components to generate the initial page users see (root `HydrateFallback`)
7273
- Re-enable server rendering later without changing anything about your UI
7374

74-
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.
75+
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.

integration/helpers/create-fixture.ts

+17-7
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,16 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
9494
prerender: init.prerender,
9595
requestDocument(href: string) {
9696
let file = new URL(href, "test://test").pathname + "/index.html";
97-
let html = fse.readFileSync(
98-
path.join(projectDir, "build/client" + file)
97+
let mainPath = path.join(projectDir, "build", "client", file);
98+
let fallbackPath = path.join(
99+
projectDir,
100+
"build",
101+
"client",
102+
"__spa-fallback.html"
99103
);
104+
let html = fse.existsSync(mainPath)
105+
? fse.readFileSync(mainPath)
106+
: fse.readFileSync(fallbackPath);
100107
return new Response(html, {
101108
headers: {
102109
"Content-Type": "text/html",
@@ -284,15 +291,18 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
284291
return new Promise(async (accept) => {
285292
let port = await getPort();
286293
let app = express();
287-
app.use(express.static(path.join(fixture.projectDir, "build/client")));
294+
app.use(
295+
express.static(path.join(fixture.projectDir, "build", "client"))
296+
);
288297
app.get("*", (req, res, next) => {
298+
let dir = path.join(fixture.projectDir, "build", "client");
289299
let file = req.path.endsWith(".data")
290300
? req.path
291301
: req.path + "/index.html";
292-
res.sendFile(
293-
path.join(fixture.projectDir, "build/client", file),
294-
next
295-
);
302+
if (file.endsWith(".html") && !fse.existsSync(path.join(dir, file))) {
303+
file = "__spa-fallback.html";
304+
}
305+
res.sendFile(path.join(dir, file), next);
296306
});
297307
let server = app.listen(port);
298308
accept({ stop: server.close.bind(server), port });

0 commit comments

Comments
 (0)