@virtualstate/navigation
Version:
Native JavaScript [navigation](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) implementation
865 lines (775 loc) • 28.4 kB
text/typescript
import type {Navigation, NavigationHistoryEntry} from "./spec/navigation";
import {
Navigation as NavigationPolyfill,
NavigationOptions,
NavigationSetCurrentKey,
NavigationSetEntries, NavigationSetOptions
} from "./navigation";
import { InvalidStateError } from "./navigation-errors";
import { InternalNavigationNavigateOptions, NavigationDownloadRequest, NavigationFormData, NavigationOriginalEvent, NavigationUserInitiated } from "./create-navigation-transition";
import { stringify, parse } from './util/serialization';
import {NavigationHistory} from "./history";
import {like, ok} from "./is";
import {
ElementPrototype, EventPrototype,
globalWindow,
HTMLAnchorElementPrototype,
HTMLFormElementPrototype,
MouseEventPrototype, SubmitEventPrototype,
WindowLike
} from "./global-window";
import {globalSelf} from "./global-self";
import {v4} from "./util/uuid-or-random";
import {NavigationHistoryEntrySerialized} from "./navigation-entry";
export interface NavigationPolyfillOptions {
/**
* Integrate polyfilled Navigation API with legacy History API.
* Specifically, `popstate` will trigger navigation traversal and
* navigates will push state on History API.
* This enables forward/backward to work in most cases, but not after page refresh, etc.
* See {@link NavigationPolyfillOptions.persist} on how to recover from hard resets.
*
* __WIP__: No consideration given to iframes and other edge cases.
* `hashchange` not implemented (but might work anyway for most cases with {@link INTERCEPT_EVENTS}?).
* `scroll` not implemented.
*/
history?: boolean | NavigationHistory<object>;
/**
* Persists all navigation entries in history state.
* This enables forward/backward to work after hard refresh, closing/reopening tab, etc.
* but comes at the cost of storing all navigation history entries _on every history frame_.
* This isn't quite as crazy as it seems, as each entry only consists of `url`, `key` and `id`,
* but you might want to disable it regardless.
*
* __WIP__: Maybe store entries in session storage instead and only keep an id in history state?
* What's worse, sync access + stringification or duplication on history state? 🤔
*/
persist?: boolean;
/** Limits max # of entries stored in history storage */
limit?: number;
/**
* Like {@link NavigationPolyfillOptions.limit}, except also stores the state for each navigation entry.
* Note that you might not need this if you only need the state of the current navigation entry,
* which works with {@link NavigationPolyfillOptions.history} alone.
* Enabling this allows retrieving the state of *any navigation entry even after a hard refresh*.
*
* __WIP__: State is stringified and stored in `sessionStorage`. This works for small objects,
* but gets awkward when storing large array buffers, etc.
* A more advanced implementation could combine session storage with a
* [Storage Area](https://workers.tools/kv-storage-polyfill) (Indexed DB) for better perf...
* __NOTE__: Turns out session storage is easier lost than history state (in webkit at least)
* It will survive hard refresh (but not always?) but not closing tab and restoring it.
*/
persistState?: boolean;
/**
* Monkey patches History API methods to call new Navigation API methods instead.
* Could solve issues when combining Navigation API with frameworks that use the legacy History API,
* or it might cause additional issues instead 🤷♂️.
*
* __NOTE__: This performs some prototype acrobatics to hide the "real" history state from the application.
* If this sounds scary you might want to disable this.
*/
patch?: boolean;
/**
* Intercepts clicks on `a` tags and `form` submissions and conditionally calls `preventDefault` based on
* application code response to the `navigate` event. This is the final piece of the Navigation API puzzle,
* as it allows using vanilla HTML elements instead of framework specific components like `<Link/>` or `<A/>`.
* In practice you might want to use those anyway, in which case you wouldn't need to enable this setting.
*/
interceptEvents?: boolean;
window?: WindowLike;
navigation?: Navigation
}
export const NavigationKey = "__@virtualstate/navigation/key";
export const NavigationMeta = "__@virtualstate/navigation/meta";
declare var FormData: {
new(element: HTMLFormElementPrototype): FormData
}
function getWindowHistory(givenWindow: WindowLike | undefined = globalWindow) {
if (typeof givenWindow === "undefined") return undefined;
return givenWindow.history;
}
function getStateFromWindowHistory<T extends object = Record<string | symbol, unknown>>(givenWindow: WindowLike | undefined = globalWindow): T | undefined {
const history = getWindowHistory(givenWindow);
if (!history) return undefined;
const value = history.state;
return like<T>(value) ? value : undefined;
}
interface StateHistoryMeta<T = unknown> {
[NavigationMeta]: true;
key: string;
currentIndex: number;
entries: NavigationHistoryEntrySerialized[];
state: T;
}
interface StateHistoryWithMeta<T = unknown> {
[NavigationKey]: StateHistoryMeta<T>
}
function isStateHistoryMeta<T>(state: T): state is T & StateHistoryWithMeta<T> {
return like<StateHistoryMeta<T>>(state) && state[NavigationMeta] === true;
}
function isStateHistoryWithMeta<T>(state: T): state is T & StateHistoryWithMeta<T> {
return like<StateHistoryWithMeta<T>>(state) && isStateHistoryMeta(state[NavigationKey]);
}
function disposeHistoryState<T extends object>(
entry: NavigationHistoryEntry<T>,
persist: boolean
) {
if (!persist) return;
if (typeof sessionStorage === "undefined") return;
sessionStorage.removeItem(entry.key)
}
function getEntries(navigation: Navigation, limit: number = DEFAULT_POLYFILL_OPTIONS.limit): NavigationHistoryEntrySerialized[] {
let entries = navigation.entries();
if (typeof limit === "number") {
entries = entries.slice(-limit);
}
return entries.map(({ id, key, url, sameDocument }) => ({
id,
key,
url,
sameDocument
}));
}
function getNavigationEntryMeta<T>(navigation: Navigation<T>, entry: NavigationHistoryEntry<T>, limit = DEFAULT_POLYFILL_OPTIONS.limit): StateHistoryMeta<T> {
return {
[NavigationMeta]: true,
currentIndex: entry.index,
key: entry.key,
entries: getEntries(navigation, limit),
state: entry.getState()
}
}
function getNavigationEntryWithMeta<T>(navigation: Navigation<T>, entry: NavigationHistoryEntry<T>, limit = DEFAULT_POLYFILL_OPTIONS.limit): StateHistoryWithMeta<T> {
return {
[NavigationKey]: getNavigationEntryMeta(navigation, entry, limit)
}
}
function setHistoryState<T extends object>(
navigation: Navigation<T>,
history: NavigationHistory<T>,
entry: NavigationHistoryEntry<T>,
persist: boolean,
limit: number | undefined
) {
setStateInSession()
function getSerializableState(): StateHistoryWithMeta<T> {
return getNavigationEntryWithMeta(navigation, entry, limit);
}
function setStateInSession() {
if (typeof sessionStorage === "undefined") return;
try {
const raw = stringify(
getSerializableState()
);
sessionStorage.setItem(entry.key, raw);
} catch {}
}
}
function getHistoryState<T extends object>(
history: NavigationHistory<T> & { originalState?: T },
entry: NavigationHistoryEntry<T>
): T {
return (
getStateFromHistoryIfMatchingKey() ??
getStateFromSession()
);
function getStateFromHistoryDirectly() {
try {
return history.state;
} catch {
return undefined;
}
}
function getBaseState() {
const value = (
history.originalState ??
getStateFromHistoryDirectly()
);
return like<T>(value) ? value : undefined;
}
function getStateFromHistoryIfMatchingKey() {
const state = getBaseState();
if (!isStateHistoryWithMeta(state)) return undefined;
if (state[NavigationKey].key !== entry.key) return undefined;
return state[NavigationKey].state;
}
function getStateFromSession(): T | undefined {
if (typeof sessionStorage === "undefined") return undefined;
try {
const raw = sessionStorage.getItem(entry.key);
if (!raw) return undefined;
const state = parse(raw);
if (!like<T>(state)) return undefined;
if (!isStateHistoryWithMeta<T>(state)) return undefined;
return state[NavigationKey].state;
} catch {
return undefined;
}
}
}
export const DEFAULT_POLYFILL_OPTIONS: NavigationPolyfillOptions = Object.freeze({
persist: true,
persistState: true,
history: true,
limit: 50,
patch: true,
interceptEvents: true
});
export function getPolyfill(options: NavigationPolyfillOptions = DEFAULT_POLYFILL_OPTIONS): Navigation {
const { navigation } = getCompletePolyfill(options);
return navigation;
}
function isNavigationPolyfill(navigation?: Navigation): navigation is NavigationPolyfill {
return (
like<NavigationPolyfill>(navigation) &&
typeof navigation[NavigationSetEntries] === "function" &&
typeof navigation[NavigationSetCurrentKey] === "function"
)
}
function getNavigationOnlyPolyfill(givenNavigation?: Navigation) {
// When using as a polyfill, we will auto initiate a single
// entry, but not cause an event for it
const entries = [
{
key: v4()
}
];
const navigation = givenNavigation ?? new NavigationPolyfill({
entries
});
const history = new NavigationHistory<object>({
navigation
})
return {
navigation,
history,
apply() {
if (isNavigationPolyfill(givenNavigation) && !navigation.entries().length) {
givenNavigation[NavigationSetEntries](entries)
}
}
}
}
function interceptWindowClicks(navigation: Navigation, window: WindowLike) {
function clickCallback(ev: MouseEventPrototype, aEl: HTMLAnchorElementPrototype) {
// console.log("<-- clickCallback -->");
// TODO opt into queueMicrotask before process
process();
function process() {
if (!isAppNavigation(ev)) return;
ok<Event>(ev);
const target = aEl.getAttribute("target");
// See target detailed here
// https://github.com/WICG/navigation-api/blob/7b2d326b8eeb75680e568dadaa67e3bb54c9ca7f/README.md?plain=1#L1465
if (target) {
if (target === "_blank") {
// Continue with default, new window
return;
}
if (target !== window.name) {
// Continue with default, not current window
return;
}
}
const options: InternalNavigationNavigateOptions = {
history: "auto",
[NavigationUserInitiated]: true,
[NavigationDownloadRequest]: aEl.download,
[NavigationOriginalEvent]: ev,
};
navigation.navigate(aEl.href, options);
}
}
function submitCallback(ev: SubmitEventPrototype, form: HTMLFormElementPrototype) {
// console.log("<-- submitCallback -->");
// TODO opt into queueMicrotask before process
process();
function process() {
if (ev.defaultPrevented) return;
const method = ev.submitter && 'formMethod' in ev.submitter && ev.submitter.formMethod
? ev.submitter.formMethod as string
: form.method;
// XXX: safe to ignore dialog method?
if (method === 'dialog') return;
const action = ev.submitter && 'formAction' in ev.submitter && ev.submitter.formAction
? ev.submitter.formAction as string
: form.action;
const target = form.getAttribute("target");
// See target detailed here
// https://github.com/WICG/navigation-api/blob/7b2d326b8eeb75680e568dadaa67e3bb54c9ca7f/README.md?plain=1#L1465
if (target) {
if (target === "_blank") {
// Continue with default, new window
return;
}
if (target !== window.name) {
// Continue with default, not current window
return;
}
}
let formData;
/* c8 ignore start */
try {
formData = new FormData(form)
} catch {
// For runtimes where we polyfilled the window & then evented it
// ... for some reason
formData = new FormData(undefined)
}
/* c8 ignore end */
const params = method === 'get'
? new URLSearchParams([...formData].map(([k, v]) => v instanceof File ? [k, v.name] : [k, v]))
: undefined;
const navFormData = method === 'post'
? formData
: undefined;
// action is always a fully qualified url in browsers
const url = new URL(
action,
navigation.currentEntry.url
);
if (params)
url.search = params.toString();
const unknownEvent = ev;
ok<Event>(unknownEvent);
const options: InternalNavigationNavigateOptions = {
history: "auto",
[NavigationUserInitiated]: true,
[NavigationFormData]: navFormData,
[NavigationOriginalEvent]: unknownEvent,
};
navigation.navigate(url.href, options);
}
}
// console.log("click event added")
window.addEventListener("click", (ev: MouseEventPrototype) => {
// console.log("click event", ev)
if (ev.target?.ownerDocument === window.document) {
const aEl = getAnchorFromEvent(ev); // XXX: not sure what <a> tags without href do
if (like<HTMLAnchorElementPrototype>(aEl)) {
clickCallback(ev, aEl);
}
}
});
window.addEventListener("submit", (ev: SubmitEventPrototype) => {
// console.log("submit event")
if (ev.target?.ownerDocument === window.document) {
const form: unknown = getFormFromEvent(ev);
if (like<HTMLFormElementPrototype>(form)) {
submitCallback(ev, form);
}
}
});
}
function getAnchorFromEvent(event: EventPrototype) {
return matchesAncestor(getComposedPathTarget(event), "a[href]:not([data-navigation-ignore])");
}
function getFormFromEvent(event: EventPrototype) {
return matchesAncestor(getComposedPathTarget(event), "form:not([data-navigation-ignore])");
}
function getComposedPathTarget(event: EventPrototype) {
if (!event.composedPath) {
return event.target;
}
const targets = event.composedPath();
return targets[0] ?? event.target;
}
function patchGlobalScope(window: WindowLike, history: NavigationHistory<object>, navigation: Navigation) {
patchGlobals();
patchPopState();
patchHistory();
function patchWindow(window: WindowLike) {
try {
Object.defineProperty(window, "navigation", {
value: navigation,
});
} catch (e) {}
if (!window.history) {
try {
Object.defineProperty(window, "history", {
value: history,
});
} catch (e) {}
}
}
function patchGlobals() {
patchWindow(window);
// If we don't have the global window, don't also patch global scope
if (window !== globalWindow) return;
if (globalSelf) {
try {
Object.defineProperty(globalSelf, "navigation", {
value: navigation,
});
} catch (e) {}
}
if (typeof globalThis !== "undefined") {
try {
Object.defineProperty(globalThis, "navigation", {
value: navigation,
});
} catch (e) {}
}
}
function patchHistory() {
if (history instanceof NavigationHistory) {
// It's our polyfill, but probably externally passed to getPolyfill
return;
}
const polyfillHistory = new NavigationHistory({
navigation
});
const pushState = polyfillHistory.pushState.bind(polyfillHistory);
const replaceState = polyfillHistory.replaceState.bind(polyfillHistory);
const go = polyfillHistory.go.bind(polyfillHistory);
const back = polyfillHistory.back.bind(polyfillHistory);
const forward = polyfillHistory.forward.bind(polyfillHistory);
const prototype = Object.getPrototypeOf(history);
const descriptor: PropertyDescriptorMap = {
pushState: {
...Object.getOwnPropertyDescriptor(prototype, "pushState"),
value: pushState
},
replaceState: {
...Object.getOwnPropertyDescriptor(prototype, "replaceState"),
value: replaceState
},
go: {
...Object.getOwnPropertyDescriptor(prototype, "go"),
value: go
},
back: {
...Object.getOwnPropertyDescriptor(prototype, "back"),
value: back
},
forward: {
...Object.getOwnPropertyDescriptor(prototype, "forward"),
value: forward
}
}
Object.defineProperties(
prototype,
descriptor
);
const stateDescriptor = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(history),
"state"
);
Object.defineProperty(history, "state", {
...stateDescriptor,
get() {
// Derive history state only ever directly from navigation state
//
// Decouple from classic history.state
//
// If the original state is wanted, use history.originalState,
// which is done on a best effort basis and may be out of alignment from
// navigation.currentEntry.getState()
//
// This state will always be tied to the navigation, not the background
// browser's history stack, which could be offset from the applications
// expected state between moments of transition.
//
// The change of using navigation.currentEntry.getState()
// in place of history.state is significant, it's shifting to a model where
// there can be an entry only for one single operation and then replaced
//
// e.g.
//
// navigation.navigate("/1", { state: { key: 1 }});
// navigation.navigate("/2", { state: { key: 2 }});
// await navigation.transition?.finished;
//
// The above code, if ran, history.state might not keep up...
//
// ... In safari if we run replaceState too many times in 30 seconds
// then we will get an exception. So, inherently we know we
// cannot just freely make use of history.state as a deterministic like
// reference.
return polyfillHistory.state;
}
});
Object.defineProperty(history, "originalState", {
...stateDescriptor
});
}
function patchPopState() {
if (!window.PopStateEvent) return;
const popStateEventPrototype = window.PopStateEvent.prototype
if (!popStateEventPrototype) return;
const descriptor = Object.getOwnPropertyDescriptor(popStateEventPrototype, "state");
Object.defineProperty(
popStateEventPrototype,
"state", {
...descriptor,
get() {
const original: unknown = descriptor.get.call(this);
if (!isStateHistoryWithMeta(original)) return original;
return original[NavigationKey].state;
}
}
);
Object.defineProperty(
popStateEventPrototype,
"originalState", {
...descriptor
}
);
}
}
export function getCompletePolyfill(options: NavigationPolyfillOptions = DEFAULT_POLYFILL_OPTIONS): { navigation: Navigation, history: NavigationHistory<object>, apply(): void } {
const {
persist: PERSIST_ENTRIES,
persistState: PERSIST_ENTRIES_STATE,
history: givenHistory,
limit: patchLimit,
patch: PATCH_HISTORY,
interceptEvents: INTERCEPT_EVENTS,
window: givenWindow = globalWindow,
navigation: givenNavigation
} = {
// These are super default options, if the object de
...DEFAULT_POLYFILL_OPTIONS,
...options
}
// console.log({
// ...DEFAULT_POLYFILL_OPTIONS,
// ...options
// })
const IS_PERSIST = PERSIST_ENTRIES || PERSIST_ENTRIES_STATE;
const window = givenWindow ?? globalWindow;
const history = options.history && typeof options.history !== "boolean" ?
options.history :
getWindowHistory(window);
if (!history) {
return getNavigationOnlyPolyfill();
}
// console.log("POLYFILL LOADING");
ok(window, "window required when using polyfill with history, this shouldn't be seen");
// Use baseHistory so that we don't initialise entries we didn't intend to
// if we used a polyfill history
const historyInitialState = history?.state;
let initialMeta: StateHistoryMeta = {
[NavigationMeta]: true,
currentIndex: -1,
entries: [],
key: "",
state: undefined
};
if (isStateHistoryWithMeta(historyInitialState)) {
initialMeta = historyInitialState[NavigationKey];
}
let initialEntries: NavigationHistoryEntrySerialized[] = initialMeta.entries;
const HISTORY_INTEGRATION = !!((givenWindow || givenHistory) && history);
if (!initialEntries.length) {
let url: string | undefined = undefined;
if (window.location?.href) {
url = window.location.href;
}
let state = undefined;
if (!isStateHistoryWithMeta(historyInitialState) && !isStateHistoryMeta(historyInitialState)) {
// console.log("Using state history direct", historyInitialState, history.state);
state = historyInitialState;
}
const key = v4();
initialEntries = [
{
key,
state,
url
}
];
initialMeta.key = key;
initialMeta.currentIndex = 0;
}
// console.log("Initial Entries", initialEntries)
const navigationOptions: NavigationOptions = {
entries: initialEntries,
currentIndex: initialMeta?.currentIndex,
currentKey: initialMeta?.key,
getState(entry: NavigationHistoryEntry) {
if (!HISTORY_INTEGRATION) return;
return getHistoryState(history, entry)
},
setState(entry: NavigationHistoryEntry) {
// console.log({
// setState: entry.getState(),
// entry
// })
if (!HISTORY_INTEGRATION) return;
if (!entry.sameDocument) return;
setHistoryState(
navigation,
history,
entry,
IS_PERSIST,
patchLimit
);
},
disposeState(entry: NavigationHistoryEntry) {
if (!HISTORY_INTEGRATION) return;
disposeHistoryState(
entry,
IS_PERSIST
);
}
};
const navigation: Navigation = givenNavigation ?? new NavigationPolyfill(navigationOptions);
const pushState = history?.pushState.bind(history);
const replaceState = history?.replaceState.bind(history);
const historyGo = history?.go.bind(history);
// const back = history?.back.bind(history);
// const forward = history?.forward.bind(history);
// const origin = typeof location === "undefined" ? "https://example.com" : location.origin;
return {
navigation,
history,
apply() {
// console.log("APPLYING POLYFILL TO NAVIGATION");
if (isNavigationPolyfill(navigation)) {
// Initialise navigation options
navigation[NavigationSetOptions](navigationOptions);
}
if (HISTORY_INTEGRATION) {
const ignorePopState = new Set<string>();
const ignoreCurrentEntryChange = new Set<string>();
navigation.addEventListener("navigate", event => {
if (event.destination.sameDocument) {
return;
}
// If the destination is not the same document, we are navigating away
event.intercept({
// Set commit after transition... and never commit!
commit: "after-transition",
async handler() {
// Let other tasks do something and abort if needed
queueMicrotask(() => {
if (event.signal.aborted) return;
submit();
})
}
});
function submit() {
if (like<EventPrototype>(event.originalEvent)) {
const anchor = getAnchorFromEvent(event.originalEvent);
if (anchor) {
return submitAnchor(anchor);
} else {
const form = getFormFromEvent(event.originalEvent);
if (form) {
return submitForm(form);
}
}
}
// Assumption that navigation event means to navigate...
location.href = event.destination.url;
}
function submitAnchor(element: ElementPrototype) {
const cloned = element.cloneNode();
cloned.setAttribute("data-navigation-ignore", "1");
cloned.click();
}
function submitForm(element: ElementPrototype) {
const cloned = element.cloneNode();
cloned.setAttribute("data-navigation-ignore", "1");
cloned.submit();
}
});
navigation.addEventListener("currententrychange", ({ navigationType, from }) => {
// console.log("<-- currententrychange event listener -->");
const { currentEntry } = navigation;
if (!currentEntry) return;
const { key, url } = currentEntry;
if (ignoreCurrentEntryChange.delete(key) || !currentEntry?.sameDocument) return;
const historyState = getNavigationEntryWithMeta(navigation, currentEntry, patchLimit);
// console.log("currentEntry change", historyState);
switch (navigationType || "replace") {
case "push":
return pushState(historyState, "", url)
case "replace":
return replaceState(historyState, "", url)
case "traverse":
const delta = currentEntry.index - from.index;
ignorePopState.add(key);
return historyGo(delta);
case "reload":
// TODO
}
});
window.addEventListener("popstate", (event) => {
// console.log("<-- popstate event listener -->");
const { state, originalState } = event;
const foundState = originalState ?? state;
if (!isStateHistoryWithMeta(foundState)) return;
const {
[NavigationKey]: {
key
}
} = foundState
if (ignorePopState.delete(key)) return;
ignoreCurrentEntryChange.add(key);
let committed;
try {
committed = navigation.traverseTo(key).committed;
} catch (error) {
if (error instanceof InvalidStateError && !PERSIST_ENTRIES) {
// ignore the error
return;
}
throw error;
}
if (PERSIST_ENTRIES || PERSIST_ENTRIES_STATE) {
committed
.then(entry => {
const historyState = getNavigationEntryWithMeta(navigation, entry, patchLimit)
replaceState(historyState, "", entry.url);
})
// Noop catch
.catch(() => {})
}
})
// window.addEventListener("hashchange", (ev) => {
// // TODO
// })
}
if (INTERCEPT_EVENTS) {
interceptWindowClicks(navigation, window);
}
if (PATCH_HISTORY) {
patchGlobalScope(window, history, navigation);
}
if (!history.state) {
// Initialise history state if not available
const historyState = getNavigationEntryWithMeta(navigation, navigation.currentEntry, patchLimit);
replaceState(historyState, "", navigation.currentEntry.url);
}
}
};
}
function isAppNavigation(evt: MouseEventPrototype) {
return evt.button === 0 &&
!evt.defaultPrevented &&
!evt.metaKey &&
!evt.altKey &&
!evt.ctrlKey &&
!evt.shiftKey
}
/** Checks if this element or any of its parents matches a given `selector` */
function matchesAncestor(givenElement: ElementPrototype | undefined, selector: string): ElementPrototype | undefined {
let element = getDefaultElement();
// console.log({ element })
while (element) {
if (element.matches(selector)) {
ok<ElementPrototype>(element);
return element;
}
element = element.parentElement ?? element.getRootNode()?.host;
}
return undefined;
function getDefaultElement(): ElementPrototype | undefined {
if (!givenElement) return undefined;
if (givenElement.matches instanceof Function) return givenElement;
return givenElement.parentElement;
}
}