Skip to content

Commit 5a03954

Browse files
committed
Update prerendering trailing slash logic
1 parent ef77ca8 commit 5a03954

File tree

3 files changed

+161
-20
lines changed

3 files changed

+161
-20
lines changed

.changeset/many-moons-build.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
---
2+
"@react-router/dev": patch
3+
"react-router": patch
4+
---
5+
6+
Rename `future.unstable_trailingSlashAwareDataRequests` -> `future.trailingSlashAware` and update pre-rendering to be more flexible with trailing slashes.
7+
8+
Previously, pre-rendering coerced a trailing slash onto all paths, and always rendered `index.html` files in directory for the path:
9+
10+
| Prerender Path | `.html` file | `.data` file |
11+
| -------------- | --------------------- | ---------------- |
12+
| `/` | `/index.html`| `/_root.data`|
13+
| `/path` | `/path/index.html` ⚠️ | `/path.data`|
14+
| `/path/` | `/path/index.html`| `/path.data` ⚠️ |
15+
16+
With this flag enabled, pre-rendering will determine the output file name according to the presence of a trailing slash on the provided path:
17+
18+
| Prerender Path | `.html` file | `.data` file |
19+
| -------------- | --------------------- | ----------------- |
20+
| `/` | `/index.html`| `/_.data`|
21+
| `/path` | `/path.html`| `/path.data`|
22+
| `/path/` | `/path/index.html`| `/path/_.data`|
23+
24+
Currently, the `getStaticPaths()` function available in the `prerender` function signature always returns paths without a trailing slash. We have also introduced a new option to that method allowing you to specify whether you want the static paths to reflect a trailing slash or not:
25+
26+
```ts
27+
// Previously - no trailing slash
28+
getStaticPaths(); // ["/", "/path", ...]
29+
30+
// future.unstable_trailingSlashAware = false (defaults to no trailing slash)
31+
getStaticPaths(); // ["/", "/path", ...]
32+
getStaticPaths({ trailingSlash: false }); // ["/", "/path", ...]
33+
getStaticPaths({ trailingSlash: true }); // ["/", "/path/", ...]
34+
getStaticPaths({ trailingSlash: "both" }); // ["/", "/path", "/path/", ...]
35+
36+
// future.unstable_trailingSlashAware = true ('both' behavior becomes the default)
37+
getStaticPaths(); // ["/", "/path", "/path/", ...]
38+
getStaticPaths({ trailingSlash: false }); // ["/", "/path", ...]
39+
getStaticPaths({ trailingSlash: true }); // ["/", "/path/", ...]
40+
getStaticPaths({ trailingSlash: "both" }); // ["/", "/path", "/path/", ...]
41+
```
42+
43+
It will depend on what you are using to serve your pre-rendered pages, but generally we recommend the `both` behavior because that seems to play nicest across various different ways of serving static HTML files:
44+
45+
- Current:
46+
- `prerender: ['/', '/page']` and `prerender: ['/', '/page/']`
47+
- `express.static`
48+
- SPA `/page` - ✅
49+
- SSR `/page` - ✅ (via redirect)
50+
- SPA `/page/` - ✅
51+
- SSR `/page/` - ✅
52+
- `npx http-server`
53+
- SPA `/page` - ✅
54+
- SSR `/page` - ✅ (via redirect)
55+
- SPA `/page/` - ✅
56+
- SSR `/page/` - ✅
57+
- `npx sirv-cli`
58+
- SPA `/page` - ✅
59+
- SSR `/page` - ✅
60+
- SPA `/page/` - ✅
61+
- SSR `/page/` - ✅
62+
- New:
63+
- `prerender: ['/', '/page']` - `getStaticPaths({ trailingSlash: false })`
64+
- `express.static`
65+
- SPA `/page` - ✅
66+
- SSR `/page` - ❌
67+
- SPA `/page/` - ❌
68+
- SSR `/page/` - ❌
69+
- `npx http-server`
70+
- SPA `/page` - ✅
71+
- SSR `/page` - ✅
72+
- SPA `/page/` - ❌
73+
- SSR `/page/` - ❌
74+
- `npx sirv-cli`
75+
- SPA `/page` - ✅
76+
- SSR `/page` - ✅
77+
- SPA `/page/` - ✅
78+
- SSR `/page/` - ❌
79+
- `prerender: ['/', '/page/']` - `getStaticPaths({ trailingSlash: true })`
80+
- `express.static`
81+
- SPA `/page` - ❌
82+
- SSR `/page` - ✅ (via redirect)
83+
- SPA `/page/` - ✅
84+
- SSR `/page/` - ✅
85+
- `npx http-server`
86+
- SPA `/page` - ❌
87+
- SSR `/page` - ✅ (via redirect)
88+
- SPA `/page/` - ✅
89+
- SSR `/page/` - ✅
90+
- `npx sirv-cli`
91+
- SPA `/page` - ❌
92+
- SSR `/page` - ✅
93+
- SPA `/page/` - ✅
94+
- SSR `/page/` - ✅
95+
- `prerender: ['/', '/page', '/page/']` - `getStaticPaths({ trailingSlash: 'both' })`
96+
- `express.static`
97+
- SPA `/page` - ✅
98+
- SSR `/page` - ✅ (via redirect)
99+
- SPA `/page/` - ✅
100+
- SSR `/page/` - ✅
101+
- `npx http-server`
102+
- SPA `/page` - ✅
103+
- SSR `/page` - ✅ (via redirect)
104+
- SPA `/page/` - ✅
105+
- SSR `/page/` - ✅
106+
- `npx sirv-cli`
107+
- SPA `/page` - ✅
108+
- SSR `/page` - ✅
109+
- SPA `/page/` - ✅
110+
- SSR `/page/` - ✅

packages/react-router-dev/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export type PrerenderPaths =
114114
| boolean
115115
| Array<string>
116116
| ((args: {
117-
getStaticPaths: () => string[];
117+
getStaticPaths: (opts?: { trailingSlash?: boolean | "both" }) => string[];
118118
}) => Array<string> | Promise<Array<string>>);
119119

120120
/**

packages/react-router-dev/vite/plugin.ts

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -794,11 +794,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
794794
: // Otherwise, all routes are imported as usual
795795
ctx.reactRouterConfig.routes;
796796

797-
let prerenderPaths = await getPrerenderPaths(
798-
ctx.reactRouterConfig.prerender,
799-
ctx.reactRouterConfig.ssr,
800-
routes,
801-
);
797+
let prerenderPaths = await getPrerenderPaths(ctx.reactRouterConfig, routes);
802798

803799
let isSpaMode = isSpaModeEnabled(ctx.reactRouterConfig);
804800

@@ -2943,10 +2939,15 @@ async function prerenderRoute(
29432939
viteConfig: Vite.ResolvedConfig,
29442940
requestInit?: RequestInit,
29452941
) {
2946-
let normalizedPath = `${reactRouterConfig.basename}${prerenderPath}/`.replace(
2947-
/\/\/+/g,
2948-
"/",
2949-
);
2942+
// Only append trailing slashes without the future flag for backwards compatibility.
2943+
// With the flag, we let the incoming path dictate the trailing slash behavior.
2944+
let suffix = reactRouterConfig.future.unstable_trailingSlashAware ? "" : "/";
2945+
let normalizedPath =
2946+
`${reactRouterConfig.basename}${prerenderPath}${suffix}`.replace(
2947+
/\/\/+/g,
2948+
"/",
2949+
);
2950+
29502951
let request = new Request(`http://localhost${normalizedPath}`, requestInit);
29512952
let response = await handler(request);
29522953
let html = await response.text();
@@ -2982,11 +2983,19 @@ async function prerenderRoute(
29822983
}
29832984

29842985
// Write out the HTML file
2985-
let outfile = path.join(
2986-
clientBuildDirectory,
2987-
...normalizedPath.split("/"),
2988-
"index.html",
2989-
);
2986+
let segments = normalizedPath.split("/");
2987+
let outfile: string;
2988+
if (reactRouterConfig.future.unstable_trailingSlashAware) {
2989+
if (normalizedPath.endsWith("/")) {
2990+
outfile = path.join(clientBuildDirectory, ...segments, "index.html");
2991+
} else {
2992+
let file = segments.pop() + ".html";
2993+
outfile = path.join(clientBuildDirectory, ...segments, file);
2994+
}
2995+
} else {
2996+
outfile = path.join(clientBuildDirectory, ...segments, "index.html");
2997+
}
2998+
29902999
await mkdir(path.dirname(outfile), { recursive: true });
29913000
await writeFile(outfile, html);
29923001
viteConfig.logger.info(
@@ -3036,11 +3045,12 @@ export interface GenericRouteManifest {
30363045
}
30373046

30383047
export async function getPrerenderPaths(
3039-
prerender: ResolvedReactRouterConfig["prerender"],
3040-
ssr: ResolvedReactRouterConfig["ssr"],
3048+
reactRouterConfig: ResolvedReactRouterConfig,
30413049
routes: GenericRouteManifest,
30423050
logWarning = false,
30433051
): Promise<string[]> {
3052+
let { future, prerender, ssr } = reactRouterConfig;
3053+
30443054
if (prerender == null || prerender === false) {
30453055
return [];
30463056
}
@@ -3078,7 +3088,29 @@ export async function getPrerenderPaths(
30783088

30793089
if (typeof pathsConfig === "function") {
30803090
let paths = await pathsConfig({
3081-
getStaticPaths: () => getStaticPrerenderPaths(prerenderRoutes).paths,
3091+
getStaticPaths(opts: { trailingSlash?: boolean | "both" } = {}) {
3092+
let withoutTrailingSlash =
3093+
getStaticPrerenderPaths(prerenderRoutes).paths;
3094+
3095+
if (opts?.trailingSlash === true) {
3096+
return withoutTrailingSlash.map((p) =>
3097+
p.endsWith("/") ? p : `${p}/`,
3098+
);
3099+
}
3100+
3101+
if (
3102+
opts?.trailingSlash === "both" ||
3103+
// `both` is the default when the future flag is enabled
3104+
(opts?.trailingSlash === undefined &&
3105+
future.unstable_trailingSlashAware)
3106+
) {
3107+
return withoutTrailingSlash.flatMap((p) =>
3108+
p.endsWith("/") ? [p, p.replace(/\/$/, "")] : [p, `${p}/`],
3109+
);
3110+
}
3111+
3112+
return withoutTrailingSlash;
3113+
},
30823114
});
30833115
return paths;
30843116
}
@@ -3136,8 +3168,7 @@ async function validateSsrFalsePrerenderExports(
31363168
viteChildCompiler: Vite.ViteDevServer | null,
31373169
) {
31383170
let prerenderPaths = await getPrerenderPaths(
3139-
ctx.reactRouterConfig.prerender,
3140-
ctx.reactRouterConfig.ssr,
3171+
ctx.reactRouterConfig,
31413172
manifest.routes,
31423173
true,
31433174
);

0 commit comments

Comments
 (0)