Skip to content

Commit

Permalink
fix: handle encoding of dynamic params in descendant routes (#9589)
Browse files Browse the repository at this point in the history
* fix: handle encoding of dynamic params in descendant routes

* add changeset

* fix var name

* fix jsdoc

* encode pathnames in NavLink check

* Bump bundle
  • Loading branch information
brophdawg11 committed Nov 18, 2022
1 parent ed12019 commit 729cd46
Show file tree
Hide file tree
Showing 12 changed files with 206 additions and 20 deletions.
6 changes: 6 additions & 0 deletions .changeset/pretty-dolls-bathe.md
@@ -0,0 +1,6 @@
---
"react-router": patch
"react-router-dom": patch
---

Fix issues with encoded characters in descendant routes
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -116,7 +116,7 @@
"none": "14.5 kB"
},
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
"none": "10 kB"
"none": "10.5 kB"
},
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
"none": "16 kB"
Expand Down
8 changes: 8 additions & 0 deletions packages/react-router-dom-v5-compat/lib/components.tsx
Expand Up @@ -81,6 +81,14 @@ export function StaticRouter({
createHref(to: To) {
return typeof to === "string" ? to : createPath(to);
},
encodeLocation(to: To) {
let path = typeof to === "string" ? parsePath(to) : to;
return {
pathname: path.pathname || "",
search: path.search || "",
hash: path.hash || "",
};
},
push(to: To) {
throw new Error(
`You cannot use navigator.push() on the server because it is a stateless ` +
Expand Down
90 changes: 90 additions & 0 deletions packages/react-router-dom/__tests__/nav-link-active-test.tsx
Expand Up @@ -8,6 +8,7 @@ import { JSDOM } from "jsdom";
import * as React from "react";
import * as TestRenderer from "react-test-renderer";
import {
BrowserRouter,
MemoryRouter,
Routes,
Route,
Expand Down Expand Up @@ -189,6 +190,37 @@ describe("NavLink", () => {

expect(anchor.children[0]).toMatch("Home (current)");
});

it("matches when portions of the url are encoded", () => {
let renderer: TestRenderer.ReactTestRenderer;

TestRenderer.act(() => {
renderer = TestRenderer.create(
<BrowserRouter window={getWindow("/users/matt brophy")}>
<Routes>
<Route
path="/users/:name"
element={
<>
<NavLink to=".">Matt</NavLink>
<NavLink to="/users/matt brophy">Matt</NavLink>
<NavLink to="/users/michael jackson">Michael</NavLink>
</>
}
/>
</Routes>
</BrowserRouter>
);
});

let anchors = renderer.root.findAllByType("a");

expect(anchors.map((a) => a.props.className)).toEqual([
"active",
"active",
"",
]);
});
});

describe("when it matches a partial URL segment", () => {
Expand Down Expand Up @@ -712,6 +744,64 @@ describe("NavLink using a data router", () => {
await waitFor(() => screen.getByText("Baz page"));
expect(screen.getByText("Link to Bar").className).toBe("");
});

it("applies the default 'active'/'pending' classNames when the url has encoded characters", async () => {
let barDfd = createDeferred();
let bazDfd = createDeferred();
let router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<Layout />}>
<Route path="foo" element={<p>Foo page</p>} />
<Route
path="bar/:param"
loader={() => barDfd.promise}
element={<p>Bar page</p>}
/>
<Route
path="baz-✅"
loader={() => bazDfd.promise}
element={<p>Baz page</p>}
/>
</Route>
),
{
window: getWindow("/foo"),
}
);
render(<RouterProvider router={router} />);

function Layout() {
return (
<>
<NavLink to="/foo">Link to Foo</NavLink>
<NavLink to="/bar/matt brophy">Link to Bar</NavLink>
<NavLink to="/baz-✅">Link to Baz</NavLink>
<Outlet />
</>
);
}

expect(screen.getByText("Link to Bar").className).toBe("");
expect(screen.getByText("Link to Baz").className).toBe("");

fireEvent.click(screen.getByText("Link to Bar"));
expect(screen.getByText("Link to Bar").className).toBe("pending");
expect(screen.getByText("Link to Baz").className).toBe("");

barDfd.resolve(null);
await waitFor(() => screen.getByText("Bar page"));
expect(screen.getByText("Link to Bar").className).toBe("active");
expect(screen.getByText("Link to Baz").className).toBe("");

fireEvent.click(screen.getByText("Link to Baz"));
expect(screen.getByText("Link to Bar").className).toBe("active");
expect(screen.getByText("Link to Baz").className).toBe("pending");

bazDfd.resolve(null);
await waitFor(() => screen.getByText("Baz page"));
expect(screen.getByText("Link to Bar").className).toBe("");
expect(screen.getByText("Link to Baz").className).toBe("active");
});
});

describe("NavLink under a Routes with a basename", () => {
Expand Down
39 changes: 39 additions & 0 deletions packages/react-router-dom/__tests__/special-characters-test.tsx
Expand Up @@ -221,6 +221,17 @@ describe("special character tests", () => {
path="/reset"
element={<Link to={navigatePath}>Link to path</Link>}
/>
<Route
path="/descendant/:param/*"
element={
<Routes>
<Route
path="match"
element={<Comp heading="Descendant Route" />}
/>
</Routes>
}
/>
<Route path="/*" element={<Comp heading="Root Splat Route" />} />
</>
);
Expand Down Expand Up @@ -487,6 +498,34 @@ describe("special character tests", () => {
}
});

it("handles special chars in descendant routes paths", async () => {
for (let charDef of specialChars) {
let { char, pathChar } = charDef;

await testParamValues(
`/descendant/${char}/match`,
"Descendant Route",
{
pathname: `/descendant/${pathChar}/match`,
search: "",
hash: "",
},
{ param: char, "*": "match" }
);

await testParamValues(
`/descendant/foo${char}bar/match`,
"Descendant Route",
{
pathname: `/descendant/foo${pathChar}bar/match`,
search: "",
hash: "",
},
{ param: `foo${char}bar`, "*": "match" }
);
}
});

it("handles special chars in search params", async () => {
for (let charDef of specialChars) {
let { char, searchChar } = charDef;
Expand Down
3 changes: 2 additions & 1 deletion packages/react-router-dom/index.tsx
Expand Up @@ -444,8 +444,9 @@ export const NavLink = React.forwardRef<HTMLAnchorElement, NavLinkProps>(
let path = useResolvedPath(to, { relative: rest.relative });
let location = useLocation();
let routerState = React.useContext(DataRouterStateContext);
let { navigator } = React.useContext(NavigationContext);

let toPathname = path.pathname;
let toPathname = navigator.encodeLocation(path).pathname;
let locationPathname = location.pathname;
let nextLocationPathname =
routerState && routerState.navigation && routerState.navigation.location
Expand Down
25 changes: 19 additions & 6 deletions packages/react-router-dom/server.tsx
@@ -1,5 +1,6 @@
import * as React from "react";
import type {
Path,
RevalidationState,
Router as RemixRouter,
StaticHandlerContext,
Expand Down Expand Up @@ -141,9 +142,8 @@ export function unstable_StaticRouterProvider({

function getStatelessNavigator() {
return {
createHref(to: To) {
return typeof to === "string" ? to : createPath(to);
},
createHref,
encodeLocation,
push(to: To) {
throw new Error(
`You cannot use navigator.push() on the server because it is a stateless ` +
Expand Down Expand Up @@ -230,9 +230,8 @@ export function unstable_createStaticRouter(
revalidate() {
throw msg("revalidate");
},
createHref() {
throw msg("createHref");
},
createHref,
encodeLocation,
getFetcher() {
return IDLE_FETCHER;
},
Expand All @@ -246,3 +245,17 @@ export function unstable_createStaticRouter(
_internalActiveDeferreds: new Map(),
};
}

function createHref(to: To) {
return typeof to === "string" ? to : createPath(to);
}

function encodeLocation(to: To): Path {
// Locations should already be encoded on the server, so just return as-is
let path = typeof to === "string" ? parsePath(to) : to;
return {
pathname: path.pathname || "",
search: path.search || "",
hash: path.hash || "",
};
}
1 change: 1 addition & 0 deletions packages/react-router/lib/components.tsx
Expand Up @@ -69,6 +69,7 @@ export function RouterProvider({
let navigator = React.useMemo((): Navigator => {
return {
createHref: router.createHref,
encodeLocation: router.encodeLocation,
go: (n) => router.navigate(n),
push: (to, state, opts) =>
router.navigate(to, {
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/lib/context.ts
Expand Up @@ -107,6 +107,7 @@ export interface NavigateOptions {
*/
export interface Navigator {
createHref: History["createHref"];
encodeLocation: History["encodeLocation"];
go: History["go"];
push(to: To, state?: any, opts?: NavigateOptions): void;
replace(to: To, state?: any, opts?: NavigateOptions): void;
Expand Down
13 changes: 11 additions & 2 deletions packages/react-router/lib/hooks.tsx
Expand Up @@ -310,6 +310,7 @@ export function useRoutes(
`useRoutes() may be used only in the context of a <Router> component.`
);

let { navigator } = React.useContext(NavigationContext);
let dataRouterStateContext = React.useContext(DataRouterStateContext);
let { matches: parentMatches } = React.useContext(RouteContext);
let routeMatch = parentMatches[parentMatches.length - 1];
Expand Down Expand Up @@ -401,11 +402,19 @@ export function useRoutes(
matches.map((match) =>
Object.assign({}, match, {
params: Object.assign({}, parentParams, match.params),
pathname: joinPaths([parentPathnameBase, match.pathname]),
pathname: joinPaths([
parentPathnameBase,
// Re-encode pathnames that were decoded inside matchRoutes
navigator.encodeLocation(match.pathname).pathname,
]),
pathnameBase:
match.pathnameBase === "/"
? parentPathnameBase
: joinPaths([parentPathnameBase, match.pathnameBase]),
: joinPaths([
parentPathnameBase,
// Re-encode pathnames that were decoded inside matchRoutes
navigator.encodeLocation(match.pathnameBase).pathname,
]),
})
),
parentMatches,
Expand Down
20 changes: 12 additions & 8 deletions packages/router/history.ts
Expand Up @@ -127,12 +127,12 @@ export interface History {

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

/**
* Pushes a new location onto the history stack, increasing its length by one.
Expand Down Expand Up @@ -268,8 +268,13 @@ export function createMemoryHistory(
createHref(to) {
return typeof to === "string" ? to : createPath(to);
},
encodeLocation(location) {
return location;
encodeLocation(to: To) {
let path = typeof to === "string" ? parsePath(to) : to;
return {
pathname: path.pathname || "",
search: path.search || "",
hash: path.hash || "",
};
},
push(to, state) {
action = Action.Push;
Expand Down Expand Up @@ -636,11 +641,10 @@ function getUrlBasedHistory(
createHref(to) {
return createHref(window, to);
},
encodeLocation(location) {
encodeLocation(to) {
// Encode a Location the same way window.location would
let url = createURL(createPath(location));
let url = createURL(typeof to === "string" ? to : createPath(to));
return {
...location,
pathname: url.pathname,
search: url.search,
hash: url.hash,
Expand Down
18 changes: 16 additions & 2 deletions packages/router/router.ts
@@ -1,4 +1,4 @@
import type { History, Location, To } from "./history";
import type { History, Location, Path, To } from "./history";
import {
Action as HistoryAction,
createLocation,
Expand Down Expand Up @@ -154,6 +154,16 @@ export interface Router {
*/
createHref(location: Location | URL): string;

/**
* @internal
* PRIVATE - DO NOT USE
*
* Utility function to URL encode a destination path according to the internal
* history implementation
* @param to
*/
encodeLocation(to: To): Path;

/**
* @internal
* PRIVATE - DO NOT USE
Expand Down Expand Up @@ -773,7 +783,10 @@ export function createRouter(init: RouterInit): Router {
// remains the same as POP and non-data-router usages. new URL() does all
// the same encoding we'd get from a history.pushState/window.location read
// without having to touch history
location = init.history.encodeLocation(location);
location = {
...location,
...init.history.encodeLocation(location),
};

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

0 comments on commit 729cd46

Please sign in to comment.