@virtualstate/navigation
Version:
Native JavaScript [navigation](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) implementation
892 lines (809 loc) • 28 kB
text/typescript
import {
NavigationHistoryEntry, NavigationHistoryEntryFn, NavigationHistoryEntryGetStateFn,
NavigationHistoryEntryInit,
NavigationHistoryEntryKnownAs,
NavigationHistoryEntryNavigationType, NavigationHistoryEntrySerialized,
NavigationHistoryEntrySetState,
} from "./navigation-entry";
import {
Navigation as NavigationPrototype,
NavigationEventMap,
NavigationReloadOptions,
NavigationResult,
NavigationUpdateCurrentOptions,
NavigationTransition as NavigationTransitionPrototype,
NavigationNavigationOptions,
NavigationNavigationType, NavigationEntriesChangeEvent
} from "./spec/navigation";
import { NavigationEventTarget } from "./navigation-event-target";
import { InvalidStateError } from "./navigation-errors";
import { EventTargetListeners } from "./event-target";
import {
NavigationTransition,
NavigationTransitionEntry,
NavigationTransitionError,
NavigationTransitionFinally,
NavigationTransitionStart,
NavigationTransitionInitialEntries,
NavigationTransitionInitialIndex,
NavigationTransitionKnown,
NavigationTransitionNavigationType,
NavigationTransitionParentEventTarget,
NavigationTransitionPromises,
NavigationTransitionWait,
InternalNavigationNavigationType,
Rollback,
Unset,
NavigationIntercept,
NavigationTransitionStartDeadline,
NavigationTransitionCommit,
NavigationTransitionFinish,
NavigationTransitionAbort,
NavigationTransitionIsOngoing,
NavigationTransitionFinishedDeferred,
NavigationTransitionCommittedDeferred,
NavigationTransitionIsAsync,
NavigationTransitionInterceptOptionsCommit,
NavigationTransitionCommitIsManual,
NavigationTransitionRejected,
} from "./navigation-transition";
import {
NavigationTransitionResult,
createNavigationTransition,
EventAbortController,
InternalNavigationNavigateOptions,
NavigationNavigateOptions, NavigationTransitionCommitContext,
} from "./create-navigation-transition";
import { createEvent } from "./event-target/create-event";
import { getBaseURL } from "./base-url";
import {isPromise, isPromiseRejectedResult} from "./is";
import {NavigationCurrentEntryChangeEvent} from "./events";
export * from "./spec/navigation";
export interface NavigationOptions<S = unknown> {
baseURL?: URL | string;
getState?: NavigationHistoryEntryGetStateFn<S>
setState?: NavigationHistoryEntryFn<S>
disposeState?: NavigationHistoryEntryFn<S>
entries?: NavigationHistoryEntrySerialized<S>[];
currentIndex?: number;
currentKey?: string;
}
export const NavigationSetOptions = Symbol.for("@virtualstate/navigation/setOptions");
export const NavigationSetEntries = Symbol.for("@virtualstate/navigation/setEntries");
export const NavigationSetCurrentIndex = Symbol.for("@virtualstate/navigation/setCurrentIndex");
export const NavigationSetCurrentKey = Symbol.for("@virtualstate/navigation/setCurrentKey");
export const NavigationGetState = Symbol.for("@virtualstate/navigation/getState");
export const NavigationSetState = Symbol.for("@virtualstate/navigation/setState");
export const NavigationDisposeState = Symbol.for("@virtualstate/navigation/disposeState");
export function isNavigationNavigationType(value: unknown): value is NavigationNavigationType {
return (
value === "reload" ||
value === "push" ||
value === "replace" ||
value === "traverse"
);
}
export class Navigation<S = unknown, R = unknown | void>
extends NavigationEventTarget<NavigationEventMap<S, R>>
implements NavigationPrototype<S, R>
{
// Should be always 0 or 1
#transitionInProgressCount = 0;
// #activePromise?: Promise<void> = undefined;
#entries: NavigationHistoryEntry<S>[] = [];
#known = new Set<NavigationHistoryEntry<S>>();
#currentIndex = -1;
#activeTransition?: NavigationTransition<S>;
#knownTransitions = new WeakSet();
#baseURL: string | URL = "";
#initialEntry: NavigationHistoryEntry<S> | undefined = undefined;
#options: NavigationOptions<S> | undefined = undefined;
get canGoBack() {
return !!this.#entries[this.#currentIndex - 1];
}
get canGoForward() {
return !!this.#entries[this.#currentIndex + 1];
}
get currentEntry(): NavigationHistoryEntry<S> {
if (this.#currentIndex === -1) {
if (!this.#initialEntry) {
this.#initialEntry = new NavigationHistoryEntry<S>({
getState: this[NavigationGetState],
navigationType: "push",
index: -1,
sameDocument: false,
url: this.#baseURL.toString()
});
}
return this.#initialEntry;
}
return this.#entries[this.#currentIndex];
}
get transition(): NavigationTransitionPrototype | 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: NavigationOptions<S> = {}) {
super();
this[NavigationSetOptions](options);
}
[NavigationSetOptions](options: NavigationOptions<S>) {
this.#options = options;
this.#baseURL = getBaseURL(options?.baseURL);
this.#entries = [];
if (options.entries) {
this[NavigationSetEntries](options.entries);
}
if (options.currentKey) {
this[NavigationSetCurrentKey](options.currentKey);
} else if (typeof options.currentIndex === "number") {
this[NavigationSetCurrentIndex](options.currentIndex)
}
}
/**
* Set the current entry key without any lifecycle eventing
*
* This would be more exact than providing an index
* @param key
*/
[NavigationSetCurrentKey](key: string) {
const index = this.#entries.findIndex(
entry => entry.key === key
);
// If the key can't be found, becomes a no-op
if (index === -1) return;
this.#currentIndex = index;
}
/**
* Set the current entry index without any lifecycle eventing
* @param index
*/
[NavigationSetCurrentIndex](index: number) {
if (index <= -1) return;
if (index >= this.#entries.length) return;
this.#currentIndex = index;
}
/**
* Set the entries available without any lifecycle eventing
* @param entries
*/
[NavigationSetEntries](entries: NavigationHistoryEntrySerialized<S>[]) {
this.#entries = entries.map(
({ key, url, navigationType, state, sameDocument }, index) => new NavigationHistoryEntry<S>({
getState: this[NavigationGetState],
navigationType: isNavigationNavigationType(navigationType) ? navigationType : "push",
sameDocument: sameDocument ?? true,
index,
url,
key,
state
})
);
if (this.#currentIndex === -1 && this.#entries.length) {
// Initialise, even if its not the one that was expected
this.#currentIndex = 0;
}
}
[NavigationGetState] = (entry: NavigationHistoryEntry<S>): S | undefined => {
return this.#options?.getState?.(entry) ?? undefined;
}
[NavigationSetState] = (entry: NavigationHistoryEntry<S>) => {
return this.#options?.setState?.(entry);
}
[NavigationDisposeState] = (entry: NavigationHistoryEntry<S>) => {
return this.#options?.disposeState?.(entry);
}
back(options?: NavigationNavigationOptions): NavigationResult<S> {
if (!this.canGoBack) throw new InvalidStateError("Cannot go back");
const entry = this.#entries[this.#currentIndex - 1];
return this.#pushEntry(
"traverse",
this.#cloneNavigationHistoryEntry(entry, {
...options,
navigationType: "traverse",
})
);
}
entries(): NavigationHistoryEntry<S>[] {
return [...this.#entries];
}
forward(options?: NavigationNavigationOptions): NavigationResult<S> {
if (!this.canGoForward) throw new InvalidStateError();
const entry = this.#entries[this.#currentIndex + 1];
return this.#pushEntry(
"traverse",
this.#cloneNavigationHistoryEntry(entry, {
...options,
navigationType: "traverse",
})
);
}
/**
/**
* @deprecated use traverseTo
*/
goTo(key: string, options?: NavigationNavigateOptions): NavigationResult<R> {
return this.traverseTo(key, options);
}
traverseTo(key: string, options?: NavigationNavigationOptions): NavigationResult<S> {
const found = this.#entries.find((entry) => entry.key === key);
if (found) {
return this.#pushEntry(
"traverse",
this.#cloneNavigationHistoryEntry(found, {
...options,
navigationType: "traverse",
})
);
}
throw new InvalidStateError();
}
#isSameDocument = (url: string) => {
function isSameOrigins(a: URL, b: URL) {
return a.origin === b.origin;
}
const currentEntryUrl = this.currentEntry?.url;
if (!currentEntryUrl) return true;
return isSameOrigins(
new URL(currentEntryUrl),
new URL(url)
);
}
navigate(
url: string,
options?: NavigationNavigateOptions<S>
): NavigationResult {
let baseURL = this.#baseURL
if (this.currentEntry?.url) {
// This allows use to use relative
baseURL = this.currentEntry?.url;
}
const nextUrl = new URL(url, baseURL).toString();
let navigationType: NavigationNavigationType = "push"
if (options?.history === "push" || options?.history === "replace") {
navigationType = options?.history;
}
const entry = this.#createNavigationHistoryEntry({
getState: this[NavigationGetState],
url: nextUrl,
...options,
sameDocument: this.#isSameDocument(nextUrl),
navigationType,
});
return this.#pushEntry(navigationType, entry, undefined, options);
}
#cloneNavigationHistoryEntry = (
entry?: NavigationHistoryEntry<S>,
options?: InternalNavigationNavigateOptions<S>
): NavigationHistoryEntry<S> => {
return this.#createNavigationHistoryEntry({
...entry,
getState: this[NavigationGetState],
index: entry?.index ?? undefined,
state: options?.state ?? entry?.getState<S>(),
navigationType:
entry?.[NavigationHistoryEntryNavigationType] ??
(typeof options?.navigationType === "string"
? options.navigationType
: "replace"),
...options,
get [NavigationHistoryEntryKnownAs]() {
return entry?.[NavigationHistoryEntryKnownAs];
},
get [EventTargetListeners]() {
return entry?.[EventTargetListeners];
},
});
};
#createNavigationHistoryEntry = (
options: Partial<NavigationHistoryEntryInit<S>> &
Omit<NavigationHistoryEntryInit<S>, "index">
) => {
const entry: NavigationHistoryEntry<S> = new NavigationHistoryEntry<S>({
...options,
index:
options.index ??
(() => {
return this.#entries.indexOf(entry);
}),
});
return entry;
};
#pushEntry = (
navigationType: InternalNavigationNavigationType,
entry: NavigationHistoryEntry<S>,
transition?: NavigationTransition<S>,
options?: InternalNavigationNavigateOptions<S>
) => {
/* c8 ignore start */
if (entry === this.currentEntry) 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: InternalNavigationNavigationType,
entry: NavigationHistoryEntry<S>,
transition?: NavigationTransition<S>,
options?: InternalNavigationNavigateOptions<S>
) => {
const nextTransition: NavigationTransition<S> =
transition ??
new NavigationTransition<S>({
from: this.currentEntry,
navigationType:
typeof givenNavigationType === "string"
? givenNavigationType
: "replace",
rollback: (options) => {
return this.#rollback(nextTransition, options);
},
[NavigationTransitionNavigationType]: givenNavigationType,
[NavigationTransitionInitialEntries]: [...this.#entries],
[NavigationTransitionInitialIndex]: this.#currentIndex,
[NavigationTransitionKnown]: [...this.#known],
[NavigationTransitionEntry]: entry,
[NavigationTransitionParentEventTarget]: this,
});
const { finished, committed } = nextTransition;
const handler = () => {
return this.#immediateTransition(
givenNavigationType,
entry,
nextTransition,
options
);
};
this.#queueTransition(nextTransition);
void handler().catch((error) => void error);
// let nextPromise;
// if (!this.#transitionInProgressCount || !this.#activePromise) {
// nextPromise = handler().catch((error) => void error);
// } else {
// nextPromise = this.#activePromise.then(handler);
// }
//
// const promise = nextPromise
// .catch(error => void error)
// .then(() => {
// if (this.#activePromise === promise) {
// this.#activePromise = undefined;
// }
// })
//
// this.#activePromise = promise;
return { committed, finished };
};
#queueTransition = (transition: NavigationTransition<S>) => {
// TODO consume errors that are not abort errors
// transition.finished.catch(error => void error);
this.#knownTransitions.add(transition);
};
#immediateTransition = (
givenNavigationType: InternalNavigationNavigationType,
entry: NavigationHistoryEntry<S>,
transition: NavigationTransition<S>,
options?: InternalNavigationNavigateOptions<S>
) => {
try {
// This number can grow if navigation is
// called during a transition
//
// ... I had used transitionInProgressCount as a
// safeguard until I could see this flow firsthand
this.#transitionInProgressCount += 1;
return this.#transition(givenNavigationType, entry, transition, options);
} finally {
this.#transitionInProgressCount -= 1;
}
};
#rollback = (
rollbackTransition: NavigationTransition<S>,
options?: NavigationNavigationOptions
): NavigationResult => {
const previousEntries =
rollbackTransition[NavigationTransitionInitialEntries];
const previousIndex = rollbackTransition[NavigationTransitionInitialIndex];
const previousCurrent = previousEntries[previousIndex];
// console.log("z");
// console.log("Rollback!", { previousCurrent, previousEntries, previousIndex });
const entry = previousCurrent
? this.#cloneNavigationHistoryEntry(previousCurrent, options)
: undefined;
const nextOptions: InternalNavigationNavigateOptions<S> = {
...options,
index: previousIndex,
known: new Set([...this.#known, ...previousEntries]),
navigationType:
entry?.[NavigationHistoryEntryNavigationType] ?? "replace",
entries: previousEntries,
} as const;
const resolvedNavigationType = entry ? Rollback : Unset;
const resolvedEntry =
entry ??
this.#createNavigationHistoryEntry({
getState: this[NavigationGetState],
navigationType: "replace",
index: nextOptions.index,
sameDocument: true,
...options,
});
return this.#pushEntry(
resolvedNavigationType,
resolvedEntry,
undefined,
nextOptions
);
};
#transition = (
givenNavigationType: InternalNavigationNavigationType,
entry: NavigationHistoryEntry<S>,
transition: NavigationTransition<S>,
options?: InternalNavigationNavigateOptions<S>
): Promise<NavigationHistoryEntry<S>> => {
// console.log({ givenNavigationType, transition });
let navigationType = givenNavigationType;
const performance = getPerformance();
if (
performance &&
entry.sameDocument &&
typeof navigationType === "string"
) {
performance?.mark?.(`same-document-navigation:${entry.id}`);
}
let currentEntryChangeEvent = false,
committedCurrentEntryChange = false;
const { currentEntry } = this;
void this.#activeTransition?.finished?.catch((error) => error);
void this.#activeTransition?.[
NavigationTransitionFinishedDeferred
]?.promise?.catch((error) => error);
void this.#activeTransition?.[
NavigationTransitionCommittedDeferred
]?.promise?.catch((error) => error);
this.#activeTransition?.[NavigationTransitionAbort]();
this.#activeTransition = transition;
const startEventPromise = transition.dispatchEvent({
type: NavigationTransitionStart,
transition,
entry,
});
const syncCommit = ({ entries, index, known }: NavigationTransitionCommitContext<S>) => {
if (transition.signal.aborted) return;
this.#entries = entries;
if (known) {
this.#known = new Set([...this.#known, ...known]);
}
this.#currentIndex = index;
// Let's trigger external state here
// because it is the absolute point of
// committing to using an entry
//
// If the entry came from an external source
// then internal to getState the external source will be pulled from
// only if the entry doesn't hold the state in memory
//
// TLDR I believe this will be no issue doing here, even if we end up
// calling an external setState multiple times, it is better than
// loss of the state
this[NavigationSetState](this.currentEntry);
};
const asyncCommit = async (commit: NavigationTransitionCommitContext<S>) => {
if (committedCurrentEntryChange) {
return;
}
committedCurrentEntryChange = true;
syncCommit(commit);
const { entriesChange } = commit;
const promises = [
transition.dispatchEvent(
createEvent({
type: NavigationTransitionCommit,
transition,
entry,
})
)
];
if (entriesChange) {
promises.push(
this.dispatchEvent(
createEvent<NavigationEntriesChangeEvent<S>>({
type: "entrieschange",
...entriesChange
})
)
)
}
await Promise.all(promises);
}
const unsetTransition = async () => {
await startEventPromise;
if (!(typeof options?.index === "number" && options.entries))
throw new InvalidStateError();
const previous = this.entries();
const previousKeys = previous.map(entry => entry.key);
const keys = options.entries.map(entry => entry.key);
const removedEntries = previous.filter(entry => !keys.includes(entry.key));
const addedEntries = options.entries.filter(entry => !previousKeys.includes(entry.key));
await asyncCommit({
entries: options.entries,
index: options.index,
known: options.known,
entriesChange: (removedEntries.length || addedEntries.length) ? {
removedEntries,
addedEntries,
updatedEntries: []
} : undefined
});
await this.dispatchEvent(
createEvent({
type: "currententrychange",
})
);
currentEntryChangeEvent = true;
return entry;
};
const completeTransition = (): Promise<NavigationHistoryEntry<S>> => {
if (givenNavigationType === Unset) {
return unsetTransition();
}
const transitionResult = createNavigationTransition({
currentEntry,
currentIndex: this.#currentIndex,
options,
transition,
known: this.#known,
commit: asyncCommit,
reportError: transition[NavigationTransitionRejected]
});
const microtask = new Promise<void>(queueMicrotask);
let promises: Promise<PromiseSettledResult<unknown>>[] = [];
const iterator = transitionSteps(transitionResult)[Symbol.iterator]();
const iterable = {
[Symbol.iterator]: () => ({ next: () => iterator.next() }),
};
async function syncTransition(): Promise<void> {
for (const promise of iterable) {
if (isPromise(promise)) {
promises.push(Promise.allSettled([
promise
]).then(([result]) => result));
}
if (
transition[NavigationTransitionCommitIsManual] ||
(currentEntryChangeEvent && transition[NavigationTransitionIsAsync])
) {
return asyncTransition().then(syncTransition)
}
if (transition.signal.aborted) {
break;
}
}
if (promises.length) {
return asyncTransition();
}
}
async function asyncTransition(): Promise<void> {
const captured = [...promises];
if (captured.length) {
promises = [];
const results = await Promise.all(captured);
const rejected = results.filter(isPromiseRejectedResult);
if (rejected.length === 1) {
throw await Promise.reject(rejected[0]);
} else if (rejected.length) {
throw new AggregateError(rejected, rejected[0].reason?.message);
}
} else if (!transition[NavigationTransitionIsOngoing]) {
await microtask;
}
}
// console.log("Returning", { entry });
return syncTransition()
.then(() =>
transition[NavigationTransitionIsOngoing] ? undefined : microtask
)
.then(() => entry);
};
const dispose = async () => this.#dispose();
function* transitionSteps(
transitionResult: NavigationTransitionResult<S>
): Iterable<Promise<unknown> | unknown | void> {
const microtask = new Promise<void>(queueMicrotask);
const { currentEntryChange, navigate, waitForCommit, commit, abortController } =
transitionResult;
const navigateAbort = abortController.abort.bind(abortController);
transition.signal.addEventListener("abort", navigateAbort, {
once: true,
});
if (typeof navigationType === "string" || navigationType === Rollback) {
const promise = currentEntry?.dispatchEvent(
createEvent({
type: "navigatefrom",
intercept: transition[NavigationIntercept],
/**
* @deprecated
*/
transitionWhile: transition[NavigationIntercept],
})
);
if (promise) yield promise;
}
if (typeof navigationType === "string") {
yield transition.dispatchEvent(navigate);
}
if (!transition[NavigationTransitionCommitIsManual]) {
commit()
}
yield waitForCommit;
if (entry.sameDocument) {
yield transition.dispatchEvent(currentEntryChange);
}
currentEntryChangeEvent = true;
if (typeof navigationType === "string") {
yield entry.dispatchEvent(
createEvent({
type: "navigateto",
intercept: transition[NavigationIntercept],
/**
* @deprecated
*/
transitionWhile: transition[NavigationIntercept],
})
);
}
yield dispose();
if (!transition[NavigationTransitionPromises].size) {
yield microtask;
}
yield transition.dispatchEvent({
type: NavigationTransitionStartDeadline,
transition,
entry,
});
yield transition[NavigationTransitionWait]();
transition.signal.removeEventListener("abort", navigateAbort);
yield transition[NavigationTransitionFinish]();
if (typeof navigationType === "string") {
yield transition.dispatchEvent(
createEvent({
type: "finish",
intercept: transition[NavigationIntercept],
/**
* @deprecated
*/
transitionWhile: transition[NavigationIntercept],
})
);
yield transition.dispatchEvent(
createEvent({
type: "navigatesuccess",
intercept: transition[NavigationIntercept],
/**
* @deprecated
*/
transitionWhile: transition[NavigationIntercept],
})
);
}
}
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: NavigationTransitionError,
error: detail.reason,
transition,
entry,
});
}
await dispose();
await transition.dispatchEvent({
type: NavigationTransitionFinally,
transition,
entry,
});
await transition[NavigationTransitionWait]();
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,
});
this[NavigationDisposeState](known);
await known.dispatchEvent(event);
await this.dispatchEvent(event);
}
// console.log(JSON.stringify({ pruned: [...this.#known] }));
};
reload(
options?: NavigationReloadOptions<S>
): NavigationResult<S> {
const { currentEntry } = this;
if (!currentEntry) throw new InvalidStateError();
const entry = this.#cloneNavigationHistoryEntry(currentEntry, options);
return this.#pushEntry("reload", entry, undefined, options);
}
updateCurrentEntry(options: NavigationUpdateCurrentOptions<S>): unknown;
updateCurrentEntry(options: NavigationUpdateCurrentOptions<S>): void;
updateCurrentEntry(options: NavigationUpdateCurrentOptions<S>): unknown {
const { currentEntry } = this;
if (!currentEntry) {
throw new InvalidStateError("Expected current entry");
}
// Instant change
currentEntry[NavigationHistoryEntrySetState](options.state);
this[NavigationSetState](currentEntry);
const currentEntryChange = new NavigationCurrentEntryChangeEvent("currententrychange", {
from: currentEntry,
navigationType: undefined,
});
const entriesChange: NavigationEntriesChangeEvent = createEvent({
type: "entrieschange",
addedEntries: [],
removedEntries: [],
updatedEntries: [
currentEntry
]
});
return Promise.all([
this.dispatchEvent(currentEntryChange),
this.dispatchEvent(entriesChange)
]) ;
}
}
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 */
}