//////////////////////////////////////////////////////////////////////////////// //#region Types and Constants //////////////////////////////////////////////////////////////////////////////// /** * Actions represent the type of change to a location value. */ export enum Action { /** * A POP indicates a change to an arbitrary index in the history stack, such * as a back or forward navigation. It does not describe the direction of the * navigation, only that the current index changed. * * Note: This is the default action for newly created history objects. */ Pop = "POP", /** * A PUSH indicates a new entry being added to the history stack, such as when * a link is clicked and a new page loads. When this happens, all subsequent * entries in the stack are lost. */ Push = "PUSH", /** * A REPLACE indicates the entry at the current index in the history stack * being replaced by a new one. */ Replace = "REPLACE", } /** * The pathname, search, and hash values of a URL. */ export interface Path { /** * A URL pathname, beginning with a /. */ pathname: string; /** * A URL search string, beginning with a ?. */ search: string; /** * A URL fragment identifier, beginning with a #. */ hash: string; } // TODO: (v7) Change the Location generic default from `any` to `unknown` and // remove Remix `useLocation` wrapper. /** * An entry in a history stack. A location contains information about the * URL path, as well as possibly some arbitrary state and a key. */ export interface Location extends Path { /** * A value of arbitrary data associated with this location. */ state: State; /** * A unique string associated with this location. May be used to safely store * and retrieve data in some other storage API, like `localStorage`. * * Note: This value is always "default" on the initial location. */ key: string; } /** * A change to the current location. */ export interface Update { /** * The action that triggered the change. */ action: Action; /** * The new location. */ location: Location; /** * The delta between this location and the former location in the history stack */ delta: number | null; } /** * A function that receives notifications about location changes. */ export interface Listener { (update: Update): void; } /** * Describes a location that is the destination of some navigation, either via * `history.push` or `history.replace`. This may be either a URL or the pieces * of a URL path. */ export type To = string | Partial; /** * A history is an interface to the navigation stack. The history serves as the * source of truth for the current location, as well as provides a set of * methods that may be used to change it. * * It is similar to the DOM's `window.history` object, but with a smaller, more * focused API. */ export interface History { /** * The last action that modified the current location. This will always be * Action.Pop when a history instance is first created. This value is mutable. */ readonly action: Action; /** * The current location. This value is mutable. */ readonly location: Location; /** * Returns a valid href for the given `to` value that may be used as * the value of an attribute. * * @param to - The destination URL */ createHref(to: To): string; /** * Returns a URL for the given `to` value * * @param to - The destination URL */ createURL(to: To): URL; /** * Encode a location the same way window.history would do (no-op for memory * history) so we ensure our PUSH/REPLACE navigations for data routers * behave the same as POP * * @param to Unencoded path */ encodeLocation(to: To): Path; /** * Pushes a new location onto the history stack, increasing its length by one. * If there were any entries in the stack after the current one, they are * lost. * * @param to - The new URL * @param state - Data to associate with the new location */ push(to: To, state?: any): void; /** * Replaces the current location in the history stack with a new one. The * location that was replaced will no longer be available. * * @param to - The new URL * @param state - Data to associate with the new location */ replace(to: To, state?: any): void; /** * Navigates `n` entries backward/forward in the history stack relative to the * current index. For example, a "back" navigation would use go(-1). * * @param delta - The delta in the stack index */ go(delta: number): void; /** * Sets up a listener that will be called whenever the current location * changes. * * @param listener - A function that will be called when the location changes * @returns unlisten - A function that may be used to stop listening */ listen(listener: Listener): () => void; } type HistoryState = { usr: any; key?: string; idx: number; }; const PopStateEventType = "popstate"; //#endregion //////////////////////////////////////////////////////////////////////////////// //#region Memory History //////////////////////////////////////////////////////////////////////////////// /** * A user-supplied object that describes a location. Used when providing * entries to `createMemoryHistory` via its `initialEntries` option. */ export type InitialEntry = string | Partial; export type MemoryHistoryOptions = { initialEntries?: InitialEntry[]; initialIndex?: number; v5Compat?: boolean; }; /** * A memory history stores locations in memory. This is useful in stateful * environments where there is no web browser, such as node tests or React * Native. */ export interface MemoryHistory extends History { /** * The current index in the history stack. */ readonly index: number; } /** * Memory history stores the current location in memory. It is designed for use * in stateful non-browser environments like tests and React Native. */ export function createMemoryHistory( options: MemoryHistoryOptions = {} ): MemoryHistory { let { initialEntries = ["/"], initialIndex, v5Compat = false } = options; let entries: Location[]; // Declare so we can access from createMemoryLocation entries = initialEntries.map((entry, index) => createMemoryLocation( entry, typeof entry === "string" ? null : entry.state, index === 0 ? "default" : undefined ) ); let index = clampIndex( initialIndex == null ? entries.length - 1 : initialIndex ); let action = Action.Pop; let listener: Listener | null = null; function clampIndex(n: number): number { return Math.min(Math.max(n, 0), entries.length - 1); } function getCurrentLocation(): Location { return entries[index]; } function createMemoryLocation( to: To, state: any = null, key?: string ): Location { let location = createLocation( entries ? getCurrentLocation().pathname : "/", to, state, key ); warning( location.pathname.charAt(0) === "/", `relative pathnames are not supported in memory history: ${JSON.stringify( to )}` ); return location; } function createHref(to: To) { return typeof to === "string" ? to : createPath(to); } let history: MemoryHistory = { get index() { return index; }, get action() { return action; }, get location() { return getCurrentLocation(); }, createHref, createURL(to) { return new URL(createHref(to), "http://localhost"); }, encodeLocation(to: To) { let path = typeof to === "string" ? parsePath(to) : to; return { pathname: path.pathname || "", search: path.search || "", hash: path.hash || "", }; }, push(to, state) { action = Action.Push; let nextLocation = createMemoryLocation(to, state); index += 1; entries.splice(index, entries.length, nextLocation); if (v5Compat && listener) { listener({ action, location: nextLocation, delta: 1 }); } }, replace(to, state) { action = Action.Replace; let nextLocation = createMemoryLocation(to, state); entries[index] = nextLocation; if (v5Compat && listener) { listener({ action, location: nextLocation, delta: 0 }); } }, go(delta) { action = Action.Pop; let nextIndex = clampIndex(index + delta); let nextLocation = entries[nextIndex]; index = nextIndex; if (listener) { listener({ action, location: nextLocation, delta }); } }, listen(fn: Listener) { listener = fn; return () => { listener = null; }; }, }; return history; } //#endregion //////////////////////////////////////////////////////////////////////////////// //#region Browser History //////////////////////////////////////////////////////////////////////////////// /** * A browser history stores the current location in regular URLs in a web * browser environment. This is the standard for most web apps and provides the * cleanest URLs the browser's address bar. * * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#browserhistory */ export interface BrowserHistory extends UrlHistory {} export type BrowserHistoryOptions = UrlHistoryOptions; /** * Browser history stores the location in regular URLs. This is the standard for * most web apps, but it requires some configuration on the server to ensure you * serve the same app at multiple URLs. * * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createbrowserhistory */ export function createBrowserHistory( options: BrowserHistoryOptions = {} ): BrowserHistory { function createBrowserLocation( window: Window, globalHistory: Window["history"] ) { let { pathname, search, hash } = window.location; return createLocation( "", { pathname, search, hash }, // state defaults to `null` because `window.history.state` does (globalHistory.state && globalHistory.state.usr) || null, (globalHistory.state && globalHistory.state.key) || "default" ); } function createBrowserHref(window: Window, to: To) { return typeof to === "string" ? to : createPath(to); } return getUrlBasedHistory( createBrowserLocation, createBrowserHref, null, options ); } //#endregion //////////////////////////////////////////////////////////////////////////////// //#region Hash History //////////////////////////////////////////////////////////////////////////////// /** * A hash history stores the current location in the fragment identifier portion * of the URL in a web browser environment. * * This is ideal for apps that do not control the server for some reason * (because the fragment identifier is never sent to the server), including some * shared hosting environments that do not provide fine-grained controls over * which pages are served at which URLs. * * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#hashhistory */ export interface HashHistory extends UrlHistory {} export type HashHistoryOptions = UrlHistoryOptions; /** * Hash history stores the location in window.location.hash. This makes it ideal * for situations where you don't want to send the location to the server for * some reason, either because you do cannot configure it or the URL space is * reserved for something else. * * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createhashhistory */ export function createHashHistory( options: HashHistoryOptions = {} ): HashHistory { function createHashLocation( window: Window, globalHistory: Window["history"] ) { let { pathname = "/", search = "", hash = "", } = parsePath(window.location.hash.substr(1)); // Hash URL should always have a leading / just like window.location.pathname // does, so if an app ends up at a route like /#something then we add a // leading slash so all of our path-matching behaves the same as if it would // in a browser router. This is particularly important when there exists a // root splat route () since that matches internally against // "/*" and we'd expect /#something to 404 in a hash router app. if (!pathname.startsWith("/") && !pathname.startsWith(".")) { pathname = "/" + pathname; } return createLocation( "", { pathname, search, hash }, // state defaults to `null` because `window.history.state` does (globalHistory.state && globalHistory.state.usr) || null, (globalHistory.state && globalHistory.state.key) || "default" ); } function createHashHref(window: Window, to: To) { let base = window.document.querySelector("base"); let href = ""; if (base && base.getAttribute("href")) { let url = window.location.href; let hashIndex = url.indexOf("#"); href = hashIndex === -1 ? url : url.slice(0, hashIndex); } return href + "#" + (typeof to === "string" ? to : createPath(to)); } function validateHashLocation(location: Location, to: To) { warning( location.pathname.charAt(0) === "/", `relative pathnames are not supported in hash history.push(${JSON.stringify( to )})` ); } return getUrlBasedHistory( createHashLocation, createHashHref, validateHashLocation, options ); } //#endregion //////////////////////////////////////////////////////////////////////////////// //#region UTILS //////////////////////////////////////////////////////////////////////////////// /** * @private */ export function invariant(value: boolean, message?: string): asserts value; export function invariant( value: T | null | undefined, message?: string ): asserts value is T; export function invariant(value: any, message?: string) { if (value === false || value === null || typeof value === "undefined") { throw new Error(message); } } export function warning(cond: any, message: string) { if (!cond) { // eslint-disable-next-line no-console if (typeof console !== "undefined") console.warn(message); try { // Welcome to debugging history! // // This error is thrown as a convenience, so you can more easily // find the source for a warning that appears in the console by // enabling "pause on exceptions" in your JavaScript debugger. throw new Error(message); // eslint-disable-next-line no-empty } catch (e) {} } } function createKey() { return Math.random().toString(36).substr(2, 8); } /** * For browser-based histories, we combine the state and key into an object */ function getHistoryState(location: Location, index: number): HistoryState { return { usr: location.state, key: location.key, idx: index, }; } /** * Creates a Location object with a unique key from the given Path */ export function createLocation( current: string | Location, to: To, state: any = null, key?: string ): Readonly { let location: Readonly = { pathname: typeof current === "string" ? current : current.pathname, search: "", hash: "", ...(typeof to === "string" ? parsePath(to) : to), state, // TODO: This could be cleaned up. push/replace should probably just take // full Locations now and avoid the need to run through this flow at all // But that's a pretty big refactor to the current test suite so going to // keep as is for the time being and just let any incoming keys take precedence key: (to && (to as Location).key) || key || createKey(), }; return location; } /** * Creates a string URL path from the given pathname, search, and hash components. */ export function createPath({ pathname = "/", search = "", hash = "", }: Partial) { if (search && search !== "?") pathname += search.charAt(0) === "?" ? search : "?" + search; if (hash && hash !== "#") pathname += hash.charAt(0) === "#" ? hash : "#" + hash; return pathname; } /** * Parses a string URL path into its separate pathname, search, and hash components. */ export function parsePath(path: string): Partial { let parsedPath: Partial = {}; if (path) { let hashIndex = path.indexOf("#"); if (hashIndex >= 0) { parsedPath.hash = path.substr(hashIndex); path = path.substr(0, hashIndex); } let searchIndex = path.indexOf("?"); if (searchIndex >= 0) { parsedPath.search = path.substr(searchIndex); path = path.substr(0, searchIndex); } if (path) { parsedPath.pathname = path; } } return parsedPath; } export interface UrlHistory extends History {} export type UrlHistoryOptions = { window?: Window; v5Compat?: boolean; }; function getUrlBasedHistory( getLocation: (window: Window, globalHistory: Window["history"]) => Location, createHref: (window: Window, to: To) => string, validateLocation: ((location: Location, to: To) => void) | null, options: UrlHistoryOptions = {} ): UrlHistory { let { window = document.defaultView!, v5Compat = false } = options; let globalHistory = window.history; let action = Action.Pop; let listener: Listener | null = null; let index = getIndex()!; // Index should only be null when we initialize. If not, it's because the // user called history.pushState or history.replaceState directly, in which // case we should log a warning as it will result in bugs. if (index == null) { index = 0; globalHistory.replaceState({ ...globalHistory.state, idx: index }, ""); } function getIndex(): number { let state = globalHistory.state || { idx: null }; return state.idx; } function handlePop() { action = Action.Pop; let nextIndex = getIndex(); let delta = nextIndex == null ? null : nextIndex - index; index = nextIndex; if (listener) { listener({ action, location: history.location, delta }); } } function push(to: To, state?: any) { action = Action.Push; let location = createLocation(history.location, to, state); if (validateLocation) validateLocation(location, to); index = getIndex() + 1; let historyState = getHistoryState(location, index); let url = history.createHref(location); // try...catch because iOS limits us to 100 pushState calls :/ try { globalHistory.pushState(historyState, "", url); } catch (error) { // If the exception is because `state` can't be serialized, let that throw // outwards just like a replace call would so the dev knows the cause // https://html.spec.whatwg.org/multipage/nav-history-apis.html#shared-history-push/replace-state-steps // https://html.spec.whatwg.org/multipage/structured-data.html#structuredserializeinternal if (error instanceof DOMException && error.name === "DataCloneError") { throw error; } // They are going to lose state here, but there is no real // way to warn them about it since the page will refresh... window.location.assign(url); } if (v5Compat && listener) { listener({ action, location: history.location, delta: 1 }); } } function replace(to: To, state?: any) { action = Action.Replace; let location = createLocation(history.location, to, state); if (validateLocation) validateLocation(location, to); index = getIndex(); let historyState = getHistoryState(location, index); let url = history.createHref(location); globalHistory.replaceState(historyState, "", url); if (v5Compat && listener) { listener({ action, location: history.location, delta: 0 }); } } function createURL(to: To): URL { // window.location.origin is "null" (the literal string value) in Firefox // under certain conditions, notably when serving from a local HTML file // See https://bugzilla.mozilla.org/show_bug.cgi?id=878297 let base = window.location.origin !== "null" ? window.location.origin : window.location.href; let href = typeof to === "string" ? to : createPath(to); // Treating this as a full URL will strip any trailing spaces so we need to // pre-encode them since they might be part of a matching splat param from // an ancestor route href = href.replace(/ $/, "%20"); invariant( base, `No window.location.(origin|href) available to create URL for href: ${href}` ); return new URL(href, base); } let history: History = { get action() { return action; }, get location() { return getLocation(window, globalHistory); }, listen(fn: Listener) { if (listener) { throw new Error("A history only accepts one active listener"); } window.addEventListener(PopStateEventType, handlePop); listener = fn; return () => { window.removeEventListener(PopStateEventType, handlePop); listener = null; }; }, createHref(to) { return createHref(window, to); }, createURL, encodeLocation(to) { // Encode a Location the same way window.location would let url = createURL(to); return { pathname: url.pathname, search: url.search, hash: url.hash, }; }, push, replace, go(n) { return globalHistory.go(n); }, }; return history; } //#endregion