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,

packages/router/history.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,12 @@ export interface History {
127127

128128
/**
129129
* Encode a location the same way window.history would do (no-op for memory
130-
* history) so we ensure our PUSH/REPLAC e navigations for data routers
130+
* history) so we ensure our PUSH/REPLACE navigations for data routers
131131
* behave the same as POP
132132
*
133-
* @param location The incoming location from router.navigate()
133+
* @param to Unencoded path
134134
*/
135-
encodeLocation(location: Location): Location;
135+
encodeLocation(to: To): Path;
136136

137137
/**
138138
* Pushes a new location onto the history stack, increasing its length by one.
@@ -268,8 +268,13 @@ export function createMemoryHistory(
268268
createHref(to) {
269269
return typeof to === "string" ? to : createPath(to);
270270
},
271-
encodeLocation(location) {
272-
return location;
271+
encodeLocation(to: To) {
272+
let path = typeof to === "string" ? parsePath(to) : to;
273+
return {
274+
pathname: path.pathname || "",
275+
search: path.search || "",
276+
hash: path.hash || "",
277+
};
273278
},
274279
push(to, state) {
275280
action = Action.Push;
@@ -636,11 +641,10 @@ function getUrlBasedHistory(
636641
createHref(to) {
637642
return createHref(window, to);
638643
},
639-
encodeLocation(location) {
644+
encodeLocation(to) {
640645
// Encode a Location the same way window.location would
641-
let url = createURL(createPath(location));
646+
let url = createURL(typeof to === "string" ? to : createPath(to));
642647
return {
643-
...location,
644648
pathname: url.pathname,
645649
search: url.search,
646650
hash: url.hash,

packages/router/router.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { History, Location, To } from "./history";
1+
import type { History, Location, Path, To } from "./history";
22
import {
33
Action as HistoryAction,
44
createLocation,
@@ -154,6 +154,16 @@ export interface Router {
154154
*/
155155
createHref(location: Location | URL): string;
156156

157+
/**
158+
* @internal
159+
* PRIVATE - DO NOT USE
160+
*
161+
* Utility function to URL encode a destination path according to the internal
162+
* history implementation
163+
* @param to
164+
*/
165+
encodeLocation(to: To): Path;
166+
157167
/**
158168
* @internal
159169
* PRIVATE - DO NOT USE
@@ -773,7 +783,10 @@ export function createRouter(init: RouterInit): Router {
773783
// remains the same as POP and non-data-router usages. new URL() does all
774784
// the same encoding we'd get from a history.pushState/window.location read
775785
// without having to touch history
776-
location = init.history.encodeLocation(location);
786+
location = {
787+
...location,
788+
...init.history.encodeLocation(location),
789+
};
777790

778791
let historyAction =
779792
(opts && opts.replace) === true || submission != null
@@ -1825,6 +1838,7 @@ export function createRouter(init: RouterInit): Router {
18251838
// Passthrough to history-aware createHref used by useHref so we get proper
18261839
// hash-aware URLs in DOM paths
18271840
createHref: (to: To) => init.history.createHref(to),
1841+
encodeLocation: (to: To) => init.history.encodeLocation(to),
18281842
getFetcher,
18291843
deleteFetcher,
18301844
dispose,

0 commit comments

Comments
 (0)