UNPKG

@virtualstate/navigation

Version:

Native JavaScript [navigation](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) implementation

1,313 lines (1,287 loc) 105 kB
function isEvent(value) { function isLike(value) { return !!value; } return (isLike(value) && (typeof value.type === "string" || typeof value.type === "symbol")); } function assertEvent(value, type) { if (!isEvent(value)) { throw new Error("Expected event"); } if (typeof type !== "undefined" && value.type !== type) { throw new Error(`Expected event type ${String(type)}, got ${value.type.toString()}`); } } function isParallelEvent(value) { return isEvent(value) && value.parallel !== false; } class AbortError extends Error { constructor(message) { super(`AbortError${message ? `: ${message}` : ""}`); this.name = "AbortError"; } } function isAbortError(error) { return error instanceof Error && error.name === "AbortError"; } class InvalidStateError extends Error { constructor(message) { super(`InvalidStateError${message ? `: ${message}` : ""}`); this.name = "InvalidStateError"; } } function isInvalidStateError(error) { return error instanceof Error && error.name === "InvalidStateError"; } function isAbortSignal(value) { function isAbortSignalLike(value) { return typeof value === "object"; } return (isAbortSignalLike(value) && typeof value.aborted === "boolean" && typeof value.addEventListener === "function"); } function isSignalEvent(value) { function isSignalEventLike(value) { return value.hasOwnProperty("signal"); } return (isEvent(value) && isSignalEventLike(value) && isAbortSignal(value.signal)); } function isSignalHandled(event, error) { if (isSignalEvent(event) && event.signal.aborted && error instanceof Error && isAbortError(error)) { return true; } } /** * @experimental */ const EventTargetListeners$1 = Symbol.for("@opennetwork/environment/events/target/listeners"); /** * @experimental */ const EventTargetListenersIgnore = Symbol.for("@opennetwork/environment/events/target/listeners/ignore"); /** * @experimental */ const EventTargetListenersMatch = Symbol.for("@opennetwork/environment/events/target/listeners/match"); /** * @experimental */ const EventTargetListenersThis = Symbol.for("@opennetwork/environment/events/target/listeners/this"); const EventDescriptorSymbol = Symbol.for("@opennetwork/environment/events/descriptor"); function matchEventCallback(type, callback, options) { const optionsDescriptor = isOptionsDescriptor(options) ? options : undefined; return (descriptor) => { if (optionsDescriptor) { return optionsDescriptor === descriptor; } return ((!callback || callback === descriptor.callback) && type === descriptor.type); }; function isOptionsDescriptor(options) { function isLike(options) { return !!options; } return isLike(options) && options[EventDescriptorSymbol] === true; } } function isFunctionEventCallback(fn) { return typeof fn === "function"; } const EventTargetDescriptors = Symbol.for("@virtualstate/navigation/event-target/descriptors"); class EventTargetListeners { [EventTargetDescriptors] = []; [EventTargetListenersIgnore] = new WeakSet(); get [EventTargetListeners$1]() { return [...(this[EventTargetDescriptors] ?? [])]; } [EventTargetListenersMatch](type) { const external = this[EventTargetListeners$1]; const matched = [ ...new Set([...(external ?? []), ...(this[EventTargetDescriptors] ?? [])]), ] .filter((descriptor) => descriptor.type === type || descriptor.type === "*") .filter((descriptor) => !this[EventTargetListenersIgnore]?.has(descriptor)); const listener = typeof type === "string" ? this[`on${type}`] : undefined; if (typeof listener === "function" && isFunctionEventCallback(listener)) { matched.push({ type, callback: listener, [EventDescriptorSymbol]: true, }); } return matched; } addEventListener(type, callback, options) { const listener = { ...options, isListening: () => !!this[EventTargetDescriptors]?.find(matchEventCallback(type, callback)), descriptor: { [EventDescriptorSymbol]: true, ...options, type, callback, }, timestamp: Date.now(), }; if (listener.isListening()) { return; } this[EventTargetDescriptors]?.push(listener.descriptor); } removeEventListener(type, callback, options) { if (!isFunctionEventCallback(callback)) { return; } const externalListeners = this[EventTargetListeners$1] ?? this[EventTargetDescriptors] ?? []; const externalIndex = externalListeners.findIndex(matchEventCallback(type, callback, options)); if (externalIndex === -1) { return; } const index = this[EventTargetDescriptors]?.findIndex(matchEventCallback(type, callback, options)) ?? -1; if (index !== -1) { this[EventTargetDescriptors]?.splice(index, 1); } const descriptor = externalListeners[externalIndex]; if (descriptor) { this[EventTargetListenersIgnore]?.add(descriptor); } } hasEventListener(type, callback) { if (callback && !isFunctionEventCallback(callback)) { return false; } const foundIndex = this[EventTargetDescriptors]?.findIndex(matchEventCallback(type, callback)) ?? -1; return foundIndex > -1; } } class AsyncEventTarget extends EventTargetListeners { [EventTargetListenersThis]; constructor(thisValue = undefined) { super(); this[EventTargetListenersThis] = thisValue; } async dispatchEvent(event) { const listeners = this[EventTargetListenersMatch]?.(event.type) ?? []; // Don't even dispatch an aborted event if (isSignalEvent(event) && event.signal.aborted) { throw new AbortError(); } const parallel = isParallelEvent(event); const promises = []; for (let index = 0; index < listeners.length; index += 1) { const descriptor = listeners[index]; const promise = (async () => { // Remove the listener before invoking the callback // This ensures that inside of the callback causes no more additional event triggers to this // listener if (descriptor.once) { // by passing the descriptor as the options, we get an internal redirect // that forces an instance level object equals, meaning // we will only remove _this_ descriptor! this.removeEventListener(descriptor.type, descriptor.callback, descriptor); } await descriptor.callback.call(this[EventTargetListenersThis] ?? this, event); })(); if (!parallel) { try { await promise; } catch (error) { if (!isSignalHandled(event, error)) { await Promise.reject(error); } } if (isSignalEvent(event) && event.signal.aborted) { // bye return; } } else { promises.push(promise); } } if (promises.length) { // Allows for all promises to settle finish so we can stay within the event, we then // will utilise Promise.all which will reject with the first rejected promise const results = await Promise.allSettled(promises); const rejected = results.filter((result) => { return result.status === "rejected"; }); if (rejected.length) { let unhandled = rejected; // If the event was aborted, then allow abort errors to occur, and handle these as handled errors // The dispatcher does not care about this because they requested it // // There may be other unhandled errors that are more pressing to the task they are doing. // // The dispatcher can throw an abort error if they need to throw it up the chain if (isSignalEvent(event) && event.signal.aborted) { unhandled = unhandled.filter((result) => !isSignalHandled(event, result.reason)); } if (unhandled.length === 1) { await Promise.reject(unhandled[0].reason); throw unhandled[0].reason; // We shouldn't get here } else if (unhandled.length > 1) { throw new AggregateError(unhandled.map(({ reason }) => reason)); } } } } } const defaultEventTargetModule = { EventTarget: AsyncEventTarget, AsyncEventTarget, SyncEventTarget: AsyncEventTarget, }; let eventTargetModule = defaultEventTargetModule; // // try { // eventTargetModule = await import("@virtualstate/navigation/event-target"); // console.log("Using @virtualstate/navigation/event-target", eventTargetModule); // } catch { // console.log("Using defaultEventTargetModule"); // eventTargetModule = defaultEventTargetModule; // } const EventTargetImplementation = eventTargetModule.EventTarget || eventTargetModule.SyncEventTarget || eventTargetModule.AsyncEventTarget; function assertEventTarget(target) { if (typeof target !== "function") { throw new Error("Could not load EventTarget implementation"); } } class EventTarget extends AsyncEventTarget { constructor(...args) { super(); if (EventTargetImplementation) { assertEventTarget(EventTargetImplementation); const { dispatchEvent } = new EventTargetImplementation(...args); this.dispatchEvent = dispatchEvent; } } } function isInterceptEvent(value) { function isInterceptEventLike(value) { return isEvent(value); } return (isInterceptEventLike(value) && typeof value.intercept === "function"); } class NavigationEventTarget extends EventTarget { addEventListener(type, listener, options) { assertEventCallback(listener); return super.addEventListener(type, listener, typeof options === "boolean" ? { once: options } : options); function assertEventCallback(listener) { if (typeof listener !== "function") throw new Error("Please us the function variant of event listener"); } } removeEventListener(type, listener, options) { assertEventCallback(listener); return super.removeEventListener(type, listener); function assertEventCallback(listener) { if (typeof listener !== "function") throw new Error("Please us the function variant of event listener"); } } } const isWebCryptoSupported = "crypto" in globalThis && typeof globalThis.crypto.randomUUID === "function"; const v4 = isWebCryptoSupported ? globalThis.crypto.randomUUID.bind(globalThis.crypto) : () => Array.from({ length: 5 }, () => `${Math.random()}`.replace(/^0\./, "")) .join("-") .replace(".", ""); // To prevent cyclic imports, where a circular is used, instead use the prototype interface // and then copy over the "private" symbol const NavigationGetState$1 = Symbol.for("@virtualstate/navigation/getState"); const NavigationHistoryEntryNavigationType = Symbol.for("@virtualstate/navigation/entry/navigationType"); const NavigationHistoryEntryKnownAs = Symbol.for("@virtualstate/navigation/entry/knownAs"); const NavigationHistoryEntrySetState = Symbol.for("@virtualstate/navigation/entry/setState"); function isPrimitiveValue(state) { return (typeof state === "number" || typeof state === "boolean" || typeof state === "symbol" || typeof state === "bigint" || typeof state === "string"); } function isValue(state) { return !!(state || isPrimitiveValue(state)); } class NavigationHistoryEntry extends NavigationEventTarget { #index; #state; get index() { return typeof this.#index === "number" ? this.#index : this.#index(); } key; id; url; sameDocument; get [NavigationHistoryEntryNavigationType]() { return this.#options.navigationType; } get [NavigationHistoryEntryKnownAs]() { const set = new Set(this.#options[NavigationHistoryEntryKnownAs]); set.add(this.id); return set; } #options; get [EventTargetListeners$1]() { return [ ...(super[EventTargetListeners$1] ?? []), ...(this.#options[EventTargetListeners$1] ?? []), ]; } constructor(init) { super(); this.#options = init; this.key = init.key || v4(); this.id = v4(); this.url = init.url ?? undefined; this.#index = init.index; this.sameDocument = init.sameDocument ?? true; this.#state = init.state ?? undefined; } [NavigationGetState$1]() { return this.#options?.getState?.(this); } getState() { let state = this.#state; if (!isValue(state)) { const external = this[NavigationGetState$1](); if (isValue(external)) { state = this.#state = external; } } /** * https://github.com/WICG/app-history/blob/7c0332b30746b14863f717404402bc49e497a2b2/spec.bs#L1406 * Note that in general, unless the state value is a primitive, entry.getState() !== entry.getState(), since a fresh copy is returned each time. */ if (typeof state === "undefined" || isPrimitiveValue(state)) { return state; } if (typeof state === "function") { console.warn("State passed to Navigation.navigate was a function, this may be unintentional"); console.warn("Unless a state value is primitive, with a standard implementation of Navigation"); console.warn("your state value will be serialized and deserialized before this point, meaning"); console.warn("a function would not be usable."); } return { ...state, }; } [NavigationHistoryEntrySetState](state) { this.#state = state; } } /** * @param handleCatch rejected promises automatically to allow free usage */ function deferred(handleCatch) { let resolve = undefined, reject = undefined; const promise = new Promise((resolveFn, rejectFn) => { resolve = resolveFn; reject = rejectFn; }); ok$1(resolve); ok$1(reject); return { resolve, reject, promise: handleCatch ? promise.catch(handleCatch) : promise, }; } function ok$1(value) { if (!value) { throw new Error("Value not provided"); } } const GlobalAbortController = typeof AbortController !== "undefined" ? AbortController : undefined; if (!GlobalAbortController) { throw new Error("AbortController expected to be available or polyfilled"); } const AbortController$1 = GlobalAbortController; function isPromise(value) { return (like(value) && typeof value.then === "function"); } function ok(value, message = "Expected value") { if (!value) { throw new Error(message); } } function isPromiseRejectedResult(value) { return value.status === "rejected"; } function like(value) { return !!value; } const THIS_WILL_BE_REMOVED = "This will be removed when the first major release of @virtualstate/navigation is published"; const WARNINGS = { EVENT_INTERCEPT_HANDLER: `You are using a non standard interface, please update your code to use event.intercept({ async handler() {} })\n${THIS_WILL_BE_REMOVED}` }; let GLOBAL_IS_WARNINGS_IGNORED = false; let GLOBAL_IS_WARNINGS_TRACED = true; function setIgnoreWarnings(ignore) { GLOBAL_IS_WARNINGS_IGNORED = ignore; } function setTraceWarnings(ignore) { GLOBAL_IS_WARNINGS_TRACED = ignore; } function logWarning(warning, ...message) { if (GLOBAL_IS_WARNINGS_IGNORED) { return; } try { if (GLOBAL_IS_WARNINGS_TRACED) { console.trace(WARNINGS[warning], ...message); } else { console.warn(WARNINGS[warning], ...message); } } catch { // We don't want attempts to log causing issues // maybe we don't have a console } } const Rollback = Symbol.for("@virtualstate/navigation/rollback"); const Unset = Symbol.for("@virtualstate/navigation/unset"); const NavigationTransitionParentEventTarget = Symbol.for("@virtualstate/navigation/transition/parentEventTarget"); const NavigationTransitionFinishedDeferred = Symbol.for("@virtualstate/navigation/transition/deferred/finished"); const NavigationTransitionCommittedDeferred = Symbol.for("@virtualstate/navigation/transition/deferred/committed"); const NavigationTransitionNavigationType = Symbol.for("@virtualstate/navigation/transition/navigationType"); const NavigationTransitionInitialEntries = Symbol.for("@virtualstate/navigation/transition/entries/initial"); const NavigationTransitionFinishedEntries = Symbol.for("@virtualstate/navigation/transition/entries/finished"); const NavigationTransitionInitialIndex = Symbol.for("@virtualstate/navigation/transition/index/initial"); const NavigationTransitionFinishedIndex = Symbol.for("@virtualstate/navigation/transition/index/finished"); const NavigationTransitionEntry = Symbol.for("@virtualstate/navigation/transition/entry"); const NavigationTransitionIsCommitted = Symbol.for("@virtualstate/navigation/transition/isCommitted"); const NavigationTransitionIsFinished = Symbol.for("@virtualstate/navigation/transition/isFinished"); const NavigationTransitionIsRejected = Symbol.for("@virtualstate/navigation/transition/isRejected"); const NavigationTransitionKnown = Symbol.for("@virtualstate/navigation/transition/known"); const NavigationTransitionPromises = Symbol.for("@virtualstate/navigation/transition/promises"); const NavigationIntercept = Symbol.for("@virtualstate/navigation/intercept"); const NavigationTransitionIsOngoing = Symbol.for("@virtualstate/navigation/transition/isOngoing"); const NavigationTransitionIsPending = Symbol.for("@virtualstate/navigation/transition/isPending"); const NavigationTransitionIsAsync = Symbol.for("@virtualstate/navigation/transition/isAsync"); const NavigationTransitionWait = Symbol.for("@virtualstate/navigation/transition/wait"); const NavigationTransitionPromiseResolved = Symbol.for("@virtualstate/navigation/transition/promise/resolved"); const NavigationTransitionRejected = Symbol.for("@virtualstate/navigation/transition/rejected"); const NavigationTransitionCommit = Symbol.for("@virtualstate/navigation/transition/commit"); const NavigationTransitionFinish = Symbol.for("@virtualstate/navigation/transition/finish"); const NavigationTransitionStart = Symbol.for("@virtualstate/navigation/transition/start"); const NavigationTransitionStartDeadline = Symbol.for("@virtualstate/navigation/transition/start/deadline"); const NavigationTransitionError = Symbol.for("@virtualstate/navigation/transition/error"); const NavigationTransitionFinally = Symbol.for("@virtualstate/navigation/transition/finally"); const NavigationTransitionAbort = Symbol.for("@virtualstate/navigation/transition/abort"); const NavigationTransitionInterceptOptionsCommit = Symbol.for("@virtualstate/navigation/transition/intercept/options/commit"); const NavigationTransitionCommitIsManual = Symbol.for("@virtualstate/navigation/transition/commit/isManual"); 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$1(); 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], }); }; } function getWindowBaseURL() { try { if (typeof window !== "undefined" && window.location) { return window.location.href; } } catch { } } function getBaseURL(url) { const baseURL = getWindowBaseURL() ?? "https://html.spec.whatwg.org/"; return new URL( // Deno wants this to be always a string (url ?? "").toString(), baseURL); } function defer() { let resolve = undefined, reject = undefined, settled = false, status = "pending"; const promise = new Promise((resolveFn, rejectFn) => { resolve = (value) => { status = "fulfilled"; settled = true; resolveFn(value); }; reject = (reason) => { status = "rejected"; settled = true; rejectFn(reason); }; }); ok(resolve); ok(reject); return { get settled() { return settled; }, get status() { return status; }, resolve, reject, promise, }; } class NavigationCurrentEntryChangeEvent { type; from; navigationType; constructor(type, init) { this.type = type; if (!init) { throw new TypeError("init required"); } if (!init.from) { throw new TypeError("from required"); } this.from = init.from; this.navigationType = init.navigationType ?? undefined; } } class NavigateEvent { type; canIntercept; /** * @deprecated */ canTransition; destination; downloadRequest; formData; hashChange; info; signal; userInitiated; navigationType; constructor(type, init) { this.type = type; if (!init) { throw new TypeError("init required"); } if (!init.destination) { throw new TypeError("destination required"); } if (!init.signal) { throw new TypeError("signal required"); } this.canIntercept = init.canIntercept ?? false; this.canTransition = init.canIntercept ?? false; this.destination = init.destination; this.downloadRequest = init.downloadRequest; this.formData = init.formData; this.hashChange = init.hashChange ?? false; this.info = init.info; this.signal = init.signal; this.userInitiated = init.userInitiated ?? false; this.navigationType = init.navigationType ?? "push"; } commit() { throw new Error("Not implemented"); } intercept(options) { throw new Error("Not implemented"); } preventDefault() { throw new Error("Not implemented"); } reportError(reason) { throw new Error("Not implemented"); } scroll() { throw new Error("Not implemented"); } /** * @deprecated */ transitionWhile(options) { return this.intercept(options); } } const NavigationFormData = Symbol.for("@virtualstate/navigation/formData"); const NavigationDownloadRequest = Symbol.for("@virtualstate/navigation/downloadRequest"); const NavigationCanIntercept = Symbol.for("@virtualstate/navigation/canIntercept"); const NavigationUserInitiated = Symbol.for("@virtualstate/navigation/userInitiated"); const NavigationOriginalEvent = Symbol.for("@virtualstate/navigation/originalEvent"); function noop() { return undefined; } function getEntryIndex(entries, entry) { const knownIndex = entry.index; if (knownIndex !== -1) { return knownIndex; } // TODO find an entry if it has changed id return -1; } function createNavigationTransition(context) { const { commit: transitionCommit, currentIndex, options, known: initialKnown, currentEntry, transition, transition: { [NavigationTransitionInitialEntries]: previousEntries, [NavigationTransitionEntry]: entry, [NavigationIntercept]: intercept, }, reportError } = context; let { transition: { [NavigationTransitionNavigationType]: navigationType }, } = context; let resolvedEntries = [...previousEntries]; const known = new Set(initialKnown); let destinationIndex = -1, nextIndex = currentIndex; if (navigationType === Rollback) { const { index } = options ?? { index: undefined }; if (typeof index !== "number") throw new InvalidStateError("Expected index to be provided for rollback"); destinationIndex = index; nextIndex = index; } else if (navigationType === "traverse" || navigationType === "reload") { destinationIndex = getEntryIndex(previousEntries, entry); nextIndex = destinationIndex; } else if (navigationType === "replace") { if (currentIndex === -1) { navigationType = "push"; destinationIndex = currentIndex + 1; nextIndex = destinationIndex; } else { destinationIndex = currentIndex; nextIndex = currentIndex; } } else { destinationIndex = currentIndex + 1; nextIndex = destinationIndex; } if (typeof destinationIndex !== "number" || destinationIndex === -1) { throw new InvalidStateError("Could not resolve next index"); } // console.log({ navigationType, entry, options }); if (!entry.url) { console.trace({ navigationType, entry, options }); throw new InvalidStateError("Expected entry url"); } const destination = { url: entry.url, key: entry.key, index: destinationIndex, sameDocument: entry.sameDocument, getState() { return entry.getState(); }, }; let hashChange = false; const currentUrlInstance = getBaseURL(currentEntry?.url); const destinationUrlInstance = new URL(destination.url); const currentHash = currentUrlInstance.hash; const destinationHash = destinationUrlInstance.hash; // console.log({ currentHash, destinationHash }); if (currentHash !== destinationHash) { const currentUrlInstanceWithoutHash = new URL(currentUrlInstance.toString()); currentUrlInstanceWithoutHash.hash = ""; const destinationUrlInstanceWithoutHash = new URL(destinationUrlInstance.toString()); destinationUrlInstanceWithoutHash.hash = ""; hashChange = currentUrlInstanceWithoutHash.toString() === destinationUrlInstanceWithoutHash.toString(); // console.log({ hashChange, currentUrlInstanceWithoutHash: currentUrlInstanceWithoutHash.toString(), before: destinationUrlInstanceWithoutHash.toString() }) } let contextToCommit; const { resolve: resolveCommit, promise: waitForCommit } = defer(); function commit() { ok(contextToCommit, "Expected contextToCommit"); resolveCommit(transitionCommit(contextToCommit)); } const abortController = new AbortController$1(); const event = new NavigateEvent("navigate", { signal: abortController.signal, info: undefined, ...options, canIntercept: options?.[NavigationCanIntercept] ?? true, formData: options?.[NavigationFormData] ?? undefined, downloadRequest: options?.[NavigationDownloadRequest] ?? undefined, hashChange, navigationType: options?.navigationType ?? (typeof navigationType === "string" ? navigationType : "replace"), userInitiated: options?.[NavigationUserInitiated] ?? false, destination, }); const originalEvent = options?.[NavigationOriginalEvent]; const preventDefault = transition[NavigationTransitionAbort].bind(transition); if (originalEvent) { const definedEvent = originalEvent; event.intercept = function originalEventIntercept(options) { definedEvent.preventDefault(); return intercept(options); }; event.preventDefault = function originalEventPreventDefault() { definedEvent.preventDefault(); return preventDefault(); }; } else { event.intercept = intercept; event.preventDefault = preventDefault; } // Enforce that transitionWhile and intercept match event.transitionWhile = event.intercept; event.commit = commit; if (reportError) { event.reportError = reportError; } event.scroll = noop; if (originalEvent) { event.originalEvent = originalEvent; } const currentEntryChange = new NavigationCurrentEntryChangeEvent("currententrychange", { from: currentEntry, navigationType: event.navigationType, }); let updatedEntries = [], removedEntries = [], addedEntries = []; const previousKeys = previousEntries.map(entry => entry.key); if (navigationType === Rollback) { const { entries } = options ?? { entries: undefined }; if (!entries) throw new InvalidStateError("Expected entries to be provided for rollback"); resolvedEntries = entries; resolvedEntries.forEach((entry) => known.add(entry)); const keys = resolvedEntries.map(entry => entry.key); removedEntries = previousEntries.filter(entry => !keys.includes(entry.key)); addedEntries = resolvedEntries.filter(entry => !previousKeys.includes(entry.key)); } // Default next index is current entries length, aka // console.log({ navigationType, givenNavigationType, index: this.#currentIndex, resolvedNextIndex }); else if (navigationType === "replace" || navigationType === "traverse" || navigationType === "reload") { resolvedEntries[destination.index] = entry; if (navigationType !== "traverse") { updatedEntries.push(entry); } if (navigationType === "replace") { resolvedEntries = resolvedEntries.slice(0, destination.index + 1); } const keys = resolvedEntries.map(entry => entry.key); removedEntries = previousEntries.filter(entry => !keys.includes(entry.key)); if (previousKeys.includes(entry.id)) { addedEntries = [entry]; } } else if (navigationType === "push") { let removed = false; // Trim forward, we have reset our stack if (resolvedEntries[destination.index]) { // const before = [...this.#entries]; resolvedEntries = resolvedEntries.slice(0, destination.index); // console.log({ before, after: [...this.#entries]}) removed = true; } resolvedEntries.push(entry); addedEntries = [entry]; if (removed) { const keys = resolvedEntries.map(entry => entry.key); removedEntries = previousEntries.filter(entry => !keys.includes(entry.key)); } } known.add(entry); let entriesChange = undefined; if (updatedEntries.length || addedEntries.length || removedEntries.length) { entriesChange = { updatedEntries, addedEntries, removedEntries }; } contextToCommit = { entries: resolvedEntries, index: nextIndex, known, entriesChange }; return { entries: resolvedEntries, known, index: nextIndex, currentEntryChange, destination, navigate: event, navigationType, waitForCommit, commit, abortController }; } function createEvent(event) { if (typeof CustomEvent !== "undefined" && typeof event.type === "string") { if (event instanceof CustomEvent) { return event; } const { type, detail, ...rest } = event; const customEvent = new CustomEvent(type, { detail: detail ?? rest, }); Object.assign(customEvent, rest); assertEvent(customEvent, event.type); return customEvent; } return event; } const NavigationSetOptions = Symbol.for("@virtualstate/navigation/setOptions"); const NavigationSetEntries = Symbol.for("@virtualstate/navigation/setEntries"); const NavigationSetCurrentIndex = Symbol.for("@virtualstate/navigation/setCurrentIndex"); const NavigationSetCurrentKey = Symbol.for("@virtualstate/navigation/setCurrentKey"); const NavigationGetState = Symbol.for("@virtualstate/navigation/getState"); const NavigationSetState = Symbol.for("@virtualstate/navigation/setState"); const NavigationDisposeState = Symbol.for("@virtualstate/navigation/disposeState"); function isNavigationNavigationType(value) { return (value === "reload" || value === "push" || value === "replace" || value === "traverse"); } 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.#createNavigationHist