UNPKG

@virtualstate/app-history

Version:

Native JavaScript [app-history](https://github.com/WICG/app-history) implementation

459 lines 19.2 kB
import { AppHistoryEntry, AppHistoryEntryKnownAs, AppHistoryEntryNavigationType, AppHistoryEntrySetState } from "./app-history-entry.js"; import { AppHistoryEventTarget } from "./app-history-event-target.js"; import { InvalidStateError } from "./app-history-errors.js"; import { EventTargetListeners } from "./event-target/index.js"; import { AppHistoryTransition, AppHistoryTransitionEntry, AppHistoryTransitionError, AppHistoryTransitionFinally, AppHistoryTransitionStart, AppHistoryTransitionInitialEntries, AppHistoryTransitionInitialIndex, AppHistoryTransitionKnown, AppHistoryTransitionNavigationType, AppHistoryTransitionParentEventTarget, AppHistoryTransitionPromises, AppHistoryTransitionWait, Rollback, Unset, AppHistoryTransitionWhile, AppHistoryTransitionStartDeadline, AppHistoryTransitionCommit, AppHistoryTransitionFinish, AppHistoryTransitionAbort, AppHistoryTransitionIsOngoing, AppHistoryTransitionFinishedDeferred, AppHistoryTransitionCommittedDeferred, AppHistoryTransitionIsPending } from "./app-history-transition.js"; import { createAppHistoryTransition, EventAbortController } from "./create-app-history-transition.js"; import { createEvent } from "./event-target/create-event.js"; export * from "./spec/app-history.js"; const baseUrl = "https://html.spec.whatwg.org/"; export class AppHistory extends AppHistoryEventTarget { // Should be always 0 or 1 #transitionInProgressCount = 0; #entries = []; #known = new Set(); #currentIndex = -1; #activePromise; #activeTransition; // // #upcomingNonTraverseTransition: AppHistoryTransition; #knownTransitions = new WeakSet(); #initialUrl; get canGoBack() { return !!this.#entries[this.#currentIndex - 1]; } ; get canGoForward() { return !!this.#entries[this.#currentIndex + 1]; } ; get current() { if (this.#currentIndex === -1) { return undefined; } 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(); const initialUrl = options?.initialUrl ?? "/"; this.#initialUrl = (typeof initialUrl === "string" ? new URL(initialUrl, baseUrl) : initialUrl).toString(); } back(options) { if (!this.canGoBack) throw new InvalidStateError("Cannot go back"); const entry = this.#entries[this.#currentIndex - 1]; return this.#pushEntry("traverse", this.#cloneAppHistoryEntry(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.#cloneAppHistoryEntry(entry, { ...options, navigationType: "traverse" })); } goTo(key, options) { const found = this.#entries.find(entry => entry.key === key); if (found) { return this.#pushEntry("traverse", this.#cloneAppHistoryEntry(found, { ...options, navigationType: "traverse" })); } throw new InvalidStateError(); } navigate(url, options) { const nextUrl = new URL(url, this.#initialUrl).toString(); console.log({ nextUrl }); const navigationType = options?.replace ? "replace" : "push"; const entry = this.#createAppHistoryEntry({ url: nextUrl, ...options, navigationType }); return this.#pushEntry(navigationType, entry, undefined, options); } #cloneAppHistoryEntry = (entry, options) => { return this.#createAppHistoryEntry({ ...entry, index: entry?.index ?? undefined, state: options?.state ?? entry?.getState() ?? {}, navigationType: entry?.[AppHistoryEntryNavigationType] ?? (typeof options?.navigationType === "string" ? options.navigationType : "replace"), ...options, get [AppHistoryEntryKnownAs]() { return entry?.[AppHistoryEntryKnownAs]; }, get [EventTargetListeners]() { return entry?.[EventTargetListeners]; } }); }; #createAppHistoryEntry = (options) => { const entry = new AppHistoryEntry({ ...options, index: options.index ?? (() => { return this.#entries.indexOf(entry); }), }); return entry; }; #pushEntry = (navigationType, entry, transition, options) => { /* c8 ignore start */ if (entry === this.current) 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 AppHistoryTransition({ from: entry, navigationType: typeof givenNavigationType === "string" ? givenNavigationType : "replace", rollback: (options) => { return this.#rollback(nextTransition, options); }, [AppHistoryTransitionNavigationType]: givenNavigationType, [AppHistoryTransitionInitialEntries]: [...this.#entries], [AppHistoryTransitionInitialIndex]: this.#currentIndex, [AppHistoryTransitionKnown]: [...this.#known], [AppHistoryTransitionEntry]: entry, [AppHistoryTransitionParentEventTarget]: this }); const { finished, committed } = nextTransition; const handler = () => { return this.#immediateTransition(givenNavigationType, entry, nextTransition, options); }; void handler().catch(error => void error); // const previousPromise = this.#activePromise; // let nextPromise; // // console.log({ givenNavigationType }); // if (givenNavigationType === Rollback) { // nextPromise = handler().then(() => previousPromise); // } else { // if (previousPromise) { // nextPromise = previousPromise.then(handler); // } else { // nextPromise = handler(); // } // } // console.log({ previousPromise, nextPromise }); // const promise = nextPromise // .catch(error => void error) // .then(() => { // if (this.#activePromise === promise) { // this.#activePromise = undefined; // } // }) this.#queueTransition(nextTransition); 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.#transitionInProgressCount += 1; if (this.#transitionInProgressCount > 1 && !(givenNavigationType === Rollback)) { throw new InvalidStateError("Unexpected multiple transitions"); } return this.#transition(givenNavigationType, entry, transition, options); } finally { this.#transitionInProgressCount -= 1; } }; #rollback = (rollbackTransition, options) => { const previousEntries = rollbackTransition[AppHistoryTransitionInitialEntries]; const previousIndex = rollbackTransition[AppHistoryTransitionInitialIndex]; const previousCurrent = previousEntries[previousIndex]; // console.log("z"); // console.log("Rollback!", { previousCurrent, previousEntries, previousIndex }); const entry = previousCurrent ? this.#cloneAppHistoryEntry(previousCurrent, options) : undefined; const nextOptions = { ...options, index: previousIndex, known: new Set([...this.#known, ...previousEntries]), navigationType: entry?.[AppHistoryEntryNavigationType] ?? "replace", entries: previousEntries, }; const resolvedNavigationType = entry ? Rollback : Unset; const resolvedEntry = entry ?? this.#createAppHistoryEntry({ 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 (entry.sameDocument && typeof navigationType === "string") { performance.mark(`same-document-navigation:${entry.id}`); } let committed = false; const { current } = this; void this.#activeTransition?.finished?.catch(error => error); void this.#activeTransition?.[AppHistoryTransitionFinishedDeferred]?.promise?.catch(error => error); void this.#activeTransition?.[AppHistoryTransitionCommittedDeferred]?.promise?.catch(error => error); this.#activeTransition?.[AppHistoryTransitionAbort](); this.#activeTransition = transition; const startEventPromise = transition.dispatchEvent({ type: AppHistoryTransitionStart, transition, entry }); const unsetTransition = async () => { await startEventPromise; if (!(typeof options?.index === "number" && options.entries)) throw new InvalidStateError(); await asyncCommit({ entries: options.entries, index: options.index, known: options.known, }); await this.dispatchEvent(createEvent({ type: "currentchange" })); committed = true; return entry; }; const completeTransition = () => { if (givenNavigationType === Unset) { return unsetTransition(); } const transitionResult = createAppHistoryTransition({ current, currentIndex: this.#currentIndex, options, transition, known: this.#known }); const microtask = new Promise(queueMicrotask); let promises = []; const iterator = transitionSteps(transitionResult)[Symbol.iterator](); const iterable = { [Symbol.iterator]: () => ({ next: () => iterator.next() }) }; function syncTransition() { for (const promise of iterable) { if (promise && typeof promise === "object" && "then" in promise) { promises.push(promise); void promise.catch(error => error); } if (committed) { return asyncTransition(); } if (transition.signal.aborted) { break; } } return Promise.resolve(); // We got through with no async } async function asyncTransition() { const captured = [...promises]; if (captured.length) { promises = []; await Promise.all(captured); } else if (!transition[AppHistoryTransitionIsOngoing]) { await microtask; } return syncTransition(); } // console.log("Returning", { entry }); return syncTransition() .then(() => transition[AppHistoryTransitionIsOngoing] ? undefined : microtask) .then(() => 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; }; const asyncCommit = (commit) => { syncCommit(commit); return transition.dispatchEvent(createEvent({ type: AppHistoryTransitionCommit, transition, entry })); }; const dispose = async () => this.#dispose(); function* transitionSteps(transitionResult) { const microtask = new Promise(queueMicrotask); const { known, entries, index, currentChange, navigate, } = transitionResult; const navigateAbort = navigate[EventAbortController].abort.bind(navigate[EventAbortController]); transition.signal.addEventListener("abort", navigateAbort, { once: true }); if (typeof navigationType === "string" || navigationType === Rollback) { const promise = current?.dispatchEvent(createEvent({ type: "navigatefrom", transitionWhile: transition[AppHistoryTransitionWhile], })); if (promise) yield promise; } if (typeof navigationType === "string") { yield transition.dispatchEvent(navigate); } yield asyncCommit({ entries: entries, index: index, known: known, }); if (entry.sameDocument) { yield transition.dispatchEvent(currentChange); } committed = true; if (typeof navigationType === "string") { yield entry.dispatchEvent(createEvent({ type: "navigateto", transitionWhile: transition[AppHistoryTransitionWhile], })); } yield dispose(); if (!transition[AppHistoryTransitionPromises].size) { yield microtask; } yield transition.dispatchEvent({ type: AppHistoryTransitionStartDeadline, transition, entry }); yield transition[AppHistoryTransitionWait](); transition.signal.removeEventListener("abort", navigateAbort); yield transition[AppHistoryTransitionFinish](); if (typeof navigationType === "string") { yield transition.dispatchEvent(createEvent({ type: "finish", transitionWhile: transition[AppHistoryTransitionWhile] })); yield transition.dispatchEvent(createEvent({ type: "navigatesuccess", transitionWhile: transition[AppHistoryTransitionWhile] })); } // If we have more length here, we have added more transition if (transition[AppHistoryTransitionIsPending]) { yield Promise.reject(new InvalidStateError("Unexpected pending promises after finish")); } } 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: AppHistoryTransitionError, error: detail.reason, transition, entry }); } await dispose(); await transition.dispatchEvent({ type: AppHistoryTransitionFinally, transition, entry }); await transition[AppHistoryTransitionWait](); 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 }); await known.dispatchEvent(event); await this.dispatchEvent(event); } // console.log(JSON.stringify({ pruned: [...this.#known] })); }; reload(options) { const { current } = this; if (!current) throw new InvalidStateError(); const entry = this.#cloneAppHistoryEntry(current, options); return this.#pushEntry("reload", entry, undefined, options); } updateCurrent(options) { const { current } = this; if (!current) { throw new InvalidStateError("Expected current entry"); } // Instant change current[AppHistoryEntrySetState](options.state); const currentChange = createEvent({ from: current, type: "currentchange", navigationType: undefined, }); return this.dispatchEvent(currentChange); } } 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=app-history.js.map