Skip to content

Commit bea38ab

Browse files
authored
feat: ignore trailing slashes during route matching, configurable via trailingSlash prop (#226)
1 parent 5d3d8e7 commit bea38ab

10 files changed

Lines changed: 304 additions & 12 deletions

File tree

packages/docs/src/pages/ApiComponentsPage.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ export function ApiComponentsPage() {
6666
<code>window.location</code> without navigation interception (MPA behavior).
6767
</td>
6868
</tr>
69+
<tr>
70+
<td>
71+
<code>trailingSlash</code>
72+
</td>
73+
<td>
74+
<code>{'"ignore" | "strict"'}</code>
75+
</td>
76+
<td>
77+
How trailing slashes are treated during route matching. <code>"ignore"</code>{" "}
78+
(default) ignores a single trailing slash on the pathname or on route{" "}
79+
<code>path</code> patterns, so <code>/users/</code> matches{" "}
80+
<code>path: "/users"</code>; the URL itself is never rewritten.{" "}
81+
<code>"strict"</code> requires pathnames to match patterns exactly.
82+
</td>
83+
</tr>
6984
<tr>
7085
<td>
7186
<code>ssr</code>

packages/docs/src/pages/LearnNestedRoutesPage.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,24 @@ const routes = [
244244
245245
// /files → matches FileExplorer (outlet is null)
246246
// /files/123 → matches FileExplorer + FileDetails`}</CodeBlock>
247+
248+
<h4>Trailing Slashes</h4>
249+
<p>
250+
By default, a single trailing slash in the URL is <strong>ignored</strong> during
251+
matching: <code>/users/</code> matches a route with <code>path: "/users"</code> (and a
252+
trailing slash in a route's <code>path</code> is likewise ignored). Only matching is
253+
affected&mdash;the URL itself is never rewritten, so <code>useLocation()</code> still
254+
reports the pathname with its trailing slash. The root path <code>/</code> is unaffected.
255+
</p>
256+
<p>
257+
To require URLs to match patterns exactly, set <code>trailingSlash: "strict"</code> on the{" "}
258+
<code>{"<Router>"}</code>:
259+
</p>
260+
<CodeBlock language="tsx">{`<Router routes={routes} trailingSlash="strict" />
261+
262+
// With trailingSlash="strict":
263+
// /users → matches path: "/users"
264+
// /users/ → does NOT match path: "/users"`}</CodeBlock>
247265
</section>
248266

249267
<section>
@@ -564,6 +582,10 @@ function App() {
564582
</li>
565583
<li>Child route paths are relative to their parent route's path</li>
566584
<li>Parent routes use prefix matching; leaf routes use exact matching</li>
585+
<li>
586+
A single trailing slash is ignored during matching by default; opt into strict matching
587+
with <code>trailingSlash: "strict"</code> on <code>{"<Router>"}</code>
588+
</li>
567589
<li>Use pathless routes for layouts that don't affect the URL</li>
568590
<li>Parent route loaders run before children, making them ideal for shared data</li>
569591
<li>Deep nesting is supported&mdash;compose as many layout levels as you need</li>

packages/router/src/Router/index.tsx

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
type NavigateOptions,
1818
type OnNavigateCallback,
1919
type FallbackMode,
20+
type TrailingSlashMode,
2021
type TransitionTypeContext,
2122
internalRoutes,
2223
} from "../types.js";
@@ -95,6 +96,19 @@ export type RouterProps = {
9596
* ```
9697
*/
9798
ssr?: SSRConfig;
99+
/**
100+
* How trailing slashes in the URL pathname are treated during route matching.
101+
*
102+
* - `"ignore"` (default): a single trailing slash is ignored, so `/users/`
103+
* matches a route with `path: "users"` (and a trailing slash in a route's
104+
* `path` pattern is likewise ignored). The URL itself is never rewritten —
105+
* only matching is affected, and the root pathname `/` is unaffected.
106+
* - `"strict"`: pathnames must match patterns exactly; `/users/` does not
107+
* match `path: "users"`.
108+
*
109+
* @default "ignore"
110+
*/
111+
trailingSlash?: TrailingSlashMode;
98112
/**
99113
* **Experimental.** Function returning the React transition types to attach
100114
* to entry-change transitions via `addTransitionType`. Called inside
@@ -118,6 +132,7 @@ export function Router({
118132
onNavigate,
119133
fallback = "none",
120134
ssr,
135+
trailingSlash,
121136
experimentalTransitionTypes,
122137
}: RouterProps): ReactNode {
123138
const routes = internalRoutes(inputRoutes);
@@ -225,13 +240,22 @@ export function Router({
225240
});
226241
}, [adapter, startTransition]);
227242

228-
// Wrap in useEffectEvent so interception doesn't re-setup when routes or onNavigate change
243+
// Wrap in useEffectEvent so interception doesn't re-setup when routes,
244+
// onNavigate, or trailingSlash change
229245
const getRoutes = useEffectEvent(() => routes);
230246
const handleNavigate = useEffectEvent<OnNavigateCallback>((...args) => onNavigate?.(...args));
247+
// Interception must match with the same trailing-slash policy as rendering,
248+
// so the interception decision agrees with what will render.
249+
const getMatchOptions = useEffectEvent(() => ({ trailingSlash }));
231250

232251
// Set up navigation interception via adapter
233252
useEffect(() => {
234-
return adapter.setupInterception(getRoutes, handleNavigate, blockerRegistry.checkAll);
253+
return adapter.setupInterception(
254+
getRoutes,
255+
handleNavigate,
256+
blockerRegistry.checkAll,
257+
getMatchOptions,
258+
);
235259
}, [adapter, blockerRegistry]);
236260

237261
// Navigate function that returns a Promise
@@ -303,6 +327,7 @@ export function Router({
303327
// Routes with loaders are skipped (skipLoaders: true).
304328
const matched = matchRoutes(routes, urlObject?.pathname ?? null, {
305329
skipLoaders: true,
330+
trailingSlash,
306331
});
307332
if (!matched) return null;
308333
return matched.map((m) => ({ ...m, data: undefined }));
@@ -314,7 +339,7 @@ export function Router({
314339

315340
// Unified path: SSR with loaders or client-side.
316341
// Both cases match routes normally and execute loaders.
317-
const matched = matchRoutes(routes, urlObject.pathname);
342+
const matched = matchRoutes(routes, urlObject.pathname, { trailingSlash });
318343
if (!matched) return null;
319344

320345
const entryKey = locationKey;
@@ -330,7 +355,16 @@ export function Router({
330355
// instance instead of the shared module-level cache.
331356
realLocationKey === null ? instanceLoaderCache : undefined,
332357
);
333-
}, [routes, adapter, urlObject, runLoaders, locationKey, realLocationKey, instanceLoaderCache]);
358+
}, [
359+
routes,
360+
adapter,
361+
urlObject,
362+
runLoaders,
363+
trailingSlash,
364+
locationKey,
365+
realLocationKey,
366+
instanceLoaderCache,
367+
]);
334368

335369
const locationState = locationEntry?.state;
336370
const locationInfo = locationEntry?.info;

packages/router/src/__tests__/Router.test.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,4 +401,80 @@ describe("Router", () => {
401401
expect(addTransitionTypeSpy).not.toHaveBeenCalled();
402402
});
403403
});
404+
405+
describe("trailingSlash prop", () => {
406+
it("ignores a trailing slash by default", () => {
407+
mockNavigation = setupNavigationMock("http://localhost/about/");
408+
409+
const routes: RouteDefinition[] = [{ path: "/about", component: () => <div>About</div> }];
410+
411+
render(<Router routes={routes} />);
412+
expect(screen.getByText("About")).toBeInTheDocument();
413+
});
414+
415+
it("does not rewrite the URL when ignoring a trailing slash", () => {
416+
mockNavigation = setupNavigationMock("http://localhost/about/");
417+
418+
function About() {
419+
const location = useLocation();
420+
return <div data-testid="pathname">{location.pathname}</div>;
421+
}
422+
423+
const routes: RouteDefinition[] = [{ path: "/about", component: About }];
424+
425+
render(<Router routes={routes} />);
426+
expect(screen.getByTestId("pathname").textContent).toBe("/about/");
427+
});
428+
429+
it("renders nothing for a trailing-slash URL in strict mode", () => {
430+
mockNavigation = setupNavigationMock("http://localhost/about/");
431+
432+
const routes: RouteDefinition[] = [{ path: "/about", component: () => <div>About</div> }];
433+
434+
const { container } = render(<Router routes={routes} trailingSlash="strict" />);
435+
expect(container.textContent).toBe("");
436+
});
437+
438+
it("intercepts navigation to a trailing-slash URL by default", () => {
439+
const onNavigate = vi.fn();
440+
441+
const routes: RouteDefinition[] = [
442+
{ path: "/", component: () => <div>Home</div> },
443+
{ path: "/about", component: () => <div>About</div> },
444+
];
445+
446+
render(<Router routes={routes} onNavigate={onNavigate} />);
447+
448+
act(() => {
449+
const { proceed } = mockNavigation.__simulateNavigationWithEvent("http://localhost/about/");
450+
proceed();
451+
});
452+
453+
expect(onNavigate).toHaveBeenCalledWith(
454+
expect.anything(),
455+
expect.objectContaining({ intercepting: true }),
456+
);
457+
expect(screen.getByText("About")).toBeInTheDocument();
458+
});
459+
460+
it("does not intercept navigation to a trailing-slash URL in strict mode", () => {
461+
const onNavigate = vi.fn();
462+
463+
const routes: RouteDefinition[] = [
464+
{ path: "/", component: () => <div>Home</div> },
465+
{ path: "/about", component: () => <div>About</div> },
466+
];
467+
468+
render(<Router routes={routes} onNavigate={onNavigate} trailingSlash="strict" />);
469+
470+
act(() => {
471+
mockNavigation.__simulateNavigationWithEvent("http://localhost/about/");
472+
});
473+
474+
expect(onNavigate).toHaveBeenCalledWith(
475+
expect.anything(),
476+
expect.objectContaining({ matches: null, intercepting: false }),
477+
);
478+
});
479+
});
404480
});

packages/router/src/__tests__/matchRoutes.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,4 +981,101 @@ describe("matchRoutes", () => {
981981
expect(result).toBeNull();
982982
});
983983
});
984+
985+
describe("trailing slash handling", () => {
986+
describe('default mode ("ignore")', () => {
987+
it("matches a pathname with trailing slash against an exact route", () => {
988+
const routes = internalRoutes([{ path: "/users", component: () => null }]);
989+
990+
const result = matchRoutes(routes, "/users/");
991+
expect(result).toHaveLength(1);
992+
expect(result![0].pathname).toBe("/users");
993+
});
994+
995+
it("matches a pathname with trailing slash against a param route", () => {
996+
const routes = internalRoutes([{ path: "/users/:id", component: () => null }]);
997+
998+
const result = matchRoutes(routes, "/users/123/");
999+
expect(result).toHaveLength(1);
1000+
expect(result![0].params).toEqual({ id: "123" });
1001+
expect(result![0].pathname).toBe("/users/123");
1002+
});
1003+
1004+
it("matches a pathname with trailing slash against nested routes", () => {
1005+
const routes = internalRoutes([
1006+
{
1007+
path: "/users",
1008+
component: () => null,
1009+
children: [{ path: ":id", component: () => null }],
1010+
},
1011+
]);
1012+
1013+
const result = matchRoutes(routes, "/users/123/");
1014+
expect(result).toHaveLength(2);
1015+
expect(result![1].params).toEqual({ id: "123" });
1016+
});
1017+
1018+
it("ignores a trailing slash in the route pattern", () => {
1019+
const routes = internalRoutes([{ path: "/users/", component: () => null }]);
1020+
1021+
expect(matchRoutes(routes, "/users")).toHaveLength(1);
1022+
expect(matchRoutes(routes, "/users/")).toHaveLength(1);
1023+
});
1024+
1025+
it("does not strip the root pathname", () => {
1026+
const routes = internalRoutes([{ path: "/", component: () => null }]);
1027+
1028+
const result = matchRoutes(routes, "/");
1029+
expect(result).toHaveLength(1);
1030+
});
1031+
1032+
it("excludes the trailing slash from wildcard-like captures", () => {
1033+
const routes = internalRoutes([{ path: "/files/:path+", component: () => null }]);
1034+
1035+
const result = matchRoutes(routes, "/files/a/b/");
1036+
expect(result).toHaveLength(1);
1037+
expect(result![0].params).toEqual({ path: "a/b" });
1038+
});
1039+
1040+
it("strips only a single trailing slash", () => {
1041+
const routes = internalRoutes([{ path: "/users", component: () => null }]);
1042+
1043+
// "/users//" contains an empty segment and is not repaired
1044+
expect(matchRoutes(routes, "/users//")).toBeNull();
1045+
});
1046+
1047+
it("does not fall through to a catch-all for a trailing-slash URL", () => {
1048+
const routes = internalRoutes([
1049+
{ path: "/users", component: () => null },
1050+
{ path: "/:rest*", component: () => null },
1051+
]);
1052+
1053+
const result = matchRoutes(routes, "/users/");
1054+
expect(result).toHaveLength(1);
1055+
expect(result![0].route.path).toBe("/users");
1056+
});
1057+
});
1058+
1059+
describe('"strict" mode', () => {
1060+
it("does not match a pathname with trailing slash against an exact route", () => {
1061+
const routes = internalRoutes([{ path: "/users", component: () => null }]);
1062+
1063+
expect(matchRoutes(routes, "/users/", { trailingSlash: "strict" })).toBeNull();
1064+
expect(matchRoutes(routes, "/users", { trailingSlash: "strict" })).toHaveLength(1);
1065+
});
1066+
1067+
it("keeps a trailing slash in the route pattern significant", () => {
1068+
const routes = internalRoutes([{ path: "/users/", component: () => null }]);
1069+
1070+
expect(matchRoutes(routes, "/users", { trailingSlash: "strict" })).toBeNull();
1071+
expect(matchRoutes(routes, "/users/", { trailingSlash: "strict" })).toHaveLength(1);
1072+
});
1073+
1074+
it("still matches the root pathname", () => {
1075+
const routes = internalRoutes([{ path: "/", component: () => null }]);
1076+
1077+
expect(matchRoutes(routes, "/", { trailingSlash: "strict" })).toHaveLength(1);
1078+
});
1079+
});
1080+
});
9841081
});

packages/router/src/core/NavigationAPIAdapter.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
NavigationType,
77
OnNavigateCallback,
88
} from "../types.js";
9-
import { matchRoutes } from "./matchRoutes.js";
9+
import { matchRoutes, type MatchRoutesOptions } from "./matchRoutes.js";
1010
import { isBypassInterception } from "../bypassInterception.js";
1111
import {
1212
executeLoaders,
@@ -260,6 +260,7 @@ export class NavigationAPIAdapter implements RouterAdapter {
260260
getRoutes: () => InternalRouteDefinition[],
261261
onNavigate?: OnNavigateCallback,
262262
checkBlockers?: () => boolean,
263+
getMatchOptions?: () => MatchRoutesOptions,
263264
): (() => void) | undefined {
264265
const handleNavigate = (event: NavigateEvent) => {
265266
// If the navigation was triggered by hardReload/hardNavigate, skip blockers and interception
@@ -305,7 +306,7 @@ export class NavigationAPIAdapter implements RouterAdapter {
305306

306307
// Check if the URL matches any of our routes
307308
const url = new URL(event.destination.url);
308-
const matched = matchRoutes(getRoutes(), url.pathname);
309+
const matched = matchRoutes(getRoutes(), url.pathname, getMatchOptions?.());
309310

310311
const isFormSubmission = event.formData !== null;
311312

packages/router/src/core/RouterAdapter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
NavigationType,
55
OnNavigateCallback,
66
} from "../types.js";
7+
import type { MatchRoutesOptions } from "./matchRoutes.js";
78

89
/**
910
* The type of change that caused a location entry update.
@@ -85,11 +86,16 @@ export interface RouterAdapter {
8586
* @param onNavigate - Optional callback invoked before navigation is intercepted
8687
* @param checkBlockers - Optional function to check if any blockers are active.
8788
* If this function returns true, navigation is prevented.
89+
* @param getMatchOptions - Optional function that returns the matching options
90+
* (e.g. trailing slash handling). Must be consistent with
91+
* the options the Router uses for rendering, so the
92+
* interception decision agrees with what will render.
8893
*/
8994
setupInterception(
9095
getRoutes: () => InternalRouteDefinition[],
9196
onNavigate?: OnNavigateCallback,
9297
checkBlockers?: () => boolean,
98+
getMatchOptions?: () => MatchRoutesOptions,
9399
): (() => void) | undefined;
94100

95101
/**

0 commit comments

Comments
 (0)