@virtualstate/navigation
Version:
Native JavaScript [navigation](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) implementation
652 lines • 27.1 kB
JavaScript
import { NavigationHistoryEntry, NavigationHistoryEntryKnownAs, NavigationHistoryEntryNavigationType, NavigationHistoryEntrySetState, } from "./navigation-entry.js";
import { NavigationEventTarget } from "./navigation-event-target.js";
import { InvalidStateError } from "./navigation-errors.js";
import { EventTargetListeners } from "./event-target/index.js";
import { NavigationTransition, NavigationTransitionEntry, NavigationTransitionError, NavigationTransitionFinally, NavigationTransitionStart, NavigationTransitionInitialEntries, NavigationTransitionInitialIndex, NavigationTransitionKnown, NavigationTransitionNavigationType, NavigationTransitionParentEventTarget, NavigationTransitionPromises, NavigationTransitionWait, Rollback, Unset, NavigationIntercept, NavigationTransitionStartDeadline, NavigationTransitionCommit, NavigationTransitionFinish, NavigationTransitionAbort, NavigationTransitionIsOngoing, NavigationTransitionFinishedDeferred, NavigationTransitionCommittedDeferred, NavigationTransitionIsAsync, NavigationTransitionCommitIsManual, NavigationTransitionRejected, } from "./navigation-transition.js";
import { createNavigationTransition, } from "./create-navigation-transition.js";
import { createEvent } from "./event-target/create-event.js";
import { getBaseURL } from "./base-url.js";
import { isPromise, isPromiseRejectedResult } from "./is.js";
import { NavigationCurrentEntryChangeEvent } from "./events/index.js";
export * from "./spec/navigation.js";
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) {
return (value === "reload" ||
value === "push" ||
value === "replace" ||
value === "traverse");
}
export class Navigation extends NavigationEventTarget {
// Should be always 0 or 1
#transitionInProgressCount = 0;
// #activePromise?: Promise<void> = undefined;
#entries = [];
#known = new Set();
#currentIndex = -1;
#activeTransition;
#knownTransitions = new WeakSet();
#baseURL = "";
#initialEntry = undefined;
#options = undefined;
get canGoBack() {
return !!this.#entries[this.#currentIndex - 1];
}
get canGoForward() {
return !!this.#entries[this.#currentIndex + 1];
}
get currentEntry() {
if (this.#currentIndex === -1) {
if (!this.#initialEntry) {
this.#initialEntry = new NavigationHistoryEntry({
getState: this[NavigationGetState],
navigationType: "push",
index: -1,
sameDocument: false,
url: this.#baseURL.toString()
});
}
return this.#initialEntry;
}
return this.#entries[this.#currentIndex];
}
get transition() {
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 = {}) {
super();
this[NavigationSetOptions](options);
}
[NavigationSetOptions](options) {
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) {
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) {
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) {
this.#entries = entries.map(({ key, url, navigationType, state, sameDocument }, index) => new NavigationHistoryEntry({
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) => {
return this.#options?.getState?.(entry) ?? undefined;
};
[NavigationSetState] = (entry) => {
return this.#options?.setState?.(entry);
};
[NavigationDisposeState] = (entry) => {
return this.#options?.disposeState?.(entry);
};
back(options) {
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() {
return [...this.#entries];
}
forward(options) {
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, options) {
return this.traverseTo(key, options);
}
traverseTo(key, options) {
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) => {
function isSameOrigins(a, b) {
return a.origin === b.origin;
}
const currentEntryUrl = this.currentEntry?.url;
if (!currentEntryUrl)
return true;
return isSameOrigins(new URL(currentEntryUrl), new URL(url));
};
navigate(url, options) {
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 = "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, options) => {
return this.#createNavigationHistoryEntry({
...entry,
getState: this[NavigationGetState],
index: entry?.index ?? undefined,
state: options?.state ?? entry?.getState(),
navigationType: entry?.[NavigationHistoryEntryNavigationType] ??
(typeof options?.navigationType === "string"
? options.navigationType
: "replace"),
...options,
get [NavigationHistoryEntryKnownAs]() {
return entry?.[NavigationHistoryEntryKnownAs];
},
get [EventTargetListeners]() {
return entry?.[EventTargetListeners];
},
});
};
#createNavigationHistoryEntry = (options) => {
const entry = new NavigationHistoryEntry({
...options,
index: options.index ??
(() => {
return this.#entries.indexOf(entry);
}),
});
return entry;
};
#pushEntry = (navigationType, entry, transition, options) => {
/* 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, entry, transition, options) => {
const nextTransition = transition ??
new NavigationTransition({
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) => {
// TODO consume errors that are not abort errors
// transition.finished.catch(error => void error);
this.#knownTransitions.add(transition);
};
#immediateTransition = (givenNavigationType, entry, transition, options) => {
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, options) => {
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 = {
...options,
index: previousIndex,
known: new Set([...this.#known, ...previousEntries]),
navigationType: entry?.[NavigationHistoryEntryNavigationType] ?? "replace",
entries: previousEntries,
};
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, entry, transition, options) => {
// 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 }) => {
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) => {
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({
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 = () => {
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(queueMicrotask);
let promises = [];
const iterator = transitionSteps(transitionResult)[Symbol.iterator]();
const iterable = {
[Symbol.iterator]: () => ({ next: () => iterator.next() }),
};
async function syncTransition() {
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() {
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) {
const microtask = new Promise(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) {
const { currentEntry } = this;
if (!currentEntry)
throw new InvalidStateError();
const entry = this.#cloneNavigationHistoryEntry(currentEntry, options);
return this.#pushEntry("reload", entry, undefined, options);
}
updateCurrentEntry(options) {
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 = createEvent({
type: "entrieschange",
addedEntries: [],
removedEntries: [],
updatedEntries: [
currentEntry
]
});
return Promise.all([
this.dispatchEvent(currentEntryChange),
this.dispatchEvent(entriesChange)
]);
}
}
function getPerformance() {
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 */
}
//# sourceMappingURL=navigation.js.map