Skip to content

Commit 779d4af

Browse files
authored
fix: update matchPath to avoid false positives on dash-separated segments (#9300)
* fix: update matchPath to avoid false positives on dash-separated segments * Add changeset * remove uneeded else branch in matching empty paths
1 parent 9e386f5 commit 779d4af

File tree

4 files changed

+92
-9
lines changed

4 files changed

+92
-9
lines changed

.changeset/sweet-chicken-suffer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
fix: update matchPath to avoid false positives on dash-separated segments (#9300)

packages/react-router-dom/__tests__/nav-link-active-test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,76 @@ describe("NavLink", () => {
218218

219219
expect(anchor.props.className).not.toMatch("active");
220220
});
221+
222+
it("does not match when <Link to> path is a subset of the active url", () => {
223+
let renderer: TestRenderer.ReactTestRenderer;
224+
TestRenderer.act(() => {
225+
renderer = TestRenderer.create(
226+
<MemoryRouter initialEntries={["/user-preferences"]}>
227+
<Routes>
228+
<Route
229+
path="/"
230+
element={
231+
<div>
232+
<NavLink to="user">Go to /user</NavLink>
233+
<NavLink to="user-preferences">
234+
Go to /user-preferences
235+
</NavLink>
236+
<Outlet />
237+
</div>
238+
}
239+
>
240+
<Route index element={<p>Index</p>} />
241+
<Route path="user" element={<p>User</p>} />
242+
<Route
243+
path="user-preferences"
244+
element={<p>User Preferences</p>}
245+
/>
246+
</Route>
247+
</Routes>
248+
</MemoryRouter>
249+
);
250+
});
251+
252+
let anchors = renderer.root.findAllByType("a");
253+
254+
expect(anchors.map((a) => a.props.className)).toEqual(["", "active"]);
255+
});
256+
257+
it("does not match when active url is a subset of a <Route path> segment", () => {
258+
let renderer: TestRenderer.ReactTestRenderer;
259+
TestRenderer.act(() => {
260+
renderer = TestRenderer.create(
261+
<MemoryRouter initialEntries={["/user"]}>
262+
<Routes>
263+
<Route
264+
path="/"
265+
element={
266+
<div>
267+
<NavLink to="user">Go to /user</NavLink>
268+
<NavLink to="user-preferences">
269+
Go to /user-preferences
270+
</NavLink>
271+
<Outlet />
272+
</div>
273+
}
274+
>
275+
<Route index element={<p>Index</p>} />
276+
<Route path="user" element={<p>User</p>} />
277+
<Route
278+
path="user-preferences"
279+
element={<p>User Preferences</p>}
280+
/>
281+
</Route>
282+
</Routes>
283+
</MemoryRouter>
284+
);
285+
});
286+
287+
let anchors = renderer.root.findAllByType("a");
288+
289+
expect(anchors.map((a) => a.props.className)).toEqual(["active", ""]);
290+
});
221291
});
222292

223293
describe("when it matches just the beginning but not to the end", () => {

packages/react-router/__tests__/matchPath-test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ describe("matchPath", () => {
156156
it("fails to match a pathname where the segments do not match", () => {
157157
expect(matchPath({ path: "/users", end: false }, "/")).toBeNull();
158158
expect(matchPath({ path: "/users", end: false }, "/users2")).toBeNull();
159+
expect(matchPath({ path: "/users", end: false }, "/users-2")).toBeNull();
160+
expect(matchPath({ path: "/users", end: false }, "/users~2")).toBeNull();
161+
expect(matchPath({ path: "/users", end: false }, "/users@2")).toBeNull();
162+
expect(matchPath({ path: "/users", end: false }, "/users.2")).toBeNull();
159163
expect(
160164
matchPath({ path: "/users/mj", end: false }, "/users/mj2")
161165
).toBeNull();

packages/router/utils.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -634,16 +634,20 @@ function compilePath(
634634
path === "*" || path === "/*"
635635
? "(.*)$" // Already matched the initial /, just match the rest
636636
: "(?:\\/(.+)|\\/*)$"; // Don't include the / in params["*"]
637+
} else if (end) {
638+
// When matching to the end, ignore trailing slashes
639+
regexpSource += "\\/*$";
640+
} else if (path !== "" && path !== "/") {
641+
// If our path is non-empty and contains anything beyond an initial slash,
642+
// then we have _some_ form of path in our regex so we should expect to
643+
// match only if we find the end of this path segment. Look for an optional
644+
// non-captured trailing slash (to match a portion of the URL) or the end
645+
// of the path (if we've matched to the end). We used to do this with a
646+
// word boundary but that gives false positives on routes like
647+
// /user-preferences since `-` counts as a word boundary.
648+
regexpSource += "(?:(?=\\/|$))";
637649
} else {
638-
regexpSource += end
639-
? "\\/*$" // When matching to the end, ignore trailing slashes
640-
: // Otherwise, match a word boundary or a proceeding /. The word boundary restricts
641-
// parent routes to matching only their own words and nothing more, e.g. parent
642-
// route "/home" should not match "/home2".
643-
// Additionally, allow paths starting with `.`, `-`, `~`, and url-encoded entities,
644-
// but do not consume the character in the matched path so they can match against
645-
// nested paths.
646-
"(?:(?=[@.~-]|%[0-9A-F]{2})|\\b|\\/|$)";
650+
// Nothing to match for "" or "/"
647651
}
648652

649653
let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i");

0 commit comments

Comments
 (0)