Skip to content

Commit 729cd46

Browse files
authored
fix: handle encoding of dynamic params in descendant routes (#9589)
* fix: handle encoding of dynamic params in descendant routes * add changeset * fix var name * fix jsdoc * encode pathnames in NavLink check * Bump bundle
1 parent ed12019 commit 729cd46

File tree

12 files changed

+206
-20
lines changed

12 files changed

+206
-20
lines changed

.changeset/pretty-dolls-bathe.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"react-router": patch
3+
"react-router-dom": patch
4+
---
5+
6+
Fix issues with encoded characters in descendant routes

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@
116116
"none": "14.5 kB"
117117
},
118118
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
119-
"none": "10 kB"
119+
"none": "10.5 kB"
120120
},
121121
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
122122
"none": "16 kB"

packages/react-router-dom-v5-compat/lib/components.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ export function StaticRouter({
8181
createHref(to: To) {
8282
return typeof to === "string" ? to : createPath(to);
8383
},
84+
encodeLocation(to: To) {
85+
let path = typeof to === "string" ? parsePath(to) : to;
86+
return {
87+
pathname: path.pathname || "",
88+
search: path.search || "",
89+
hash: path.hash || "",
90+
};
91+
},
8492
push(to: To) {
8593
throw new Error(
8694
`You cannot use navigator.push() on the server because it is a stateless ` +

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { JSDOM } from "jsdom";
88
import * as React from "react";
99
import * as TestRenderer from "react-test-renderer";
1010
import {
11+
BrowserRouter,
1112
MemoryRouter,
1213
Routes,
1314
Route,
@@ -189,6 +190,37 @@ describe("NavLink", () => {
189190

190191
expect(anchor.children[0]).toMatch("Home (current)");
191192
});
193+
194+
it("matches when portions of the url are encoded", () => {
195+
let renderer: TestRenderer.ReactTestRenderer;
196+
197+
TestRenderer.act(() => {
198+
renderer = TestRenderer.create(
199+
<BrowserRouter window={getWindow("/users/matt brophy")}>
200+
<Routes>
201+
<Route
202+
path="/users/:name"
203+
element={
204+
<>
205+
<NavLink to=".">Matt</NavLink>
206+
<NavLink to="/users/matt brophy">Matt</NavLink>
207+
<NavLink to="/users/michael jackson">Michael</NavLink>
208+
</>
209+
}
210+
/>
211+
</Routes>
212+
</BrowserRouter>
213+
);
214+
});
215+
216+
let anchors = renderer.root.findAllByType("a");
217+
218+
expect(anchors.map((a) => a.props.className)).toEqual([
219+
"active",
220+
"active",
221+
"",
222+
]);
223+
});
192224
});
193225

194226
describe("when it matches a partial URL segment", () => {
@@ -712,6 +744,64 @@ describe("NavLink using a data router", () => {
712744
await waitFor(() => screen.getByText("Baz page"));
713745
expect(screen.getByText("Link to Bar").className).toBe("");
714746
});
747+
748+
it("applies the default 'active'/'pending' classNames when the url has encoded characters", async () => {
749+
let barDfd = createDeferred();
750+
let bazDfd = createDeferred();
751+
let router = createBrowserRouter(
752+
createRoutesFromElements(
753+
<Route path="/" element={<Layout />}>
754+
<Route path="foo" element={<p>Foo page</p>} />
755+
<Route
756+
path="bar/:param"
757+
loader={() => barDfd.promise}
758+
element={<p>Bar page</p>}
759+
/>
760+
<Route
761+
path="baz-✅"
762+
loader={() => bazDfd.promise}
763+
element={<p>Baz page</p>}
764+
/>
765+
</Route>
766+
),
767+
{
768+
window: getWindow("/foo"),
769+
}
770+
);
771+
render(<RouterProvider router={router} />);
772+
773+
function Layout() {
774+
return (
775+
<>
776+
<NavLink to="/foo">Link to Foo</NavLink>
777+
<NavLink to="/bar/matt brophy">Link to Bar</NavLink>
778+
<NavLink to="/baz-✅">Link to Baz</NavLink>
779+
<Outlet />
780+
</>
781+
);
782+
}
783+
784+
expect(screen.getByText("Link to Bar").className).toBe("");
785+
expect(screen.getByText("Link to Baz").className).toBe("");
786+
787+
fireEvent.click(screen.getByText("Link to Bar"));
788+
expect(screen.getByText("Link to Bar").className).toBe("pending");
789+
expect(screen.getByText("Link to Baz").className).toBe("");
790+
791+
barDfd.resolve(null);
792+
await waitFor(() => screen.getByText("Bar page"));
793+
expect(screen.getByText("Link to Bar").className).toBe("active");
794+
expect(screen.getByText("Link to Baz").className).toBe("");
795+
796+
fireEvent.click(screen.getByText("Link to Baz"));
797+
expect(screen.getByText("Link to Bar").className).toBe("active");
798+
expect(screen.getByText("Link to Baz").className).toBe("pending");
799+
800+
bazDfd.resolve(null);
801+
await waitFor(() => screen.getByText("Baz page"));
802+
expect(screen.getByText("Link to Bar").className).toBe("");
803+
expect(screen.getByText("Link to Baz").className).toBe("active");
804+
});
715805
});
716806

717807
describe("NavLink under a Routes with a basename", () => {

packages/react-router-dom/__tests__/special-characters-test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,17 @@ describe("special character tests", () => {
221221
path="/reset"
222222
element={<Link to={navigatePath}>Link to path</Link>}
223223
/>
224+
<Route
225+
path="/descendant/:param/*"
226+
element={
227+
<Routes>
228+
<Route
229+
path="match"
230+
element={<Comp heading="Descendant Route" />}
231+
/>
232+
</Routes>
233+
}
234+
/>
224235
<Route path="/*" element={<Comp heading="Root Splat Route" />} />
225236
</>
226237
);
@@ -487,6 +498,34 @@ describe("special character tests", () => {
487498
}
488499
});
489500

501+
it("handles special chars in descendant routes paths", async () => {
502+
for (let charDef of specialChars) {
503+
let { char, pathChar } = charDef;
504+
505+
await testParamValues(
506+
`/descendant/${char}/match`,
507+
"Descendant Route",
508+
{
509+
pathname: `/descendant/${pathChar}/match`,
510+
search: "",
511+
hash: "",
512+
},
513+
{ param: char, "*": "match" }
514+
);
515+
516+
await testParamValues(
517+
`/descendant/foo${char}bar/match`,
518+
"Descendant Route",
519+
{
520+
pathname: `/descendant/foo${pathChar}bar/match`,
521+
search: "",
522+
hash: "",
523+
},
524+
{ param: `foo${char}bar`, "*": "match" }
525+
);
526+
}
527+
});
528+
490529
it("handles special chars in search params", async () => {
491530
for (let charDef of specialChars) {
492531
let { char, searchChar } = charDef;

packages/react-router-dom/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,9 @@ export const NavLink = React.forwardRef<HTMLAnchorElement, NavLinkProps>(
444444
let path = useResolvedPath(to, { relative: rest.relative });
445445
let location = useLocation();
446446
let routerState = React.useContext(DataRouterStateContext);
447+
let { navigator } = React.useContext(NavigationContext);
447448

448-
let toPathname = path.pathname;
449+
let toPathname = navigator.encodeLocation(path).pathname;
449450
let locationPathname = location.pathname;
450451
let nextLocationPathname =
451452
routerState && routerState.navigation && routerState.navigation.location

packages/react-router-dom/server.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from "react";
22
import type {
3+
Path,
34
RevalidationState,
45
Router as RemixRouter,
56
StaticHandlerContext,
@@ -141,9 +142,8 @@ export function unstable_StaticRouterProvider({
141142

142143
function getStatelessNavigator() {
143144
return {
144-
createHref(to: To) {
145-
return typeof to === "string" ? to : createPath(to);
146-
},
145+
createHref,
146+
encodeLocation,
147147
push(to: To) {
148148
throw new Error(
149149
`You cannot use navigator.push() on the server because it is a stateless ` +
@@ -230,9 +230,8 @@ export function unstable_createStaticRouter(
230230
revalidate() {
231231
throw msg("revalidate");
232232
},
233-
createHref() {
234-
throw msg("createHref");
235-
},
233+
createHref,
234+
encodeLocation,
236235
getFetcher() {
237236
return IDLE_FETCHER;
238237
},
@@ -246,3 +245,17 @@ export function unstable_createStaticRouter(
246245
_internalActiveDeferreds: new Map(),
247246
};
248247
}
248+
249+
function createHref(to: To) {
250+
return typeof to === "string" ? to : createPath(to);
251+
}
252+
253+
function encodeLocation(to: To): Path {
254+
// Locations should already be encoded on the server, so just return as-is
255+
let path = typeof to === "string" ? parsePath(to) : to;
256+
return {
257+
pathname: path.pathname || "",
258+
search: path.search || "",
259+
hash: path.hash || "",
260+
};
261+
}

packages/react-router/lib/components.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export function RouterProvider({
6969
let navigator = React.useMemo((): Navigator => {
7070
return {
7171
createHref: router.createHref,
72+
encodeLocation: router.encodeLocation,
7273
go: (n) => router.navigate(n),
7374
push: (to, state, opts) =>
7475
router.navigate(to, {

packages/react-router/lib/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export interface NavigateOptions {
107107
*/
108108
export interface Navigator {
109109
createHref: History["createHref"];
110+
encodeLocation: History["encodeLocation"];
110111
go: History["go"];
111112
push(to: To, state?: any, opts?: NavigateOptions): void;
112113
replace(to: To, state?: any, opts?: NavigateOptions): void;

packages/react-router/lib/hooks.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ export function useRoutes(
310310
`useRoutes() may be used only in the context of a <Router> component.`
311311
);
312312

313+
let { navigator } = React.useContext(NavigationContext);
313314
let dataRouterStateContext = React.useContext(DataRouterStateContext);
314315
let { matches: parentMatches } = React.useContext(RouteContext);
315316
let routeMatch = parentMatches[parentMatches.length - 1];
@@ -401,11 +402,19 @@ export function useRoutes(
401402
matches.map((match) =>
402403
Object.assign({}, match, {
403404
params: Object.assign({}, parentParams, match.params),
404-
pathname: joinPaths([parentPathnameBase, match.pathname]),
405+
pathname: joinPaths([
406+
parentPathnameBase,
407+
// Re-encode pathnames that were decoded inside matchRoutes
408+
navigator.encodeLocation(match.pathname).pathname,
409+
]),
405410
pathnameBase:
406411
match.pathnameBase === "/"
407412
? parentPathnameBase
408-
: joinPaths([parentPathnameBase, match.pathnameBase]),
413+
: joinPaths([
414+
parentPathnameBase,
415+
// Re-encode pathnames that were decoded inside matchRoutes
416+
navigator.encodeLocation(match.pathnameBase).pathname,
417+
]),
409418
})
410419
),
411420
parentMatches,

0 commit comments

Comments
 (0)