Skip to content

Commit b6e712e

Browse files
authored
Fix: allow nested splat routes to begin with "special" url-safe characters (#8563)
* allow nested splat routes to include `.`, `-`, `~`, or a url-encoded entity as the first character * sign cla * expand url-encoded entity matching to include any two hex characters * add more exhaustive descendant splat route tests * remove extra non-capturing group
1 parent 3f6bc1b commit b6e712e

File tree

4 files changed

+367
-2
lines changed

4 files changed

+367
-2
lines changed

contributors.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- petersendidit
2020
- RobHannay
2121
- sergiodxa
22+
- shamsup
2223
- shivamsinghchahar
2324
- thisiskartik
2425
- timdorr

packages/react-router/__tests__/descendant-routes-splat-matching-test.tsx

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from "react";
22
import * as TestRenderer from "react-test-renderer";
3-
import { MemoryRouter, Outlet, Routes, Route } from "react-router";
3+
import { MemoryRouter, Outlet, Routes, Route, useParams } from "react-router";
4+
import type { InitialEntry } from "history";
45

56
describe("Descendant <Routes> splat matching", () => {
67
describe("when the parent route path ends with /*", () => {
@@ -57,5 +58,158 @@ describe("Descendant <Routes> splat matching", () => {
5758
</div>
5859
`);
5960
});
61+
describe("works with paths beginning with special characters", () => {
62+
function PrintParams() {
63+
return <p>The params are {JSON.stringify(useParams())}</p>;
64+
}
65+
function ReactCourses() {
66+
return (
67+
<div>
68+
<h1>React</h1>
69+
<Routes>
70+
<Route
71+
path=":splat"
72+
element={
73+
<div>
74+
<h1>React Fundamentals</h1>
75+
<PrintParams />
76+
</div>
77+
}
78+
/>
79+
</Routes>
80+
</div>
81+
);
82+
}
83+
84+
function Courses() {
85+
return (
86+
<div>
87+
<h1>Courses</h1>
88+
<Outlet />
89+
</div>
90+
);
91+
}
92+
93+
function renderNestedSplatRoute(initialEntries: InitialEntry[]) {
94+
let renderer: TestRenderer.ReactTestRenderer;
95+
TestRenderer.act(() => {
96+
renderer = TestRenderer.create(
97+
<MemoryRouter initialEntries={initialEntries}>
98+
<Routes>
99+
<Route path="courses" element={<Courses />}>
100+
<Route path="react/*" element={<ReactCourses />} />
101+
</Route>
102+
</Routes>
103+
</MemoryRouter>
104+
);
105+
});
106+
return renderer;
107+
}
108+
109+
it("allows `-` to appear at the beginning", () => {
110+
let renderer = renderNestedSplatRoute([
111+
"/courses/react/-react-fundamentals"
112+
]);
113+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
114+
<div>
115+
<h1>
116+
Courses
117+
</h1>
118+
<div>
119+
<h1>
120+
React
121+
</h1>
122+
<div>
123+
<h1>
124+
React Fundamentals
125+
</h1>
126+
<p>
127+
The params are
128+
{"*":"-react-fundamentals","splat":"-react-fundamentals"}
129+
</p>
130+
</div>
131+
</div>
132+
</div>
133+
`);
134+
});
135+
it("allows `.` to appear at the beginning", () => {
136+
let renderer = renderNestedSplatRoute([
137+
"/courses/react/.react-fundamentals"
138+
]);
139+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
140+
<div>
141+
<h1>
142+
Courses
143+
</h1>
144+
<div>
145+
<h1>
146+
React
147+
</h1>
148+
<div>
149+
<h1>
150+
React Fundamentals
151+
</h1>
152+
<p>
153+
The params are
154+
{"*":".react-fundamentals","splat":".react-fundamentals"}
155+
</p>
156+
</div>
157+
</div>
158+
</div>
159+
`);
160+
});
161+
it("allows `~` to appear at the beginning", () => {
162+
let renderer = renderNestedSplatRoute([
163+
"/courses/react/~react-fundamentals"
164+
]);
165+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
166+
<div>
167+
<h1>
168+
Courses
169+
</h1>
170+
<div>
171+
<h1>
172+
React
173+
</h1>
174+
<div>
175+
<h1>
176+
React Fundamentals
177+
</h1>
178+
<p>
179+
The params are
180+
{"*":"~react-fundamentals","splat":"~react-fundamentals"}
181+
</p>
182+
</div>
183+
</div>
184+
</div>
185+
`);
186+
});
187+
it("allows url-encoded entities to appear at the beginning", () => {
188+
let renderer = renderNestedSplatRoute([
189+
"/courses/react/%20react-fundamentals"
190+
]);
191+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
192+
<div>
193+
<h1>
194+
Courses
195+
</h1>
196+
<div>
197+
<h1>
198+
React
199+
</h1>
200+
<div>
201+
<h1>
202+
React Fundamentals
203+
</h1>
204+
<p>
205+
The params are
206+
{"*":" react-fundamentals","splat":" react-fundamentals"}
207+
</p>
208+
</div>
209+
</div>
210+
</div>
211+
`);
212+
});
213+
});
60214
});
61215
});

packages/react-router/__tests__/layout-routes-test.tsx

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,211 @@ describe("A layout route", () => {
3131
</h1>
3232
`);
3333
});
34+
describe("matches when a nested splat route begins with a special character", () => {
35+
it("allows routes starting with `-`", () => {
36+
let renderer: TestRenderer.ReactTestRenderer;
37+
TestRenderer.act(() => {
38+
renderer = TestRenderer.create(
39+
<MemoryRouter initialEntries={["/-splat"]}>
40+
<Routes>
41+
<Route
42+
element={
43+
<div>
44+
<h1>Layout</h1>
45+
<Outlet />
46+
</div>
47+
}
48+
>
49+
<Route
50+
path="*"
51+
element={
52+
<div>
53+
<h1>Splat</h1>
54+
</div>
55+
}
56+
/>
57+
</Route>
58+
</Routes>
59+
</MemoryRouter>
60+
);
61+
});
62+
63+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
64+
<div>
65+
<h1>
66+
Layout
67+
</h1>
68+
<div>
69+
<h1>
70+
Splat
71+
</h1>
72+
</div>
73+
</div>
74+
`);
75+
});
76+
it("allows routes starting with `~`", () => {
77+
let renderer: TestRenderer.ReactTestRenderer;
78+
TestRenderer.act(() => {
79+
renderer = TestRenderer.create(
80+
<MemoryRouter initialEntries={["/~splat"]}>
81+
<Routes>
82+
<Route
83+
element={
84+
<div>
85+
<h1>Layout</h1>
86+
<Outlet />
87+
</div>
88+
}
89+
>
90+
<Route
91+
path="*"
92+
element={
93+
<div>
94+
<h1>Splat</h1>
95+
</div>
96+
}
97+
/>
98+
</Route>
99+
</Routes>
100+
</MemoryRouter>
101+
);
102+
});
103+
104+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
105+
<div>
106+
<h1>
107+
Layout
108+
</h1>
109+
<div>
110+
<h1>
111+
Splat
112+
</h1>
113+
</div>
114+
</div>
115+
`);
116+
});
117+
it("allows routes starting with `_`", () => {
118+
let renderer: TestRenderer.ReactTestRenderer;
119+
TestRenderer.act(() => {
120+
renderer = TestRenderer.create(
121+
<MemoryRouter initialEntries={["/_splat"]}>
122+
<Routes>
123+
<Route
124+
element={
125+
<div>
126+
<h1>Layout</h1>
127+
<Outlet />
128+
</div>
129+
}
130+
>
131+
<Route
132+
path="*"
133+
element={
134+
<div>
135+
<h1>Splat</h1>
136+
</div>
137+
}
138+
/>
139+
</Route>
140+
</Routes>
141+
</MemoryRouter>
142+
);
143+
});
144+
145+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
146+
<div>
147+
<h1>
148+
Layout
149+
</h1>
150+
<div>
151+
<h1>
152+
Splat
153+
</h1>
154+
</div>
155+
</div>
156+
`);
157+
});
158+
it("allows routes starting with `.`", () => {
159+
let renderer: TestRenderer.ReactTestRenderer;
160+
TestRenderer.act(() => {
161+
renderer = TestRenderer.create(
162+
<MemoryRouter initialEntries={["/.splat"]}>
163+
<Routes>
164+
<Route
165+
element={
166+
<div>
167+
<h1>Layout</h1>
168+
<Outlet />
169+
</div>
170+
}
171+
>
172+
<Route
173+
path="*"
174+
element={
175+
<div>
176+
<h1>Splat</h1>
177+
</div>
178+
}
179+
/>
180+
</Route>
181+
</Routes>
182+
</MemoryRouter>
183+
);
184+
});
185+
186+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
187+
<div>
188+
<h1>
189+
Layout
190+
</h1>
191+
<div>
192+
<h1>
193+
Splat
194+
</h1>
195+
</div>
196+
</div>
197+
`);
198+
});
199+
it("allows routes starting with url-encoded entities", () => {
200+
let renderer: TestRenderer.ReactTestRenderer;
201+
TestRenderer.act(() => {
202+
renderer = TestRenderer.create(
203+
<MemoryRouter initialEntries={["/%20splat"]}>
204+
<Routes>
205+
<Route
206+
element={
207+
<div>
208+
<h1>Layout</h1>
209+
<Outlet />
210+
</div>
211+
}
212+
>
213+
<Route
214+
path="*"
215+
element={
216+
<div>
217+
<h1>Splat</h1>
218+
</div>
219+
}
220+
/>
221+
</Route>
222+
</Routes>
223+
</MemoryRouter>
224+
);
225+
});
226+
227+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
228+
<div>
229+
<h1>
230+
Layout
231+
</h1>
232+
<div>
233+
<h1>
234+
Splat
235+
</h1>
236+
</div>
237+
</div>
238+
`);
239+
});
240+
});
34241
});

packages/react-router/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1227,7 +1227,10 @@ function compilePath(
12271227
: // Otherwise, match a word boundary or a proceeding /. The word boundary restricts
12281228
// parent routes to matching only their own words and nothing more, e.g. parent
12291229
// route "/home" should not match "/home2".
1230-
"(?:\\b|\\/|$)";
1230+
// Additionally, allow paths starting with `.`, `-`, `~`, and url-encoded entities,
1231+
// but do not consume the character in the matched path so they can match against
1232+
// nested paths.
1233+
"(?:(?=[.~-]|%[0-9A-F]{2})|\\b|\\/|$)";
12311234
}
12321235

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

0 commit comments

Comments
 (0)