Spaces:
Sleeping
Sleeping
//////////////////////////////////////////////////////////////////////////////// | |
//#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<State = any> 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<Path>; | |
/** | |
* 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 <a href> 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<Location>; | |
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 (<Route path="*">) 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<T>( | |
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<Location> { | |
let location: Readonly<Location> = { | |
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<Path>) { | |
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<Path> { | |
let parsedPath: Partial<Path> = {}; | |
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 | |