diff --git a/.changeset/serious-bobcats-impress.md b/.changeset/serious-bobcats-impress.md new file mode 100644 index 0000000000..b34d2b3d6d --- /dev/null +++ b/.changeset/serious-bobcats-impress.md @@ -0,0 +1,34 @@ +--- +"@react-router/dev": patch +"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. + +Currently, your HTTP and `request` pathnames would be as follows for `/a/b/c` and `/a/b/c/` + +| URL `/a/b/c` | **HTTP pathname** | **`request` pathname`** | +| ------------ | ----------------- | ----------------------- | +| **Document** | `/a/b/c` | `/a/b/c` ✅ | +| **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + +| URL `/a/b/c/` | **HTTP pathname** | **`request` pathname`** | +| ------------- | ----------------- | ----------------------- | +| **Document** | `/a/b/c/` | `/a/b/c/` ✅ | +| **Data** | `/a/b/c.data` | `/a/b/c` ⚠️ | + +With this flag enabled, these pathnames will be made consistent though a new `_.data` format for client-side `.data` requests: + +| URL `/a/b/c` | **HTTP pathname** | **`request` pathname`** | +| ------------ | ----------------- | ----------------------- | +| **Document** | `/a/b/c` | `/a/b/c` ✅ | +| **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + +| URL `/a/b/c/` | **HTTP pathname** | **`request` pathname`** | +| ------------- | ------------------ | ----------------------- | +| **Document** | `/a/b/c/` | `/a/b/c/` ✅ | +| **Data** | `/a/b/c/_.data` ⬅️ | `/a/b/c/` ✅ | + +This a bug fix but we are putting it behind an opt-in flag because it has the potential to be a "breaking bug fix" if you are relying on the URL format for any other application or caching logic. + +Enabling this flag also changes the format of client side `.data` requests from `/_root.data` to `/_.data` when navigating to `/` to align with the new format. This does not impact the `request` pathname which is still `/` in all cases. diff --git a/contributors.yml b/contributors.yml index c98ad34092..d16142f94d 100644 --- a/contributors.yml +++ b/contributors.yml @@ -336,6 +336,7 @@ - renyu-io - reyronald - RFCreate +- richardkall - richardscarrott - rifaidev - rimian diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index b4db12bc26..625a7fd09b 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -4476,4 +4476,197 @@ test.describe("single-fetch", () => { await page.waitForSelector("h1"); expect(await app.getHtml("h1")).toMatch("It worked!"); }); + + test("always uses /{path}.data without future.unstable_trailingSlashAwareDataRequests flag", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + + export default function Index() { + return ( +
+

Home

+ Go to About (with trailing slash) + Go to About (without trailing slash) +
+ ); + } + `, + "app/routes/about.tsx": js` + import { Link, useLoaderData } from "react-router"; + + export function loader({ request }) { + let url = new URL(request.url); + return { + pathname: url.pathname, + hasTrailingSlash: url.pathname.endsWith("/"), + }; + } + + export default function About() { + let { pathname, hasTrailingSlash } = useLoaderData(); + return ( +
+

About

+

{pathname}

+

{String(hasTrailingSlash)}

+ Go back home +
+ ); + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let requests: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (url.pathname.endsWith(".data")) { + requests.push(url.pathname + url.search); + } + }); + + // Document load without trailing slash + await app.goto("/about"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + + // Client-side navigation without trailing slash + await app.goto("/"); + await app.clickLink("/about"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + expect(requests).toEqual(["/about.data"]); + requests = []; + + // Document load with trailing slash + await app.goto("/about/"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about/"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("true"); + + // Client-side navigation with trailing slash + await app.goto("/"); + await app.clickLink("/about/"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + expect(requests).toEqual(["/about.data"]); + requests = []; + + // Client-side navigation back to / + await app.clickLink("/"); + await page.waitForSelector("h1:has-text('Home')"); + expect(requests).toEqual(["/_root.data"]); + requests = []; + }); + + test("uses {path}.data or {path}/_.data depending on trailing slash with future.unstable_trailingSlashAwareDataRequests flag", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "react-router.config.ts": js` + import type { Config } from "@react-router/dev/config"; + + export default { + future: { + unstable_trailingSlashAwareDataRequests: true, + } + } satisfies Config; + `, + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + + export default function Index() { + return ( +
+

Home

+ Go to About (with trailing slash) + Go to About (without trailing slash) +
+ ); + } + `, + "app/routes/about.tsx": js` + import { Link, useLoaderData } from "react-router"; + + export function loader({ request }) { + let url = new URL(request.url); + return { + pathname: url.pathname, + hasTrailingSlash: url.pathname.endsWith("/"), + }; + } + + export default function About() { + let { pathname, hasTrailingSlash } = useLoaderData(); + return ( +
+

About

+

{pathname}

+

{String(hasTrailingSlash)}

+ Go back home +
+ ); + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let requests: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (url.pathname.endsWith(".data")) { + requests.push(url.pathname + url.search); + } + }); + + // Document load without trailing slash + await app.goto("/about"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + + // Client-side navigation without trailing slash + await app.goto("/"); + await app.clickLink("/about"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("false"); + expect(requests).toEqual(["/about.data"]); + requests = []; + + // Document load with trailing slash + await app.goto("/about/"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about/"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("true"); + + // Client-side navigation with trailing slash + await app.goto("/"); + await app.clickLink("/about/"); + await page.waitForSelector("#pathname"); + expect(await page.locator("#pathname").innerText()).toEqual("/about/"); + expect(await page.locator("#trailing-slash").innerText()).toEqual("true"); + expect(requests).toEqual(["/about/_.data"]); + requests = []; + + // Client-side navigation back to / + await app.clickLink("/"); + await page.waitForSelector("h1:has-text('Home')"); + expect(requests).toEqual(["/_.data"]); + requests = []; + }); }); diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 00b83a1b5a..6bf1d96221 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -245,6 +245,7 @@ test.describe("Vite / presets", async () => { expect(buildEndArgsMeta.futureFlags).toEqual({ unstable_optimizeDeps: true, unstable_subResourceIntegrity: false, + unstable_trailingSlashAwareDataRequests: 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 2211d71ab1..1a0892ec88 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -87,6 +87,7 @@ type ValidateConfigFunction = (config: ReactRouterConfig) => string | void; interface FutureConfig { unstable_optimizeDeps: boolean; unstable_subResourceIntegrity: boolean; + unstable_trailingSlashAwareDataRequests: boolean; /** * Enable route middleware */ @@ -634,6 +635,9 @@ async function resolveConfig({ userAndPresetConfigs.future?.unstable_optimizeDeps ?? false, unstable_subResourceIntegrity: userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? false, + unstable_trailingSlashAwareDataRequests: + userAndPresetConfigs.future?.unstable_trailingSlashAwareDataRequests ?? + 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 6a77987977..0534d33563 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -2895,11 +2895,22 @@ async function prerenderData( viteConfig: Vite.ResolvedConfig, requestInit?: RequestInit, ) { - let normalizedPath = `${reactRouterConfig.basename}${ - prerenderPath === "/" - ? "/_root.data" - : `${prerenderPath.replace(/\/$/, "")}.data` - }`.replace(/\/\/+/g, "/"); + let dataRequestPath: string; + if (reactRouterConfig.future.unstable_trailingSlashAwareDataRequests) { + if (prerenderPath.endsWith("/")) { + dataRequestPath = `${prerenderPath}_.data`; + } else { + dataRequestPath = `${prerenderPath}.data`; + } + } else { + if (prerenderPath === "/") { + dataRequestPath = "/_root.data"; + } else { + dataRequestPath = `${prerenderPath.replace(/\/$/, "")}.data`; + } + } + let normalizedPath = + `${reactRouterConfig.basename}${dataRequestPath}`.replace(/\/\/+/g, "/"); let url = new URL(`http://localhost${normalizedPath}`); if (onlyRoutes?.length) { url.searchParams.set("_routes", onlyRoutes.join(",")); diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 19c2c1c691..e2dc320796 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -186,6 +186,7 @@ function createHydratedRouter({ ssrInfo.routeModules, ssrInfo.context.ssr, ssrInfo.context.basename, + ssrInfo.context.future.unstable_trailingSlashAwareDataRequests, ), 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 318fa0bf51..9bfd616506 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -370,7 +370,7 @@ function PrefetchPageLinksImpl({ matches: AgnosticDataRouteMatch[]; }) { let location = useLocation(); - let { manifest, routeModules } = useFrameworkContext(); + let { future, manifest, routeModules } = useFrameworkContext(); let { basename } = useDataRouterContext(); let { loaderData, matches } = useDataRouterStateContext(); @@ -434,7 +434,12 @@ function PrefetchPageLinksImpl({ return []; } - let url = singleFetchUrl(page, basename, "data"); + let url = singleFetchUrl( + page, + basename, + future.unstable_trailingSlashAwareDataRequests, + "data", + ); // 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 @@ -451,6 +456,7 @@ function PrefetchPageLinksImpl({ return [url.pathname + url.search]; }, [ basename, + future.unstable_trailingSlashAwareDataRequests, loaderData, location, manifest, diff --git a/packages/react-router/lib/dom/ssr/entry.ts b/packages/react-router/lib/dom/ssr/entry.ts index 1752c5578f..f965b43444 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -45,6 +45,7 @@ export interface EntryContext extends FrameworkContextObject { export interface FutureConfig { unstable_subResourceIntegrity: boolean; + unstable_trailingSlashAwareDataRequests: 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 5dc5b2d25e..b9038287cc 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -135,6 +135,8 @@ export function createRoutesStub( unstable_subResourceIntegrity: future?.unstable_subResourceIntegrity === true, v8_middleware: future?.v8_middleware === true, + unstable_trailingSlashAwareDataRequests: + future?.unstable_trailingSlashAwareDataRequests === true, }, manifest: { routes: {}, diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index ced041546d..fdf78ff273 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -170,6 +170,7 @@ type ShouldAllowOptOutFunction = (match: DataRouteMatch) => boolean; export type FetchAndDecodeFunction = ( args: DataStrategyFunctionArgs, basename: string | undefined, + trailingSlashAware: boolean, targetRoutes?: string[], shouldAllowOptOut?: ShouldAllowOptOutFunction, ) => Promise<{ status: number; data: DecodedSingleFetchResults }>; @@ -180,6 +181,7 @@ export function getTurboStreamSingleFetchDataStrategy( routeModules: RouteModules, ssr: boolean, basename: string | undefined, + trailingSlashAware: boolean, ): DataStrategyFunction { let dataStrategy = getSingleFetchDataStrategyImpl( getRouter, @@ -196,6 +198,7 @@ export function getTurboStreamSingleFetchDataStrategy( fetchAndDecodeViaTurboStream, ssr, basename, + trailingSlashAware, ); return async (args) => args.runClientMiddleware(dataStrategy); } @@ -206,6 +209,7 @@ export function getSingleFetchDataStrategyImpl( fetchAndDecode: FetchAndDecodeFunction, ssr: boolean, basename: string | undefined, + trailingSlashAware: boolean, shouldAllowOptOut: ShouldAllowOptOutFunction = () => true, ): DataStrategyFunction { return async (args) => { @@ -214,7 +218,12 @@ export function getSingleFetchDataStrategyImpl( // Actions are simple and behave the same for navigations and fetchers if (request.method !== "GET") { - return singleFetchActionStrategy(args, fetchAndDecode, basename); + return singleFetchActionStrategy( + args, + fetchAndDecode, + basename, + trailingSlashAware, + ); } let foundRevalidatingServerLoader = matches.some((m) => { @@ -254,12 +263,23 @@ export function getSingleFetchDataStrategyImpl( // errored otherwise // - So it's safe to make the call knowing there will be a `.data` file on // the other end - return nonSsrStrategy(args, getRouteInfo, fetchAndDecode, basename); + return nonSsrStrategy( + args, + getRouteInfo, + fetchAndDecode, + basename, + trailingSlashAware, + ); } // Fetcher loads are singular calls to one loader if (fetcherKey) { - return singleFetchLoaderFetcherStrategy(args, fetchAndDecode, basename); + return singleFetchLoaderFetcherStrategy( + args, + fetchAndDecode, + basename, + trailingSlashAware, + ); } // Navigational loads are more complex... @@ -270,6 +290,7 @@ export function getSingleFetchDataStrategyImpl( fetchAndDecode, ssr, basename, + trailingSlashAware, shouldAllowOptOut, ); }; @@ -281,15 +302,19 @@ async function singleFetchActionStrategy( args: DataStrategyFunctionArgs, fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined, + trailingSlashAware: boolean, ) { let actionMatch = args.matches.find((m) => m.shouldCallHandler()); invariant(actionMatch, "No action match found"); let actionStatus: number | undefined = undefined; let result = await actionMatch.resolve(async (handler) => { let result = await handler(async () => { - let { data, status } = await fetchAndDecode(args, basename, [ - actionMatch!.route.id, - ]); + let { data, status } = await fetchAndDecode( + args, + basename, + trailingSlashAware, + [actionMatch!.route.id], + ); actionStatus = status; return unwrapSingleFetchResult(data, actionMatch!.route.id); }); @@ -320,6 +345,7 @@ async function nonSsrStrategy( getRouteInfo: GetRouteInfoFunction, fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined, + trailingSlashAware: boolean, ) { let matchesToLoad = args.matches.filter((m) => m.shouldCallHandler()); let results: Record = {}; @@ -334,7 +360,12 @@ async function nonSsrStrategy( let routeId = m.route.id; let result = hasClientLoader ? await handler(async () => { - let { data } = await fetchAndDecode(args, basename, [routeId]); + let { data } = await fetchAndDecode( + args, + basename, + trailingSlashAware, + [routeId], + ); return unwrapSingleFetchResult(data, routeId); }) : await handler(); @@ -357,6 +388,7 @@ async function singleFetchLoaderNavigationStrategy( fetchAndDecode: FetchAndDecodeFunction, ssr: boolean, basename: string | undefined, + trailingSlashAware: boolean, shouldAllowOptOut: (match: DataRouteMatch) => boolean = () => true, ) { // Track which routes need a server load for use in a `_routes` param @@ -405,7 +437,12 @@ async function singleFetchLoaderNavigationStrategy( } try { let result = await handler(async () => { - let { data } = await fetchAndDecode(args, basename, [routeId]); + let { data } = await fetchAndDecode( + args, + basename, + trailingSlashAware, + [routeId], + ); return unwrapSingleFetchResult(data, routeId); }); @@ -463,7 +500,12 @@ async function singleFetchLoaderNavigationStrategy( ? [...routesParams.keys()] : undefined; try { - let data = await fetchAndDecode(args, basename, targetRoutes); + let data = await fetchAndDecode( + args, + basename, + trailingSlashAware, + targetRoutes, + ); singleFetchDfd.resolve(data.data); } catch (e) { singleFetchDfd.reject(e); @@ -535,13 +577,16 @@ async function singleFetchLoaderFetcherStrategy( args: DataStrategyFunctionArgs, fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined, + trailingSlashAware: boolean, ) { let fetcherMatch = args.matches.find((m) => m.shouldCallHandler()); invariant(fetcherMatch, "No fetcher match found"); let routeId = fetcherMatch.route.id; let result = await fetcherMatch.resolve(async (handler) => handler(async () => { - let { data } = await fetchAndDecode(args, basename, [routeId]); + let { data } = await fetchAndDecode(args, basename, trailingSlashAware, [ + routeId, + ]); return unwrapSingleFetchResult(data, routeId); }), ); @@ -567,6 +612,7 @@ export function stripIndexParam(url: URL) { export function singleFetchUrl( reqUrl: URL | string, basename: string | undefined, + trailingSlashAware: boolean, extension: "data" | "rsc", ) { let url = @@ -581,12 +627,22 @@ export function singleFetchUrl( ) : reqUrl; - if (url.pathname === "/") { - url.pathname = `_root.${extension}`; - } else if (basename && stripBasename(url.pathname, basename) === "/") { - url.pathname = `${basename.replace(/\/$/, "")}/_root.${extension}`; + if (trailingSlashAware) { + if (url.pathname.endsWith("/")) { + // Preserve trailing slash by using /_.data pattern + // e.g., /about/ -> /about/_.data + url.pathname = `${url.pathname}_.${extension}`; + } else { + url.pathname = `${url.pathname}.${extension}`; + } } else { - url.pathname = `${url.pathname.replace(/\/$/, "")}.${extension}`; + if (url.pathname === "/") { + url.pathname = `_root.${extension}`; + } else if (basename && stripBasename(url.pathname, basename) === "/") { + url.pathname = `${basename.replace(/\/$/, "")}/_root.${extension}`; + } else { + url.pathname = `${url.pathname.replace(/\/$/, "")}.${extension}`; + } } return url; @@ -595,10 +651,11 @@ export function singleFetchUrl( async function fetchAndDecodeViaTurboStream( args: DataStrategyFunctionArgs, basename: string | undefined, + trailingSlashAware: boolean, targetRoutes?: string[], ): Promise<{ status: number; data: DecodedSingleFetchResults }> { let { request } = args; - let url = singleFetchUrl(request.url, basename, "data"); + let url = singleFetchUrl(request.url, basename, trailingSlashAware, "data"); if (request.method === "GET") { url = stripIndexParam(url); if (targetRoutes) { diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 462ef03097..865ef8832f 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -458,6 +458,8 @@ export function getRSCSingleFetchDataStrategy( getFetchAndDecodeViaRSC(createFromReadableStream, fetchImplementation), ssr, basename, + // .rsc requests are always trailing slash aware + true, // If the route has a component but we don't have an element, we need to hit // the server loader flow regardless of whether the client loader calls // `serverLoader` or not, otherwise we'll have nothing to render. @@ -520,10 +522,11 @@ function getFetchAndDecodeViaRSC( return async ( args: DataStrategyFunctionArgs, basename: string | undefined, + trailingSlashAware: boolean, targetRoutes?: string[], ) => { let { request, context } = args; - let url = singleFetchUrl(request.url, basename, "rsc"); + let url = singleFetchUrl(request.url, basename, trailingSlashAware, "rsc"); if (request.method === "GET") { url = stripIndexParam(url); if (targetRoutes) { @@ -820,6 +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 }, isSpaMode: false, ssr: true, diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 79f726a1cb..87d8d1b06c 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -398,10 +398,10 @@ export async function matchRSCServerRequest({ basename = basename || "/"; let normalizedPath = url.pathname; - if (stripBasename(normalizedPath, basename) === "/_root.rsc") { - normalizedPath = basename; - } else if (normalizedPath.endsWith(".rsc")) { - normalizedPath = normalizedPath.replace(/\.rsc$/, ""); + if (url.pathname.endsWith("/_.rsc")) { + normalizedPath = url.pathname.replace(/_\.rsc$/, ""); + } else if (url.pathname.endsWith(".rsc")) { + normalizedPath = url.pathname.replace(/\.rsc$/, ""); } if ( diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index bf49d14ba9..845d04544f 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -580,6 +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 }, 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 6a8091cf25..8ce88bf45f 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -109,17 +109,26 @@ function derive(build: ServerBuild, mode?: string) { let normalizedBasename = build.basename || "/"; let normalizedPath = url.pathname; - if (stripBasename(normalizedPath, normalizedBasename) === "/_root.data") { - normalizedPath = normalizedBasename; - } else if (normalizedPath.endsWith(".data")) { - normalizedPath = normalizedPath.replace(/\.data$/, ""); - } + if (build.future.unstable_trailingSlashAwareDataRequests) { + if (normalizedPath.endsWith("/_.data")) { + // Handle trailing slash URLs: /about/_.data -> /about/ + normalizedPath = normalizedPath.replace(/_.data$/, ""); + } else { + normalizedPath = normalizedPath.replace(/\.data$/, ""); + } + } else { + if (stripBasename(normalizedPath, normalizedBasename) === "/_root.data") { + normalizedPath = normalizedBasename; + } else if (normalizedPath.endsWith(".data")) { + normalizedPath = normalizedPath.replace(/\.data$/, ""); + } - if ( - stripBasename(normalizedPath, normalizedBasename) !== "/" && - normalizedPath.endsWith("/") - ) { - normalizedPath = normalizedPath.slice(0, -1); + if ( + stripBasename(normalizedPath, normalizedBasename) !== "/" && + normalizedPath.endsWith("/") + ) { + normalizedPath = normalizedPath.slice(0, -1); + } } let isSpaMode = diff --git a/playground/framework/app/routes/$.tsx b/playground/framework/app/routes/$.tsx new file mode 100644 index 0000000000..b63acd7ffe --- /dev/null +++ b/playground/framework/app/routes/$.tsx @@ -0,0 +1,5 @@ +import { data } from "react-router"; + +export function loader() { + return data(null, { status: 404 }); +}