@virtualstate/app-history
Version:
Native JavaScript [app-history](https://github.com/WICG/app-history) implementation
384 lines (328 loc) • 16.2 kB
text/typescript
import {
AppHistoryEntry as AppHistoryEntryPrototype,
AppHistoryNavigationOptions,
AppHistoryNavigationType,
AppHistoryResult,
AppHistoryTransition as AppHistoryTransitionPrototype,
AppHistoryTransitionInit as AppHistoryTransitionInitPrototype
} from "./spec/app-history";
import {AppHistoryEntry} from "./app-history-entry";
import {deferred, Deferred} from "./util/deferred";
import {AbortError, InvalidStateError, isAbortError, isInvalidStateError} from "./app-history-errors";
import {Event, EventTarget} from "./event-target";
import AbortController from "abort-controller";
export const Rollback = Symbol.for("@virtualstate/app-history/rollback");
export const Unset = Symbol.for("@virtualstate/app-history/unset");
export type InternalAppHistoryNavigationType =
| AppHistoryNavigationType
| typeof Rollback
| typeof 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 interface AppHistoryTransitionInit extends Omit<AppHistoryTransitionInitPrototype, "finished"> {
rollback(options?: AppHistoryNavigationOptions): AppHistoryResult;
[AppHistoryTransitionFinishedDeferred]?: Deferred<AppHistoryEntry>;
[AppHistoryTransitionCommittedDeferred]?: Deferred<AppHistoryEntry>;
[AppHistoryTransitionNavigationType]: InternalAppHistoryNavigationType;
[AppHistoryTransitionInitialEntries]: AppHistoryEntry[];
[AppHistoryTransitionInitialIndex]: number;
[AppHistoryTransitionFinishedEntries]?: AppHistoryEntry[];
[AppHistoryTransitionFinishedIndex]?: number;
[AppHistoryTransitionKnown]?: Iterable<EventTarget>;
[AppHistoryTransitionEntry]: AppHistoryEntry;
[AppHistoryTransitionParentEventTarget]: EventTarget;
}
export class AppHistoryTransition extends EventTarget implements AppHistoryTransitionPrototype {
readonly finished: Promise<AppHistoryEntryPrototype>;
/**
* @experimental
*/
readonly committed: Promise<AppHistoryEntryPrototype>;
readonly from: AppHistoryEntryPrototype;
readonly navigationType: AppHistoryNavigationType;
readonly #options: AppHistoryTransitionInit;
readonly [AppHistoryTransitionFinishedDeferred] = deferred<AppHistoryEntry>();
readonly [AppHistoryTransitionCommittedDeferred] = deferred<AppHistoryEntry>();
get [AppHistoryTransitionIsPending]() {
return !!this.#promises.size;
}
get [AppHistoryTransitionNavigationType](): InternalAppHistoryNavigationType {
return this.#options[AppHistoryTransitionNavigationType];
}
get [AppHistoryTransitionInitialEntries](): AppHistoryEntry[] {
return this.#options[AppHistoryTransitionInitialEntries];
}
get [AppHistoryTransitionInitialIndex](): number {
return this.#options[AppHistoryTransitionInitialIndex];
}
[AppHistoryTransitionFinishedEntries]?: AppHistoryEntry[];
[AppHistoryTransitionFinishedIndex]?: number;
[AppHistoryTransitionIsCommitted] = false;
[AppHistoryTransitionIsFinished] = false;
[AppHistoryTransitionIsRejected] = false;
[AppHistoryTransitionIsOngoing] = false;
readonly [AppHistoryTransitionKnown] = new Set<EventTarget>();
readonly [AppHistoryTransitionEntry]: AppHistoryEntry;
#promises = new Set<Promise<PromiseSettledResult<void>>>()
#rolledBack = false;
#abortController = new AbortController();
get signal() {
return this.#abortController.signal;
}
get [AppHistoryTransitionPromises]() {
return this.#promises;
}
constructor(init: AppHistoryTransitionInit) {
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?: AppHistoryNavigationOptions): AppHistoryResult => {
// 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: Event & { error: unknown }) => {
return this[AppHistoryTransitionRejected](event.error);
}
[AppHistoryTransitionPromiseResolved] = (...promises: Promise<PromiseSettledResult<void>>[]) => {
for (const promise of promises) {
this.#promises.delete(promise);
}
}
[AppHistoryTransitionRejected] = async (reason: unknown) => {
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: Promise<unknown>): void => {
this[AppHistoryTransitionIsOngoing] = true;
// console.log({ AppHistoryTransitionWhile, promise });
const statusPromise = promise
.then((): PromiseSettledResult<void> => ({
status: "fulfilled",
value: undefined
}))
.catch(async (reason): Promise<PromiseSettledResult<void>> => {
await this[AppHistoryTransitionRejected](reason);
return {
status: "rejected",
reason
}
});
this.#promises.add(statusPromise);
}
[AppHistoryTransitionWait] = async (): Promise<AppHistoryEntry> => {
if (!this.#promises.size) return this[AppHistoryTransitionEntry];
try {
const captured = [...this.#promises];
const results = await Promise.all(captured);
const rejected = results.filter<PromiseRejectedResult>(
(result): result is PromiseRejectedResult => 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]
})
}
}