@virtualstate/navigation
Version:
Native JavaScript [navigation](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) implementation
325 lines • 15.4 kB
JavaScript
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