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 });
+}