From fe0e465c97530dc1fea565452e74f6b0dac0632e Mon Sep 17 00:00:00 2001 From: Matteo Gassend Date: Wed, 15 Oct 2025 13:14:44 +0200 Subject: [PATCH 1/5] add: memory router history implementation --- package-lock.json | 32 ++++++-- package.json | 3 + src/MemoryRouter.tsx | 81 ++++++++++++++++--- .../MemoryRouterProvider.tsx | 11 ++- 4 files changed, 103 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 948319c..e042093 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "next-router-mock", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "next-router-mock", - "version": "1.0.0", + "version": "1.0.2", "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,15 @@ "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==", + "license": "MIT", + "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 +7625,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 +10327,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 +13479,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 +15958,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", diff --git a/package.json b/package.json index 0e284c8..9eb1ed7 100644 --- a/package.json +++ b/package.json @@ -87,5 +87,8 @@ "rimraf": "^3.0.2", "ts-jest": "^26.4.4", "typescript": "^4.9.5" + }, + "dependencies": { + "history": "^5.3.0" } } diff --git a/src/MemoryRouter.tsx b/src/MemoryRouter.tsx index 3c1ea5e..2238b1d 100644 --- a/src/MemoryRouter.tsx +++ b/src/MemoryRouter.tsx @@ -1,6 +1,7 @@ import type { NextRouter, RouterEvent } from "next/router"; import mitt, { MittEmitter } from "./lib/mitt"; import { parseUrl, parseQueryString, stringifyQueryString } from "./urls"; +import { createMemoryHistory, MemoryHistory } from "history"; export type Url = string | UrlObject; export type UrlObject = { @@ -33,6 +34,14 @@ type InternalEventTypes = /** Emitted when 'router.replace' is called */ | "NEXT_ROUTER_MOCK:replace"; +type RouterState = { + asPath: string; + pathname: string; + query: NextRouter["query"]; + hash: string; + locale?: string; +}; + /** * A base implementation of NextRouter that does nothing; all methods throw. */ @@ -46,6 +55,8 @@ export abstract class BaseRouter implements NextRouter { */ hash = ""; + _history = createMemoryHistory(); + // These are constant: isReady = true; basePath = ""; @@ -61,9 +72,7 @@ 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 +102,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); } /** @@ -138,6 +148,57 @@ export class MemoryRouter extends BaseRouter { return this._setCurrentUrl(url, as, options, "replace"); }; + back = (): void => { + this.setCurrentUrl(this.history.location.pathname + this.history.location.search + this.history.location.hash); + }; + + public setCurrentHistory = (history: MemoryHistory) => { + this._history = history; + this.setCurrentUrl(history.location.pathname + history.location.search + history.location.hash); + }; + + get history() { + return this._history; + } + + /** + * Store the current MemoryHistory state to history.state for the next location. + */ + private _updateHistory(source?: "push" | "replace" | "set" | "back") { + 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 _updateState(asPath: string, route: UrlObjectComplete, locale: TransitionOptions["locale"]) { + this.asPath = asPath; + this.pathname = route.pathname; + this.query = { ...route.query, ...route.routeParams }; + this.hash = route.hash; + if (locale) this.locale = locale; + } + /** * Sets the current Memory route to the specified url, synchronously. */ @@ -150,7 +211,7 @@ export class MemoryRouter extends BaseRouter { url: Url, as?: Url, options?: TransitionOptions, - source?: "push" | "replace" | "set", + source?: "push" | "replace" | "set" | "back", async = this.async ) { // Parse the URL if needed: @@ -186,17 +247,11 @@ export class MemoryRouter extends BaseRouter { 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; + this._updateHistory(source); + this._updateState(asPath, newRoute, options?.locale); this.internal.query = newRoute.query; this.internal.routeParams = newRoute.routeParams; - if (options?.locale) { - this.locale = options.locale; - } - // Fire "complete" event: if (triggerHashChange) { this.events.emit("hashChangeComplete", this.asPath, { shallow }); diff --git a/src/MemoryRouterProvider/MemoryRouterProvider.tsx b/src/MemoryRouterProvider/MemoryRouterProvider.tsx index 8c69030..c60e0ef 100644 --- a/src/MemoryRouterProvider/MemoryRouterProvider.tsx +++ b/src/MemoryRouterProvider/MemoryRouterProvider.tsx @@ -4,6 +4,7 @@ import { useMemoryRouter, MemoryRouter, Url, default as singletonRouter } from " import { default as asyncSingletonRouter } from "../async"; import { MemoryRouterEventHandlers } from "../useMemoryRouter"; import { MemoryRouterContext } from "../MemoryRouterContext"; +import { MemoryHistory } from "history"; type AbstractedNextDependencies = Pick< typeof import("next/dist/shared/lib/router-context.shared-runtime"), @@ -16,21 +17,25 @@ 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") { + return new MemoryRouter(url, async, history); } // Normally we'll just use the singleton: return async ? asyncSingletonRouter : singletonRouter; - }, [url, async]); + }, [url, async, history]); const routerSnapshot = useMemoryRouter(memoryRouter, eventHandlers); From bcd3c23121d9f8f2658ee30f70b4e6114a13320f Mon Sep 17 00:00:00 2001 From: Matteo Gassend Date: Wed, 15 Oct 2025 13:14:49 +0200 Subject: [PATCH 2/5] add: tests --- src/MemoryRouter.test.tsx | 53 ++++++++++++++++++++++++++++++++++++ src/index.test.tsx | 3 ++ src/useMemoryRouter.test.tsx | 27 ++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/src/MemoryRouter.test.tsx b/src/MemoryRouter.test.tsx index b44c33e..f90a80a 100644 --- a/src/MemoryRouter.test.tsx +++ b/src/MemoryRouter.test.tsx @@ -1,5 +1,6 @@ import { MemoryRouter } from "./MemoryRouter"; import { expectMatch } from "../test/test-utils"; +import { createMemoryHistory } from "history"; describe("MemoryRouter", () => { beforeEach(() => { @@ -585,5 +586,57 @@ describe("MemoryRouter", () => { }); }); }); + + describe("history", () => { + it("push", async () => { + const memoryRouter = new MemoryRouter(); + + const history = createMemoryHistory(); + memoryRouter.setCurrentHistory(history); + expect(memoryRouter.asPath).toEqual("/"); + expect(memoryRouter.history.index).toEqual(0); + + await memoryRouter.push("/one?foo=bar"); + expect(memoryRouter.asPath).toEqual("/one?foo=bar"); + expect(memoryRouter.history.index).toEqual(1); + + await memoryRouter.push("/two"); + expect(memoryRouter.asPath).toEqual("/two"); + expect(memoryRouter.history.index).toEqual(2); + + await memoryRouter.push("/three#hash"); + expect(memoryRouter.asPath).toEqual("/three#hash"); + expect(memoryRouter.history.index).toEqual(3); + }); + + it("replace", async () => { + const memoryRouter = new MemoryRouter(); + + const history = createMemoryHistory({ initialEntries: ["/one"] }); + memoryRouter.setCurrentHistory(history); + expect(memoryRouter.asPath).toEqual("/one"); + expect(memoryRouter.history.index).toEqual(0); + + await memoryRouter.push("/two"); + expect(memoryRouter.asPath).toEqual("/two"); + expect(memoryRouter.history.index).toEqual(1); + + await memoryRouter.replace("/three"); + expect(memoryRouter.asPath).toEqual("/three"); + expect(memoryRouter.history.index).toEqual(1); + }); + + it("back", async () => { + const memoryRouter = new MemoryRouter(); + const history = createMemoryHistory({ initialEntries: ["/one"] }); + memoryRouter.setCurrentHistory(history); + expect(memoryRouter.history.index).toEqual(0); + await memoryRouter.push("/two"); + expect(memoryRouter.history.index).toEqual(1); + memoryRouter.back(); + expect(memoryRouter.history.index).toEqual(0); + expect(memoryRouter.asPath).toEqual("/one"); + }); + }); }); }); diff --git a/src/index.test.tsx b/src/index.test.tsx index d39dd07..7f09436 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -18,6 +18,9 @@ describe("next-overridable-hook", () => { push: expect.any(Function), replace: expect.any(Function), setCurrentUrl: expect.any(Function), + _history: expect.any(Object), + setCurrentHistory: expect.any(Function), + back: expect.any(Function), // Ensure the router has exactly these properties: asPath: "/", basePath: "", diff --git a/src/useMemoryRouter.test.tsx b/src/useMemoryRouter.test.tsx index e8a6486..06c0df3 100644 --- a/src/useMemoryRouter.test.tsx +++ b/src/useMemoryRouter.test.tsx @@ -143,6 +143,33 @@ export function useRouterTests(singletonRouter: MemoryRouter, useRouter: () => M }); expect(result.current.locale).toBe("en"); }); + + it("following history", async () => { + const { result } = renderHook(() => useRouter()); + await act(() => { + result.current.reset(); + }); + + 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 + await act(() => { + result.current.back(); + }); + expect(result.current.asPath).toBe("/three"); + }); } describe("useMemoryRouter", () => { From 8ef14499ea5e7e779d1936b4b89e91b70115fd33 Mon Sep 17 00:00:00 2001 From: Matteo Gassend Date: Thu, 30 Oct 2025 10:41:35 +0100 Subject: [PATCH 3/5] move history to peerDependency --- package-lock.json | 6 +++--- package.json | 6 ++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index e042093..261aafb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "next-router-mock", "version": "1.0.2", "license": "MIT", - "dependencies": { - "history": "^5.3.0" - }, "devDependencies": { "@changesets/cli": "^2.26.2", "@testing-library/react": "^13.4.0", @@ -27,6 +24,7 @@ "typescript": "^4.9.5" }, "peerDependencies": { + "history": "^5.3.0", "next": ">=10.0.0", "react": ">=17.0.0" } @@ -4342,6 +4340,7 @@ "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.6" } @@ -13483,6 +13482,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "peer": true, "requires": { "@babel/runtime": "^7.7.6" } diff --git a/package.json b/package.json index 9eb1ed7..1ead66f 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,8 @@ "homepage": "https://github.com/scottrippey/next-router-mock#readme", "peerDependencies": { "next": ">=10.0.0", - "react": ">=17.0.0" + "react": ">=17.0.0", + "history": "^5.3.0" }, "devDependencies": { "@changesets/cli": "^2.26.2", @@ -87,8 +88,5 @@ "rimraf": "^3.0.2", "ts-jest": "^26.4.4", "typescript": "^4.9.5" - }, - "dependencies": { - "history": "^5.3.0" } } From b497ef6d26f06d5d3d8e438aa9ba3df4682276f4 Mon Sep 17 00:00:00 2001 From: Matteo Gassend Date: Thu, 30 Oct 2025 10:41:49 +0100 Subject: [PATCH 4/5] add checks for history before using back & updateHistory --- src/MemoryRouter.test.tsx | 9 +++++++++ src/MemoryRouter.tsx | 13 +++++++++++-- src/useMemoryRouter.test.tsx | 6 +++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/MemoryRouter.test.tsx b/src/MemoryRouter.test.tsx index f90a80a..ed012f1 100644 --- a/src/MemoryRouter.test.tsx +++ b/src/MemoryRouter.test.tsx @@ -593,6 +593,9 @@ describe("MemoryRouter", () => { const history = createMemoryHistory(); memoryRouter.setCurrentHistory(history); + if (!memoryRouter.history) { + return; + } expect(memoryRouter.asPath).toEqual("/"); expect(memoryRouter.history.index).toEqual(0); @@ -614,6 +617,9 @@ describe("MemoryRouter", () => { const history = createMemoryHistory({ initialEntries: ["/one"] }); memoryRouter.setCurrentHistory(history); + if (!memoryRouter.history) { + return; + } expect(memoryRouter.asPath).toEqual("/one"); expect(memoryRouter.history.index).toEqual(0); @@ -630,6 +636,9 @@ describe("MemoryRouter", () => { const memoryRouter = new MemoryRouter(); const history = createMemoryHistory({ initialEntries: ["/one"] }); memoryRouter.setCurrentHistory(history); + if (!memoryRouter.history) { + return; + } expect(memoryRouter.history.index).toEqual(0); await memoryRouter.push("/two"); expect(memoryRouter.history.index).toEqual(1); diff --git a/src/MemoryRouter.tsx b/src/MemoryRouter.tsx index 2238b1d..f50652e 100644 --- a/src/MemoryRouter.tsx +++ b/src/MemoryRouter.tsx @@ -55,7 +55,7 @@ export abstract class BaseRouter implements NextRouter { */ hash = ""; - _history = createMemoryHistory(); + _history: MemoryHistory | null = null; // These are constant: isReady = true; @@ -149,6 +149,9 @@ export class MemoryRouter extends BaseRouter { }; back = (): void => { + if (this.history === null) { + throw Error("Please provide a history instance with setCurrentHistory"); + } this.setCurrentUrl(this.history.location.pathname + this.history.location.search + this.history.location.hash); }; @@ -165,6 +168,10 @@ export class MemoryRouter extends BaseRouter { * Store the current MemoryHistory state to history.state for the next location. */ private _updateHistory(source?: "push" | "replace" | "set" | "back") { + if (this._history === null) { + throw Error("Please provide a history instance with setCurrentHistory"); + return; + } switch (source) { case "push": this._history.push(this._state.asPath, this._state); @@ -247,7 +254,9 @@ export class MemoryRouter extends BaseRouter { if (async) await new Promise((resolve) => setTimeout(resolve, 0)); // Update this instance: - this._updateHistory(source); + if (this.history) { + this._updateHistory(source); + } this._updateState(asPath, newRoute, options?.locale); this.internal.query = newRoute.query; this.internal.routeParams = newRoute.routeParams; diff --git a/src/useMemoryRouter.test.tsx b/src/useMemoryRouter.test.tsx index 06c0df3..3985e9b 100644 --- a/src/useMemoryRouter.test.tsx +++ b/src/useMemoryRouter.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook } from "@testing-library/react"; import { MemoryRouter, MemoryRouterSnapshot } from "./MemoryRouter"; import { useMemoryRouter } from "./useMemoryRouter"; +import { createMemoryHistory } from "history"; export function useRouterTests(singletonRouter: MemoryRouter, useRouter: () => MemoryRouterSnapshot) { it("the useRouter hook only returns a snapshot of the singleton router", async () => { @@ -148,8 +149,11 @@ export function useRouterTests(singletonRouter: MemoryRouter, useRouter: () => M const { result } = renderHook(() => useRouter()); await act(() => { result.current.reset(); + result.current.setCurrentHistory(createMemoryHistory()); }); - + if (!result.current.history) { + return; + } await act(async () => { await result.current.push("/one"); }); From 824575a0f499fc3390f0e5c7c12ef085cea6cd38 Mon Sep 17 00:00:00 2001 From: Matteo Gassend Date: Thu, 30 Oct 2025 10:42:02 +0100 Subject: [PATCH 5/5] update readme with history instruction --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9834eac..93fafdd 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ For usage with `next/navigation` jump to [Usage with next/navigation Beta](#usag - [Example: `next/link` with Storybook](#example-nextlink-with-storybook) - [Dynamic Routes](#dynamic-routes) - [Sync vs Async](#sync-vs-async) +- [Back & History](#back--history) - [Supported Features](#supported-features) - [Not yet supported](#not-yet-supported) - [Usage with next/navigation Beta](#usage-with-nextnavigation-beta) @@ -275,6 +276,13 @@ it("next/link can be tested too", async () => { }); ``` +# Back & History + +In order to use the `router.back()` method, you need to add a (MemoryHistory)[] instance to the router. You can do this either by: + +- passing a MemoryHistory instance to the router constructor +- calling the `setCurrentHistory` method on an already existing instance + # Supported Features - `useRouter()` @@ -285,6 +293,7 @@ it("next/link can be tested too", async () => { - `router.pathname` - `router.asPath` - `router.query` +- `router.back()` (requires history) - Works with `next/link` (see Jest notes) - `router.events` supports: - `routeChangeStart(url, { shallow })` @@ -306,7 +315,6 @@ 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: