Skip to content

Commit 9a35285

Browse files
authored
Fix inadvertent support for partial dynamic parameters (#9506)
* fix: ensure consistency in generatePath/compilePath for partial splat params * Do not match partial dynamic parameters
1 parent 3778eac commit 9a35285

File tree

6 files changed

+146
-11
lines changed

6 files changed

+146
-11
lines changed

.changeset/happy-balloons-buy.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"react-router": patch
3+
"@remix-run/router": patch
4+
---
5+
6+
Stop incorrectly matching on partial named parameters, i.e. `<Route path="prefix-:param">`, to align with how splat parameters work. If you were previously relying on this behavior then it's recommended to extract the static portion of the path at the `useParams` call site:
7+
8+
```jsx
9+
// Old behavior at URL /prefix-123
10+
<Route path="prefix-:id" element={<Comp /> }>
11+
12+
function Comp() {
13+
let params = useParams(); // { id: '123' }
14+
let id = params.id; // "123"
15+
...
16+
}
17+
18+
// New behavior at URL /prefix-123
19+
<Route path=":id" element={<Comp /> }>
20+
21+
function Comp() {
22+
let params = useParams(); // { id: 'prefix-123' }
23+
let id = params.id.replace(/^prefix-/, ''); // "123"
24+
...
25+
}
26+
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@
107107
},
108108
"filesize": {
109109
"packages/router/dist/router.umd.min.js": {
110-
"none": "35.5 kB"
110+
"none": "36 kB"
111111
},
112112
"packages/react-router/dist/react-router.production.min.js": {
113113
"none": "12.5 kB"

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ describe("generatePath", () => {
1313
expect(generatePath("/courses/:id", { id: "routing" })).toBe(
1414
"/courses/routing"
1515
);
16+
expect(
17+
generatePath("/courses/:id/student/:studentId", {
18+
id: "routing",
19+
studentId: "matt",
20+
})
21+
).toBe("/courses/routing/student/matt");
1622
expect(generatePath("/courses/*", { "*": "routing/grades" })).toBe(
1723
"/courses/routing/grades"
1824
);
@@ -51,6 +57,8 @@ describe("generatePath", () => {
5157
});
5258

5359
it("only interpolates and does not add slashes", () => {
60+
let consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => {});
61+
5462
expect(generatePath("*")).toBe("");
5563
expect(generatePath("/*")).toBe("/");
5664

@@ -63,10 +71,32 @@ describe("generatePath", () => {
6371
expect(generatePath("*", { "*": "bar" })).toBe("bar");
6472
expect(generatePath("/*", { "*": "bar" })).toBe("/bar");
6573

66-
expect(generatePath("foo:bar", { bar: "baz" })).toBe("foobaz");
67-
expect(generatePath("/foo:bar", { bar: "baz" })).toBe("/foobaz");
74+
// No support for partial dynamic params
75+
expect(generatePath("foo:bar", { bar: "baz" })).toBe("foo:bar");
76+
expect(generatePath("/foo:bar", { bar: "baz" })).toBe("/foo:bar");
77+
78+
// Partial splats are treated as independent path segments
79+
expect(generatePath("foo*", { "*": "bar" })).toBe("foo/bar");
80+
expect(generatePath("/foo*", { "*": "bar" })).toBe("/foo/bar");
81+
82+
// Ensure we warn on partial splat usages
83+
expect(consoleWarn.mock.calls).toMatchInlineSnapshot(`
84+
Array [
85+
Array [
86+
"Route path \\"foo*\\" will be treated as if it were \\"foo/*\\" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to \\"foo/*\\".",
87+
],
88+
Array [
89+
"Route path \\"/foo*\\" will be treated as if it were \\"/foo/*\\" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to \\"/foo/*\\".",
90+
],
91+
Array [
92+
"Route path \\"foo*\\" will be treated as if it were \\"foo/*\\" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to \\"foo/*\\".",
93+
],
94+
Array [
95+
"Route path \\"/foo*\\" will be treated as if it were \\"/foo/*\\" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to \\"/foo/*\\".",
96+
],
97+
]
98+
`);
6899

69-
expect(generatePath("foo*", { "*": "bar" })).toBe("foobar");
70-
expect(generatePath("/foo*", { "*": "bar" })).toBe("/foobar");
100+
consoleWarn.mockRestore();
71101
});
72102
});

packages/react-router/__tests__/path-matching-test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,58 @@ describe("path matching with splats", () => {
275275
pathnameBase: "/courses",
276276
});
277277
});
278+
279+
test("does not support partial path matching with named parameters", () => {
280+
let routes = [{ path: "/prefix:id" }];
281+
expect(matchRoutes(routes, "/prefix:id")).toMatchInlineSnapshot(`
282+
Array [
283+
Object {
284+
"params": Object {},
285+
"pathname": "/prefix:id",
286+
"pathnameBase": "/prefix:id",
287+
"route": Object {
288+
"path": "/prefix:id",
289+
},
290+
},
291+
]
292+
`);
293+
expect(matchRoutes(routes, "/prefixabc")).toEqual(null);
294+
expect(matchRoutes(routes, "/prefix/abc")).toEqual(null);
295+
});
296+
297+
test("does not support partial path matching with splat parameters", () => {
298+
let consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => {});
299+
let routes = [{ path: "/prefix*" }];
300+
expect(matchRoutes(routes, "/prefix/abc")).toMatchInlineSnapshot(`
301+
Array [
302+
Object {
303+
"params": Object {
304+
"*": "abc",
305+
},
306+
"pathname": "/prefix/abc",
307+
"pathnameBase": "/prefix",
308+
"route": Object {
309+
"path": "/prefix*",
310+
},
311+
},
312+
]
313+
`);
314+
expect(matchRoutes(routes, "/prefixabc")).toMatchInlineSnapshot(`null`);
315+
316+
// Should warn on each invocation of matchRoutes
317+
expect(consoleWarn.mock.calls).toMatchInlineSnapshot(`
318+
Array [
319+
Array [
320+
"Route path \\"/prefix*\\" will be treated as if it were \\"/prefix/*\\" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to \\"/prefix/*\\".",
321+
],
322+
Array [
323+
"Route path \\"/prefix*\\" will be treated as if it were \\"/prefix/*\\" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to \\"/prefix/*\\".",
324+
],
325+
]
326+
`);
327+
328+
consoleWarn.mockRestore();
329+
});
278330
});
279331

280332
describe("path matchine with optional segments", () => {

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ describe("useRoutes", () => {
7676
});
7777

7878
it("returns null when no route matches", () => {
79+
let spy = jest.spyOn(console, "warn").mockImplementation(() => {});
80+
7981
let routes = [{ path: "one", element: <h1>one</h1> }];
8082

8183
const NullRenderer = (props: { routes: RouteObject[] }) => {
@@ -97,9 +99,13 @@ describe("useRoutes", () => {
9799
is null
98100
</div>
99101
`);
102+
103+
spy.mockRestore();
100104
});
101105

102106
it("returns null when no route matches and a `location` prop is passed", () => {
107+
let spy = jest.spyOn(console, "warn").mockImplementation(() => {});
108+
103109
let routes = [{ path: "one", element: <h1>one</h1> }];
104110

105111
const NullRenderer = (props: {
@@ -114,7 +120,10 @@ describe("useRoutes", () => {
114120
TestRenderer.act(() => {
115121
renderer = TestRenderer.create(
116122
<MemoryRouter initialEntries={["/two"]}>
117-
<NullRenderer routes={routes} location={{ pathname: "/three" }} />
123+
<NullRenderer
124+
routes={routes}
125+
location={{ pathname: "/three", search: "", hash: "" }}
126+
/>
118127
</MemoryRouter>
119128
);
120129
});
@@ -124,6 +133,8 @@ describe("useRoutes", () => {
124133
is null
125134
</div>
126135
`);
136+
137+
spy.mockRestore();
127138
});
128139

129140
describe("warns", () => {

packages/router/utils.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ type _PathParam<Path extends string> =
197197
Path extends `${infer L}/${infer R}`
198198
? _PathParam<L> | _PathParam<R>
199199
: // find params after `:`
200-
Path extends `${string}:${infer Param}`
200+
Path extends `:${infer Param}`
201201
? Param
202202
: // otherwise, there aren't any params present
203203
never;
@@ -570,16 +570,32 @@ function matchRouteBranch<
570570
* @see https://reactrouter.com/docs/en/v6/utils/generate-path
571571
*/
572572
export function generatePath<Path extends string>(
573-
path: Path,
573+
originalPath: Path,
574574
params: {
575575
[key in PathParam<Path>]: string;
576576
} = {} as any
577577
): string {
578+
let path = originalPath;
579+
if (path.endsWith("*") && path !== "*" && !path.endsWith("/*")) {
580+
warning(
581+
false,
582+
`Route path "${path}" will be treated as if it were ` +
583+
`"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` +
584+
`always follow a \`/\` in the pattern. To get rid of this warning, ` +
585+
`please change the route path to "${path.replace(/\*$/, "/*")}".`
586+
);
587+
path = path.replace(/\*$/, "/*") as Path;
588+
}
589+
578590
return path
579-
.replace(/:(\w+)/g, (_, key: PathParam<Path>) => {
591+
.replace(/^:(\w+)/g, (_, key: PathParam<Path>) => {
580592
invariant(params[key] != null, `Missing ":${key}" param`);
581593
return params[key]!;
582594
})
595+
.replace(/\/:(\w+)/g, (_, key: PathParam<Path>) => {
596+
invariant(params[key] != null, `Missing ":${key}" param`);
597+
return `/${params[key]!}`;
598+
})
583599
.replace(/(\/?)\*/, (_, prefix, __, str) => {
584600
const star = "*" as PathParam<Path>;
585601

@@ -718,9 +734,9 @@ function compilePath(
718734
.replace(/\/*\*?$/, "") // Ignore trailing / and /*, we'll handle it below
719735
.replace(/^\/*/, "/") // Make sure it has a leading /
720736
.replace(/[\\.*+^$?{}|()[\]]/g, "\\$&") // Escape special regex chars
721-
.replace(/:(\w+)/g, (_: string, paramName: string) => {
737+
.replace(/\/:(\w+)/g, (_: string, paramName: string) => {
722738
paramNames.push(paramName);
723-
return "([^\\/]+)";
739+
return "/([^\\/]+)";
724740
});
725741

726742
if (path.endsWith("*")) {

0 commit comments

Comments
 (0)