import { AbortController } from "./import-abort-controller"; import { InvalidStateError } from "./navigation-errors"; import { WritableProps } from "./util/writable"; import { NavigationDestination, NavigateEvent as NavigateEventPrototype, NavigationNavigateOptions as NavigationNavigateOptionsPrototype, NavigationNavigationType, NavigationEntriesChangeEventInit, NavigationIntercept as NavigationInterceptPrototype } from "./spec/navigation"; import { NavigationHistoryEntry } from "./navigation-entry"; import { NavigationTransition, NavigationTransitionAbort, NavigationTransitionEntry, NavigationTransitionInitialEntries, NavigationTransitionNavigationType, NavigationIntercept, InternalNavigationNavigationType, Rollback, } from "./navigation-transition"; import { createEvent } from "./event-target/create-event"; import {getBaseURL} from "./base-url"; import {defer, Deferred} from "./defer"; import {ok} from "./is"; import {NavigateEvent, NavigationCurrentEntryChangeEvent} from "./events"; export interface PreventDefaultLike { preventDefault(): void; } export const NavigationFormData = Symbol.for( "@virtualstate/navigation/formData" ); export const NavigationDownloadRequest = Symbol.for( "@virtualstate/navigation/downloadRequest" ); export const NavigationCanIntercept = Symbol.for( "@virtualstate/navigation/canIntercept" ); export const NavigationUserInitiated = Symbol.for( "@virtualstate/navigation/userInitiated" ); export const NavigationOriginalEvent = Symbol.for( "@virtualstate/navigation/originalEvent" ); export interface NavigationNavigateOptions extends NavigationNavigateOptionsPrototype { [NavigationFormData]?: FormData; [NavigationDownloadRequest]?: string; [NavigationCanIntercept]?: boolean; [NavigationUserInitiated]?: boolean; [NavigationOriginalEvent]?: PreventDefaultLike; } export const EventAbortController = Symbol.for( "@virtualstate/navigation/event/abortController" ); export interface AbortControllerEvent { [EventAbortController]: AbortController; } export interface InternalNavigationNavigateOptions extends NavigationNavigateOptions { entries?: NavigationHistoryEntry[]; index?: number; known?: Set>; navigationType?: NavigationNavigationType; } export interface NavigationTransitionCommitContext { entries: NavigationHistoryEntry[]; index: number; known?: Set>; entriesChange?: NavigationEntriesChangeEventInit } export interface NavigationTransitionContext { commit(commit: NavigationTransitionCommitContext): Promise; transition: NavigationTransition; options?: InternalNavigationNavigateOptions; currentIndex: number; known: Set>; startTime?: number; currentEntry: NavigationHistoryEntry; reportError?(reason: unknown): void; } export interface NavigationTransitionResult { entries: NavigationHistoryEntry[]; index: number; known: Set>; destination: NavigationDestination; navigate: NavigateEvent; currentEntryChange: NavigationCurrentEntryChangeEvent; navigationType: InternalNavigationNavigationType; waitForCommit: Promise; abortController: AbortController; commit(): Promise | void; } function noop(): void { return undefined; } function getEntryIndex( entries: NavigationHistoryEntry[], entry: NavigationHistoryEntry ) { const knownIndex = entry.index; if (knownIndex !== -1) { return knownIndex; } // TODO find an entry if it has changed id return -1; } export function createNavigationTransition( context: NavigationTransitionContext ): NavigationTransitionResult { const { commit: transitionCommit, currentIndex, options, known: initialKnown, currentEntry, transition, transition: { [NavigationTransitionInitialEntries]: previousEntries, [NavigationTransitionEntry]: entry, [NavigationIntercept]: intercept, }, reportError } = context; let { transition: { [NavigationTransitionNavigationType]: navigationType }, } = context; let resolvedEntries = [...previousEntries]; const known = new Set(initialKnown); let destinationIndex = -1, nextIndex = currentIndex; if (navigationType === Rollback) { const { index } = options ?? { index: undefined }; if (typeof index !== "number") throw new InvalidStateError("Expected index to be provided for rollback"); destinationIndex = index; nextIndex = index; } else if (navigationType === "traverse" || navigationType === "reload") { destinationIndex = getEntryIndex(previousEntries, entry); nextIndex = destinationIndex; } else if (navigationType === "replace") { if (currentIndex === -1) { navigationType = "push"; destinationIndex = currentIndex + 1; nextIndex = destinationIndex; } else { destinationIndex = currentIndex; nextIndex = currentIndex; } } else { destinationIndex = currentIndex + 1; nextIndex = destinationIndex; } if (typeof destinationIndex !== "number" || destinationIndex === -1) { throw new InvalidStateError("Could not resolve next index"); } // console.log({ navigationType, entry, options }); if (!entry.url) { console.trace({ navigationType, entry, options }); throw new InvalidStateError("Expected entry url"); } const destination: WritableProps> = { url: entry.url, key: entry.key, id: entry.id, index: destinationIndex, sameDocument: entry.sameDocument, getState() { return entry.getState(); }, }; let hashChange = false; const currentUrlInstance = getBaseURL(currentEntry?.url); const destinationUrlInstance = new URL(destination.url); const currentHash = currentUrlInstance.hash; const destinationHash = destinationUrlInstance.hash; // console.log({ currentHash, destinationHash }); if (currentHash !== destinationHash) { const currentUrlInstanceWithoutHash = new URL( currentUrlInstance.toString() ); currentUrlInstanceWithoutHash.hash = ""; const destinationUrlInstanceWithoutHash = new URL( destinationUrlInstance.toString() ); destinationUrlInstanceWithoutHash.hash = ""; hashChange = currentUrlInstanceWithoutHash.toString() === destinationUrlInstanceWithoutHash.toString(); // console.log({ hashChange, currentUrlInstanceWithoutHash: currentUrlInstanceWithoutHash.toString(), before: destinationUrlInstanceWithoutHash.toString() }) } let contextToCommit: NavigationTransitionCommitContext; const { resolve: resolveCommit, promise: waitForCommit }: Deferred = defer(); function commit() { ok(contextToCommit, "Expected contextToCommit"); resolveCommit( transitionCommit(contextToCommit) ); } const abortController = new AbortController(); const event = new NavigateEvent("navigate", { signal: abortController.signal, info: undefined, ...options, canIntercept: options?.[NavigationCanIntercept] ?? true, formData: options?.[NavigationFormData] ?? undefined, downloadRequest: options?.[NavigationDownloadRequest] ?? undefined, hashChange, navigationType: options?.navigationType ?? (typeof navigationType === "string" ? navigationType : "replace"), userInitiated: options?.[NavigationUserInitiated] ?? false, destination, }) const originalEvent = options?.[NavigationOriginalEvent]; const preventDefault = transition[NavigationTransitionAbort].bind(transition); if (originalEvent) { const definedEvent: PreventDefaultLike = originalEvent; event.intercept = function originalEventIntercept(options: NavigationInterceptPrototype) { definedEvent.preventDefault(); return intercept(options); } event.preventDefault = function originalEventPreventDefault() { definedEvent.preventDefault(); return preventDefault(); }; } else { event.intercept = intercept; event.preventDefault = preventDefault; } // Enforce that transitionWhile and intercept match event.transitionWhile = event.intercept; event.commit = commit; if (reportError) { event.reportError = reportError; } event.scroll = noop; if (originalEvent) { event.originalEvent = originalEvent; } const currentEntryChange = new NavigationCurrentEntryChangeEvent("currententrychange", { from: currentEntry, navigationType: event.navigationType, }); let updatedEntries: NavigationHistoryEntry[] = [], removedEntries: NavigationHistoryEntry[] = [], addedEntries: NavigationHistoryEntry[] = []; const previousKeys = previousEntries.map(entry => entry.key); if (navigationType === Rollback) { const { entries } = options ?? { entries: undefined }; if (!entries) throw new InvalidStateError( "Expected entries to be provided for rollback" ); resolvedEntries = entries; resolvedEntries.forEach((entry) => known.add(entry)); const keys = resolvedEntries.map(entry => entry.key); removedEntries = previousEntries.filter(entry => !keys.includes(entry.key)); addedEntries = resolvedEntries.filter(entry => !previousKeys.includes(entry.key)); } // Default next index is current entries length, aka // console.log({ navigationType, givenNavigationType, index: this.#currentIndex, resolvedNextIndex }); else if ( navigationType === "replace" || navigationType === "traverse" || navigationType === "reload" ) { resolvedEntries[destination.index] = entry; if (navigationType !== "traverse") { updatedEntries.push(entry); } if (navigationType === "replace") { resolvedEntries = resolvedEntries.slice(0, destination.index + 1); } const keys = resolvedEntries.map(entry => entry.key); removedEntries = previousEntries.filter(entry => !keys.includes(entry.key)); if (previousKeys.includes(entry.id)) { addedEntries = [entry]; } } else if (navigationType === "push") { let removed = false; // Trim forward, we have reset our stack if (resolvedEntries[destination.index]) { // const before = [...this.#entries]; resolvedEntries = resolvedEntries.slice(0, destination.index); // console.log({ before, after: [...this.#entries]}) removed = true; } resolvedEntries.push(entry); addedEntries = [entry]; if (removed) { const keys = resolvedEntries.map(entry => entry.key); removedEntries = previousEntries.filter(entry => !keys.includes(entry.key)); } } known.add(entry); let entriesChange: NavigationEntriesChangeEventInit | undefined = undefined; if (updatedEntries.length || addedEntries.length || removedEntries.length) { entriesChange = { updatedEntries, addedEntries, removedEntries }; } contextToCommit = { entries: resolvedEntries, index: nextIndex, known, entriesChange }; return { entries: resolvedEntries, known, index: nextIndex, currentEntryChange, destination, navigate: event, navigationType, waitForCommit, commit, abortController }; }