diff --git a/.changeset/hot-experts-give.md b/.changeset/hot-experts-give.md new file mode 100644 index 0000000..24805c5 --- /dev/null +++ b/.changeset/hot-experts-give.md @@ -0,0 +1,5 @@ +--- +"next-router-mock": patch +--- + +add support for history diff --git a/CHANGELOG.md b/CHANGELOG.md index f12ed2b..0c15713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # next-router-mock +## 0.9.16 + +- Add support for parsing `search` parameter for URLs + +## 0.9.15 + +- fix history dependency + +## 0.9.14 + +- added history and router.back() from https://github.com/ergofriend/next-router-mock/tree/add-history-support + ## 0.9.13 ### Patch Changes diff --git a/README.md b/README.md index c56409c..7c65acc 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,18 @@ Install via NPM: `npm install --save-dev next-router-mock` **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* +- [`next-router-mock`](#next-router-mock) - [Usage with Jest](#usage-with-jest) - - [Jest Configuration](#jest-configuration) + - [Jest Configuration](#jest-configuration) - [Jest Example](#jest-example) - [Usage with Storybook](#usage-with-storybook) - - [Storybook Configuration](#storybook-configuration) - - [Storybook Example](#storybook-example) + - [Storybook Configuration](#storybook-configuration) + - [Storybook Example](#storybook-example) +- [Mock `next/navigation`](#mock-nextnavigation) - [Compatibility with `next/link`](#compatibility-with-nextlink) - - [Example: `next/link` with React Testing Library](#example-nextlink-with-react-testing-library) + - [Example: `next/link` with React Testing Library](#example-nextlink-with-react-testing-library) - [Example: `next/link` with Enzyme](#example-nextlink-with-enzyme) - - [Example: `next/link` with Storybook](#example-nextlink-with-storybook) + - [Example: `next/link` with Storybook](#example-nextlink-with-storybook) - [Dynamic Routes](#dynamic-routes) - [Sync vs Async](#sync-vs-async) - [Supported Features](#supported-features) @@ -33,7 +35,8 @@ Install via NPM: `npm install --save-dev next-router-mock` # Usage with Jest -### Jest Configuration +## Jest Configuration + For unit tests, the `next-router-mock` module can be used as a drop-in replacement for `next/router`: ```js @@ -86,13 +89,14 @@ describe('next-router-mock', () => { }); ``` - # Usage with Storybook -### Storybook Configuration +## Storybook Configuration + Globally enable `next-router-mock` by adding the following webpack alias to your Storybook configuration. In `.storybook/main.js` add: + ```js module.exports = { webpackFinal: async (config, { configType }) => { @@ -107,7 +111,7 @@ module.exports = { This ensures that all your components that use `useRouter` will work in Storybook. If you also need to test `next/link`, please see the section [Example: **`next/link` with Storybook**](#example-nextlink-with-storybook). -### Storybook Example +## Storybook Example In your individual stories, you might want to mock the current URL (eg. for testing an "ActiveLink" component), or you might want to log `push/replace` actions. You can do this by wrapping your stories with the `` component. @@ -126,11 +130,13 @@ export const ExampleStory = () => ( ); ``` -> Be sure to import from **a matching Next.js version**: -> ``` +> Be sure to import from **a matching Next.js version**: +> +> ```jsx > import { MemoryRouterProvider } > from 'next-router-mock/MemoryRouterProvider/next-13.5'; > ``` +> > Choose from `next-13.5`, `next-13`, `next-12`, or `next-11`. The `MemoryRouterProvider` has the following optional properties: @@ -143,12 +149,36 @@ The `MemoryRouterProvider` has the following optional properties: - `onRouteChangeStart(url, { shallow })` - `onRouteChangeComplete(url, { shallow })` +# Mock `next/navigation` + +```tsx +import mockRouter from 'next-router-mock' + +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: mockRouter, + useParams: () => { + const { query } = mockRouter; + return query + }, + useSearchParams: () => { + const { query } = mockRouter; + const search = new URLSearchParams(); + + Object.entries(query).forEach(([key, value]) => { + search.append(key, `${value}`); + }) + + return search; + }, +})) +``` # Compatibility with `next/link` To use `next-router-mock` with `next/link`, you must use a `` to wrap the test component. -### Example: `next/link` with React Testing Library +## Example: `next/link` with React Testing Library When rendering, simply supply the option `{ wrapper: MemoryRouterProvider }` @@ -192,7 +222,7 @@ it('NextLink can be rendered', () => { }); ``` -### Example: `next/link` with Storybook +## Example: `next/link` with Storybook In Storybook, you must wrap your component with the `` component (with optional `url` set). @@ -214,7 +244,7 @@ This can be done inline (as above). It can also be implemented as a `decorator`, which can be per-Story, per-Component, or Global (see [Storybook Decorators Documentation](https://storybook.js.org/docs/react/writing-stories/decorators) for details). Global example: -``` +```js // .storybook/preview.js import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; @@ -223,7 +253,6 @@ export const decorators = [ ]; ``` - # Dynamic Routes By default, `next-router-mock` does not know about your dynamic routes (eg. files like `/pages/[id].js`). @@ -279,6 +308,7 @@ it('next/link can be tested too', async () => { - `withRouter(Component)` - `router.push(url, as?, options?)` - `router.replace(url, as?, options?)` +- `router.back()` - `router.route` - `router.pathname` - `router.asPath` @@ -304,10 +334,8 @@ These fields just have default values; these methods do nothing. - `router.defaultLocale` - `router.domainLocales` - `router.prefetch()` -- `router.back()` - `router.beforePopState(cb)` - `router.reload()` - `router.events` not implemented: - `routeChangeError` - `beforeHistoryChange` - diff --git a/package-lock.json b/package-lock.json index f37057b..4445beb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "next-router-mock", - "version": "0.9.11", + "version": "0.9.16", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "next-router-mock", - "version": "0.9.11", + "version": "0.9.16", "license": "MIT", + "dependencies": { + "history": "^5.3.0" + }, "devDependencies": { "@changesets/cli": "^2.26.2", "@testing-library/react": "^13.4.0", @@ -406,7 +409,6 @@ "version": "7.22.10", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.10.tgz", "integrity": "sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -4335,6 +4337,14 @@ "node": ">=0.10.0" } }, + "node_modules/history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "dependencies": { + "@babel/runtime": "^7.7.6" + } + }, "node_modules/hosted-git-info": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", @@ -7614,8 +7624,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==", - "dev": true + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, "node_modules/regex-not": { "version": "1.0.2", @@ -10317,7 +10326,6 @@ "version": "7.22.10", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.10.tgz", "integrity": "sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==", - "dev": true, "requires": { "regenerator-runtime": "^0.14.0" } @@ -13470,6 +13478,14 @@ } } }, + "history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "requires": { + "@babel/runtime": "^7.7.6" + } + }, "hosted-git-info": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", @@ -15941,8 +15957,7 @@ "regenerator-runtime": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==", - "dev": true + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, "regex-not": { "version": "1.0.2", @@ -17777,4 +17792,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index b202901..917bc81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-router-mock", - "version": "0.9.13", + "version": "0.9.16", "description": "Mock implementation of the Next.js Router", "main": "dist/index", "files": [ @@ -76,5 +76,8 @@ "rimraf": "^3.0.2", "ts-jest": "^26.4.4", "typescript": "^4.9.5" + }, + "dependencies": { + "history": "^5.3.0" } } diff --git a/src/MemoryRouter.test.tsx b/src/MemoryRouter.test.tsx index e88cf32..8f5439f 100644 --- a/src/MemoryRouter.test.tsx +++ b/src/MemoryRouter.test.tsx @@ -1,596 +1,172 @@ -import { MemoryRouter } from "./MemoryRouter"; -import { expectMatch } from "../test/test-utils"; +import { useEffect, useRef } from "react"; +import { act, renderHook } from "@testing-library/react"; -describe("MemoryRouter", () => { - beforeEach(() => { - jest.clearAllMocks(); +import { MemoryRouter, MemoryRouterSnapshot } from "./MemoryRouter"; +import { useMemoryRouter } from "./useMemoryRouter"; + +export function useRouterTests(singletonRouter: MemoryRouter, useRouter: () => MemoryRouterSnapshot) { + it("the useRouter hook only returns a snapshot of the singleton router", async () => { + const { result } = renderHook(() => useRouter()); + + expect(result.current).not.toBe(singletonRouter); }); - [ - // Test in both sync and async modes: - { async: false }, - { async: true }, - ].forEach(({ async }) => { - describe(async ? "async mode" : "sync mode", () => { - const memoryRouter = new MemoryRouter(); - memoryRouter.async = async; - - it("should start empty", async () => { - expectMatch(memoryRouter, { - asPath: "/", - pathname: "/", - route: "/", - query: {}, - locale: undefined, - }); - }); - it("pushing URLs should update the route", async () => { - await memoryRouter.push("/one/two/three"); - - expectMatch(memoryRouter, { - asPath: "/one/two/three", - pathname: "/one/two/three", - route: "/one/two/three", - query: {}, - }); - - await memoryRouter.push("/one/two/three?four=4&five="); - - expectMatch(memoryRouter, { - asPath: "/one/two/three?four=4&five=", - pathname: "/one/two/three", - query: { - five: "", - four: "4", - }, - }); - }); + it("will allow capturing previous route values in hooks with routing events", async () => { + // see: https://github.com/streamich/react-use/blob/master/src/usePrevious.ts + const usePrevious = function (value: T): T | undefined { + const previous = useRef(); - describe("events: routeChange and hashChange", () => { - const routeChangeStart = jest.fn(); - const routeChangeComplete = jest.fn(); - const hashChangeStart = jest.fn(); - const hashChangeComplete = jest.fn(); - beforeAll(() => { - memoryRouter.events.on("routeChangeStart", routeChangeStart); - memoryRouter.events.on("routeChangeComplete", routeChangeComplete); - memoryRouter.events.on("hashChangeStart", hashChangeStart); - memoryRouter.events.on("hashChangeComplete", hashChangeComplete); - }); - afterAll(() => { - memoryRouter.events.off("routeChangeStart", routeChangeStart); - memoryRouter.events.off("routeChangeComplete", routeChangeComplete); - memoryRouter.events.off("hashChangeStart", hashChangeStart); - memoryRouter.events.off("hashChangeComplete", hashChangeComplete); - }); - - it("should both be triggered when pushing a URL", async () => { - await memoryRouter.push("/one"); - expect(routeChangeStart).toHaveBeenCalledWith("/one", { - shallow: false, - }); - expect(routeChangeComplete).toHaveBeenCalledWith("/one", { - shallow: false, - }); - }); - - it("should trigger only hashEvents for /baz -> /baz#foo", async () => { - await memoryRouter.push("/baz"); - jest.clearAllMocks(); - await memoryRouter.push("/baz#foo"); - expect(hashChangeStart).toHaveBeenCalledWith("/baz#foo", { shallow: false }); - expect(hashChangeComplete).toHaveBeenCalledWith("/baz#foo", { shallow: false }); - expect(routeChangeStart).not.toHaveBeenCalled(); - expect(routeChangeComplete).not.toHaveBeenCalled(); - }); - - it("should trigger only hashEvents for /baz#foo -> /baz#foo", async () => { - await memoryRouter.push("/baz#foo"); - jest.clearAllMocks(); - await memoryRouter.push("/baz#foo"); - expect(hashChangeStart).toHaveBeenCalledWith("/baz#foo", { shallow: false }); - expect(hashChangeComplete).toHaveBeenCalledWith("/baz#foo", { shallow: false }); - expect(routeChangeStart).not.toHaveBeenCalled(); - expect(routeChangeComplete).not.toHaveBeenCalled(); - }); - - it("should trigger only hashEvents for /baz#foo -> /baz#bar", async () => { - await memoryRouter.push("/baz#foo"); - jest.clearAllMocks(); - await memoryRouter.push("/baz#bar"); - expect(hashChangeStart).toHaveBeenCalledWith("/baz#bar", { shallow: false }); - expect(hashChangeComplete).toHaveBeenCalledWith("/baz#bar", { shallow: false }); - expect(routeChangeStart).not.toHaveBeenCalled(); - expect(routeChangeComplete).not.toHaveBeenCalled(); - }); - - it("should trigger only hashEvents for /baz#foo -> /baz", async () => { - await memoryRouter.push("/baz#foo"); - jest.clearAllMocks(); - await memoryRouter.push("/baz"); - expect(hashChangeStart).toHaveBeenCalledWith("/baz", { shallow: false }); - expect(hashChangeComplete).toHaveBeenCalledWith("/baz", { shallow: false }); - expect(routeChangeStart).not.toHaveBeenCalled(); - expect(routeChangeComplete).not.toHaveBeenCalled(); - }); - - it("should trigger only routeEvents for /baz -> /baz", async () => { - await memoryRouter.push("/baz"); - jest.clearAllMocks(); - await memoryRouter.push("/baz"); - expect(hashChangeStart).not.toHaveBeenCalled(); - expect(hashChangeComplete).not.toHaveBeenCalled(); - expect(routeChangeStart).toHaveBeenCalledWith("/baz", { shallow: false }); - expect(routeChangeComplete).toHaveBeenCalledWith("/baz", { shallow: false }); - }); - - it("should trigger only routeEvents for /baz -> /foo#baz", async () => { - await memoryRouter.push("/baz"); - jest.clearAllMocks(); - await memoryRouter.push("/foo#baz"); - expect(hashChangeStart).not.toHaveBeenCalled(); - expect(hashChangeComplete).not.toHaveBeenCalled(); - expect(routeChangeStart).toHaveBeenCalledWith("/foo#baz", { shallow: false }); - expect(routeChangeComplete).toHaveBeenCalledWith("/foo#baz", { shallow: false }); - }); - - if (async) { - it("routeChange events should be triggered in the correct async order", async () => { - const promise = memoryRouter.push("/one/two/three"); - expect(routeChangeStart).toHaveBeenCalledWith("/one/two/three", { - shallow: false, - }); - expect(routeChangeComplete).not.toHaveBeenCalled(); - await promise; - expect(routeChangeComplete).toHaveBeenCalledWith("/one/two/three", { - shallow: false, - }); - }); - it("hashChange events should be triggered in the correct async order", async () => { - await memoryRouter.push("/baz"); - jest.clearAllMocks(); - const promise = memoryRouter.push("/baz#foo"); - expect(hashChangeStart).toHaveBeenCalledWith("/baz#foo", { - shallow: false, - }); - expect(hashChangeComplete).not.toHaveBeenCalled(); - await promise; - expect(hashChangeComplete).toHaveBeenCalledWith("/baz#foo", { - shallow: false, - }); - }); - } - - it("should be triggered when pushing a URL Object", async () => { - await memoryRouter.push({ - pathname: "/one/two", - query: { foo: "bar" }, - }); - expect(routeChangeStart).toHaveBeenCalled(); - expect(routeChangeStart).toHaveBeenCalledWith("/one/two?foo=bar", { - shallow: false, - }); - expect(routeChangeComplete).toHaveBeenCalledWith("/one/two?foo=bar", { - shallow: false, - }); - }); - - it("should be triggered when replacing", async () => { - await memoryRouter.replace("/one/two/three"); - expect(routeChangeStart).toHaveBeenCalled(); - expect(routeChangeStart).toHaveBeenCalledWith("/one/two/three", { - shallow: false, - }); - expect(routeChangeComplete).toHaveBeenCalledWith("/one/two/three", { - shallow: false, - }); - }); - - it('should provide the "shallow" value', async () => { - await memoryRouter.push("/test", undefined, { shallow: true }); - expect(routeChangeStart).toHaveBeenCalled(); - expect(routeChangeStart).toHaveBeenCalledWith("/test", { - shallow: true, - }); - expect(routeChangeComplete).toHaveBeenCalledWith("/test", { - shallow: true, - }); - }); + useEffect(() => { + previous.current = value; }); - it("pushing UrlObjects should update the route", async () => { - await memoryRouter.push({ pathname: "/one" }); - expectMatch(memoryRouter, { - asPath: "/one", - pathname: "/one", - query: {}, - }); - - await memoryRouter.push({ - pathname: "/one/two/three", - query: { four: "4", five: "" }, - }); - expectMatch(memoryRouter, { - asPath: "/one/two/three?four=4&five=", - pathname: "/one/two/three", - query: { - five: "", - four: "4", - }, - }); - }); - it("pushing UrlObjects should inject slugs", async () => { - await memoryRouter.push({ - pathname: "/one/[id]", - query: { id: "two" }, - }); - expectMatch(memoryRouter, { - asPath: "/one/two", - pathname: "/one/[id]", - query: { - id: "two", - }, - }); - - await memoryRouter.push({ - pathname: "/one/[id]/three", - query: { id: "two" }, - }); - expectMatch(memoryRouter, { - asPath: "/one/two/three", - pathname: "/one/[id]/three", - query: { - id: "two", - }, - }); - - await memoryRouter.push({ - pathname: "/one/[id]/three", - query: { id: "two", four: "4" }, - }); - expectMatch(memoryRouter, { - asPath: "/one/two/three?four=4", - pathname: "/one/[id]/three", - query: { - four: "4", - id: "two", - }, - }); - await memoryRouter.push({ - pathname: "/one/[id]/three/[four]", - query: { id: "two", four: "4" }, - }); - expectMatch(memoryRouter, { - asPath: "/one/two/three/4", - pathname: "/one/[id]/three/[four]", - query: { - four: "4", - id: "two", - }, - }); - await memoryRouter.push({ - pathname: "/one/[...slug]", - query: { slug: ["two", "three", "four"], filter: "abc" }, - }); - expectMatch(memoryRouter, { - asPath: "/one/two/three/four?filter=abc", - pathname: "/one/[...slug]", - query: { - slug: ["two", "three", "four"], - filter: "abc", - }, - }); - await memoryRouter.push({ - pathname: "/one/two/[[...slug]]", - query: { slug: ["three", "four"] }, - }); - expectMatch(memoryRouter, { - asPath: "/one/two/three/four", - pathname: "/one/two/[[...slug]]", - query: { slug: ["three", "four"] }, - }); - await memoryRouter.push({ - pathname: "/one/two/[[...slug]]", - query: {}, - }); - expectMatch(memoryRouter, { - asPath: "/one/two", - pathname: "/one/two/[[...slug]]", - query: {}, - }); - }); - it("push the locale", async () => { - await memoryRouter.push("/", undefined, { locale: "en" }); - expectMatch(memoryRouter, { - locale: "en", - }); - }); + return previous.current; + }; - it("should support the locales property", async () => { - expect(memoryRouter.locales).toEqual([]); - memoryRouter.locales = ["en", "fr"]; - expect(memoryRouter.locales).toEqual(["en", "fr"]); - }); + const useRouterWithPrevious = () => { + const { asPath } = useRouter(); + const previousAsPath = usePrevious(asPath); - it("prefetch should do nothing", async () => { - expect(await memoryRouter.prefetch()).toBeUndefined(); - }); + return [previousAsPath, asPath]; + }; - it("trailing slashes are normalized", async () => { - memoryRouter.setCurrentUrl("/path/"); - expectMatch(memoryRouter, { - asPath: "/path", - pathname: "/path", - }); - - memoryRouter.setCurrentUrl(""); - expectMatch(memoryRouter, { - asPath: "/", - pathname: "/", - }); - }); + // Set initial state: + singletonRouter.setCurrentUrl("/foo"); - it("a single slash is preserved", async () => { - memoryRouter.setCurrentUrl(""); - expectMatch(memoryRouter, { - asPath: "/", - pathname: "/", - }); - - memoryRouter.setCurrentUrl("/"); - expectMatch(memoryRouter, { - asPath: "/", - pathname: "/", - }); - }); + const { result } = renderHook(() => useRouterWithPrevious()); - it("multiple values can be specified for a query parameter", () => { - memoryRouter.setCurrentUrl("/url?foo=FOO&foo=BAR"); - expectMatch(memoryRouter, { - asPath: "/url?foo=FOO&foo=BAR", - query: { - foo: ["FOO", "BAR"], - }, - }); - - memoryRouter.setCurrentUrl({ pathname: "/object-notation", query: { foo: ["BAR", "BAZ"] } }); - expectMatch(memoryRouter, { - asPath: "/object-notation?foo=BAR&foo=BAZ", - query: { foo: ["BAR", "BAZ"] }, - }); - }); + expect(result.current).toEqual([undefined, "/foo"]); - describe('the "as" parameter', () => { - it("works with strings or objects", async () => { - await memoryRouter.push("/path", "/path?param=as"); - expectMatch(memoryRouter, { - asPath: "/path?param=as", - pathname: "/path", - query: {}, - }); - - await memoryRouter.push("/path", { pathname: "/path", query: { param: "as" } }); - expectMatch(memoryRouter, { - asPath: "/path?param=as", - pathname: "/path", - query: {}, - }); - }); - - it("the real query is always used", async () => { - await memoryRouter.push("/path?queryParam=123", "/path"); - expectMatch(memoryRouter, { - asPath: "/path", - pathname: "/path", - query: { queryParam: "123" }, - }); - - await memoryRouter.push("/path", "/path?queryParam=123"); - expectMatch(memoryRouter, { - asPath: "/path?queryParam=123", - pathname: "/path", - query: {}, - }); - - await memoryRouter.push("/path?queryParam=123", "/path?differentQueryParam=456"); - expectMatch(memoryRouter, { - asPath: "/path?differentQueryParam=456", - pathname: "/path", - query: { queryParam: "123" }, - }); - - await memoryRouter.push("/path?queryParam=123", { - pathname: "/path", - query: { differentQueryParam: "456" }, - }); - expectMatch(memoryRouter, { - asPath: "/path?differentQueryParam=456", - pathname: "/path", - query: { queryParam: "123" }, - }); - - await memoryRouter.push({ pathname: "", query: { queryParam: "123" } }, ""); - expectMatch(memoryRouter, { - asPath: "/", - pathname: "/", - query: { queryParam: "123" }, - }); - }); - - describe("search parameter", () => { - it("happy path", async () => { - await memoryRouter.push({ - pathname: "/path", - search: "foo=FOO&bar=BAR", - }); - expectMatch(memoryRouter, { - asPath: "/path?foo=FOO&bar=BAR", - pathname: "/path", - query: { - foo: "FOO", - bar: "BAR", - }, - }); - }); - - it("multiple values can be specified for a query parameter", async () => { - await memoryRouter.push({ - pathname: "/path", - search: "foo=FOO&foo=BAR", - }); - expectMatch(memoryRouter, { - asPath: "/path?foo=FOO&foo=BAR", - pathname: "/path", - query: { - foo: ["FOO", "BAR"], - }, - }); - }); - - it("if search and query are both provided preference is given to search", async () => { - await memoryRouter.push({ - pathname: "/path", - search: "foo=FOO&bar=BAR", - query: { - baz: "BAZ", - }, - }); - expectMatch(memoryRouter, { - asPath: "/path?foo=FOO&bar=BAR", - pathname: "/path", - query: { - foo: "FOO", - bar: "BAR", - }, - }); - }); - }); - - describe("with different paths", () => { - it("the real path and query are used", async () => { - await memoryRouter.push("/real-path", "/as-path"); - expectMatch(memoryRouter, { - asPath: "/as-path", - pathname: "/real-path", - query: {}, - }); - - await memoryRouter.push("/real-path?real=real", "/as-path?as=as"); - expectMatch(memoryRouter, { - asPath: "/as-path?as=as", - pathname: "/real-path", - query: { real: "real" }, - }); - - await memoryRouter.push("/real-path?param=real", "/as-path?param=as"); - expectMatch(memoryRouter, { - asPath: "/as-path?param=as", - pathname: "/real-path", - query: { param: "real" }, - }); - }); - }); - - it('"as" param hash overrides "url" hash', async () => { - await memoryRouter.push("/path", "/path#as-hash"); - expectMatch(memoryRouter, { - asPath: "/path#as-hash", - pathname: "/path", - hash: "#as-hash", - }); - - await memoryRouter.push("/path", { pathname: "/path", hash: "#as-hash" }); - expectMatch(memoryRouter, { - asPath: "/path#as-hash", - pathname: "/path", - hash: "#as-hash", - }); - - await memoryRouter.push("/path#real-hash", "/path#as-hash"); - expectMatch(memoryRouter, { - asPath: "/path#as-hash", - pathname: "/path", - hash: "#as-hash", - }); - - await memoryRouter.push("/path", { pathname: "/path", hash: "#as-hash" }); - expectMatch(memoryRouter, { asPath: "/path#as-hash", pathname: "/path", hash: "#as-hash" }); - - await memoryRouter.push("/path#real-hash", "/path"); - expectMatch(memoryRouter, { asPath: "/path", pathname: "/path", hash: "" }); - - await memoryRouter.push("/path", { pathname: "/path" }); - expectMatch(memoryRouter, { asPath: "/path", pathname: "/path", hash: "" }); - - await memoryRouter.push("/path#real-hash", "/as-path"); - expectMatch(memoryRouter, { - asPath: "/as-path", - pathname: "/path", - hash: "", - }); - - await memoryRouter.push("/path", { pathname: "/as-path" }); - expectMatch(memoryRouter, { - asPath: "/as-path", - pathname: "/path", - hash: "", - }); - - await memoryRouter.push("/path#real-hash", "/as-path#as-hash"); - expectMatch(memoryRouter, { - asPath: "/as-path#as-hash", - pathname: "/path", - hash: "#as-hash", - }); - - await memoryRouter.push("/path", { pathname: "/as-path", hash: "#as-hash" }); - expectMatch(memoryRouter, { - asPath: "/as-path#as-hash", - pathname: "/path", - hash: "#as-hash", - }); - }); - }); + await act(async () => { + await singletonRouter.push("/foo?bar=baz"); + }); - it("should allow deconstruction of push and replace", async () => { - const { push, replace } = memoryRouter; - await push("/one"); - expectMatch(memoryRouter, { asPath: "/one" }); - await replace("/two"); - expectMatch(memoryRouter, { asPath: "/two" }); - }); + expect(result.current).toEqual(["/foo", "/foo?bar=baz"]); + }); - it("should allow push with no path, just a query", async () => { - await memoryRouter.push("/path"); + it('"push" will cause a rerender with the new route', async () => { + const { result } = renderHook(() => useRouter()); - await memoryRouter.push({ query: { id: "42" } }); + await act(async () => { + await result.current.push("/foo?bar=baz"); + }); - expect(memoryRouter.asPath).toEqual("/path?id=42"); - }); + expect(result.current).not.toBe(singletonRouter); + expect(result.current).toEqual(singletonRouter); + expect(result.current).toMatchObject({ + asPath: "/foo?bar=baz", + pathname: "/foo", + query: { bar: "baz" }, + }); + }); - it("hashes are preserved", async () => { - memoryRouter.setCurrentUrl("/path#hash"); - expectMatch(memoryRouter, { - asPath: "/path#hash", - pathname: "/path", - hash: "#hash", - }); - - memoryRouter.setCurrentUrl("/path?key=value#hash"); - expectMatch(memoryRouter, { - asPath: "/path?key=value#hash", - pathname: "/path", - query: { key: "value" }, - hash: "#hash", - }); - }); + it('changing just the "hash" will cause a rerender', async () => { + const { result } = renderHook(() => useRouter()); - it('the "registerPaths" method is deprecated', async () => { - expect(() => { - // @ts-expect-error This should have type errors and runtime errors: - memoryRouter.registerPaths(["path"]); - }).toThrow("See the README for more details on upgrading."); - }); + await act(async () => { + await result.current.push("/foo"); + await result.current.push("/foo#bar"); + }); + const expected = { + asPath: "/foo#bar", + pathname: "/foo", + hash: "#bar", + }; + expect(singletonRouter).toMatchObject(expected); + expect(result.current).toMatchObject(expected); + }); + + it('calling "push" multiple times will rerender with the correct route', async () => { + const { result } = renderHook(() => useRouter()); + + // Push using the router instance: + await act(async () => { + result.current.push("/one"); + result.current.push("/two"); + await result.current.push("/three"); + }); + + expect(result.current).toMatchObject({ + asPath: "/three", + }); + + // Push using the singleton router: + await act(async () => { + singletonRouter.push("/four"); + singletonRouter.push("/five"); + await singletonRouter.push("/six"); + }); + expect(result.current).toMatchObject({ + asPath: "/six", + }); + + // Push using the router instance (again): + await act(async () => { + result.current.push("/seven"); + result.current.push("/eight"); + await result.current.push("/nine"); + }); + + expect(result.current).toMatchObject({ + asPath: "/nine", }); }); + + it("the singleton and the router instances can be used interchangeably", async () => { + const { result } = renderHook(() => useRouter()); + await act(async () => { + await result.current.push("/one"); + }); + expect(result.current).toMatchObject({ asPath: "/one" }); + expect(result.current).toMatchObject(singletonRouter); + + await act(async () => { + await result.current.push("/two"); + }); + expect(result.current).toMatchObject({ asPath: "/two" }); + expect(result.current).toMatchObject(singletonRouter); + + await act(async () => { + await singletonRouter.push("/three"); + }); + expect(result.current).toMatchObject({ asPath: "/three" }); + expect(result.current).toMatchObject(singletonRouter); + }); + + it("support the locales and locale properties", async () => { + const { result } = renderHook(() => useRouter()); + expect(result.current.locale).toBe(undefined); + expect(result.current.locales).toEqual([]); + + await act(async () => { + await result.current.push("/", undefined, { locale: "en" }); + }); + expect(result.current.locale).toBe("en"); + }); + + it("following history", async () => { + const { result } = renderHook(() => useRouter()); + result.current.setCurrentUrl("/"); + await act(async () => { + await result.current.push("/one"); + }); + expect(result.current.history.index).toBe(1); + + await act(async () => { + await result.current.push("/two"); + await result.current.push("/three"); + }); + expect(result.current.history.index).toBe(3); + + await act(async () => { + await result.current.replace("/four"); + }); + expect(result.current.history.index).toBe(3); // replace does not change history index + }); +} + +describe("useMemoryRouter", () => { + const singletonRouter = new MemoryRouter(); + const useRouter = () => useMemoryRouter(singletonRouter); + useRouterTests(singletonRouter, useRouter); }); diff --git a/src/MemoryRouter.tsx b/src/MemoryRouter.tsx index e1ba3e8..cb05a7e 100644 --- a/src/MemoryRouter.tsx +++ b/src/MemoryRouter.tsx @@ -1,4 +1,5 @@ import type { NextRouter, RouterEvent } from "next/router"; +import { createMemoryHistory, type MemoryHistory } from "history"; import mitt, { MittEmitter } from "./lib/mitt"; import { parseUrl, parseQueryString, stringifyQueryString } from "./urls"; @@ -31,7 +32,17 @@ type InternalEventTypes = /** Emitted when 'router.push' is called */ | "NEXT_ROUTER_MOCK:push" /** Emitted when 'router.replace' is called */ - | "NEXT_ROUTER_MOCK:replace"; + | "NEXT_ROUTER_MOCK:replace" + /** Emitted when 'router.back' is called */ + | "NEXT_ROUTER_MOCK:back"; + +type RouterState = { + asPath: string; + pathname: string; + query: NextRouter["query"]; + hash: string; + locale?: string | false | undefined; +}; /** * A base implementation of NextRouter that does nothing; all methods throw. @@ -46,6 +57,8 @@ export abstract class BaseRouter implements NextRouter { */ hash = ""; + _history: MemoryHistory = createMemoryHistory(); + // These are constant: isReady = true; basePath = ""; @@ -61,9 +74,8 @@ export abstract class BaseRouter implements NextRouter { abstract push(url: Url, as?: Url, options?: TransitionOptions): Promise; abstract replace(url: Url): Promise; - back() { - // Not implemented - } + abstract back(): void; + forward() { // Not implemented } @@ -93,10 +105,11 @@ export class MemoryRouter extends BaseRouter { return Object.assign(new MemoryRouter(), original); } - constructor(initialUrl?: Url, async?: boolean) { + constructor(initialUrl?: Url, async?: boolean, history?: MemoryHistory) { super(); if (initialUrl) this.setCurrentUrl(initialUrl); if (async) this.async = async; + if (history) this.setCurrentHistory(history); } /** @@ -133,6 +146,111 @@ export class MemoryRouter extends BaseRouter { return this._setCurrentUrl(url, as, options, "replace"); }; + back = () => { + this._back(); + }; + + /** + * Sets the current MemoryHistory. + */ + public setCurrentHistory = (history: MemoryHistory) => { + this._history = history; + this.setCurrentUrl(history.location.pathname + history.location.search + history.location.hash); + }; + + /** + * Returns the current MemoryHistory. + * history.state property represents the previous location's state in MemoryHistory. + */ + get history() { + return { + ...this._history, + createHref(to) { + throw new Error("You cannot use history.createHref() because it is a stateless."); + }, + push(to, state) { + throw new Error("You cannot use history.push() because it is a stateless."); + }, + replace(to, state) { + throw new Error("You cannot use history.replace() because it is a stateless."); + }, + go(delta) { + throw new Error("You cannot use history.go() because it is a stateless."); + }, + back() { + throw new Error("You cannot use history.back() because it is a stateless."); + }, + forward() { + throw new Error("You cannot use history.forward() because it is a stateless."); + }, + listen() { + throw new Error("You cannot use history.listen() because it is a stateless."); + }, + block(blocker) { + throw new Error("You cannot use history.block() because it is a stateless."); + }, + } satisfies MemoryHistory; + } + + /** + * Store the current MemoryHistory state to history.state for the next location. + */ + private _updateHistory(source?: "push" | "replace" | "back" | "set") { + switch (source) { + case "push": + this._history.push(this._state.asPath, this._state); + break; + case "replace": + this._history.replace(this._state.asPath, this._state); + break; + case "set": + this._history = createMemoryHistory({ initialEntries: [this._state.asPath] }); + break; + case "back": + this._history.back(); + break; + } + } + + private get _state(): RouterState { + return { + asPath: this.asPath, + pathname: this.pathname, + query: this.query, + hash: this.hash, + locale: this.locale, + }; + } + + private set _state(state: RouterState) { + this.asPath = state.asPath; + this.pathname = state.pathname; + this.query = state.query; + this.hash = state.hash; + if (state.locale) this.locale = state.locale; + } + + private _updateState(asPath: string, route: UrlObjectComplete, locale: TransitionOptions["locale"]) { + this._state = { + asPath, + pathname: route.pathname, + query: { ...route.query, ...route.routeParams }, + hash: route.hash, + locale, + }; + } + + private _getPreviousState() { + const state = this._history.location.state as RouterState; + return { + asPath: state.asPath, + pathname: state.pathname, + query: state.query, + hash: state.hash, + locale: state.locale, + }; + } + /** * Sets the current Memory route to the specified url, synchronously. */ @@ -180,15 +298,11 @@ export class MemoryRouter extends BaseRouter { // Simulate the async nature of this method if (async) await new Promise((resolve) => setTimeout(resolve, 0)); - // Update this instance: - this.asPath = asPath; - this.pathname = newRoute.pathname; - this.query = { ...newRoute.query, ...newRoute.routeParams }; - this.hash = newRoute.hash; + // Store the current state as the previous state. (must be called before "_updateState"!!!) + this._updateHistory(source); - if (options?.locale) { - this.locale = options.locale; - } + // Update this instance: + this._updateState(asPath, newRoute, options?.locale); // Fire "complete" event: if (triggerHashChange) { @@ -204,6 +318,15 @@ export class MemoryRouter extends BaseRouter { return true; } + + private _back() { + const previousState = this._getPreviousState(); + this.events.emit("routeChangeStart", previousState.asPath); + this._state = previousState; + this._updateHistory("back"); + this.events.emit("routeChangeComplete", previousState.asPath); + this.events.emit("NEXT_ROUTER_MOCK:back", previousState.asPath); + } } /** diff --git a/src/MemoryRouterProvider/MemoryRouterProvider.tsx b/src/MemoryRouterProvider/MemoryRouterProvider.tsx index 8c69030..a4f8f8a 100644 --- a/src/MemoryRouterProvider/MemoryRouterProvider.tsx +++ b/src/MemoryRouterProvider/MemoryRouterProvider.tsx @@ -1,4 +1,5 @@ import React, { FC, ReactNode, useMemo } from "react"; +import { MemoryHistory } from "history"; import { useMemoryRouter, MemoryRouter, Url, default as singletonRouter } from "../index"; import { default as asyncSingletonRouter } from "../async"; @@ -16,21 +17,26 @@ export type MemoryRouterProviderProps = { */ url?: Url; async?: boolean; + history?: MemoryHistory; children?: ReactNode; } & MemoryRouterEventHandlers; export function factory(dependencies: AbstractedNextDependencies) { const { RouterContext } = dependencies; - const MemoryRouterProvider: FC = ({ children, url, async, ...eventHandlers }) => { + const MemoryRouterProvider: FC = ({ children, url, async, history, ...eventHandlers }) => { const memoryRouter = useMemo(() => { if (typeof url !== "undefined") { // If the `url` was specified, we'll use an "isolated router" instead of the singleton. - return new MemoryRouter(url, async); + return new MemoryRouter(url, async, undefined); + } + if (typeof history !== "undefined") { + // If the `history` was specified, we'll use an "isolated router" instead of the singleton. + return new MemoryRouter(undefined, async, history); } // Normally we'll just use the singleton: return async ? asyncSingletonRouter : singletonRouter; - }, [url, async]); + }, [url, async, history]); const routerSnapshot = useMemoryRouter(memoryRouter, eventHandlers); diff --git a/src/index.test.tsx b/src/index.test.tsx index 4628458..4a2a61b 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -16,12 +16,15 @@ describe("next-overridable-hook", () => { async: expect.any(Boolean), push: expect.any(Function), replace: expect.any(Function), + back: expect.any(Function), setCurrentUrl: expect.any(Function), + setCurrentHistory: expect.any(Function), registerPaths: expect.any(Function), // Ensure the router has exactly these properties: asPath: "/", basePath: "", hash: "", + _history: expect.any(Object), isFallback: false, isLocaleDomain: false, isPreview: false, diff --git a/src/urls.ts b/src/urls.ts index 3bd1b39..280bd0c 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -16,6 +16,7 @@ export function parseUrl(url: string): UrlObject { query, }; } + export function stringifyQueryString(query: NextRouter["query"]): string { const params = new URLSearchParams(); Object.keys(query).forEach((key) => { @@ -26,6 +27,7 @@ export function stringifyQueryString(query: NextRouter["query"]): string { }); return params.toString(); } + export function parseQueryString(query: string): NextRouter["query"] | undefined { const parsedUrl = parseUrl(`?${query}`); diff --git a/src/withMemoryRouter.test.tsx b/src/withMemoryRouter.test.tsx index ff78ba6..8f5439f 100644 --- a/src/withMemoryRouter.test.tsx +++ b/src/withMemoryRouter.test.tsx @@ -1,46 +1,172 @@ -import React, { Component } from "react"; -import { NextRouter } from "next/router"; -import { act, render, screen } from "@testing-library/react"; -import { memoryRouter, withRouter } from "./index"; - -class TestComponent extends Component<{ router: NextRouter; title?: string }, {}> { - render() { - return ( - - {this.props.title || "Current path"}: "{this.props.router.asPath}" - - ); - } - - static getInitialProps() {} -} +import { useEffect, useRef } from "react"; +import { act, renderHook } from "@testing-library/react"; + +import { MemoryRouter, MemoryRouterSnapshot } from "./MemoryRouter"; +import { useMemoryRouter } from "./useMemoryRouter"; + +export function useRouterTests(singletonRouter: MemoryRouter, useRouter: () => MemoryRouterSnapshot) { + it("the useRouter hook only returns a snapshot of the singleton router", async () => { + const { result } = renderHook(() => useRouter()); + + expect(result.current).not.toBe(singletonRouter); + }); + + it("will allow capturing previous route values in hooks with routing events", async () => { + // see: https://github.com/streamich/react-use/blob/master/src/usePrevious.ts + const usePrevious = function (value: T): T | undefined { + const previous = useRef(); + + useEffect(() => { + previous.current = value; + }); + + return previous.current; + }; + + const useRouterWithPrevious = () => { + const { asPath } = useRouter(); + const previousAsPath = usePrevious(asPath); + + return [previousAsPath, asPath]; + }; + + // Set initial state: + singletonRouter.setCurrentUrl("/foo"); + + const { result } = renderHook(() => useRouterWithPrevious()); -const TestComponentWrapper = withRouter(TestComponent); + expect(result.current).toEqual([undefined, "/foo"]); -describe("withRouter", () => { - beforeEach(() => { - memoryRouter.setCurrentUrl("/test"); + await act(async () => { + await singletonRouter.push("/foo?bar=baz"); + }); + + expect(result.current).toEqual(["/foo", "/foo?bar=baz"]); + }); + + it('"push" will cause a rerender with the new route', async () => { + const { result } = renderHook(() => useRouter()); + + await act(async () => { + await result.current.push("/foo?bar=baz"); + }); + + expect(result.current).not.toBe(singletonRouter); + expect(result.current).toEqual(singletonRouter); + expect(result.current).toMatchObject({ + asPath: "/foo?bar=baz", + pathname: "/foo", + query: { bar: "baz" }, + }); + }); + + it('changing just the "hash" will cause a rerender', async () => { + const { result } = renderHook(() => useRouter()); + + await act(async () => { + await result.current.push("/foo"); + await result.current.push("/foo#bar"); + }); + const expected = { + asPath: "/foo#bar", + pathname: "/foo", + hash: "#bar", + }; + expect(singletonRouter).toMatchObject(expected); + expect(result.current).toMatchObject(expected); }); - it("should have access to the current router", async () => { - render(); - expect(screen.getByText('Current path: "/test"')).toBeDefined(); + it('calling "push" multiple times will rerender with the correct route', async () => { + const { result } = renderHook(() => useRouter()); + + // Push using the router instance: + await act(async () => { + result.current.push("/one"); + result.current.push("/two"); + await result.current.push("/three"); + }); + + expect(result.current).toMatchObject({ + asPath: "/three", + }); + + // Push using the singleton router: + await act(async () => { + singletonRouter.push("/four"); + singletonRouter.push("/five"); + await singletonRouter.push("/six"); + }); + expect(result.current).toMatchObject({ + asPath: "/six", + }); + + // Push using the router instance (again): + await act(async () => { + result.current.push("/seven"); + result.current.push("/eight"); + await result.current.push("/nine"); + }); + + expect(result.current).toMatchObject({ + asPath: "/nine", + }); }); - it("should respond to updates", () => { - render(); - act(() => { - memoryRouter.push("/updated-path"); + it("the singleton and the router instances can be used interchangeably", async () => { + const { result } = renderHook(() => useRouter()); + await act(async () => { + await result.current.push("/one"); + }); + expect(result.current).toMatchObject({ asPath: "/one" }); + expect(result.current).toMatchObject(singletonRouter); + + await act(async () => { + await result.current.push("/two"); + }); + expect(result.current).toMatchObject({ asPath: "/two" }); + expect(result.current).toMatchObject(singletonRouter); + + await act(async () => { + await singletonRouter.push("/three"); }); - expect(screen.getByText('Current path: "/updated-path"')).toBeDefined(); + expect(result.current).toMatchObject({ asPath: "/three" }); + expect(result.current).toMatchObject(singletonRouter); }); - it("should pass-through extra properties", () => { - render(); - expect(screen.getByText('CURRENT PATH: "/test"')).toBeDefined(); + it("support the locales and locale properties", async () => { + const { result } = renderHook(() => useRouter()); + expect(result.current.locale).toBe(undefined); + expect(result.current.locales).toEqual([]); + + await act(async () => { + await result.current.push("/", undefined, { locale: "en" }); + }); + expect(result.current.locale).toBe("en"); }); - it("should copy the static `getInitialProps` method", () => { - expect(TestComponentWrapper.getInitialProps).toBe(TestComponent.getInitialProps); + it("following history", async () => { + const { result } = renderHook(() => useRouter()); + result.current.setCurrentUrl("/"); + await act(async () => { + await result.current.push("/one"); + }); + expect(result.current.history.index).toBe(1); + + await act(async () => { + await result.current.push("/two"); + await result.current.push("/three"); + }); + expect(result.current.history.index).toBe(3); + + await act(async () => { + await result.current.replace("/four"); + }); + expect(result.current.history.index).toBe(3); // replace does not change history index }); +} + +describe("useMemoryRouter", () => { + const singletonRouter = new MemoryRouter(); + const useRouter = () => useMemoryRouter(singletonRouter); + useRouterTests(singletonRouter, useRouter); });