/** * @license Angular v18.2.10 * (c) 2010-2024 Google LLC. https://angular.io/ * License: MIT */ import { ɵPlatformNavigation, DOCUMENT, PlatformLocation, ɵnormalizeQueryParams, LocationStrategy, Location } from '@angular/common'; import * as i0 from '@angular/core'; import { Injectable, InjectionToken, Inject, Optional, inject, EventEmitter } from '@angular/core'; import { Subject } from 'rxjs'; /** * This class wraps the platform Navigation API which allows server-specific and test * implementations. */ class PlatformNavigation { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: PlatformNavigation, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: PlatformNavigation, providedIn: 'platform', useFactory: () => window.navigation }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: PlatformNavigation, decorators: [{ type: Injectable, args: [{ providedIn: 'platform', useFactory: () => window.navigation }] }] }); /** * Fake implementation of user agent history and navigation behavior. This is a * high-fidelity implementation of browser behavior that attempts to emulate * things like traversal delay. */ class FakeNavigation { /** Equivalent to `navigation.currentEntry`. */ get currentEntry() { return this.entriesArr[this.currentEntryIndex]; } get canGoBack() { return this.currentEntryIndex > 0; } get canGoForward() { return this.currentEntryIndex < this.entriesArr.length - 1; } constructor(window, startURL) { this.window = window; /** * The fake implementation of an entries array. Only same-document entries * allowed. */ this.entriesArr = []; /** * The current active entry index into `entriesArr`. */ this.currentEntryIndex = 0; /** * The current navigate event. */ this.navigateEvent = undefined; /** * A Map of pending traversals, so that traversals to the same entry can be * re-used. */ this.traversalQueue = new Map(); /** * A Promise that resolves when the previous traversals have finished. Used to * simulate the cross-process communication necessary for traversals. */ this.nextTraversal = Promise.resolve(); /** * A prospective current active entry index, which includes unresolved * traversals. Used by `go` to determine where navigations are intended to go. */ this.prospectiveEntryIndex = 0; /** * A test-only option to make traversals synchronous, rather than emulate * cross-process communication. */ this.synchronousTraversals = false; /** Whether to allow a call to setInitialEntryForTesting. */ this.canSetInitialEntry = true; /** `EventTarget` to dispatch events. */ this.eventTarget = this.window.document.createElement('div'); /** The next unique id for created entries. Replace recreates this id. */ this.nextId = 0; /** The next unique key for created entries. Replace inherits this id. */ this.nextKey = 0; /** Whether this fake is disposed. */ this.disposed = false; // First entry. this.setInitialEntryForTesting(startURL); } /** * Sets the initial entry. */ setInitialEntryForTesting(url, options = { historyState: null }) { if (!this.canSetInitialEntry) { throw new Error('setInitialEntryForTesting can only be called before any ' + 'navigation has occurred'); } const currentInitialEntry = this.entriesArr[0]; this.entriesArr[0] = new FakeNavigationHistoryEntry(new URL(url).toString(), { index: 0, key: currentInitialEntry?.key ?? String(this.nextKey++), id: currentInitialEntry?.id ?? String(this.nextId++), sameDocument: true, historyState: options?.historyState, state: options.state, }); } /** Returns whether the initial entry is still eligible to be set. */ canSetInitialEntryForTesting() { return this.canSetInitialEntry; } /** * Sets whether to emulate traversals as synchronous rather than * asynchronous. */ setSynchronousTraversalsForTesting(synchronousTraversals) { this.synchronousTraversals = synchronousTraversals; } /** Equivalent to `navigation.entries()`. */ entries() { return this.entriesArr.slice(); } /** Equivalent to `navigation.navigate()`. */ navigate(url, options) { const fromUrl = new URL(this.currentEntry.url); const toUrl = new URL(url, this.currentEntry.url); let navigationType; if (!options?.history || options.history === 'auto') { // Auto defaults to push, but if the URLs are the same, is a replace. if (fromUrl.toString() === toUrl.toString()) { navigationType = 'replace'; } else { navigationType = 'push'; } } else { navigationType = options.history; } const hashChange = isHashChange(fromUrl, toUrl); const destination = new FakeNavigationDestination({ url: toUrl.toString(), state: options?.state, sameDocument: hashChange, historyState: null, }); const result = new InternalNavigationResult(); this.userAgentNavigate(destination, result, { navigationType, cancelable: true, canIntercept: true, // Always false for navigate(). userInitiated: false, hashChange, info: options?.info, }); return { committed: result.committed, finished: result.finished, }; } /** Equivalent to `history.pushState()`. */ pushState(data, title, url) { this.pushOrReplaceState('push', data, title, url); } /** Equivalent to `history.replaceState()`. */ replaceState(data, title, url) { this.pushOrReplaceState('replace', data, title, url); } pushOrReplaceState(navigationType, data, _title, url) { const fromUrl = new URL(this.currentEntry.url); const toUrl = url ? new URL(url, this.currentEntry.url) : fromUrl; const hashChange = isHashChange(fromUrl, toUrl); const destination = new FakeNavigationDestination({ url: toUrl.toString(), sameDocument: true, historyState: data, }); const result = new InternalNavigationResult(); this.userAgentNavigate(destination, result, { navigationType, cancelable: true, canIntercept: true, // Always false for pushState() or replaceState(). userInitiated: false, hashChange, skipPopState: true, }); } /** Equivalent to `navigation.traverseTo()`. */ traverseTo(key, options) { const fromUrl = new URL(this.currentEntry.url); const entry = this.findEntry(key); if (!entry) { const domException = new DOMException('Invalid key', 'InvalidStateError'); const committed = Promise.reject(domException); const finished = Promise.reject(domException); committed.catch(() => { }); finished.catch(() => { }); return { committed, finished, }; } if (entry === this.currentEntry) { return { committed: Promise.resolve(this.currentEntry), finished: Promise.resolve(this.currentEntry), }; } if (this.traversalQueue.has(entry.key)) { const existingResult = this.traversalQueue.get(entry.key); return { committed: existingResult.committed, finished: existingResult.finished, }; } const hashChange = isHashChange(fromUrl, new URL(entry.url, this.currentEntry.url)); const destination = new FakeNavigationDestination({ url: entry.url, state: entry.getState(), historyState: entry.getHistoryState(), key: entry.key, id: entry.id, index: entry.index, sameDocument: entry.sameDocument, }); this.prospectiveEntryIndex = entry.index; const result = new InternalNavigationResult(); this.traversalQueue.set(entry.key, result); this.runTraversal(() => { this.traversalQueue.delete(entry.key); this.userAgentNavigate(destination, result, { navigationType: 'traverse', cancelable: true, canIntercept: true, // Always false for traverseTo(). userInitiated: false, hashChange, info: options?.info, }); }); return { committed: result.committed, finished: result.finished, }; } /** Equivalent to `navigation.back()`. */ back(options) { if (this.currentEntryIndex === 0) { const domException = new DOMException('Cannot go back', 'InvalidStateError'); const committed = Promise.reject(domException); const finished = Promise.reject(domException); committed.catch(() => { }); finished.catch(() => { }); return { committed, finished, }; } const entry = this.entriesArr[this.currentEntryIndex - 1]; return this.traverseTo(entry.key, options); } /** Equivalent to `navigation.forward()`. */ forward(options) { if (this.currentEntryIndex === this.entriesArr.length - 1) { const domException = new DOMException('Cannot go forward', 'InvalidStateError'); const committed = Promise.reject(domException); const finished = Promise.reject(domException); committed.catch(() => { }); finished.catch(() => { }); return { committed, finished, }; } const entry = this.entriesArr[this.currentEntryIndex + 1]; return this.traverseTo(entry.key, options); } /** * Equivalent to `history.go()`. * Note that this method does not actually work precisely to how Chrome * does, instead choosing a simpler model with less unexpected behavior. * Chrome has a few edge case optimizations, for instance with repeated * `back(); forward()` chains it collapses certain traversals. */ go(direction) { const targetIndex = this.prospectiveEntryIndex + direction; if (targetIndex >= this.entriesArr.length || targetIndex < 0) { return; } this.prospectiveEntryIndex = targetIndex; this.runTraversal(() => { // Check again that destination is in the entries array. if (targetIndex >= this.entriesArr.length || targetIndex < 0) { return; } const fromUrl = new URL(this.currentEntry.url); const entry = this.entriesArr[targetIndex]; const hashChange = isHashChange(fromUrl, new URL(entry.url, this.currentEntry.url)); const destination = new FakeNavigationDestination({ url: entry.url, state: entry.getState(), historyState: entry.getHistoryState(), key: entry.key, id: entry.id, index: entry.index, sameDocument: entry.sameDocument, }); const result = new InternalNavigationResult(); this.userAgentNavigate(destination, result, { navigationType: 'traverse', cancelable: true, canIntercept: true, // Always false for go(). userInitiated: false, hashChange, }); }); } /** Runs a traversal synchronously or asynchronously */ runTraversal(traversal) { if (this.synchronousTraversals) { traversal(); return; } // Each traversal occupies a single timeout resolution. // This means that Promises added to commit and finish should resolve // before the next traversal. this.nextTraversal = this.nextTraversal.then(() => { return new Promise((resolve) => { setTimeout(() => { resolve(); traversal(); }); }); }); } /** Equivalent to `navigation.addEventListener()`. */ addEventListener(type, callback, options) { this.eventTarget.addEventListener(type, callback, options); } /** Equivalent to `navigation.removeEventListener()`. */ removeEventListener(type, callback, options) { this.eventTarget.removeEventListener(type, callback, options); } /** Equivalent to `navigation.dispatchEvent()` */ dispatchEvent(event) { return this.eventTarget.dispatchEvent(event); } /** Cleans up resources. */ dispose() { // Recreate eventTarget to release current listeners. // `document.createElement` because NodeJS `EventTarget` is incompatible with Domino's `Event`. this.eventTarget = this.window.document.createElement('div'); this.disposed = true; } /** Returns whether this fake is disposed. */ isDisposed() { return this.disposed; } /** Implementation for all navigations and traversals. */ userAgentNavigate(destination, result, options) { // The first navigation should disallow any future calls to set the initial // entry. this.canSetInitialEntry = false; if (this.navigateEvent) { this.navigateEvent.cancel(new DOMException('Navigation was aborted', 'AbortError')); this.navigateEvent = undefined; } const navigateEvent = createFakeNavigateEvent({ navigationType: options.navigationType, cancelable: options.cancelable, canIntercept: options.canIntercept, userInitiated: options.userInitiated, hashChange: options.hashChange, signal: result.signal, destination, info: options.info, sameDocument: destination.sameDocument, skipPopState: options.skipPopState, result, userAgentCommit: () => { this.userAgentCommit(); }, }); this.navigateEvent = navigateEvent; this.eventTarget.dispatchEvent(navigateEvent); navigateEvent.dispatchedNavigateEvent(); if (navigateEvent.commitOption === 'immediate') { navigateEvent.commit(/* internal= */ true); } } /** Implementation to commit a navigation. */ userAgentCommit() { if (!this.navigateEvent) { return; } const from = this.currentEntry; if (!this.navigateEvent.sameDocument) { const error = new Error('Cannot navigate to a non-same-document URL.'); this.navigateEvent.cancel(error); throw error; } if (this.navigateEvent.navigationType === 'push' || this.navigateEvent.navigationType === 'replace') { this.userAgentPushOrReplace(this.navigateEvent.destination, { navigationType: this.navigateEvent.navigationType, }); } else if (this.navigateEvent.navigationType === 'traverse') { this.userAgentTraverse(this.navigateEvent.destination); } this.navigateEvent.userAgentNavigated(this.currentEntry); const currentEntryChangeEvent = createFakeNavigationCurrentEntryChangeEvent({ from, navigationType: this.navigateEvent.navigationType, }); this.eventTarget.dispatchEvent(currentEntryChangeEvent); if (!this.navigateEvent.skipPopState) { const popStateEvent = createPopStateEvent({ state: this.navigateEvent.destination.getHistoryState(), }); this.window.dispatchEvent(popStateEvent); } } /** Implementation for a push or replace navigation. */ userAgentPushOrReplace(destination, { navigationType }) { if (navigationType === 'push') { this.currentEntryIndex++; this.prospectiveEntryIndex = this.currentEntryIndex; } const index = this.currentEntryIndex; const key = navigationType === 'push' ? String(this.nextKey++) : this.currentEntry.key; const entry = new FakeNavigationHistoryEntry(destination.url, { id: String(this.nextId++), key, index, sameDocument: true, state: destination.getState(), historyState: destination.getHistoryState(), }); if (navigationType === 'push') { this.entriesArr.splice(index, Infinity, entry); } else { this.entriesArr[index] = entry; } } /** Implementation for a traverse navigation. */ userAgentTraverse(destination) { this.currentEntryIndex = destination.index; } /** Utility method for finding entries with the given `key`. */ findEntry(key) { for (const entry of this.entriesArr) { if (entry.key === key) return entry; } return undefined; } set onnavigate(_handler) { throw new Error('unimplemented'); } get onnavigate() { throw new Error('unimplemented'); } set oncurrententrychange(_handler) { throw new Error('unimplemented'); } get oncurrententrychange() { throw new Error('unimplemented'); } set onnavigatesuccess(_handler) { throw new Error('unimplemented'); } get onnavigatesuccess() { throw new Error('unimplemented'); } set onnavigateerror(_handler) { throw new Error('unimplemented'); } get onnavigateerror() { throw new Error('unimplemented'); } get transition() { throw new Error('unimplemented'); } updateCurrentEntry(_options) { throw new Error('unimplemented'); } reload(_options) { throw new Error('unimplemented'); } } /** * Fake equivalent of `NavigationHistoryEntry`. */ class FakeNavigationHistoryEntry { constructor(url, { id, key, index, sameDocument, state, historyState, }) { this.url = url; // tslint:disable-next-line:no-any this.ondispose = null; this.id = id; this.key = key; this.index = index; this.sameDocument = sameDocument; this.state = state; this.historyState = historyState; } getState() { // Budget copy. return this.state ? JSON.parse(JSON.stringify(this.state)) : this.state; } getHistoryState() { // Budget copy. return this.historyState ? JSON.parse(JSON.stringify(this.historyState)) : this.historyState; } addEventListener(type, callback, options) { throw new Error('unimplemented'); } removeEventListener(type, callback, options) { throw new Error('unimplemented'); } dispatchEvent(event) { throw new Error('unimplemented'); } } /** * Create a fake equivalent of `NavigateEvent`. This is not a class because ES5 * transpiled JavaScript cannot extend native Event. */ function createFakeNavigateEvent({ cancelable, canIntercept, userInitiated, hashChange, navigationType, signal, destination, info, sameDocument, skipPopState, result, userAgentCommit, }) { const event = new Event('navigate', { bubbles: false, cancelable }); event.canIntercept = canIntercept; event.userInitiated = userInitiated; event.hashChange = hashChange; event.navigationType = navigationType; event.signal = signal; event.destination = destination; event.info = info; event.downloadRequest = null; event.formData = null; event.sameDocument = sameDocument; event.skipPopState = skipPopState; event.commitOption = 'immediate'; let handlerFinished = undefined; let interceptCalled = false; let dispatchedNavigateEvent = false; let commitCalled = false; event.intercept = function (options) { interceptCalled = true; event.sameDocument = true; const handler = options?.handler; if (handler) { handlerFinished = handler(); } if (options?.commit) { event.commitOption = options.commit; } if (options?.focusReset !== undefined || options?.scroll !== undefined) { throw new Error('unimplemented'); } }; event.scroll = function () { throw new Error('unimplemented'); }; event.commit = function (internal = false) { if (!internal && !interceptCalled) { throw new DOMException(`Failed to execute 'commit' on 'NavigateEvent': intercept() must be ` + `called before commit().`, 'InvalidStateError'); } if (!dispatchedNavigateEvent) { throw new DOMException(`Failed to execute 'commit' on 'NavigateEvent': commit() may not be ` + `called during event dispatch.`, 'InvalidStateError'); } if (commitCalled) { throw new DOMException(`Failed to execute 'commit' on 'NavigateEvent': commit() already ` + `called.`, 'InvalidStateError'); } commitCalled = true; userAgentCommit(); }; // Internal only. event.cancel = function (reason) { result.committedReject(reason); result.finishedReject(reason); }; // Internal only. event.dispatchedNavigateEvent = function () { dispatchedNavigateEvent = true; if (event.commitOption === 'after-transition') { // If handler finishes before commit, call commit. handlerFinished?.then(() => { if (!commitCalled) { event.commit(/* internal */ true); } }, () => { }); } Promise.all([result.committed, handlerFinished]).then(([entry]) => { result.finishedResolve(entry); }, (reason) => { result.finishedReject(reason); }); }; // Internal only. event.userAgentNavigated = function (entry) { result.committedResolve(entry); }; return event; } /** * Create a fake equivalent of `NavigationCurrentEntryChange`. This does not use * a class because ES5 transpiled JavaScript cannot extend native Event. */ function createFakeNavigationCurrentEntryChangeEvent({ from, navigationType, }) { const event = new Event('currententrychange', { bubbles: false, cancelable: false, }); event.from = from; event.navigationType = navigationType; return event; } /** * Create a fake equivalent of `PopStateEvent`. This does not use a class * because ES5 transpiled JavaScript cannot extend native Event. */ function createPopStateEvent({ state }) { const event = new Event('popstate', { bubbles: false, cancelable: false, }); event.state = state; return event; } /** * Fake equivalent of `NavigationDestination`. */ class FakeNavigationDestination { constructor({ url, sameDocument, historyState, state, key = null, id = null, index = -1, }) { this.url = url; this.sameDocument = sameDocument; this.state = state; this.historyState = historyState; this.key = key; this.id = id; this.index = index; } getState() { return this.state; } getHistoryState() { return this.historyState; } } /** Utility function to determine whether two UrlLike have the same hash. */ function isHashChange(from, to) { return (to.hash !== from.hash && to.hostname === from.hostname && to.pathname === from.pathname && to.search === from.search); } /** Internal utility class for representing the result of a navigation. */ class InternalNavigationResult { get signal() { return this.abortController.signal; } constructor() { this.abortController = new AbortController(); this.committed = new Promise((resolve, reject) => { this.committedResolve = resolve; this.committedReject = reject; }); this.finished = new Promise(async (resolve, reject) => { this.finishedResolve = resolve; this.finishedReject = (reason) => { reject(reason); this.abortController.abort(reason); }; }); // All rejections are handled. this.committed.catch(() => { }); this.finished.catch(() => { }); } } /** * Parser from https://tools.ietf.org/html/rfc3986#appendix-B * ^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))? * 12 3 4 5 6 7 8 9 * * Example: http://www.ics.uci.edu/pub/ietf/uri/#Related * * Results in: * * $1 = http: * $2 = http * $3 = //www.ics.uci.edu * $4 = www.ics.uci.edu * $5 = /pub/ietf/uri/ * $6 = * $7 = * $8 = #Related * $9 = Related */ const urlParse = /^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/; function parseUrl(urlStr, baseHref) { const verifyProtocol = /^((http[s]?|ftp):\/\/)/; let serverBase; // URL class requires full URL. If the URL string doesn't start with protocol, we need to add // an arbitrary base URL which can be removed afterward. if (!verifyProtocol.test(urlStr)) { serverBase = 'http://empty.com/'; } let parsedUrl; try { parsedUrl = new URL(urlStr, serverBase); } catch (e) { const result = urlParse.exec(serverBase || '' + urlStr); if (!result) { throw new Error(`Invalid URL: ${urlStr} with base: ${baseHref}`); } const hostSplit = result[4].split(':'); parsedUrl = { protocol: result[1], hostname: hostSplit[0], port: hostSplit[1] || '', pathname: result[5], search: result[6], hash: result[8], }; } if (parsedUrl.pathname && parsedUrl.pathname.indexOf(baseHref) === 0) { parsedUrl.pathname = parsedUrl.pathname.substring(baseHref.length); } return { hostname: (!serverBase && parsedUrl.hostname) || '', protocol: (!serverBase && parsedUrl.protocol) || '', port: (!serverBase && parsedUrl.port) || '', pathname: parsedUrl.pathname || '/', search: parsedUrl.search || '', hash: parsedUrl.hash || '', }; } /** * Provider for mock platform location config * * @publicApi */ const MOCK_PLATFORM_LOCATION_CONFIG = new InjectionToken('MOCK_PLATFORM_LOCATION_CONFIG'); /** * Mock implementation of URL state. * * @publicApi */ class MockPlatformLocation { constructor(config) { this.baseHref = ''; this.hashUpdate = new Subject(); this.popStateSubject = new Subject(); this.urlChangeIndex = 0; this.urlChanges = [{ hostname: '', protocol: '', port: '', pathname: '/', search: '', hash: '', state: null }]; if (config) { this.baseHref = config.appBaseHref || ''; const parsedChanges = this.parseChanges(null, config.startUrl || 'http://_empty_/', this.baseHref); this.urlChanges[0] = { ...parsedChanges }; } } get hostname() { return this.urlChanges[this.urlChangeIndex].hostname; } get protocol() { return this.urlChanges[this.urlChangeIndex].protocol; } get port() { return this.urlChanges[this.urlChangeIndex].port; } get pathname() { return this.urlChanges[this.urlChangeIndex].pathname; } get search() { return this.urlChanges[this.urlChangeIndex].search; } get hash() { return this.urlChanges[this.urlChangeIndex].hash; } get state() { return this.urlChanges[this.urlChangeIndex].state; } getBaseHrefFromDOM() { return this.baseHref; } onPopState(fn) { const subscription = this.popStateSubject.subscribe(fn); return () => subscription.unsubscribe(); } onHashChange(fn) { const subscription = this.hashUpdate.subscribe(fn); return () => subscription.unsubscribe(); } get href() { let url = `${this.protocol}//${this.hostname}${this.port ? ':' + this.port : ''}`; url += `${this.pathname === '/' ? '' : this.pathname}${this.search}${this.hash}`; return url; } get url() { return `${this.pathname}${this.search}${this.hash}`; } parseChanges(state, url, baseHref = '') { // When the `history.state` value is stored, it is always copied. state = JSON.parse(JSON.stringify(state)); return { ...parseUrl(url, baseHref), state }; } replaceState(state, title, newUrl) { const { pathname, search, state: parsedState, hash } = this.parseChanges(state, newUrl); this.urlChanges[this.urlChangeIndex] = { ...this.urlChanges[this.urlChangeIndex], pathname, search, hash, state: parsedState, }; } pushState(state, title, newUrl) { const { pathname, search, state: parsedState, hash } = this.parseChanges(state, newUrl); if (this.urlChangeIndex > 0) { this.urlChanges.splice(this.urlChangeIndex + 1); } this.urlChanges.push({ ...this.urlChanges[this.urlChangeIndex], pathname, search, hash, state: parsedState, }); this.urlChangeIndex = this.urlChanges.length - 1; } forward() { const oldUrl = this.url; const oldHash = this.hash; if (this.urlChangeIndex < this.urlChanges.length) { this.urlChangeIndex++; } this.emitEvents(oldHash, oldUrl); } back() { const oldUrl = this.url; const oldHash = this.hash; if (this.urlChangeIndex > 0) { this.urlChangeIndex--; } this.emitEvents(oldHash, oldUrl); } historyGo(relativePosition = 0) { const oldUrl = this.url; const oldHash = this.hash; const nextPageIndex = this.urlChangeIndex + relativePosition; if (nextPageIndex >= 0 && nextPageIndex < this.urlChanges.length) { this.urlChangeIndex = nextPageIndex; } this.emitEvents(oldHash, oldUrl); } getState() { return this.state; } /** * Browsers are inconsistent in when they fire events and perform the state updates * The most easiest thing to do in our mock is synchronous and that happens to match * Firefox and Chrome, at least somewhat closely * * https://github.com/WICG/navigation-api#watching-for-navigations * https://docs.google.com/document/d/1Pdve-DJ1JCGilj9Yqf5HxRJyBKSel5owgOvUJqTauwU/edit#heading=h.3ye4v71wsz94 * popstate is always sent before hashchange: * https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event#when_popstate_is_sent */ emitEvents(oldHash, oldUrl) { this.popStateSubject.next({ type: 'popstate', state: this.getState(), oldUrl, newUrl: this.url, }); if (oldHash !== this.hash) { this.hashUpdate.next({ type: 'hashchange', state: null, oldUrl, newUrl: this.url, }); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: MockPlatformLocation, deps: [{ token: MOCK_PLATFORM_LOCATION_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: MockPlatformLocation }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: MockPlatformLocation, decorators: [{ type: Injectable }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Inject, args: [MOCK_PLATFORM_LOCATION_CONFIG] }, { type: Optional }] }] }); /** * Mock implementation of URL state. */ class FakeNavigationPlatformLocation { constructor() { this._platformNavigation = inject(ɵPlatformNavigation); this.window = inject(DOCUMENT).defaultView; this.config = inject(MOCK_PLATFORM_LOCATION_CONFIG, { optional: true }); if (!(this._platformNavigation instanceof FakeNavigation)) { throw new Error('FakePlatformNavigation cannot be used without FakeNavigation. Use ' + '`provideFakeNavigation` to have all these services provided together.'); } } getBaseHrefFromDOM() { return this.config?.appBaseHref ?? ''; } onPopState(fn) { this.window.addEventListener('popstate', fn); return () => this.window.removeEventListener('popstate', fn); } onHashChange(fn) { this.window.addEventListener('hashchange', fn); return () => this.window.removeEventListener('hashchange', fn); } get href() { return this._platformNavigation.currentEntry.url; } get protocol() { return new URL(this._platformNavigation.currentEntry.url).protocol; } get hostname() { return new URL(this._platformNavigation.currentEntry.url).hostname; } get port() { return new URL(this._platformNavigation.currentEntry.url).port; } get pathname() { return new URL(this._platformNavigation.currentEntry.url).pathname; } get search() { return new URL(this._platformNavigation.currentEntry.url).search; } get hash() { return new URL(this._platformNavigation.currentEntry.url).hash; } pushState(state, title, url) { this._platformNavigation.pushState(state, title, url); } replaceState(state, title, url) { this._platformNavigation.replaceState(state, title, url); } forward() { this._platformNavigation.forward(); } back() { this._platformNavigation.back(); } historyGo(relativePosition = 0) { this._platformNavigation.go(relativePosition); } getState() { return this._platformNavigation.currentEntry.getHistoryState(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: FakeNavigationPlatformLocation, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: FakeNavigationPlatformLocation }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: FakeNavigationPlatformLocation, decorators: [{ type: Injectable }], ctorParameters: () => [] }); /** * Return a provider for the `FakeNavigation` in place of the real Navigation API. */ function provideFakePlatformNavigation() { return [ { provide: PlatformNavigation, useFactory: () => { const config = inject(MOCK_PLATFORM_LOCATION_CONFIG, { optional: true }); return new FakeNavigation(inject(DOCUMENT).defaultView, config?.startUrl ?? 'http://_empty_/'); }, }, { provide: PlatformLocation, useClass: FakeNavigationPlatformLocation }, ]; } /** * A spy for {@link Location} that allows tests to fire simulated location events. * * @publicApi */ class SpyLocation { constructor() { this.urlChanges = []; this._history = [new LocationState('', '', null)]; this._historyIndex = 0; /** @internal */ this._subject = new EventEmitter(); /** @internal */ this._basePath = ''; /** @internal */ this._locationStrategy = null; /** @internal */ this._urlChangeListeners = []; /** @internal */ this._urlChangeSubscription = null; } /** @nodoc */ ngOnDestroy() { this._urlChangeSubscription?.unsubscribe(); this._urlChangeListeners = []; } setInitialPath(url) { this._history[this._historyIndex].path = url; } setBaseHref(url) { this._basePath = url; } path() { return this._history[this._historyIndex].path; } getState() { return this._history[this._historyIndex].state; } isCurrentPathEqualTo(path, query = '') { const givenPath = path.endsWith('/') ? path.substring(0, path.length - 1) : path; const currPath = this.path().endsWith('/') ? this.path().substring(0, this.path().length - 1) : this.path(); return currPath == givenPath + (query.length > 0 ? '?' + query : ''); } simulateUrlPop(pathname) { this._subject.emit({ 'url': pathname, 'pop': true, 'type': 'popstate' }); } simulateHashChange(pathname) { const path = this.prepareExternalUrl(pathname); this.pushHistory(path, '', null); this.urlChanges.push('hash: ' + pathname); // the browser will automatically fire popstate event before each `hashchange` event, so we need // to simulate it. this._subject.emit({ 'url': pathname, 'pop': true, 'type': 'popstate' }); this._subject.emit({ 'url': pathname, 'pop': true, 'type': 'hashchange' }); } prepareExternalUrl(url) { if (url.length > 0 && !url.startsWith('/')) { url = '/' + url; } return this._basePath + url; } go(path, query = '', state = null) { path = this.prepareExternalUrl(path); this.pushHistory(path, query, state); const locationState = this._history[this._historyIndex - 1]; if (locationState.path == path && locationState.query == query) { return; } const url = path + (query.length > 0 ? '?' + query : ''); this.urlChanges.push(url); this._notifyUrlChangeListeners(path + ɵnormalizeQueryParams(query), state); } replaceState(path, query = '', state = null) { path = this.prepareExternalUrl(path); const history = this._history[this._historyIndex]; history.state = state; if (history.path == path && history.query == query) { return; } history.path = path; history.query = query; const url = path + (query.length > 0 ? '?' + query : ''); this.urlChanges.push('replace: ' + url); this._notifyUrlChangeListeners(path + ɵnormalizeQueryParams(query), state); } forward() { if (this._historyIndex < this._history.length - 1) { this._historyIndex++; this._subject.emit({ 'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate', }); } } back() { if (this._historyIndex > 0) { this._historyIndex--; this._subject.emit({ 'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate', }); } } historyGo(relativePosition = 0) { const nextPageIndex = this._historyIndex + relativePosition; if (nextPageIndex >= 0 && nextPageIndex < this._history.length) { this._historyIndex = nextPageIndex; this._subject.emit({ 'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate', }); } } onUrlChange(fn) { this._urlChangeListeners.push(fn); this._urlChangeSubscription ??= this.subscribe((v) => { this._notifyUrlChangeListeners(v.url, v.state); }); return () => { const fnIndex = this._urlChangeListeners.indexOf(fn); this._urlChangeListeners.splice(fnIndex, 1); if (this._urlChangeListeners.length === 0) { this._urlChangeSubscription?.unsubscribe(); this._urlChangeSubscription = null; } }; } /** @internal */ _notifyUrlChangeListeners(url = '', state) { this._urlChangeListeners.forEach((fn) => fn(url, state)); } subscribe(onNext, onThrow, onReturn) { return this._subject.subscribe({ next: onNext, error: onThrow, complete: onReturn }); } normalize(url) { return null; } pushHistory(path, query, state) { if (this._historyIndex > 0) { this._history.splice(this._historyIndex + 1); } this._history.push(new LocationState(path, query, state)); this._historyIndex = this._history.length - 1; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: SpyLocation, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: SpyLocation }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: SpyLocation, decorators: [{ type: Injectable }] }); class LocationState { constructor(path, query, state) { this.path = path; this.query = query; this.state = state; } } /** * A mock implementation of {@link LocationStrategy} that allows tests to fire simulated * location events. * * @publicApi */ class MockLocationStrategy extends LocationStrategy { constructor() { super(); this.internalBaseHref = '/'; this.internalPath = '/'; this.internalTitle = ''; this.urlChanges = []; /** @internal */ this._subject = new EventEmitter(); this.stateChanges = []; } simulatePopState(url) { this.internalPath = url; this._subject.emit(new _MockPopStateEvent(this.path())); } path(includeHash = false) { return this.internalPath; } prepareExternalUrl(internal) { if (internal.startsWith('/') && this.internalBaseHref.endsWith('/')) { return this.internalBaseHref + internal.substring(1); } return this.internalBaseHref + internal; } pushState(ctx, title, path, query) { // Add state change to changes array this.stateChanges.push(ctx); this.internalTitle = title; const url = path + (query.length > 0 ? '?' + query : ''); this.internalPath = url; const externalUrl = this.prepareExternalUrl(url); this.urlChanges.push(externalUrl); } replaceState(ctx, title, path, query) { // Reset the last index of stateChanges to the ctx (state) object this.stateChanges[(this.stateChanges.length || 1) - 1] = ctx; this.internalTitle = title; const url = path + (query.length > 0 ? '?' + query : ''); this.internalPath = url; const externalUrl = this.prepareExternalUrl(url); this.urlChanges.push('replace: ' + externalUrl); } onPopState(fn) { this._subject.subscribe({ next: fn }); } getBaseHref() { return this.internalBaseHref; } back() { if (this.urlChanges.length > 0) { this.urlChanges.pop(); this.stateChanges.pop(); const nextUrl = this.urlChanges.length > 0 ? this.urlChanges[this.urlChanges.length - 1] : ''; this.simulatePopState(nextUrl); } } forward() { throw 'not implemented'; } getState() { return this.stateChanges[(this.stateChanges.length || 1) - 1]; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: MockLocationStrategy, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: MockLocationStrategy }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.10", ngImport: i0, type: MockLocationStrategy, decorators: [{ type: Injectable }], ctorParameters: () => [] }); class _MockPopStateEvent { constructor(newUrl) { this.newUrl = newUrl; this.pop = true; this.type = 'popstate'; } } /** * Returns mock providers for the `Location` and `LocationStrategy` classes. * The mocks are helpful in tests to fire simulated location events. * * @publicApi */ function provideLocationMocks() { return [ { provide: Location, useClass: SpyLocation }, { provide: LocationStrategy, useClass: MockLocationStrategy }, ]; } /** * @module * @description * Entry point for all public APIs of the common/testing package. */ /** * @module * @description * Entry point for all public APIs of this package. */ // This file only reexports content of the `src` folder. Keep it that way. // This file is not used to build this module. It is only used during editing /** * Generated bundle index. Do not edit. */ export { MOCK_PLATFORM_LOCATION_CONFIG, MockLocationStrategy, MockPlatformLocation, SpyLocation, provideLocationMocks, provideFakePlatformNavigation as ɵprovideFakePlatformNavigation }; //# sourceMappingURL=testing.mjs.map