@virtualstate/app-history
Version:
Native JavaScript [app-history](https://github.com/WICG/app-history) implementation
590 lines (528 loc) • 22.2 kB
text/typescript
import {
AppHistoryEntry,
AppHistoryEntryInit,
AppHistoryEntryKnownAs,
AppHistoryEntryNavigationType, AppHistoryEntrySetState
} from "./app-history-entry";
import {
AppHistory as AppHistoryPrototype,
AppHistoryEventMap,
AppHistoryReloadOptions,
AppHistoryResult,
AppHistoryUpdateCurrentOptions,
AppHistoryTransition as AppHistoryTransitionPrototype, AppHistoryCurrentChangeEvent, AppHistoryNavigationOptions
} from "./spec/app-history";
import {AppHistoryEventTarget} from "./app-history-event-target";
import {InvalidStateError} from "./app-history-errors";
import {EventTargetListeners} from "./event-target";
import {
AppHistoryTransition,
AppHistoryTransitionEntry,
AppHistoryTransitionError,
AppHistoryTransitionFinally,
AppHistoryTransitionStart,
AppHistoryTransitionInitialEntries,
AppHistoryTransitionInitialIndex,
AppHistoryTransitionKnown,
AppHistoryTransitionNavigationType,
AppHistoryTransitionParentEventTarget,
AppHistoryTransitionPromises,
AppHistoryTransitionWait,
InternalAppHistoryNavigationType,
Rollback,
Unset,
AppHistoryTransitionWhile,
AppHistoryTransitionStartDeadline,
AppHistoryTransitionCommit,
AppHistoryTransitionFinish,
AppHistoryTransitionAbort,
AppHistoryTransitionIsOngoing,
AppHistoryTransitionFinishedDeferred, AppHistoryTransitionCommittedDeferred, AppHistoryTransitionIsPending
} from "./app-history-transition";
import {
AppHistoryTransitionResult,
createAppHistoryTransition, EventAbortController,
InternalAppHistoryNavigateOptions,
AppHistoryNavigateOptions
} from "./create-app-history-transition";
import {createEvent} from "./event-target/create-event";
export * from "./spec/app-history";
export interface AppHistoryOptions {
initialUrl?: URL | string;
}
const baseUrl = "https://html.spec.whatwg.org/";
export class AppHistory extends AppHistoryEventTarget<AppHistoryEventMap> implements AppHistoryPrototype {
// Should be always 0 or 1
#transitionInProgressCount = 0;
#entries: AppHistoryEntry[] = [];
#known = new Set<AppHistoryEntry>();
#currentIndex = -1;
#activePromise?: Promise<unknown>;
#activeTransition?: AppHistoryTransition;
//
// #upcomingNonTraverseTransition: AppHistoryTransition;
#knownTransitions = new WeakSet();
#initialUrl: string;
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(): AppHistoryTransitionPrototype | undefined {
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?: AppHistoryOptions) {
super();
const initialUrl = options?.initialUrl ?? "/";
this.#initialUrl = (typeof initialUrl === "string" ? new URL(initialUrl, baseUrl) : initialUrl).toString();
}
back(options?: AppHistoryNavigationOptions): AppHistoryResult {
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(): AppHistoryEntry[] {
return [...this.#entries];
}
forward(options?: AppHistoryNavigationOptions): AppHistoryResult {
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: string, options?: AppHistoryNavigationOptions): AppHistoryResult {
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: string, options?: AppHistoryNavigateOptions): AppHistoryResult {
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?: AppHistoryEntry, options?: InternalAppHistoryNavigateOptions): AppHistoryEntry => {
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: Partial<AppHistoryEntryInit> & Omit<AppHistoryEntryInit, "index">) => {
const entry: AppHistoryEntry = new AppHistoryEntry({
...options,
index: options.index ?? (() => {
return this.#entries.indexOf(entry);
}),
});
return entry;
}
#pushEntry = (navigationType: InternalAppHistoryNavigationType, entry: AppHistoryEntry, transition?: AppHistoryTransition, options?: InternalAppHistoryNavigateOptions) => {
/* 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: InternalAppHistoryNavigationType, entry: AppHistoryEntry, transition?: AppHistoryTransition, options?: InternalAppHistoryNavigateOptions) => {
const nextTransition: AppHistoryTransition = 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: AppHistoryTransition) => {
// TODO consume errors that are not abort errors
// transition.finished.catch(error => void error);
this.#knownTransitions.add(transition);
}
#immediateTransition = (givenNavigationType: InternalAppHistoryNavigationType, entry: AppHistoryEntry, transition: AppHistoryTransition, options?: InternalAppHistoryNavigateOptions) => {
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: AppHistoryTransition, options?: AppHistoryNavigationOptions): AppHistoryResult => {
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: InternalAppHistoryNavigateOptions = {
...options,
index: previousIndex,
known: new Set([...this.#known, ...previousEntries]),
navigationType: entry?.[AppHistoryEntryNavigationType] ?? "replace",
entries: previousEntries,
} as const;
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: InternalAppHistoryNavigationType, entry: AppHistoryEntry, transition: AppHistoryTransition, options?: InternalAppHistoryNavigateOptions): Promise<AppHistoryEntry> => {
// 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 = (): Promise<AppHistoryEntry> => {
if (givenNavigationType === Unset) {
return unsetTransition();
}
const transitionResult = createAppHistoryTransition({
current,
currentIndex: this.#currentIndex,
options,
transition,
known: this.#known
});
const microtask = new Promise<void>(queueMicrotask);
let promises: Promise<unknown>[] = [];
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(): Promise<void> {
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);
}
interface Commit {
entries: AppHistoryEntry[];
index: number;
known?: Set<AppHistoryEntry>;
}
const syncCommit = ({ entries, index, known }: Commit) => {
if (transition.signal.aborted) return;
this.#entries = entries;
if (known) {
this.#known = new Set([...this.#known, ...(known)])
}
this.#currentIndex = index;
}
const asyncCommit = (commit: Commit) => {
syncCommit(commit);
return transition.dispatchEvent(
createEvent(
{
type: AppHistoryTransitionCommit,
transition,
entry
}
)
);
}
const dispose = async () => this.#dispose();
function *transitionSteps(transitionResult: AppHistoryTransitionResult): Iterable<Promise<unknown>> {
const microtask = new Promise<void>(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?: AppHistoryReloadOptions): AppHistoryResult {
const { current } = this;
if (!current) throw new InvalidStateError();
const entry = this.#cloneAppHistoryEntry(current, options);
return this.#pushEntry("reload", entry, undefined, options);
}
updateCurrent(options: AppHistoryUpdateCurrentOptions): Promise<void>
updateCurrent(options: AppHistoryUpdateCurrentOptions): void
updateCurrent(options: AppHistoryUpdateCurrentOptions): unknown {
const { current } = this;
if (!current) {
throw new InvalidStateError("Expected current entry");
}
// Instant change
current[AppHistoryEntrySetState](options.state);
const currentChange: AppHistoryCurrentChangeEvent = createEvent({
from: current,
type: "currentchange",
navigationType: undefined,
});
return this.dispatchEvent(currentChange);
}
}
function getPerformance(): {
now(): number;
measure(name: string, start: string, finish: string): unknown;
mark(mark: string): unknown;
} {
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 */
}