UNPKG

@virtualstate/app-history

Version:

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

275 lines 13.6 kB
import { deferred } from "./util/deferred.js"; import { AbortError, InvalidStateError, isAbortError, isInvalidStateError } from "./app-history-errors.js"; import { EventTarget } from "./event-target/index.js"; import AbortController from "abort-controller"; export const Rollback = Symbol.for("@virtualstate/app-history/rollback"); export const Unset = Symbol.for("@virtualstate/app-history/unset"); export const AppHistoryTransitionParentEventTarget = Symbol.for("@virtualstate/app-history/transition/parentEventTarget"); export const AppHistoryTransitionFinishedDeferred = Symbol.for("@virtualstate/app-history/transition/deferred/finished"); export const AppHistoryTransitionCommittedDeferred = Symbol.for("@virtualstate/app-history/transition/deferred/committed"); export const AppHistoryTransitionNavigationType = Symbol.for("@virtualstate/app-history/transition/navigationType"); export const AppHistoryTransitionInitialEntries = Symbol.for("@virtualstate/app-history/transition/entries/initial"); export const AppHistoryTransitionFinishedEntries = Symbol.for("@virtualstate/app-history/transition/entries/finished"); export const AppHistoryTransitionInitialIndex = Symbol.for("@virtualstate/app-history/transition/index/initial"); export const AppHistoryTransitionFinishedIndex = Symbol.for("@virtualstate/app-history/transition/index/finished"); export const AppHistoryTransitionEntry = Symbol.for("@virtualstate/app-history/transition/entry"); export const AppHistoryTransitionIsCommitted = Symbol.for("@virtualstate/app-history/transition/isCommitted"); export const AppHistoryTransitionIsFinished = Symbol.for("@virtualstate/app-history/transition/isFinished"); export const AppHistoryTransitionIsRejected = Symbol.for("@virtualstate/app-history/transition/isRejected"); export const AppHistoryTransitionKnown = Symbol.for("@virtualstate/app-history/transition/known"); export const AppHistoryTransitionPromises = Symbol.for("@virtualstate/app-history/transition/promises"); export const AppHistoryTransitionWhile = Symbol.for("@virtualstate/app-history/transition/while"); export const AppHistoryTransitionIsOngoing = Symbol.for("@virtualstate/app-history/transition/isOngoing"); export const AppHistoryTransitionIsPending = Symbol.for("@virtualstate/app-history/transition/isPending"); export const AppHistoryTransitionWait = Symbol.for("@virtualstate/app-history/transition/wait"); export const AppHistoryTransitionPromiseResolved = Symbol.for("@virtualstate/app-history/transition/promise/resolved"); export const AppHistoryTransitionRejected = Symbol.for("@virtualstate/app-history/transition/rejected"); export const AppHistoryTransitionCommit = Symbol.for("@virtualstate/app-history/transition/commit"); export const AppHistoryTransitionFinish = Symbol.for("@virtualstate/app-history/transition/finish"); export const AppHistoryTransitionStart = Symbol.for("@virtualstate/app-history/transition/start"); export const AppHistoryTransitionStartDeadline = Symbol.for("@virtualstate/app-history/transition/start/deadline"); export const AppHistoryTransitionError = Symbol.for("@virtualstate/app-history/transition/error"); export const AppHistoryTransitionFinally = Symbol.for("@virtualstate/app-history/transition/finally"); export const AppHistoryTransitionAbort = Symbol.for("@virtualstate/app-history/transition/abort"); export class AppHistoryTransition extends EventTarget { finished; /** * @experimental */ committed; from; navigationType; #options; [AppHistoryTransitionFinishedDeferred] = deferred(); [AppHistoryTransitionCommittedDeferred] = deferred(); get [AppHistoryTransitionIsPending]() { return !!this.#promises.size; } get [AppHistoryTransitionNavigationType]() { return this.#options[AppHistoryTransitionNavigationType]; } get [AppHistoryTransitionInitialEntries]() { return this.#options[AppHistoryTransitionInitialEntries]; } get [AppHistoryTransitionInitialIndex]() { return this.#options[AppHistoryTransitionInitialIndex]; } [AppHistoryTransitionFinishedEntries]; [AppHistoryTransitionFinishedIndex]; [AppHistoryTransitionIsCommitted] = false; [AppHistoryTransitionIsFinished] = false; [AppHistoryTransitionIsRejected] = false; [AppHistoryTransitionIsOngoing] = false; [AppHistoryTransitionKnown] = new Set(); [AppHistoryTransitionEntry]; #promises = new Set(); #rolledBack = false; #abortController = new AbortController(); get signal() { return this.#abortController.signal; } get [AppHistoryTransitionPromises]() { return this.#promises; } constructor(init) { super(); this[AppHistoryTransitionFinishedDeferred] = init[AppHistoryTransitionFinishedDeferred] ?? this[AppHistoryTransitionFinishedDeferred]; this[AppHistoryTransitionCommittedDeferred] = init[AppHistoryTransitionCommittedDeferred] ?? this[AppHistoryTransitionCommittedDeferred]; this.#options = init; const finished = this.finished = this[AppHistoryTransitionFinishedDeferred].promise; const committed = this.committed = this[AppHistoryTransitionCommittedDeferred].promise; // Auto catching abort void finished.catch(error => error); void committed.catch(error => error); this.from = init.from; this.navigationType = init.navigationType; this[AppHistoryTransitionFinishedEntries] = init[AppHistoryTransitionFinishedEntries]; this[AppHistoryTransitionFinishedIndex] = init[AppHistoryTransitionFinishedIndex]; const known = init[AppHistoryTransitionKnown]; if (known) { for (const entry of known) { this[AppHistoryTransitionKnown].add(entry); } } this[AppHistoryTransitionEntry] = init[AppHistoryTransitionEntry]; // Event listeners { // Events to promises { this.addEventListener(AppHistoryTransitionCommit, this.#onCommitPromise, { once: true }); this.addEventListener(AppHistoryTransitionFinish, this.#onFinishPromise, { once: true }); } // Events to property setters { this.addEventListener(AppHistoryTransitionCommit, this.#onCommitSetProperty, { once: true }); this.addEventListener(AppHistoryTransitionFinish, this.#onFinishSetProperty, { once: true }); } // Rejection + Abort { this.addEventListener(AppHistoryTransitionError, this.#onError, { once: true }); this.addEventListener(AppHistoryTransitionAbort, () => { if (!this[AppHistoryTransitionIsFinished]) { return this[AppHistoryTransitionRejected](new AbortError()); } }); } // Proxy all events from this transition onto entry + the parent event target // // The parent could be another transition, or the appHistory, this allows us to // "bubble up" events layer by layer // // In this implementation, this allows individual transitions to "intercept" navigate and break the child // transition from happening // // TODO WARN this may not be desired behaviour vs standard spec'd appHistory { this.addEventListener("*", this[AppHistoryTransitionEntry].dispatchEvent.bind(this[AppHistoryTransitionEntry])); this.addEventListener("*", init[AppHistoryTransitionParentEventTarget].dispatchEvent.bind(init[AppHistoryTransitionParentEventTarget])); } } } rollback = (options) => { // console.log({ rolled: this.#rolledBack }); if (this.#rolledBack) { // TODO throw new InvalidStateError("Rollback invoked multiple times: Please raise an issue at https://github.com/virtualstate/app-history with the use case where you want to use a rollback multiple times, this may have been unexpected behaviour"); } this.#rolledBack = true; return this.#options.rollback(options); }; #onCommitSetProperty = () => { this[AppHistoryTransitionIsCommitted] = true; }; #onFinishSetProperty = () => { this[AppHistoryTransitionIsFinished] = true; }; #onFinishPromise = () => { // console.log("onFinishPromise") this[AppHistoryTransitionFinishedDeferred].resolve(this[AppHistoryTransitionEntry]); }; #onCommitPromise = () => { if (this.signal.aborted) { } else { this[AppHistoryTransitionCommittedDeferred].resolve(this[AppHistoryTransitionEntry]); } }; #onError = (event) => { return this[AppHistoryTransitionRejected](event.error); }; [AppHistoryTransitionPromiseResolved] = (...promises) => { for (const promise of promises) { this.#promises.delete(promise); } }; [AppHistoryTransitionRejected] = async (reason) => { if (this[AppHistoryTransitionIsRejected]) return; this[AppHistoryTransitionIsRejected] = true; this[AppHistoryTransitionAbort](); const navigationType = this[AppHistoryTransitionNavigationType]; // console.log({ navigationType, reason, entry: this[AppHistoryTransitionEntry] }); if ((typeof navigationType === "string" || navigationType === Rollback)) { // console.log("navigateerror", { reason, z: isInvalidStateError(reason) }); await this.dispatchEvent({ type: "navigateerror", error: reason, get message() { if (reason instanceof Error) { return reason.message; } return `${reason}`; } }); // console.log("navigateerror finished"); if (navigationType !== Rollback && !(isInvalidStateError(reason) || isAbortError(reason))) { try { // console.log("Rollback", navigationType); // console.warn("Rolling back immediately due to internal error", error); await this.rollback()?.finished; // console.log("Rollback complete", navigationType); } catch (error) { // console.error("Failed to rollback", error); throw new InvalidStateError("Failed to rollback, please raise an issue at https://github.com/virtualstate/app-history/issues"); } } } this[AppHistoryTransitionCommittedDeferred].reject(reason); this[AppHistoryTransitionFinishedDeferred].reject(reason); }; [AppHistoryTransitionWhile] = (promise) => { this[AppHistoryTransitionIsOngoing] = true; // console.log({ AppHistoryTransitionWhile, promise }); const statusPromise = promise .then(() => ({ status: "fulfilled", value: undefined })) .catch(async (reason) => { await this[AppHistoryTransitionRejected](reason); return { status: "rejected", reason }; }); this.#promises.add(statusPromise); }; [AppHistoryTransitionWait] = async () => { if (!this.#promises.size) return this[AppHistoryTransitionEntry]; try { const captured = [...this.#promises]; const results = await Promise.all(captured); const rejected = results.filter((result) => result.status === "rejected"); // console.log({ rejected, results, captured }); if (rejected.length) { // TODO handle differently when there are failures, e.g. we could move navigateerror to here if (rejected.length === 1) { throw rejected[0].reason; } if (typeof AggregateError !== "undefined") { throw new AggregateError(rejected.map(({ reason }) => reason)); } throw new Error(); } this[AppHistoryTransitionPromiseResolved](...captured); if (this[AppHistoryTransitionIsPending]) { return this[AppHistoryTransitionWait](); } return this[AppHistoryTransitionEntry]; } catch (error) { await this.#onError(error); await Promise.reject(error); throw error; } finally { await this[AppHistoryTransitionFinish](); } }; [AppHistoryTransitionAbort]() { if (this.#abortController.signal.aborted) return; this.#abortController.abort(); this.dispatchEvent({ type: AppHistoryTransitionAbort, transition: this, entry: this[AppHistoryTransitionEntry] }); } [AppHistoryTransitionFinish] = async () => { if (this[AppHistoryTransitionIsFinished]) { return; } await this.dispatchEvent({ type: AppHistoryTransitionFinish, transition: this, entry: this[AppHistoryTransitionEntry], transitionWhile: this[AppHistoryTransitionWhile] }); }; } //# sourceMappingURL=app-history-transition.js.map