Skip to content

Instantly share code, notes, and snippets.

@klaasman
Last active August 28, 2023 10:57
Show Gist options
  • Save klaasman/b0e06e8f63dbae70829424232285570c to your computer and use it in GitHub Desktop.
Save klaasman/b0e06e8f63dbae70829424232285570c to your computer and use it in GitHub Desktop.
algolia instantsearch history clone, adapted to the next.js router.
import { Router, UiState } from "instantsearch.js";
import NextRouter from "next/router";
import qs from "qs";
/**
* This file is a copy of the InstantSearch.js router middleware:
* https://github.com/algolia/instantsearch.js/blob/5d79d92b30e188e5206dcb5fe86fcac058c3f09b/src/lib/routers/history.ts
*
* It's internals have been adapted to use the NextJS Router (singleton) instead
* of native browser history.
*/
type CreateURL<TRouteState> = (args: {
qsModule: typeof qs;
routeState: TRouteState;
location: Location;
}) => string;
type ParseURL<TRouteState> = (args: {
qsModule: typeof qs;
location: Location;
}) => TRouteState;
type BrowserHistoryArgs<TRouteState> = {
windowTitle?: (routeState: TRouteState) => string;
writeDelay: number;
createURL: CreateURL<TRouteState>;
parseURL: ParseURL<TRouteState>;
// @MAJOR: The `Location` type is hard to simulate in non-browser environments
// so we should accept a subset of it that is easier to work with in any
// environments.
getLocation(): Location;
};
const setWindowTitle = (title?: string): void => {
if (title) {
// This function is only executed on browsers so we can disable this check.
// eslint-disable-next-line no-restricted-globals
window.document.title = title;
}
};
class BrowserHistory<TRouteState> implements Router<TRouteState> {
/**
* Transforms a UI state into a title for the page.
*/
private readonly windowTitle?: BrowserHistoryArgs<TRouteState>["windowTitle"];
/**
* Time in milliseconds before performing a write in the history.
* It prevents from adding too many entries in the history and
* makes the back button more usable.
*
* @default 400
*/
private readonly writeDelay: Required<
BrowserHistoryArgs<TRouteState>
>["writeDelay"];
/**
* Creates a full URL based on the route state.
* The storage adaptor maps all syncable keys to the query string of the URL.
*/
private readonly _createURL: Required<
BrowserHistoryArgs<TRouteState>
>["createURL"];
/**
* Parses the URL into a route state.
* It should be symmetrical to `createURL`.
*/
private readonly parseURL: Required<
BrowserHistoryArgs<TRouteState>
>["parseURL"];
/**
* Returns the location to store in the history.
* @default () => window.location
*/
private readonly getLocation: Required<
BrowserHistoryArgs<TRouteState>
>["getLocation"];
private writeTimer?: ReturnType<typeof setTimeout>;
private _onPopState?(url: string, options: { shallow?: boolean }): void;
/**
* Indicates if last action was back/forward in the browser.
*/
private inPopState = false;
/**
* Indicates whether the history router is disposed or not.
*/
private isDisposed = false;
/**
* Indicates the window.history.length before the last call to
* window.history.pushState (called in `write`).
* It allows to determine if a `pushState` has been triggered elsewhere,
* and thus to prevent the `write` method from calling `pushState`.
*/
private latestAcknowledgedHistory = 0;
/**
* Initializes a new storage provider that syncs the search state to the URL
* using web APIs (`window.location.pushState` and `onpopstate` event).
*/
public constructor({
windowTitle,
writeDelay = 400,
createURL,
parseURL,
getLocation,
}: BrowserHistoryArgs<TRouteState>) {
this.windowTitle = windowTitle;
this.writeTimer = undefined;
this.writeDelay = writeDelay;
this._createURL = createURL;
this.parseURL = parseURL;
this.getLocation = getLocation;
safelyRunOnBrowser(({ window }) => {
const title = this.windowTitle && this.windowTitle(this.read());
setWindowTitle(title);
this.latestAcknowledgedHistory = window.history.length;
});
}
/**
* Reads the URL and returns a syncable UI search state.
*/
public read(): TRouteState {
return this.parseURL({ qsModule: qs, location: this.getLocation() });
}
/**
* Pushes a search state into the URL.
*/
public write(routeState: TRouteState): void {
safelyRunOnBrowser(({ window }) => {
const url = this.createURL(routeState);
const title = this.windowTitle && this.windowTitle(routeState);
if (this.writeTimer) {
clearTimeout(this.writeTimer);
}
const loc = window.location.href;
this.writeTimer = setTimeout(() => {
setWindowTitle(title);
// console.log("write", this.shouldWrite(url), url);
if (this.shouldWrite(url) && loc === window.location.href) {
// window.history.pushState(routeState, title || "", url);
NextRouter.replace(url).catch(console.error);
this.latestAcknowledgedHistory = window.history.length;
}
this.inPopState = false;
this.writeTimer = undefined;
}, this.writeDelay);
});
}
/**
* Sets a callback on the `onpopstate` event of the history API of the current page.
* It enables the URL sync to keep track of the changes.
*/
public onUpdate(callback: (routeState: TRouteState) => void): void {
if (this._onPopState) {
NextRouter.events.off("routeChangeComplete", this._onPopState);
}
this._onPopState = (url: string, { shallow }: { shallow: boolean }) => {
if (this.writeTimer) {
clearTimeout(this.writeTimer);
this.writeTimer = undefined;
}
this.inPopState = shallow;
// const routeState = event.state;
// At initial load, the state is read from the URL without update.
// Therefore the state object is not available.
// In this case, we fallback and read the URL.
// if (!routeState) {
// callback(this.read());
// } else {
// callback(routeState);
// }
callback(this.read());
};
safelyRunOnBrowser(({ window }) => {
// window.addEventListener("popstate", this._onPopState!);
NextRouter.events.on("routeChangeComplete", this._onPopState!);
});
}
/**
* Creates a complete URL from a given syncable UI state.
*
* It always generates the full URL, not a relative one.
* This allows to handle cases like using a <base href>.
* See: https://github.com/algolia/instantsearch.js/issues/790
*/
public createURL(routeState: TRouteState): string {
return this._createURL({
qsModule: qs,
routeState,
location: this.getLocation(),
});
}
/**
* Removes the event listener and cleans up the URL.
*/
public dispose(): void {
this.isDisposed = true;
safelyRunOnBrowser(({ window }) => {
if (this._onPopState) {
// window.removeEventListener("popstate", this._onPopState);
NextRouter.events.off("routeChangeComplete", this._onPopState!);
}
});
if (this.writeTimer) {
clearTimeout(this.writeTimer);
}
this.write({} as TRouteState);
}
private shouldWrite(url: string): boolean {
return safelyRunOnBrowser(({ window }) => {
// We do want to `pushState` if:
// - the router is not disposed, IS.js needs to update the URL
// OR
// - the last write was from InstantSearch.js
// (unlike a SPA, where it would have last written)
const lastPushWasByISAfterDispose = !(
this.isDisposed &&
this.latestAcknowledgedHistory !== window.history.length
);
return (
// When the last state change was through popstate, the IS.js state changes,
// but that should not write the URL.
!this.inPopState &&
// When the previous pushState after dispose was by IS.js, we want to write the URL.
lastPushWasByISAfterDispose &&
// When the URL is the same as the current one, we do not want to write it.
url !== window.location.href
);
});
}
}
export function algoliaNextJsHistoryRouter<TRouteState = UiState>({
createURL = ({ qsModule, routeState, location }) => {
const { protocol, hostname, port = "", pathname, hash } = location;
const queryString = qsModule.stringify(routeState);
const portWithPrefix = port === "" ? "" : `:${port}`;
// IE <= 11 has no proper `location.origin` so we cannot rely on it.
if (!queryString) {
return `${protocol}//${hostname}${portWithPrefix}${pathname}${hash}`;
}
return `${protocol}//${hostname}${portWithPrefix}${pathname}?${queryString}${hash}`;
},
parseURL = ({ qsModule, location }) => {
// `qs` by default converts arrays with more than 20 items to an object.
// We want to avoid this because the data structure manipulated can therefore vary.
// Setting the limit to `100` seems a good number because the engine's default is 100
// (it can go up to 1000 but it is very unlikely to select more than 100 items in the UI).
//
// Using an `arrayLimit` of `n` allows `n + 1` items.
//
// See:
// - https://github.com/ljharb/qs#parsing-arrays
// - https://www.algolia.com/doc/api-reference/api-parameters/maxValuesPerFacet/
return qsModule.parse(location.search.slice(1), {
arrayLimit: 99,
}) as unknown as TRouteState;
},
writeDelay = 400,
windowTitle,
getLocation = () => {
return safelyRunOnBrowser<Location>(({ window }) => window.location, {
fallback: () => {
throw new Error(
"You need to provide `getLocation` to the `history` router in environments where `window` does not exist.",
);
},
});
},
}: Partial<BrowserHistoryArgs<TRouteState>> = {}): BrowserHistory<TRouteState> {
return new BrowserHistory({
createURL,
parseURL,
writeDelay,
windowTitle,
getLocation,
});
}
// eslint-disable-next-line no-restricted-globals
type BrowserCallback<TReturn> = (params: { window: typeof window }) => TReturn;
type SafelyRunOnBrowserOptions<TReturn> = {
/**
* Fallback to run on server environments.
*/
fallback: () => TReturn;
};
/**
* Runs code on browser environments safely.
*/
function safelyRunOnBrowser<TReturn>(
callback: BrowserCallback<TReturn>,
{ fallback }: SafelyRunOnBrowserOptions<TReturn> = {
fallback: () => undefined as unknown as TReturn,
},
): TReturn {
// eslint-disable-next-line no-restricted-globals
if (typeof window === "undefined") {
return fallback();
}
// eslint-disable-next-line no-restricted-globals
return callback({ window });
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment