import { NavigationHistoryEntry, NavigationHistoryEntryFn, NavigationHistoryEntryGetStateFn, NavigationHistoryEntryInit, NavigationHistoryEntryKnownAs, NavigationHistoryEntryNavigationType, NavigationHistoryEntrySerialized, NavigationHistoryEntrySetState, } from "./navigation-entry"; import { Navigation as NavigationPrototype, NavigationEventMap, NavigationReloadOptions, NavigationResult, NavigationUpdateCurrentOptions, NavigationTransition as NavigationTransitionPrototype, NavigationNavigationOptions, NavigationNavigationType, NavigationEntriesChangeEvent } from "./spec/navigation"; import { NavigationEventTarget } from "./navigation-event-target"; import { InvalidStateError } from "./navigation-errors"; import { EventTargetListeners } from "./event-target"; import { NavigationTransition, NavigationTransitionEntry, NavigationTransitionError, NavigationTransitionFinally, NavigationTransitionStart, NavigationTransitionInitialEntries, NavigationTransitionInitialIndex, NavigationTransitionKnown, NavigationTransitionNavigationType, NavigationTransitionParentEventTarget, NavigationTransitionPromises, NavigationTransitionWait, InternalNavigationNavigationType, Rollback, Unset, NavigationIntercept, NavigationTransitionStartDeadline, NavigationTransitionBeforeCommit, NavigationTransitionCommit, NavigationTransitionFinish, NavigationTransitionAbort, NavigationTransitionIsOngoing, NavigationTransitionFinishedDeferred, NavigationTransitionCommittedDeferred, NavigationTransitionIsAsync, NavigationTransitionInterceptOptionsCommit, NavigationTransitionCommitIsManual, NavigationTransitionRejected, } from "./navigation-transition"; import { NavigationTransitionResult, createNavigationTransition, EventAbortController, InternalNavigationNavigateOptions, NavigationNavigateOptions, NavigationTransitionCommitContext, } from "./create-navigation-transition"; import { createEvent } from "./event-target/create-event"; import { getBaseURL } from "./base-url"; import {isPromise, isPromiseRejectedResult} from "./is"; import {NavigationCurrentEntryChangeEvent} from "./events"; export * from "./spec/navigation"; export interface NavigationOptions { baseURL?: URL | string; getState?: NavigationHistoryEntryGetStateFn setState?: NavigationHistoryEntryFn disposeState?: NavigationHistoryEntryFn entries?: NavigationHistoryEntrySerialized[]; currentIndex?: number; currentKey?: string; } 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: unknown): value is NavigationNavigationType { return ( value === "reload" || value === "push" || value === "replace" || value === "traverse" ); } export class Navigation extends NavigationEventTarget> implements NavigationPrototype { // Should be always 0 or 1 #transitionInProgressCount = 0; // #activePromise?: Promise = undefined; #entries: NavigationHistoryEntry[] = []; #known = new Set>(); #currentIndex = -1; #activeTransition?: NavigationTransition; #knownTransitions = new WeakSet(); #baseURL: string | URL = ""; #initialEntry: NavigationHistoryEntry | undefined = undefined; #options: NavigationOptions | undefined = undefined; get canGoBack() { return !!this.#entries[this.#currentIndex - 1]; } get canGoForward() { return !!this.#entries[this.#currentIndex + 1]; } get currentEntry(): NavigationHistoryEntry { 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(): NavigationTransitionPrototype | undefined { 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: NavigationOptions = {}) { super(); this[NavigationSetOptions](options); } [NavigationSetOptions](options: NavigationOptions) { 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: string) { 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: number) { 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: NavigationHistoryEntrySerialized[]) { 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: NavigationHistoryEntry): S | undefined => { return this.#options?.getState?.(entry) ?? undefined; } [NavigationSetState] = (entry: NavigationHistoryEntry) => { return this.#options?.setState?.(entry); } [NavigationDisposeState] = (entry: NavigationHistoryEntry) => { return this.#options?.disposeState?.(entry); } back(options?: NavigationNavigationOptions): NavigationResult { 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(): NavigationHistoryEntry[] { return [...this.#entries]; } forward(options?: NavigationNavigationOptions): NavigationResult { 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: string, options?: NavigationNavigateOptions): NavigationResult { return this.traverseTo(key, options); } traverseTo(key: string, options?: NavigationNavigationOptions): NavigationResult { 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: string) => { function isSameOrigins(a: URL, b: URL) { return a.origin === b.origin; } const currentEntryUrl = this.currentEntry?.url; if (!currentEntryUrl) return true; return isSameOrigins( new URL(currentEntryUrl), new URL(url) ); } navigate( url: string, options?: NavigationNavigateOptions ): NavigationResult { 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: NavigationNavigationType = "push" if (options?.history === "auto" || !options?.history) { // https://html.spec.whatwg.org/multipage/browsing-the-web.html#navigate-convert-to-replace if (nextUrl === this.currentEntry?.url) { navigationType = "replace"; } } else 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?: NavigationHistoryEntry, options?: InternalNavigationNavigateOptions ): NavigationHistoryEntry => { 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: Partial> & Omit, "index"> ) => { const key = options.key || (options.navigationType === "replace" ? this.currentEntry?.key : undefined); const entry: NavigationHistoryEntry = new NavigationHistoryEntry({ ...options, key, index: options.index ?? (() => { return this.#entries.indexOf(entry); }), }); return entry; }; #pushEntry = ( navigationType: InternalNavigationNavigationType, entry: NavigationHistoryEntry, transition?: NavigationTransition, options?: InternalNavigationNavigateOptions ) => { /* 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: InternalNavigationNavigationType, entry: NavigationHistoryEntry, transition?: NavigationTransition, options?: InternalNavigationNavigateOptions ) => { const nextTransition: NavigationTransition = 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: unknown): void => 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: NavigationTransition) => { // TODO consume errors that are not abort errors // transition.finished.catch(error => void error); this.#knownTransitions.add(transition); }; #immediateTransition = ( givenNavigationType: InternalNavigationNavigationType, entry: NavigationHistoryEntry, transition: NavigationTransition, options?: InternalNavigationNavigateOptions ) => { 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: NavigationTransition, options?: NavigationNavigationOptions ): NavigationResult => { 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: InternalNavigationNavigateOptions = { ...options, index: previousIndex, known: new Set([...this.#known, ...previousEntries]), navigationType: entry?.[NavigationHistoryEntryNavigationType] ?? "replace", entries: previousEntries, } as const; 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: InternalNavigationNavigationType, entry: NavigationHistoryEntry, transition: NavigationTransition, options?: InternalNavigationNavigateOptions ): Promise> => { // 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 }: NavigationTransitionCommitContext) => { 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: NavigationTransitionCommitContext) => { if (committedCurrentEntryChange) { return; } const promises = [ transition.dispatchEvent( createEvent({ type: NavigationTransitionBeforeCommit, transition, entry, }) ) ]; if (transition.signal.aborted) { return; } committedCurrentEntryChange = true; syncCommit(commit); const { entriesChange } = commit; promises.push( 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 = (): Promise> => { 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: Promise>[] = []; const iterator = transitionSteps(transitionResult)[Symbol.iterator](); const iterable = { [Symbol.iterator]: () => ({ next: () => iterator.next() }), }; async function syncTransition(): Promise { 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(): Promise { 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: NavigationTransitionResult ): Iterable | unknown | void> { 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.id === known.id); 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?: NavigationReloadOptions ): NavigationResult { const { currentEntry } = this; if (!currentEntry) throw new InvalidStateError(); const entry = this.#cloneNavigationHistoryEntry(currentEntry, options); return this.#pushEntry("reload", entry, undefined, options); } updateCurrentEntry(options: NavigationUpdateCurrentOptions): unknown; updateCurrentEntry(options: NavigationUpdateCurrentOptions): void; updateCurrentEntry(options: NavigationUpdateCurrentOptions): unknown { 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: NavigationEntriesChangeEvent = createEvent({ type: "entrieschange", addedEntries: [], removedEntries: [], updatedEntries: [ currentEntry ] }); return Promise.all([ this.dispatchEvent(currentEntryChange), this.dispatchEvent(entriesChange) ]) ; } } function getPerformance(): { now(): number; measure(name: string, start: string, finish: string): unknown; mark(mark: string): unknown; } { 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 */ }