diff --git a/.changeset/many-moons-build.md b/.changeset/many-moons-build.md new file mode 100644 index 0000000000..3b51ae3ab6 --- /dev/null +++ b/.changeset/many-moons-build.md @@ -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/` - ✅ diff --git a/.changeset/serious-bobcats-impress.md b/.changeset/serious-bobcats-impress.md index b34d2b3d6d..c6ed47f5e1 100644 --- a/.changeset/serious-bobcats-impress.md +++ b/.changeset/serious-bobcats-impress.md @@ -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/` diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index 625a7fd09b..5d26987e83 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -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({ @@ -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({ @@ -4580,7 +4580,7 @@ test.describe("single-fetch", () => { export default { future: { - unstable_trailingSlashAwareDataRequests: true, + unstable_trailingSlashAware: true, } } satisfies Config; `, diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 6bf1d96221..b51b07bc3c 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -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, diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 1a0892ec88..eff3a8bf0b 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -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 */ @@ -114,7 +114,7 @@ export type PrerenderPaths = | boolean | Array | ((args: { - getStaticPaths: () => string[]; + getStaticPaths: (opts?: { trailingSlash?: boolean | "both" }) => string[]; }) => Array | Promise>); /** @@ -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, diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 6ed5990bbe..929984ac32 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -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); @@ -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 { @@ -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(); @@ -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( @@ -3036,11 +3045,12 @@ export interface GenericRouteManifest { } export async function getPrerenderPaths( - prerender: ResolvedReactRouterConfig["prerender"], - ssr: ResolvedReactRouterConfig["ssr"], + reactRouterConfig: ResolvedReactRouterConfig, routes: GenericRouteManifest, logWarning = false, ): Promise { + let { future, prerender, ssr } = reactRouterConfig; + if (prerender == null || prerender === false) { return []; } @@ -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; } @@ -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, ); diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index e2dc320796..939299712d 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -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, diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index c4e75cc66a..52a0b726c6 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -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 @@ -457,7 +457,7 @@ function PrefetchPageLinksImpl({ return [url.pathname + url.search]; }, [ basename, - future.unstable_trailingSlashAwareDataRequests, + future.unstable_trailingSlashAware, loaderData, location, manifest, diff --git a/packages/react-router/lib/dom/ssr/entry.ts b/packages/react-router/lib/dom/ssr/entry.ts index f965b43444..8c4cf245de 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -45,7 +45,7 @@ export interface EntryContext extends FrameworkContextObject { export interface FutureConfig { unstable_subResourceIntegrity: boolean; - unstable_trailingSlashAwareDataRequests: boolean; + unstable_trailingSlashAware: boolean; v8_middleware: boolean; } diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index b9038287cc..e7544cce9b 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -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: {}, diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 865ef8832f..bddb299933 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -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, diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 845d04544f..bee8dd5273 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -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, diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 8ce88bf45f..784e9f8777 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -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$/, "");