UNPKG

@virtualstate/navigation

Version:

Native JavaScript [navigation](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) implementation

652 lines 27.1 kB
import { NavigationHistoryEntry, NavigationHistoryEntryKnownAs, NavigationHistoryEntryNavigationType, NavigationHistoryEntrySetState, } from "./navigation-entry.js"; import { NavigationEventTarget } from "./navigation-event-target.js"; import { InvalidStateError } from "./navigation-errors.js"; import { EventTargetListeners } from "./event-target/index.js"; import { NavigationTransition, NavigationTransitionEntry, NavigationTransitionError, NavigationTransitionFinally, NavigationTransitionStart, NavigationTransitionInitialEntries, NavigationTransitionInitialIndex, NavigationTransitionKnown, NavigationTransitionNavigationType, NavigationTransitionParentEventTarget, NavigationTransitionPromises, NavigationTransitionWait, Rollback, Unset, NavigationIntercept, NavigationTransitionStartDeadline, NavigationTransitionCommit, NavigationTransitionFinish, NavigationTransitionAbort, NavigationTransitionIsOngoing, NavigationTransitionFinishedDeferred, NavigationTransitionCommittedDeferred, NavigationTransitionIsAsync, NavigationTransitionCommitIsManual, NavigationTransitionRejected, } from "./navigation-transition.js"; import { createNavigationTransition, } from "./create-navigation-transition.js"; import { createEvent } from "./event-target/create-event.js"; import { getBaseURL } from "./base-url.js"; import { isPromise, isPromiseRejectedResult } from "./is.js"; import { NavigationCurrentEntryChangeEvent } from "./events/index.js"; export * from "./spec/navigation.js"; export const NavigationSetOptions = Symbol.for("@virtualstate/navigation/setOptions"); export const NavigationSetEntries = Symbol.for("@virtualstate/navigation/setEntries"); export const NavigationSetCurrentIndex = Symbol.for("@virtualstate/navigation/setCurrentIndex"); export const NavigationSetCurrentKey = Symbol.for("@virtualstate/navigation/setCurrentKey"); export const NavigationGetState = Symbol.for("@virtualstate/navigation/getState"); export const NavigationSetState = Symbol.for("@virtualstate/navigation/setState"); export const NavigationDisposeState = Symbol.for("@virtualstate/navigation/disposeState"); export function isNavigationNavigationType(value) { return (value === "reload" || value === "push" || value === "replace" || value === "traverse"); } export class Navigation extends NavigationEventTarget { // Should be always 0 or 1 #transitionInProgressCount = 0; // #activePromise?: Promise<void> = undefined; #entries = []; #known = new Set(); #currentIndex = -1; #activeTransition; #knownTransitions = new WeakSet(); #baseURL = ""; #initialEntry = undefined; #options = undefined; get canGoBack() { return !!this.#entries[this.#currentIndex - 1]; } get canGoForward() { return !!this.#entries[this.#currentIndex + 1]; } get currentEntry() { if (this.#currentIndex === -1) { if (!this.#initialEntry) { this.#initialEntry = new NavigationHistoryEntry({ getState: this[NavigationGetState], navigationType: "push", index: -1, sameDocument: false, url: this.#baseURL.toString() }); } return this.#initialEntry; } return this.#entries[this.#currentIndex]; } get transition() { const transition = this.#activeTransition; // Never let an aborted transition leak, it doesn't need to be accessed any more return transition?.signal.aborted ? undefined : transition; } constructor(options = {}) { super(); this[NavigationSetOptions](options); } [NavigationSetOptions](options) { this.#options = options; this.#baseURL = getBaseURL(options?.baseURL); this.#entries = []; if (options.entries) { this[NavigationSetEntries](options.entries); } if (options.currentKey) { this[NavigationSetCurrentKey](options.currentKey); } else if (typeof options.currentIndex === "number") { this[NavigationSetCurrentIndex](options.currentIndex); } } /** * Set the current entry key without any lifecycle eventing * * This would be more exact than providing an index * @param key */ [NavigationSetCurrentKey](key) { const index = this.#entries.findIndex(entry => entry.key === key); // If the key can't be found, becomes a no-op if (index === -1) return; this.#currentIndex = index; } /** * Set the current entry index without any lifecycle eventing * @param index */ [NavigationSetCurrentIndex](index) { if (index <= -1) return; if (index >= this.#entries.length) return; this.#currentIndex = index; } /** * Set the entries available without any lifecycle eventing * @param entries */ [NavigationSetEntries](entries) { this.#entries = entries.map(({ key, url, navigationType, state, sameDocument }, index) => new NavigationHistoryEntry({ getState: this[NavigationGetState], navigationType: isNavigationNavigationType(navigationType) ? navigationType : "push", sameDocument: sameDocument ?? true, index, url, key, state })); if (this.#currentIndex === -1 && this.#entries.length) { // Initialise, even if its not the one that was expected this.#currentIndex = 0; } } [NavigationGetState] = (entry) => { return this.#options?.getState?.(entry) ?? undefined; }; [NavigationSetState] = (entry) => { return this.#options?.setState?.(entry); }; [NavigationDisposeState] = (entry) => { return this.#options?.disposeState?.(entry); }; back(options) { if (!this.canGoBack) throw new InvalidStateError("Cannot go back"); const entry = this.#entries[this.#currentIndex - 1]; return this.#pushEntry("traverse", this.#cloneNavigationHistoryEntry(entry, { ...options, navigationType: "traverse", })); } entries() { return [...this.#entries]; } forward(options) { if (!this.canGoForward) throw new InvalidStateError(); const entry = this.#entries[this.#currentIndex + 1]; return this.#pushEntry("traverse", this.#cloneNavigationHistoryEntry(entry, { ...options, navigationType: "traverse", })); } /** /** * @deprecated use traverseTo */ goTo(key, options) { return this.traverseTo(key, options); } traverseTo(key, options) { const found = this.#entries.find((entry) => entry.key === key); if (found) { return this.#pushEntry("traverse", this.#cloneNavigationHistoryEntry(found, { ...options, navigationType: "traverse", })); } throw new InvalidStateError(); } #isSameDocument = (url) => { function isSameOrigins(a, b) { return a.origin === b.origin; } const currentEntryUrl = this.currentEntry?.url; if (!currentEntryUrl) return true; return isSameOrigins(new URL(currentEntryUrl), new URL(url)); }; navigate(url, options) { let baseURL = this.#baseURL; if (this.currentEntry?.url) { // This allows use to use relative baseURL = this.currentEntry?.url; } const nextUrl = new URL(url, baseURL).toString(); let navigationType = "push"; if (options?.history === "push" || options?.history === "replace") { navigationType = options?.history; } const entry = this.#createNavigationHistoryEntry({ getState: this[NavigationGetState], url: nextUrl, ...options, sameDocument: this.#isSameDocument(nextUrl), navigationType, }); return this.#pushEntry(navigationType, entry, undefined, options); } #cloneNavigationHistoryEntry = (entry, options) => { return this.#createNavigationHistoryEntry({ ...entry, getState: this[NavigationGetState], index: entry?.index ?? undefined, state: options?.state ?? entry?.getState(), navigationType: entry?.[NavigationHistoryEntryNavigationType] ?? (typeof options?.navigationType === "string" ? options.navigationType : "replace"), ...options, get [NavigationHistoryEntryKnownAs]() { return entry?.[NavigationHistoryEntryKnownAs]; }, get [EventTargetListeners]() { return entry?.[EventTargetListeners]; }, }); }; #createNavigationHistoryEntry = (options) => { const entry = new NavigationHistoryEntry({ ...options, index: options.index ?? (() => { return this.#entries.indexOf(entry); }), }); return entry; }; #pushEntry = (navigationType, entry, transition, options) => { /* c8 ignore start */ if (entry === this.currentEntry) throw new InvalidStateError(); const existingPosition = this.#entries.findIndex((existing) => existing.id === entry.id); if (existingPosition > -1) { throw new InvalidStateError(); } /* c8 ignore end */ return this.#commitTransition(navigationType, entry, transition, options); }; #commitTransition = (givenNavigationType, entry, transition, options) => { const nextTransition = transition ?? new NavigationTransition({ from: this.currentEntry, navigationType: typeof givenNavigationType === "string" ? givenNavigationType : "replace", rollback: (options) => { return this.#rollback(nextTransition, options); }, [NavigationTransitionNavigationType]: givenNavigationType, [NavigationTransitionInitialEntries]: [...this.#entries], [NavigationTransitionInitialIndex]: this.#currentIndex, [NavigationTransitionKnown]: [...this.#known], [NavigationTransitionEntry]: entry, [NavigationTransitionParentEventTarget]: this, }); const { finished, committed } = nextTransition; const handler = () => { return this.#immediateTransition(givenNavigationType, entry, nextTransition, options); }; this.#queueTransition(nextTransition); void handler().catch((error) => void error); // let nextPromise; // if (!this.#transitionInProgressCount || !this.#activePromise) { // nextPromise = handler().catch((error) => void error); // } else { // nextPromise = this.#activePromise.then(handler); // } // // const promise = nextPromise // .catch(error => void error) // .then(() => { // if (this.#activePromise === promise) { // this.#activePromise = undefined; // } // }) // // this.#activePromise = promise; return { committed, finished }; }; #queueTransition = (transition) => { // TODO consume errors that are not abort errors // transition.finished.catch(error => void error); this.#knownTransitions.add(transition); }; #immediateTransition = (givenNavigationType, entry, transition, options) => { try { // This number can grow if navigation is // called during a transition // // ... I had used transitionInProgressCount as a // safeguard until I could see this flow firsthand this.#transitionInProgressCount += 1; return this.#transition(givenNavigationType, entry, transition, options); } finally { this.#transitionInProgressCount -= 1; } }; #rollback = (rollbackTransition, options) => { const previousEntries = rollbackTransition[NavigationTransitionInitialEntries]; const previousIndex = rollbackTransition[NavigationTransitionInitialIndex]; const previousCurrent = previousEntries[previousIndex]; // console.log("z"); // console.log("Rollback!", { previousCurrent, previousEntries, previousIndex }); const entry = previousCurrent ? this.#cloneNavigationHistoryEntry(previousCurrent, options) : undefined; const nextOptions = { ...options, index: previousIndex, known: new Set([...this.#known, ...previousEntries]), navigationType: entry?.[NavigationHistoryEntryNavigationType] ?? "replace", entries: previousEntries, }; const resolvedNavigationType = entry ? Rollback : Unset; const resolvedEntry = entry ?? this.#createNavigationHistoryEntry({ getState: this[NavigationGetState], navigationType: "replace", index: nextOptions.index, sameDocument: true, ...options, }); return this.#pushEntry(resolvedNavigationType, resolvedEntry, undefined, nextOptions); }; #transition = (givenNavigationType, entry, transition, options) => { // console.log({ givenNavigationType, transition }); let navigationType = givenNavigationType; const performance = getPerformance(); if (performance && entry.sameDocument && typeof navigationType === "string") { performance?.mark?.(`same-document-navigation:${entry.id}`); } let currentEntryChangeEvent = false, committedCurrentEntryChange = false; const { currentEntry } = this; void this.#activeTransition?.finished?.catch((error) => error); void this.#activeTransition?.[NavigationTransitionFinishedDeferred]?.promise?.catch((error) => error); void this.#activeTransition?.[NavigationTransitionCommittedDeferred]?.promise?.catch((error) => error); this.#activeTransition?.[NavigationTransitionAbort](); this.#activeTransition = transition; const startEventPromise = transition.dispatchEvent({ type: NavigationTransitionStart, transition, entry, }); const syncCommit = ({ entries, index, known }) => { if (transition.signal.aborted) return; this.#entries = entries; if (known) { this.#known = new Set([...this.#known, ...known]); } this.#currentIndex = index; // Let's trigger external state here // because it is the absolute point of // committing to using an entry // // If the entry came from an external source // then internal to getState the external source will be pulled from // only if the entry doesn't hold the state in memory // // TLDR I believe this will be no issue doing here, even if we end up // calling an external setState multiple times, it is better than // loss of the state this[NavigationSetState](this.currentEntry); }; const asyncCommit = async (commit) => { if (committedCurrentEntryChange) { return; } committedCurrentEntryChange = true; syncCommit(commit); const { entriesChange } = commit; const promises = [ transition.dispatchEvent(createEvent({ type: NavigationTransitionCommit, transition, entry, })) ]; if (entriesChange) { promises.push(this.dispatchEvent(createEvent({ type: "entrieschange", ...entriesChange }))); } await Promise.all(promises); }; const unsetTransition = async () => { await startEventPromise; if (!(typeof options?.index === "number" && options.entries)) throw new InvalidStateError(); const previous = this.entries(); const previousKeys = previous.map(entry => entry.key); const keys = options.entries.map(entry => entry.key); const removedEntries = previous.filter(entry => !keys.includes(entry.key)); const addedEntries = options.entries.filter(entry => !previousKeys.includes(entry.key)); await asyncCommit({ entries: options.entries, index: options.index, known: options.known, entriesChange: (removedEntries.length || addedEntries.length) ? { removedEntries, addedEntries, updatedEntries: [] } : undefined }); await this.dispatchEvent(createEvent({ type: "currententrychange", })); currentEntryChangeEvent = true; return entry; }; const completeTransition = () => { if (givenNavigationType === Unset) { return unsetTransition(); } const transitionResult = createNavigationTransition({ currentEntry, currentIndex: this.#currentIndex, options, transition, known: this.#known, commit: asyncCommit, reportError: transition[NavigationTransitionRejected] }); const microtask = new Promise(queueMicrotask); let promises = []; const iterator = transitionSteps(transitionResult)[Symbol.iterator](); const iterable = { [Symbol.iterator]: () => ({ next: () => iterator.next() }), }; async function syncTransition() { for (const promise of iterable) { if (isPromise(promise)) { promises.push(Promise.allSettled([ promise ]).then(([result]) => result)); } if (transition[NavigationTransitionCommitIsManual] || (currentEntryChangeEvent && transition[NavigationTransitionIsAsync])) { return asyncTransition().then(syncTransition); } if (transition.signal.aborted) { break; } } if (promises.length) { return asyncTransition(); } } async function asyncTransition() { const captured = [...promises]; if (captured.length) { promises = []; const results = await Promise.all(captured); const rejected = results.filter(isPromiseRejectedResult); if (rejected.length === 1) { throw await Promise.reject(rejected[0]); } else if (rejected.length) { throw new AggregateError(rejected, rejected[0].reason?.message); } } else if (!transition[NavigationTransitionIsOngoing]) { await microtask; } } // console.log("Returning", { entry }); return syncTransition() .then(() => transition[NavigationTransitionIsOngoing] ? undefined : microtask) .then(() => entry); }; const dispose = async () => this.#dispose(); function* transitionSteps(transitionResult) { const microtask = new Promise(queueMicrotask); const { currentEntryChange, navigate, waitForCommit, commit, abortController } = transitionResult; const navigateAbort = abortController.abort.bind(abortController); transition.signal.addEventListener("abort", navigateAbort, { once: true, }); if (typeof navigationType === "string" || navigationType === Rollback) { const promise = currentEntry?.dispatchEvent(createEvent({ type: "navigatefrom", intercept: transition[NavigationIntercept], /** * @deprecated */ transitionWhile: transition[NavigationIntercept], })); if (promise) yield promise; } if (typeof navigationType === "string") { yield transition.dispatchEvent(navigate); } if (!transition[NavigationTransitionCommitIsManual]) { commit(); } yield waitForCommit; if (entry.sameDocument) { yield transition.dispatchEvent(currentEntryChange); } currentEntryChangeEvent = true; if (typeof navigationType === "string") { yield entry.dispatchEvent(createEvent({ type: "navigateto", intercept: transition[NavigationIntercept], /** * @deprecated */ transitionWhile: transition[NavigationIntercept], })); } yield dispose(); if (!transition[NavigationTransitionPromises].size) { yield microtask; } yield transition.dispatchEvent({ type: NavigationTransitionStartDeadline, transition, entry, }); yield transition[NavigationTransitionWait](); transition.signal.removeEventListener("abort", navigateAbort); yield transition[NavigationTransitionFinish](); if (typeof navigationType === "string") { yield transition.dispatchEvent(createEvent({ type: "finish", intercept: transition[NavigationIntercept], /** * @deprecated */ transitionWhile: transition[NavigationIntercept], })); yield transition.dispatchEvent(createEvent({ type: "navigatesuccess", intercept: transition[NavigationIntercept], /** * @deprecated */ transitionWhile: transition[NavigationIntercept], })); } } const maybeSyncTransition = () => { try { return completeTransition(); } catch (error) { return Promise.reject(error); } }; return Promise.allSettled([maybeSyncTransition()]) .then(async ([detail]) => { if (detail.status === "rejected") { await transition.dispatchEvent({ type: NavigationTransitionError, error: detail.reason, transition, entry, }); } await dispose(); await transition.dispatchEvent({ type: NavigationTransitionFinally, transition, entry, }); await transition[NavigationTransitionWait](); if (this.#activeTransition === transition) { this.#activeTransition = undefined; } if (entry.sameDocument && typeof navigationType === "string") { performance.mark(`same-document-navigation-finish:${entry.id}`); performance.measure(`same-document-navigation:${entry.url}`, `same-document-navigation:${entry.id}`, `same-document-navigation-finish:${entry.id}`); } }) .then(() => entry); }; #dispose = async () => { // console.log(JSON.stringify({ known: [...this.#known], entries: this.#entries })); for (const known of this.#known) { const index = this.#entries.findIndex((entry) => entry.key === known.key); if (index !== -1) { // Still in use continue; } // No index, no longer known this.#known.delete(known); const event = createEvent({ type: "dispose", entry: known, }); this[NavigationDisposeState](known); await known.dispatchEvent(event); await this.dispatchEvent(event); } // console.log(JSON.stringify({ pruned: [...this.#known] })); }; reload(options) { const { currentEntry } = this; if (!currentEntry) throw new InvalidStateError(); const entry = this.#cloneNavigationHistoryEntry(currentEntry, options); return this.#pushEntry("reload", entry, undefined, options); } updateCurrentEntry(options) { const { currentEntry } = this; if (!currentEntry) { throw new InvalidStateError("Expected current entry"); } // Instant change currentEntry[NavigationHistoryEntrySetState](options.state); this[NavigationSetState](currentEntry); const currentEntryChange = new NavigationCurrentEntryChangeEvent("currententrychange", { from: currentEntry, navigationType: undefined, }); const entriesChange = createEvent({ type: "entrieschange", addedEntries: [], removedEntries: [], updatedEntries: [ currentEntry ] }); return Promise.all([ this.dispatchEvent(currentEntryChange), this.dispatchEvent(entriesChange) ]); } } function getPerformance() { if (typeof performance !== "undefined") { return performance; } /* c8 ignore start */ return { now() { return Date.now(); }, mark() { }, measure() { }, }; // const { performance: nodePerformance } = await import("perf_hooks"); // return nodePerformance; /* c8 ignore end */ } //# sourceMappingURL=navigation.js.map