Skip to content
34 changes: 34 additions & 0 deletions .changeset/serious-bobcats-impress.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@
- renyu-io
- reyronald
- RFCreate
- richardkall
- richardscarrott
- rifaidev
- rimian
Expand Down
193 changes: 193 additions & 0 deletions integration/single-fetch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<h1>Home</h1>
<Link to="/about/">Go to About (with trailing slash)</Link>
<Link to="/about">Go to About (without trailing slash)</Link>
</div>
);
}
`,
"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 (
<div>
<h1>About</h1>
<p id="pathname">{pathname}</p>
<p id="trailing-slash">{String(hasTrailingSlash)}</p>
<Link to="/">Go back home</Link>
</div>
);
}
`,
},
});
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 (
<div>
<h1>Home</h1>
<Link to="/about/">Go to About (with trailing slash)</Link>
<Link to="/about">Go to About (without trailing slash)</Link>
</div>
);
}
`,
"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 (
<div>
<h1>About</h1>
<p id="pathname">{pathname}</p>
<p id="trailing-slash">{String(hasTrailingSlash)}</p>
<Link to="/">Go back home</Link>
</div>
);
}
`,
},
});
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 = [];
});
});
1 change: 1 addition & 0 deletions integration/vite-presets-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/react-router-dev/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type ValidateConfigFunction = (config: ReactRouterConfig) => string | void;
interface FutureConfig {
unstable_optimizeDeps: boolean;
unstable_subResourceIntegrity: boolean;
unstable_trailingSlashAwareDataRequests: boolean;
/**
* Enable route middleware
*/
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 16 additions & 5 deletions packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(","));
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/lib/dom-export/hydrated-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ function createHydratedRouter({
ssrInfo.routeModules,
ssrInfo.context.ssr,
ssrInfo.context.basename,
ssrInfo.context.future.unstable_trailingSlashAwareDataRequests,
),
patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction(
ssrInfo.manifest,
Expand Down
10 changes: 8 additions & 2 deletions packages/react-router/lib/dom/ssr/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand All @@ -451,6 +456,7 @@ function PrefetchPageLinksImpl({
return [url.pathname + url.search];
}, [
basename,
future.unstable_trailingSlashAwareDataRequests,
loaderData,
location,
manifest,
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/lib/dom/ssr/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface EntryContext extends FrameworkContextObject {

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

Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/lib/dom/ssr/routes-test-stub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
Loading