Skip to content
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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()`
Expand All @@ -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 })`
Expand All @@ -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:
Expand Down
28 changes: 22 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.29.7",
Expand Down
62 changes: 62 additions & 0 deletions src/MemoryRouter.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MemoryRouter } from "./MemoryRouter";
import { expectMatch } from "../test/test-utils";
import { createMemoryHistory } from "history";

describe("MemoryRouter", () => {
beforeEach(() => {
Expand Down Expand Up @@ -643,5 +644,66 @@ describe("MemoryRouter", () => {
});
});
});

describe("history", () => {
it("push", async () => {
const memoryRouter = new MemoryRouter();

const history = createMemoryHistory();
memoryRouter.setCurrentHistory(history);
if (!memoryRouter.history) {
return;
}
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);
if (!memoryRouter.history) {
return;
}
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);
if (!memoryRouter.history) {
return;
}
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");
});
});
});
});
91 changes: 78 additions & 13 deletions src/MemoryRouter.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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.
*/
Expand All @@ -46,6 +55,8 @@ export abstract class BaseRouter implements NextRouter {
*/
hash = "";

_history: MemoryHistory | null = null;

// These are constant:
isReady = true;
basePath = "";
Expand All @@ -61,9 +72,8 @@ export abstract class BaseRouter implements NextRouter {

abstract push(url: Url, as?: Url, options?: TransitionOptions): Promise<boolean>;
abstract replace(url: Url, as?: Url, options?: TransitionOptions): Promise<boolean>;
back() {
// Not implemented
}
abstract back(): void;

forward() {
// Not implemented
}
Expand Down Expand Up @@ -93,10 +103,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);
}

/**
Expand Down Expand Up @@ -138,6 +149,64 @@ export class MemoryRouter extends BaseRouter {
return this._setCurrentUrl(url, as, options, "replace");
};

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);
};

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") {
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);
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.
*/
Expand All @@ -150,7 +219,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:
Expand Down Expand Up @@ -186,17 +255,13 @@ 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;
if (this.history) {
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 });
Expand Down
11 changes: 8 additions & 3 deletions src/MemoryRouterProvider/MemoryRouterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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<MemoryRouterProviderProps> = ({ children, url, async, ...eventHandlers }) => {
const MemoryRouterProvider: FC<MemoryRouterProviderProps> = ({ 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);

Expand Down
3 changes: 3 additions & 0 deletions src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
Loading