Skip to content

Commit

Permalink
fix: make url-encoding history-aware (#9496)
Browse files Browse the repository at this point in the history
* fix: make url-encoding history-aware

* add changeset

* fix lint warnings

* organize code
  • Loading branch information
brophdawg11 authored Oct 24, 2022
1 parent 4b4be06 commit bba73da
Show file tree
Hide file tree
Showing 5 changed files with 325 additions and 29 deletions.
5 changes: 5 additions & 0 deletions .changeset/ninety-countries-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/router": patch
---

make url-encoding history-aware
273 changes: 273 additions & 0 deletions packages/react-router-dom/__tests__/special-characters-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import {
import type { Location, Params } from "react-router-dom";
import {
BrowserRouter,
HashRouter,
MemoryRouter,
Link,
Routes,
Route,
RouterProvider,
createBrowserRouter,
createHashRouter,
createMemoryRouter,
createRoutesFromElements,
useLocation,
useNavigate,
useParams,
} from "react-router-dom";

Expand Down Expand Up @@ -709,4 +714,272 @@ describe("special character tests", () => {
}
});
});

describe("encodes characters based on history implementation", () => {
function ShowPath() {
let { pathname, search, hash } = useLocation();
return <pre>{JSON.stringify({ pathname, search, hash })}</pre>;
}

describe("memory routers", () => {
it("does not encode characters in MemoryRouter", () => {
let ctx = render(
<MemoryRouter initialEntries={["/with space"]}>
<Routes>
<Route path="/with space" element={<ShowPath />} />
</Routes>
</MemoryRouter>
);

expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});

it("does not encode characters in MemoryRouter (navigate)", () => {
function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
return null;
}
let ctx = render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Start />} />
<Route path="/with space" element={<ShowPath />} />
</Routes>
</MemoryRouter>
);

expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});

it("does not encode characters in createMemoryRouter", () => {
let router = createMemoryRouter(
[{ path: "/with space", element: <ShowPath /> }],
{ initialEntries: ["/with space"] }
);
let ctx = render(<RouterProvider router={router} />);

expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});

it("does not encode characters in createMemoryRouter (navigate)", () => {
function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
return null;
}
let router = createMemoryRouter([
{ path: "/", element: <Start /> },
{ path: "/with space", element: <ShowPath /> },
]);
let ctx = render(<RouterProvider router={router} />);

expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});
});

describe("browser routers", () => {
let testWindow: Window;

beforeEach(() => {
// Need to use our own custom DOM in order to get a working history
const dom = new JSDOM(`<!DOCTYPE html><html><body></body></html>`, {
url: "https://remix.run/",
});
testWindow = dom.window as unknown as Window;
testWindow.history.pushState({}, "", "/");
});

it("encodes characters in BrowserRouter", () => {
testWindow.history.replaceState(null, "", "/with space");

let ctx = render(
<BrowserRouter window={testWindow}>
<Routes>
<Route path="/with space" element={<ShowPath />} />
</Routes>
</BrowserRouter>
);

expect(testWindow.location.pathname).toBe("/with%20space");
expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with%20space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});

it("encodes characters in BrowserRouter (navigate)", () => {
testWindow.history.replaceState(null, "", "/");

function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
return null;
}

let ctx = render(
<BrowserRouter window={testWindow}>
<Routes>
<Route path="/" element={<Start />} />
<Route path="/with space" element={<ShowPath />} />
</Routes>
</BrowserRouter>
);

expect(testWindow.location.pathname).toBe("/with%20space");
expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with%20space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});

it("encodes characters in createBrowserRouter", () => {
testWindow.history.replaceState(null, "", "/with space");

let router = createBrowserRouter(
[{ path: "/with space", element: <ShowPath /> }],
{ window: testWindow }
);
let ctx = render(<RouterProvider router={router} />);

expect(testWindow.location.pathname).toBe("/with%20space");
expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with%20space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});

it("encodes characters in createBrowserRouter (navigate)", () => {
testWindow.history.replaceState(null, "", "/with space");

function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
return null;
}

let router = createBrowserRouter(
[
{ path: "/", element: <Start /> },
{ path: "/with space", element: <ShowPath /> },
],
{ window: testWindow }
);
let ctx = render(<RouterProvider router={router} />);

expect(testWindow.location.pathname).toBe("/with%20space");
expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with%20space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});
});

describe("hash routers", () => {
let testWindow: Window;

beforeEach(() => {
// Need to use our own custom DOM in order to get a working history
const dom = new JSDOM(`<!DOCTYPE html><html><body></body></html>`, {
url: "https://remix.run/",
});
testWindow = dom.window as unknown as Window;
testWindow.history.pushState({}, "", "/");
});

it("encodes characters in HashRouter", () => {
testWindow.history.replaceState(null, "", "/#/with space");

let ctx = render(
<HashRouter window={testWindow}>
<Routes>
<Route path="/with space" element={<ShowPath />} />
</Routes>
</HashRouter>
);

expect(testWindow.location.pathname).toBe("/");
expect(testWindow.location.hash).toBe("#/with%20space");
expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with%20space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});

it("encodes characters in HashRouter (navigate)", () => {
testWindow.history.replaceState(null, "", "/");

function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
return null;
}

let ctx = render(
<HashRouter window={testWindow}>
<Routes>
<Route path="/" element={<Start />} />
<Route path="/with space" element={<ShowPath />} />
</Routes>
</HashRouter>
);

expect(testWindow.location.pathname).toBe("/");
expect(testWindow.location.hash).toBe("#/with%20space");
expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with%20space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});

it("encodes characters in createHashRouter", () => {
testWindow.history.replaceState(null, "", "/#/with space");

let router = createHashRouter(
[{ path: "/with space", element: <ShowPath /> }],
{ window: testWindow }
);
let ctx = render(<RouterProvider router={router} />);

expect(testWindow.location.pathname).toBe("/");
expect(testWindow.location.hash).toBe("#/with%20space");
expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with%20space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});

it("encodes characters in createHashRouter (navigate)", () => {
testWindow.history.replaceState(null, "", "/");

function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
return null;
}

let router = createHashRouter(
[
{ path: "/", element: <Start /> },
{ path: "/with space", element: <ShowPath /> },
],
{ window: testWindow }
);
let ctx = render(<RouterProvider router={router} />);

expect(testWindow.location.pathname).toBe("/");
expect(testWindow.location.hash).toBe("#/with%20space");
expect(ctx.container.innerHTML).toMatchInlineSnapshot(
`"<pre>{\\"pathname\\":\\"/with%20space\\",\\"search\\":\\"\\",\\"hash\\":\\"\\"}</pre>"`
);
});
});
});
});
31 changes: 31 additions & 0 deletions packages/router/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ export interface History {
*/
createHref(to: To): string;

/**
* 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
* behave the same as POP
*
* @param location The incoming location from router.navigate()
*/
encodeLocation(location: Location): Location;

/**
* Pushes a new location onto the history stack, increasing its length by one.
* If there were any entries in the stack after the current one, they are
Expand Down Expand Up @@ -259,6 +268,9 @@ export function createMemoryHistory(
createHref(to) {
return typeof to === "string" ? to : createPath(to);
},
encodeLocation(location) {
return location;
},
push(to, state) {
action = Action.Push;
let nextLocation = createMemoryLocation(to, state);
Expand Down Expand Up @@ -527,6 +539,15 @@ export function parsePath(path: string): Partial<Path> {
return parsedPath;
}

export function createURL(location: Location | string): URL {
let base =
typeof window !== "undefined" && typeof window.location !== "undefined"
? window.location.origin
: "unknown://unknown";
let href = typeof location === "string" ? location : createPath(location);
return new URL(href, base);
}

export interface UrlHistory extends History {}

export type UrlHistoryOptions = {
Expand Down Expand Up @@ -610,6 +631,16 @@ function getUrlBasedHistory(
createHref(to) {
return createHref(window, to);
},
encodeLocation(location) {
// Encode a Location the same way window.location would
let url = createURL(createPath(location));
return {
...location,
pathname: url.pathname,
search: url.search,
hash: url.hash,
};
},
push,
replace,
go(n) {
Expand Down
Loading

0 comments on commit bba73da

Please sign in to comment.