Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HashRouter hashType option. Compatibility with v4/v5 #11310

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/khaki-beers-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"react-router": patch
"react-router-dom": patch
---

HashRouter hashType implementation for backwards compatibility with project migrating from React-Router v4/v5
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,4 @@
- yracnet
- yuleicul
- zheng-chuang
- Whaileee
17 changes: 17 additions & 0 deletions docs/router-components/hash-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ declare function HashRouter(

interface HashRouterProps {
basename?: string;
hashType?: HashType
children?: React.ReactNode;
future?: FutureConfig;
window?: Window;
Expand Down Expand Up @@ -55,6 +56,22 @@ function App() {
}
```

## `hashType`

Decide wether to put a slash after the '#' in the URL (default: 'slash')

```jsx
function App() {
return (
<HashRouter hashType='noslash'>
<Routes>
<Route path="/bookmark" /> {/* 👈 Renders at /#bookmark/ */}
</Routes>
</HashRouter>
);
}
```

## `future`

An optional set of [Future Flags][api-development-strategy] to enable. We recommend opting into newly released future flags sooner rather than later to ease your eventual migration to v7.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,4 @@
"@changesets/[email protected]": "patches/@[email protected]"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,41 @@ describe("Handles concurrent mode features during navigations", () => {

await assertNavigation(container, resolve, resolveLazy);
});
// eslint-disable-next-line jest/expect-expect
it("HashRouter with noslash", async () => {
let { Home, About, LazyComponent, resolve, resolveLazy } =
getComponents();

let { container } = render(
<HashRouter
window={getWindowImpl("/", true)}
future={{ v7_startTransition: true }}
hashType="noslash"
>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/about"
element={
<React.Suspense fallback={<p>Loading...</p>}>
<About />
</React.Suspense>
}
/>
<Route
path="/lazy"
element={
<React.Suspense fallback={<p>Loading Lazy Component...</p>}>
<LazyComponent />
</React.Suspense>
}
/>
</Routes>
</HashRouter>
);

await assertNavigation(container, resolve, resolveLazy);
});

// eslint-disable-next-line jest/expect-expect
it("RouterProvider", async () => {
Expand Down Expand Up @@ -289,7 +324,6 @@ describe("Handles concurrent mode features during navigations", () => {
await waitFor(() => screen.getByText("Lazy"));
expect(getHtml(container)).toMatch("Lazy");
}

// eslint-disable-next-line jest/expect-expect
it("MemoryRouter", async () => {
let { Home, About, resolve, LazyComponent, resolveLazy } =
Expand All @@ -308,6 +342,28 @@ describe("Handles concurrent mode features during navigations", () => {
await assertNavigation(container, resolve, resolveLazy);
});

// eslint-disable-next-line jest/expect-expect
it("HashRouter with noslash", async () => {
let { Home, About, resolve, LazyComponent, resolveLazy } =
getComponents();

let { container } = render(
<HashRouter
window-={getWindowImpl("/", true)}
future={{ v7_startTransition: true }}
hashType="noslash"
>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/lazy" element={<LazyComponent />} />
</Routes>
</HashRouter>
);

await assertNavigation(container, resolve, resolveLazy);
});

// eslint-disable-next-line jest/expect-expect
it("BrowserRouter", async () => {
let { Home, About, resolve, LazyComponent, resolveLazy } =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ testDomRouter("<DataHashRouter>", createHashRouter, (url) =>
getWindowImpl(url, true)
);

testDomRouter("<DataHashRouterNoSlash>", (routes, opts) => createHashRouter(routes, { ...opts, hashType: 'noslash' }), (url) =>
getWindowImpl(url, true)
);

function testDomRouter(
name: string,
createTestRouter: typeof createBrowserRouter | typeof createHashRouter,
Expand All @@ -33,8 +37,8 @@ function testDomRouter(
let consoleError: jest.SpyInstance;

beforeEach(() => {
consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => {});
consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => { });
consoleError = jest.spyOn(console, "error").mockImplementation(() => { });
});

afterEach(() => {
Expand Down
31 changes: 21 additions & 10 deletions packages/react-router-dom/__tests__/data-browser-router-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,20 @@ import {

import getHtml from "../../react-router/__tests__/utils/getHtml";
import { createDeferred } from "../../router/__tests__/utils/utils";
function createNoSlashRouter(routes, opts?) {
return opts === undefined ? createHashRouter(routes, { hashType: 'noslash' }) : createHashRouter(routes, { ...opts, hashType: 'noslash' })
}

testDomRouter("<DataHashRouter>", createHashRouter, (url) =>
getWindowImpl(url, true)
);

testDomRouter("<DataBrowserRouter>", createBrowserRouter, (url) =>
getWindowImpl(url, false)
);

testDomRouter("<DataHashRouter>", createHashRouter, (url) =>
getWindowImpl(url, true)
);
testDomRouter("<DataHashRouterNoSlash>", createNoSlashRouter, (url) =>
getWindowImpl(url.substring(1), true))

function testDomRouter(
name: string,
Expand All @@ -61,6 +67,9 @@ function testDomRouter(
if (name === "<DataHashRouter>") {
// eslint-disable-next-line jest/no-conditional-expect
expect(testWindow.location.hash).toEqual("#" + pathname + (search || ""));
}
else if (name === "<DataHashRouterNoSlash>") {
expect(testWindow.location.hash).toEqual("#" + pathname.slice(1).concat() + (search || ""));
} else {
// eslint-disable-next-line jest/no-conditional-expect
expect(testWindow.location.pathname).toEqual(pathname);
Expand All @@ -75,8 +84,8 @@ function testDomRouter(
let consoleError: jest.SpyInstance;

beforeEach(() => {
consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => {});
consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => { });
consoleError = jest.spyOn(console, "error").mockImplementation(() => { });
});

afterEach(() => {
Expand All @@ -90,7 +99,6 @@ function testDomRouter(
createRoutesFromElements(<Route path="/" element={<h1>Home</h1>} />)
);
let { container } = render(<RouterProvider router={router} />);

expect(getHtml(container)).toMatchInlineSnapshot(`
"<div>
<h1>
Expand Down Expand Up @@ -4698,6 +4706,9 @@ function testDomRouter(

// Resolve Comp2 loader and complete navigation
navDfd.resolve("nav data");
// On slower machines test could find updated `/2.*idle/` but `["idle"]` hasn't updated yet. This is purely performance issue.
// Sometimes closing all apps and letting test run caused test to pass after many failures
await waitFor(() => screen.getByText(/\[\]/), { timeout: 500 });
await waitFor(() => screen.getByText(/2.*idle/));
expect(getHtml(container.querySelector("#output")!))
.toMatchInlineSnapshot(`
Expand Down Expand Up @@ -5370,9 +5381,9 @@ function testDomRouter(
let { container } = render(<RouterProvider router={router} />);
expect(container.innerHTML).not.toMatch(/my-key/);
await waitFor(() =>
// React `useId()` results in either `:r2a:` or `:rp:` depending on
// `DataBrowserRouter`/`DataHashRouter`
expect(container.innerHTML).toMatch(/(:r2a:|:rp:),my-key/)
// React `useId()` results in either `:r2a:` or `:rp:` or `:r3r:` depending on
// `DataBrowserRouter`/`DataHashRouter`/`DataHashRouterNoSlash`
expect(container.innerHTML).toMatch(/(:r2a:|:rp:|:r3r:),my-key/)
);
});
});
Expand Down Expand Up @@ -7392,7 +7403,7 @@ function testDomRouter(
ready: Promise.resolve(),
finished: Promise.resolve(),
updateCallbackDone: Promise.resolve(),
skipTransition: () => {},
skipTransition: () => { },
};
});
testWindow.document.startViewTransition = spy;
Expand Down
35 changes: 35 additions & 0 deletions packages/react-router-dom/__tests__/link-href-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,23 @@ describe("<Link> href", () => {
);
});

describe("when using a hash router with noslash", () => {
it("renders proper <a href> for HashRouter", () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<HashRouter hashType="noslash">
<Routes>
<Route path="/" element={<Link to="/path?search=value#hash" />} />
</Routes>
</HashRouter>
);
});
expect(renderer.root.findByType("a").props.href).toEqual(
"#path?search=value#hash"
);
});

it("renders proper <a href> for createHashRouter", () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
Expand All @@ -906,7 +923,25 @@ describe("<Link> href", () => {
"#/path?search=value#hash"
);
});

it("renders proper <a href> for createHashRouter with noslash", () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
let router = createHashRouter([
{
path: "/",
element: <Link to="/path?search=value#hash">Link</Link>,
},
],
{hashType: 'noslash'});
renderer = TestRenderer.create(<RouterProvider router={router} />);
});
expect(renderer.root.findByType("a").props.href).toEqual(
"#path?search=value#hash"
);
});
});
});

test("fails gracefully on invalid `to` values", () => {
let warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ describe("v7_partialHydration", () => {
describe("createHashRouter", () => {
testPartialHydration(createHashRouter, ReactRouterDom_RouterProvider);
});
describe("createHashRouter with noslash", () => {
testPartialHydration((routes, opts) => createHashRouter(routes, {...opts, hashType:'noslash'}), ReactRouterDom_RouterProvider);
});

describe("createMemoryRouter", () => {
testPartialHydration(createMemoryRouter, ReactRouter_RouterPRovider);
Expand Down
87 changes: 87 additions & 0 deletions packages/react-router-dom/__tests__/special-characters-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,93 @@ describe("special character tests", () => {
);
});
});
describe("hash routers with noslash", () => {
it("encodes characters in HashRouter", () => {
let testWindow = getWindow("/#with space");

let ctx = render(
<HashRouter window={testWindow} hashType="noslash">
<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)", () => {
let testWindow = getWindow("/");

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} hashType="noslash">
<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", () => {
let testWindow = getWindow("/#with space");

let router = createHashRouter(
[{ path: "/with space", element: <ShowPath /> }],
{ window: testWindow, hashType:'noslash' }
);
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)", () => {
let testWindow = getWindow("/");

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, hashType:'noslash' }
);
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>"`
);
});
});
});
});

Expand Down
Loading