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"; import { NavigationTransition, NavigationTransitionAbort, NavigationTransitionBeforeCommit, NavigationTransitionIsAsync, NavigationTransitionIsOngoing } from "./navigation-transition"; 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; /** * 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 `` or ``. * 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>(givenWindow: WindowLike | undefined = globalWindow): T | undefined { const history = getWindowHistory(givenWindow); if (!history) return undefined; const value = history.state; return like(value) ? value : undefined; } interface StateHistoryMeta { [NavigationMeta]: true; key: string; currentIndex: number; entries: NavigationHistoryEntrySerialized[]; state: T; } interface StateHistoryWithMeta { [NavigationKey]: StateHistoryMeta } function isStateHistoryMeta(state: T): state is T & StateHistoryWithMeta { return like>(state) && state[NavigationMeta] === true; } function isStateHistoryWithMeta(state: T): state is T & StateHistoryWithMeta { return like>(state) && isStateHistoryMeta(state[NavigationKey]); } function disposeHistoryState( entry: NavigationHistoryEntry, 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(navigation: Navigation, entry: NavigationHistoryEntry, limit = DEFAULT_POLYFILL_OPTIONS.limit): StateHistoryMeta { return { [NavigationMeta]: true, currentIndex: entry.index, key: entry.key, entries: getEntries(navigation, limit), state: entry.getState() } } function getNavigationEntryWithMeta(navigation: Navigation, entry: NavigationHistoryEntry, limit = DEFAULT_POLYFILL_OPTIONS.limit): StateHistoryWithMeta { return { [NavigationKey]: getNavigationEntryMeta(navigation, entry, limit) } } function setHistoryState( navigation: Navigation, history: NavigationHistory, entry: NavigationHistoryEntry, persist: boolean, limit: number | undefined ) { setStateInSession() function getSerializableState(): StateHistoryWithMeta { 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( history: NavigationHistory & { originalState?: T }, entry: NavigationHistoryEntry ): T { return ( getStateFromHistoryIfMatchingKey() ?? getStateFromSession() ); function getStateFromHistoryDirectly() { try { return history.state; } catch { return undefined; } } function getBaseState() { const value = ( history.originalState ?? getStateFromHistoryDirectly() ); return like(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(state)) return undefined; if (!isStateHistoryWithMeta(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(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({ 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(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(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 tags without href do if (like(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(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, 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, 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"); const location = window.location; // 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 (location?.href) { url = 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(); const ignoreCurrentEntryChange = new Set(); navigation.addEventListener("navigate", event => { if (event.destination.sameDocument) { if (navigation.transition instanceof NavigationTransition) { const { transition } = navigation; const { destination } = event; transition.addEventListener(NavigationTransitionBeforeCommit, () => { if (!transition[NavigationTransitionIsOngoing]) { // TODO do we need to push state here too? if (transition.navigationType === "push") { location.href = destination.url; } else if (transition.navigationType === "reload") { location.reload(); } transition[NavigationTransitionAbort](); } }, { once: true }); } 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(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, transition } = 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(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; } }