UNPKG

@virtualstate/navigation

Version:

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

325 lines 15.4 kB
import { deferred } from "./util/deferred.js"; import { AbortError, InvalidStateError, isAbortError, isInvalidStateError, } from "./navigation-errors.js"; import { EventTarget } from "./event-target/index.js"; import { AbortController } from "./import-abort-controller.js"; import { isPromise } from "./is.js"; import { logWarning } from "./util/warnings.js"; export const Rollback = Symbol.for("@virtualstate/navigation/rollback"); export const Unset = Symbol.for("@virtualstate/navigation/unset"); export const NavigationTransitionParentEventTarget = Symbol.for("@virtualstate/navigation/transition/parentEventTarget"); export const NavigationTransitionFinishedDeferred = Symbol.for("@virtualstate/navigation/transition/deferred/finished"); export const NavigationTransitionCommittedDeferred = Symbol.for("@virtualstate/navigation/transition/deferred/committed"); export const NavigationTransitionNavigationType = Symbol.for("@virtualstate/navigation/transition/navigationType"); export const NavigationTransitionInitialEntries = Symbol.for("@virtualstate/navigation/transition/entries/initial"); export const NavigationTransitionFinishedEntries = Symbol.for("@virtualstate/navigation/transition/entries/finished"); export const NavigationTransitionInitialIndex = Symbol.for("@virtualstate/navigation/transition/index/initial"); export const NavigationTransitionFinishedIndex = Symbol.for("@virtualstate/navigation/transition/index/finished"); export const NavigationTransitionEntry = Symbol.for("@virtualstate/navigation/transition/entry"); export const NavigationTransitionIsCommitted = Symbol.for("@virtualstate/navigation/transition/isCommitted"); export const NavigationTransitionIsFinished = Symbol.for("@virtualstate/navigation/transition/isFinished"); export const NavigationTransitionIsRejected = Symbol.for("@virtualstate/navigation/transition/isRejected"); export const NavigationTransitionKnown = Symbol.for("@virtualstate/navigation/transition/known"); export const NavigationTransitionPromises = Symbol.for("@virtualstate/navigation/transition/promises"); export const NavigationIntercept = Symbol.for("@virtualstate/navigation/intercept"); export const NavigationTransitionIsOngoing = Symbol.for("@virtualstate/navigation/transition/isOngoing"); export const NavigationTransitionIsPending = Symbol.for("@virtualstate/navigation/transition/isPending"); export const NavigationTransitionIsAsync = Symbol.for("@virtualstate/navigation/transition/isAsync"); export const NavigationTransitionWait = Symbol.for("@virtualstate/navigation/transition/wait"); export const NavigationTransitionPromiseResolved = Symbol.for("@virtualstate/navigation/transition/promise/resolved"); export const NavigationTransitionRejected = Symbol.for("@virtualstate/navigation/transition/rejected"); export const NavigationTransitionCommit = Symbol.for("@virtualstate/navigation/transition/commit"); export const NavigationTransitionFinish = Symbol.for("@virtualstate/navigation/transition/finish"); export const NavigationTransitionStart = Symbol.for("@virtualstate/navigation/transition/start"); export const NavigationTransitionStartDeadline = Symbol.for("@virtualstate/navigation/transition/start/deadline"); export const NavigationTransitionError = Symbol.for("@virtualstate/navigation/transition/error"); export const NavigationTransitionFinally = Symbol.for("@virtualstate/navigation/transition/finally"); export const NavigationTransitionAbort = Symbol.for("@virtualstate/navigation/transition/abort"); export const NavigationTransitionInterceptOptionsCommit = Symbol.for("@virtualstate/navigation/transition/intercept/options/commit"); export const NavigationTransitionCommitIsManual = Symbol.for("@virtualstate/navigation/transition/commit/isManual"); export class NavigationTransition extends EventTarget { finished; /** * @experimental */ committed; from; navigationType; /** * true if transition has an async intercept */ [NavigationTransitionIsAsync] = false; /** * @experimental */ [NavigationTransitionInterceptOptionsCommit]; #options; [NavigationTransitionFinishedDeferred] = deferred(); [NavigationTransitionCommittedDeferred] = deferred(); get [NavigationTransitionIsPending]() { return !!this.#promises.size; } get [NavigationTransitionNavigationType]() { return this.#options[NavigationTransitionNavigationType]; } get [NavigationTransitionInitialEntries]() { return this.#options[NavigationTransitionInitialEntries]; } get [NavigationTransitionInitialIndex]() { return this.#options[NavigationTransitionInitialIndex]; } get [NavigationTransitionCommitIsManual]() { return !!(this[NavigationTransitionInterceptOptionsCommit]?.includes("after-transition") || this[NavigationTransitionInterceptOptionsCommit]?.includes("manual")); } [NavigationTransitionFinishedEntries]; [NavigationTransitionFinishedIndex]; [NavigationTransitionIsCommitted] = false; [NavigationTransitionIsFinished] = false; [NavigationTransitionIsRejected] = false; [NavigationTransitionIsOngoing] = false; [NavigationTransitionKnown] = new Set(); [NavigationTransitionEntry]; #promises = new Set(); #rolledBack = false; #abortController = new AbortController(); get signal() { return this.#abortController.signal; } get [NavigationTransitionPromises]() { return this.#promises; } constructor(init) { super(); this[NavigationTransitionInterceptOptionsCommit] = []; this[NavigationTransitionFinishedDeferred] = init[NavigationTransitionFinishedDeferred] ?? this[NavigationTransitionFinishedDeferred]; this[NavigationTransitionCommittedDeferred] = init[NavigationTransitionCommittedDeferred] ?? this[NavigationTransitionCommittedDeferred]; this.#options = init; const finished = (this.finished = this[NavigationTransitionFinishedDeferred].promise); const committed = (this.committed = this[NavigationTransitionCommittedDeferred].promise); // Auto catching abort void finished.catch((error) => error); void committed.catch((error) => error); this.from = init.from; this.navigationType = init.navigationType; this[NavigationTransitionFinishedEntries] = init[NavigationTransitionFinishedEntries]; this[NavigationTransitionFinishedIndex] = init[NavigationTransitionFinishedIndex]; const known = init[NavigationTransitionKnown]; if (known) { for (const entry of known) { this[NavigationTransitionKnown].add(entry); } } this[NavigationTransitionEntry] = init[NavigationTransitionEntry]; // Event listeners { // Events to promises { this.addEventListener(NavigationTransitionCommit, this.#onCommitPromise, { once: true }); this.addEventListener(NavigationTransitionFinish, this.#onFinishPromise, { once: true }); } // Events to property setters { this.addEventListener(NavigationTransitionCommit, this.#onCommitSetProperty, { once: true }); this.addEventListener(NavigationTransitionFinish, this.#onFinishSetProperty, { once: true }); } // Rejection + Abort { this.addEventListener(NavigationTransitionError, this.#onError, { once: true, }); this.addEventListener(NavigationTransitionAbort, () => { if (!this[NavigationTransitionIsFinished]) { return this[NavigationTransitionRejected](new AbortError()); } }); } // Proxy all events from this transition onto entry + the parent event target // // The parent could be another transition, or the Navigation, 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 Navigation { this.addEventListener("*", this[NavigationTransitionEntry].dispatchEvent.bind(this[NavigationTransitionEntry])); this.addEventListener("*", init[NavigationTransitionParentEventTarget].dispatchEvent.bind(init[NavigationTransitionParentEventTarget])); } } } 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/navigation 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[NavigationTransitionIsCommitted] = true; }; #onFinishSetProperty = () => { this[NavigationTransitionIsFinished] = true; }; #onFinishPromise = () => { // console.log("onFinishPromise") this[NavigationTransitionFinishedDeferred].resolve(this[NavigationTransitionEntry]); }; #onCommitPromise = () => { if (this.signal.aborted) { } else { this[NavigationTransitionCommittedDeferred].resolve(this[NavigationTransitionEntry]); } }; #onError = (event) => { return this[NavigationTransitionRejected](event.error); }; [NavigationTransitionPromiseResolved] = (...promises) => { for (const promise of promises) { this.#promises.delete(promise); } }; [NavigationTransitionRejected] = async (reason) => { if (this[NavigationTransitionIsRejected]) return; this[NavigationTransitionIsRejected] = true; this[NavigationTransitionAbort](); const navigationType = this[NavigationTransitionNavigationType]; // console.log({ navigationType, reason, entry: this[NavigationTransitionEntry] }); 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/navigation/issues"); } } } this[NavigationTransitionCommittedDeferred].reject(reason); this[NavigationTransitionFinishedDeferred].reject(reason); }; [NavigationIntercept] = (options) => { const transition = this; const promise = parseOptions(); this[NavigationTransitionIsOngoing] = true; if (!promise) return; this[NavigationTransitionIsAsync] = true; const statusPromise = promise .then(() => ({ status: "fulfilled", value: undefined, })) .catch(async (reason) => { await this[NavigationTransitionRejected](reason); return { status: "rejected", reason, }; }); this.#promises.add(statusPromise); function parseOptions() { if (!options) return undefined; if (isPromise(options)) { logWarning("EVENT_INTERCEPT_HANDLER"); return options; } if (typeof options === "function") { logWarning("EVENT_INTERCEPT_HANDLER"); return options(); } const { handler, commit } = options; if (commit && typeof commit === "string") { transition[NavigationTransitionInterceptOptionsCommit].push(commit); } if (typeof handler !== "function") { return; } return handler(); } }; [NavigationTransitionWait] = async () => { if (!this.#promises.size) return this[NavigationTransitionEntry]; 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[NavigationTransitionPromiseResolved](...captured); if (this[NavigationTransitionIsPending]) { return this[NavigationTransitionWait](); } return this[NavigationTransitionEntry]; } catch (error) { await this.#onError(error); throw await Promise.reject(error); } finally { await this[NavigationTransitionFinish](); } }; [NavigationTransitionAbort]() { if (this.#abortController.signal.aborted) return; this.#abortController.abort(); this.dispatchEvent({ type: NavigationTransitionAbort, transition: this, entry: this[NavigationTransitionEntry], }); } [NavigationTransitionFinish] = async () => { if (this[NavigationTransitionIsFinished]) { return; } await this.dispatchEvent({ type: NavigationTransitionFinish, transition: this, entry: this[NavigationTransitionEntry], intercept: this[NavigationIntercept], }); }; } //# sourceMappingURL=navigation-transition.js.map