Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .changeset/many-moons-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
"@react-router/dev": patch
"react-router": patch
---

Rename `future.unstable_trailingSlashAwareDataRequests` -> `future.trailingSlashAware` and update pre-rendering to be more flexible with trailing slashes.

Previously, pre-rendering coerced a trailing slash onto all paths, and always rendered `index.html` files in directory for the path:

| Prerender Path | `.html` file | `.data` file |
| -------------- | --------------------- | ---------------- |
| `/` | `/index.html` ✅ | `/_root.data` ✅ |
| `/path` | `/path/index.html` ⚠️ | `/path.data` ✅ |
| `/path/` | `/path/index.html` ✅ | `/path.data` ⚠️ |

With this flag enabled, pre-rendering will determine the output file name according to the presence of a trailing slash on the provided path:

| Prerender Path | `.html` file | `.data` file |
| -------------- | --------------------- | ----------------- |
| `/` | `/index.html` ✅ | `/_.data` ✅ |
| `/path` | `/path.html` ✅ | `/path.data` ✅ |
| `/path/` | `/path/index.html` ✅ | `/path/_.data` ✅ |

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:

```ts
// Previously - no trailing slash
getStaticPaths(); // ["/", "/path", ...]

// future.unstable_trailingSlashAware = false (defaults to no trailing slash)
getStaticPaths(); // ["/", "/path", ...]
getStaticPaths({ trailingSlash: false }); // ["/", "/path", ...]
getStaticPaths({ trailingSlash: true }); // ["/", "/path/", ...]
getStaticPaths({ trailingSlash: "both" }); // ["/", "/path", "/path/", ...]

// future.unstable_trailingSlashAware = true ('both' behavior becomes the default)
getStaticPaths(); // ["/", "/path", "/path/", ...]
getStaticPaths({ trailingSlash: false }); // ["/", "/path", ...]
getStaticPaths({ trailingSlash: true }); // ["/", "/path/", ...]
getStaticPaths({ trailingSlash: "both" }); // ["/", "/path", "/path/", ...]
```

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:

- Current:
- `prerender: ['/', '/page']` and `prerender: ['/', '/page/']`
- `express.static`
- SPA `/page` - ✅
- SSR `/page` - ✅ (via redirect)
- SPA `/page/` - ✅
- SSR `/page/` - ✅
- `npx http-server`
- SPA `/page` - ✅
- SSR `/page` - ✅ (via redirect)
- SPA `/page/` - ✅
- SSR `/page/` - ✅
- `npx sirv-cli`
- SPA `/page` - ✅
- SSR `/page` - ✅
- SPA `/page/` - ✅
- SSR `/page/` - ✅
- New:
- `prerender: ['/', '/page']` - `getStaticPaths({ trailingSlash: false })`
- `express.static`
- SPA `/page` - ✅
- SSR `/page` - ❌
- SPA `/page/` - ❌
- SSR `/page/` - ❌
- `npx http-server`
- SPA `/page` - ✅
- SSR `/page` - ✅
- SPA `/page/` - ❌
- SSR `/page/` - ❌
- `npx sirv-cli`
- SPA `/page` - ✅
- SSR `/page` - ✅
- SPA `/page/` - ✅
- SSR `/page/` - ❌
- `prerender: ['/', '/page/']` - `getStaticPaths({ trailingSlash: true })`
- `express.static`
- SPA `/page` - ❌
- SSR `/page` - ✅ (via redirect)
- SPA `/page/` - ✅
- SSR `/page/` - ✅
- `npx http-server`
- SPA `/page` - ❌
- SSR `/page` - ✅ (via redirect)
- SPA `/page/` - ✅
- SSR `/page/` - ✅
- `npx sirv-cli`
- SPA `/page` - ❌
- SSR `/page` - ✅
- SPA `/page/` - ✅
- SSR `/page/` - ✅
- `prerender: ['/', '/page', '/page/']` - `getStaticPaths({ trailingSlash: 'both' })`
- `express.static`
- SPA `/page` - ✅
- SSR `/page` - ✅ (via redirect)
- SPA `/page/` - ✅
- SSR `/page/` - ✅
- `npx http-server`
- SPA `/page` - ✅
- SSR `/page` - ✅ (via redirect)
- SPA `/page/` - ✅
- SSR `/page/` - ✅
- `npx sirv-cli`
- SPA `/page` - ✅
- SSR `/page` - ✅
- SPA `/page/` - ✅
- SSR `/page/` - ✅
2 changes: 1 addition & 1 deletion .changeset/serious-bobcats-impress.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"react-router": patch
---

[UNSTABLE] Add a new `future.unstable_trailingSlashAwareDataRequests` flag to provide consistent behavior of `request.pathname` inside `middleware`, `loader`, and `action` functions on document and data requests when a trailing slash is present in the browser URL.
[UNSTABLE] Add a new `future.unstable_trailingSlashAware` flag to provide consistent behavior of `request.pathname` inside `middleware`, `loader`, and `action` functions on document and data requests when a trailing slash is present in the browser URL.

Currently, your HTTP and `request` pathnames would be as follows for `/a/b/c` and `/a/b/c/`

Expand Down
6 changes: 3 additions & 3 deletions integration/single-fetch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4477,7 +4477,7 @@ test.describe("single-fetch", () => {
expect(await app.getHtml("h1")).toMatch("It worked!");
});

test("always uses /{path}.data without future.unstable_trailingSlashAwareDataRequests flag", async ({
test("always uses /{path}.data without future.unstable_trailingSlashAware flag", async ({
page,
}) => {
let fixture = await createFixture({
Expand Down Expand Up @@ -4569,7 +4569,7 @@ test.describe("single-fetch", () => {
requests = [];
});

test("uses {path}.data or {path}/_.data depending on trailing slash with future.unstable_trailingSlashAwareDataRequests flag", async ({
test("uses {path}.data or {path}/_.data depending on trailing slash with future.unstable_trailingSlashAware flag", async ({
page,
}) => {
let fixture = await createFixture({
Expand All @@ -4580,7 +4580,7 @@ test.describe("single-fetch", () => {

export default {
future: {
unstable_trailingSlashAwareDataRequests: true,
unstable_trailingSlashAware: true,
}
} satisfies Config;
`,
Expand Down
2 changes: 1 addition & 1 deletion integration/vite-presets-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ test.describe("Vite / presets", async () => {
expect(buildEndArgsMeta.futureFlags).toEqual({
unstable_optimizeDeps: true,
unstable_subResourceIntegrity: false,
unstable_trailingSlashAwareDataRequests: false,
unstable_trailingSlashAware: false,
v8_middleware: true,
v8_splitRouteModules: false,
v8_viteEnvironmentApi: false,
Expand Down
9 changes: 4 additions & 5 deletions packages/react-router-dev/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ type ValidateConfigFunction = (config: ReactRouterConfig) => string | void;
interface FutureConfig {
unstable_optimizeDeps: boolean;
unstable_subResourceIntegrity: boolean;
unstable_trailingSlashAwareDataRequests: boolean;
unstable_trailingSlashAware: boolean;
/**
* Enable route middleware
*/
Expand All @@ -114,7 +114,7 @@ export type PrerenderPaths =
| boolean
| Array<string>
| ((args: {
getStaticPaths: () => string[];
getStaticPaths: (opts?: { trailingSlash?: boolean | "both" }) => string[];
}) => Array<string> | Promise<Array<string>>);

/**
Expand Down Expand Up @@ -635,9 +635,8 @@ async function resolveConfig({
userAndPresetConfigs.future?.unstable_optimizeDeps ?? false,
unstable_subResourceIntegrity:
userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? false,
unstable_trailingSlashAwareDataRequests:
userAndPresetConfigs.future?.unstable_trailingSlashAwareDataRequests ??
false,
unstable_trailingSlashAware:
userAndPresetConfigs.future?.unstable_trailingSlashAware ?? false,
v8_middleware: userAndPresetConfigs.future?.v8_middleware ?? false,
v8_splitRouteModules:
userAndPresetConfigs.future?.v8_splitRouteModules ?? false,
Expand Down
71 changes: 51 additions & 20 deletions packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,11 +794,7 @@ 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,
);
let prerenderPaths = await getPrerenderPaths(ctx.reactRouterConfig, routes);

let isSpaMode = isSpaModeEnabled(ctx.reactRouterConfig);

Expand Down Expand Up @@ -2889,7 +2885,7 @@ async function prerenderData(
requestInit?: RequestInit,
) {
let dataRequestPath: string;
if (reactRouterConfig.future.unstable_trailingSlashAwareDataRequests) {
if (reactRouterConfig.future.unstable_trailingSlashAware) {
if (prerenderPath.endsWith("/")) {
dataRequestPath = `${prerenderPath}_.data`;
} else {
Expand Down Expand Up @@ -2943,10 +2939,15 @@ async function prerenderRoute(
viteConfig: Vite.ResolvedConfig,
requestInit?: RequestInit,
) {
let normalizedPath = `${reactRouterConfig.basename}${prerenderPath}/`.replace(
/\/\/+/g,
"/",
);
// Only append trailing slashes without the future flag for backwards compatibility.
// With the flag, we let the incoming path dictate the trailing slash behavior.
let suffix = reactRouterConfig.future.unstable_trailingSlashAware ? "" : "/";
let normalizedPath =
`${reactRouterConfig.basename}${prerenderPath}${suffix}`.replace(
/\/\/+/g,
"/",
);

let request = new Request(`http://localhost${normalizedPath}`, requestInit);
let response = await handler(request);
let html = await response.text();
Expand Down Expand Up @@ -2982,11 +2983,19 @@ async function prerenderRoute(
}

// Write out the HTML file
let outfile = path.join(
clientBuildDirectory,
...normalizedPath.split("/"),
"index.html",
);
let segments = normalizedPath.split("/");
let outfile: string;
if (reactRouterConfig.future.unstable_trailingSlashAware) {
if (normalizedPath.endsWith("/")) {
outfile = path.join(clientBuildDirectory, ...segments, "index.html");
} else {
let file = segments.pop() + ".html";
outfile = path.join(clientBuildDirectory, ...segments, file);
}
} else {
outfile = path.join(clientBuildDirectory, ...segments, "index.html");
}

await mkdir(path.dirname(outfile), { recursive: true });
await writeFile(outfile, html);
viteConfig.logger.info(
Expand Down Expand Up @@ -3036,11 +3045,12 @@ export interface GenericRouteManifest {
}

export async function getPrerenderPaths(
prerender: ResolvedReactRouterConfig["prerender"],
ssr: ResolvedReactRouterConfig["ssr"],
reactRouterConfig: ResolvedReactRouterConfig,
routes: GenericRouteManifest,
logWarning = false,
): Promise<string[]> {
let { future, prerender, ssr } = reactRouterConfig;

if (prerender == null || prerender === false) {
return [];
}
Expand Down Expand Up @@ -3078,7 +3088,29 @@ export async function getPrerenderPaths(

if (typeof pathsConfig === "function") {
let paths = await pathsConfig({
getStaticPaths: () => getStaticPrerenderPaths(prerenderRoutes).paths,
getStaticPaths(opts: { trailingSlash?: boolean | "both" } = {}) {
let withoutTrailingSlash =
getStaticPrerenderPaths(prerenderRoutes).paths;

if (opts?.trailingSlash === true) {
return withoutTrailingSlash.map((p) =>
p.endsWith("/") ? p : `${p}/`,
);
}

if (
opts?.trailingSlash === "both" ||
// `both` is the default when the future flag is enabled
(opts?.trailingSlash === undefined &&
future.unstable_trailingSlashAware)
) {
return withoutTrailingSlash.flatMap((p) =>
p.endsWith("/") ? [p, p.replace(/\/$/, "")] : [p, `${p}/`],
);
}

return withoutTrailingSlash;
},
});
return paths;
}
Expand Down Expand Up @@ -3136,8 +3168,7 @@ async function validateSsrFalsePrerenderExports(
viteChildCompiler: Vite.ViteDevServer | null,
) {
let prerenderPaths = await getPrerenderPaths(
ctx.reactRouterConfig.prerender,
ctx.reactRouterConfig.ssr,
ctx.reactRouterConfig,
manifest.routes,
true,
);
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/lib/dom-export/hydrated-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ function createHydratedRouter({
ssrInfo.routeModules,
ssrInfo.context.ssr,
ssrInfo.context.basename,
ssrInfo.context.future.unstable_trailingSlashAwareDataRequests,
ssrInfo.context.future.unstable_trailingSlashAware,
),
patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction(
ssrInfo.manifest,
Expand Down
4 changes: 2 additions & 2 deletions packages/react-router/lib/dom/ssr/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ function PrefetchPageLinksImpl({
let url = singleFetchUrl(
page,
basename,
future.unstable_trailingSlashAwareDataRequests,
future.unstable_trailingSlashAware,
"data",
);
// When one or more routes have opted out, we add a _routes param to
Expand All @@ -457,7 +457,7 @@ function PrefetchPageLinksImpl({
return [url.pathname + url.search];
}, [
basename,
future.unstable_trailingSlashAwareDataRequests,
future.unstable_trailingSlashAware,
loaderData,
location,
manifest,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/lib/dom/ssr/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface EntryContext extends FrameworkContextObject {

export interface FutureConfig {
unstable_subResourceIntegrity: boolean;
unstable_trailingSlashAwareDataRequests: boolean;
unstable_trailingSlashAware: boolean;
v8_middleware: boolean;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/react-router/lib/dom/ssr/routes-test-stub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ export function createRoutesStub(
unstable_subResourceIntegrity:
future?.unstable_subResourceIntegrity === true,
v8_middleware: future?.v8_middleware === true,
unstable_trailingSlashAwareDataRequests:
future?.unstable_trailingSlashAwareDataRequests === true,
unstable_trailingSlashAware:
future?.unstable_trailingSlashAware === true,
},
manifest: {
routes: {},
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/lib/rsc/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ export function RSCHydratedRouter({
// flags that drive runtime behavior they'll need to be proxied through.
v8_middleware: false,
unstable_subResourceIntegrity: false,
unstable_trailingSlashAwareDataRequests: true, // always on for RSC
unstable_trailingSlashAware: true, // always on for RSC
},
isSpaMode: false,
ssr: true,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/lib/rsc/server.ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) {
// flags that drive runtime behavior they'll need to be proxied through.
v8_middleware: false,
unstable_subResourceIntegrity: false,
unstable_trailingSlashAwareDataRequests: true, // always on for RSC
unstable_trailingSlashAware: true, // always on for RSC
},
isSpaMode: false,
ssr: true,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/lib/server-runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ function derive(build: ServerBuild, mode?: string) {

let normalizedBasename = build.basename || "/";
let normalizedPath = url.pathname;
if (build.future.unstable_trailingSlashAwareDataRequests) {
if (build.future.unstable_trailingSlashAware) {
if (normalizedPath.endsWith("/_.data")) {
// Handle trailing slash URLs: /about/_.data -> /about/
normalizedPath = normalizedPath.replace(/_.data$/, "");
Expand Down