diff --git a/packages/common/BUILD.bazel b/packages/common/BUILD.bazel index 225d3f7699f..e00ee26fdfc 100644 --- a/packages/common/BUILD.bazel +++ b/packages/common/BUILD.bazel @@ -30,6 +30,7 @@ ng_module( ), deps = [ "//packages/core", + "//packages/core/primitives/dom-navigation", "@npm//rxjs", ], ) diff --git a/packages/common/src/navigation/navigation_types.ts b/packages/common/src/navigation/navigation_types.ts deleted file mode 100644 index 37b101482aa..00000000000 --- a/packages/common/src/navigation/navigation_types.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -export interface NavigationEventMap { - navigate: NavigateEvent; - navigatesuccess: Event; - navigateerror: ErrorEvent; - currententrychange: NavigationCurrentEntryChangeEvent; -} - -export interface NavigationResult { - committed: Promise; - finished: Promise; -} - -export declare class Navigation extends EventTarget { - entries(): NavigationHistoryEntry[]; - readonly currentEntry: NavigationHistoryEntry | null; - updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void; - readonly transition: NavigationTransition | null; - - readonly canGoBack: boolean; - readonly canGoForward: boolean; - - navigate(url: string, options?: NavigationNavigateOptions): NavigationResult; - reload(options?: NavigationReloadOptions): NavigationResult; - - traverseTo(key: string, options?: NavigationOptions): NavigationResult; - back(options?: NavigationOptions): NavigationResult; - forward(options?: NavigationOptions): NavigationResult; - - onnavigate: ((this: Navigation, ev: NavigateEvent) => any) | null; - onnavigatesuccess: ((this: Navigation, ev: Event) => any) | null; - onnavigateerror: ((this: Navigation, ev: ErrorEvent) => any) | null; - oncurrententrychange: ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) | null; - - addEventListener( - type: K, - listener: (this: Navigation, ev: NavigationEventMap[K]) => any, - options?: boolean | AddEventListenerOptions, - ): void; - addEventListener( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions, - ): void; - removeEventListener( - type: K, - listener: (this: Navigation, ev: NavigationEventMap[K]) => any, - options?: boolean | EventListenerOptions, - ): void; - removeEventListener( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions, - ): void; -} - -export declare class NavigationTransition { - readonly navigationType: NavigationTypeString; - readonly from: NavigationHistoryEntry; - readonly finished: Promise; -} - -export interface NavigationHistoryEntryEventMap { - dispose: Event; -} - -export declare class NavigationHistoryEntry extends EventTarget { - readonly key: string; - readonly id: string; - readonly url: string | null; - readonly index: number; - readonly sameDocument: boolean; - - getState(): unknown; - - ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null; - - addEventListener( - type: K, - listener: (this: NavigationHistoryEntry, ev: NavigationHistoryEntryEventMap[K]) => any, - options?: boolean | AddEventListenerOptions, - ): void; - addEventListener( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions, - ): void; - removeEventListener( - type: K, - listener: (this: NavigationHistoryEntry, ev: NavigationHistoryEntryEventMap[K]) => any, - options?: boolean | EventListenerOptions, - ): void; - removeEventListener( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions, - ): void; -} - -type NavigationTypeString = 'reload' | 'push' | 'replace' | 'traverse'; - -export interface NavigationUpdateCurrentEntryOptions { - state: unknown; -} - -export interface NavigationOptions { - info?: unknown; -} - -export interface NavigationNavigateOptions extends NavigationOptions { - state?: unknown; - history?: 'auto' | 'push' | 'replace'; -} - -export interface NavigationReloadOptions extends NavigationOptions { - state?: unknown; -} - -export declare class NavigationCurrentEntryChangeEvent extends Event { - constructor(type: string, eventInit?: NavigationCurrentEntryChangeEventInit); - - readonly navigationType: NavigationTypeString | null; - readonly from: NavigationHistoryEntry; -} - -export interface NavigationCurrentEntryChangeEventInit extends EventInit { - navigationType?: NavigationTypeString | null; - from: NavigationHistoryEntry; -} - -export declare class NavigateEvent extends Event { - constructor(type: string, eventInit?: NavigateEventInit); - - readonly navigationType: NavigationTypeString; - readonly canIntercept: boolean; - readonly userInitiated: boolean; - readonly hashChange: boolean; - readonly destination: NavigationDestination; - readonly signal: AbortSignal; - readonly formData: FormData | null; - readonly downloadRequest: string | null; - readonly info?: unknown; - - intercept(options?: NavigationInterceptOptions): void; - scroll(): void; -} - -export interface NavigateEventInit extends EventInit { - navigationType?: NavigationTypeString; - canIntercept?: boolean; - userInitiated?: boolean; - hashChange?: boolean; - destination: NavigationDestination; - signal: AbortSignal; - formData?: FormData | null; - downloadRequest?: string | null; - info?: unknown; -} - -export interface NavigationInterceptOptions { - handler?: () => Promise; - focusReset?: 'after-transition' | 'manual'; - scroll?: 'after-transition' | 'manual'; -} - -export declare class NavigationDestination { - readonly url: string; - readonly key: string | null; - readonly id: string | null; - readonly index: number; - readonly sameDocument: boolean; - - getState(): unknown; -} diff --git a/packages/common/src/navigation/platform_navigation.ts b/packages/common/src/navigation/platform_navigation.ts index 551f7e0d3a7..bcc08d4539b 100644 --- a/packages/common/src/navigation/platform_navigation.ts +++ b/packages/common/src/navigation/platform_navigation.ts @@ -6,20 +6,19 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Injectable} from '@angular/core'; - import { - NavigateEvent, - Navigation, - NavigationCurrentEntryChangeEvent, - NavigationHistoryEntry, - NavigationNavigateOptions, - NavigationOptions, - NavigationReloadOptions, - NavigationResult, - NavigationTransition, - NavigationUpdateCurrentEntryOptions, -} from './navigation_types'; + Injectable, + ɵNavigateEvent as NavigateEvent, + ɵNavigation as Navigation, + ɵNavigationCurrentEntryChangeEvent as NavigationCurrentEntryChangeEvent, + ɵNavigationHistoryEntry as NavigationHistoryEntry, + ɵNavigationNavigateOptions as NavigationNavigateOptions, + ɵNavigationOptions as NavigationOptions, + ɵNavigationReloadOptions as NavigationReloadOptions, + ɵNavigationResult as NavigationResult, + ɵNavigationTransition as NavigationTransition, + ɵNavigationUpdateCurrentEntryOptions as NavigationUpdateCurrentEntryOptions, +} from '@angular/core'; /** * This class wraps the platform Navigation API which allows server-specific and test diff --git a/packages/common/testing/BUILD.bazel b/packages/common/testing/BUILD.bazel index 781151bb3a5..6df726b48bb 100644 --- a/packages/common/testing/BUILD.bazel +++ b/packages/common/testing/BUILD.bazel @@ -10,6 +10,7 @@ ng_module( deps = [ "//packages/common", "//packages/core", + "//packages/core/testing", "@npm//rxjs", ], ) diff --git a/packages/common/testing/src/navigation/fake_navigation.ts b/packages/common/testing/src/navigation/fake_navigation.ts index b84b85b96b8..c4f190f79fe 100644 --- a/packages/common/testing/src/navigation/fake_navigation.ts +++ b/packages/common/testing/src/navigation/fake_navigation.ts @@ -6,972 +6,4 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - NavigateEvent, - Navigation, - NavigationCurrentEntryChangeEvent, - NavigationDestination, - NavigationHistoryEntry, - NavigationInterceptOptions, - NavigationNavigateOptions, - NavigationOptions, - NavigationReloadOptions, - NavigationResult, - NavigationTransition, - NavigationTypeString, - NavigationUpdateCurrentEntryOptions, -} from './navigation_types'; - -/** - * 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. - */ -export class FakeNavigation implements Navigation { - /** - * The fake implementation of an entries array. Only same-document entries - * allowed. - */ - private readonly entriesArr: FakeNavigationHistoryEntry[] = []; - - /** - * The current active entry index into `entriesArr`. - */ - private currentEntryIndex = 0; - - /** - * The current navigate event. - */ - private navigateEvent: InternalFakeNavigateEvent | undefined = undefined; - - /** - * A Map of pending traversals, so that traversals to the same entry can be - * re-used. - */ - private readonly traversalQueue = new Map(); - - /** - * A Promise that resolves when the previous traversals have finished. Used to - * simulate the cross-process communication necessary for traversals. - */ - private nextTraversal = Promise.resolve(); - - /** - * A prospective current active entry index, which includes unresolved - * traversals. Used by `go` to determine where navigations are intended to go. - */ - private prospectiveEntryIndex = 0; - - /** - * A test-only option to make traversals synchronous, rather than emulate - * cross-process communication. - */ - private synchronousTraversals = false; - - /** Whether to allow a call to setInitialEntryForTesting. */ - private canSetInitialEntry = true; - - /** `EventTarget` to dispatch events. */ - private eventTarget: EventTarget; - - /** The next unique id for created entries. Replace recreates this id. */ - private nextId = 0; - - /** The next unique key for created entries. Replace inherits this id. */ - private nextKey = 0; - - /** Whether this fake is disposed. */ - private disposed = false; - - /** Equivalent to `navigation.currentEntry`. */ - get currentEntry(): FakeNavigationHistoryEntry { - return this.entriesArr[this.currentEntryIndex]; - } - - get canGoBack(): boolean { - return this.currentEntryIndex > 0; - } - - get canGoForward(): boolean { - return this.currentEntryIndex < this.entriesArr.length - 1; - } - - constructor( - private readonly window: Window, - startURL: `http${string}`, - ) { - this.eventTarget = this.window.document.createElement('div'); - // First entry. - this.setInitialEntryForTesting(startURL); - } - - /** - * Sets the initial entry. - */ - private setInitialEntryForTesting( - url: `http${string}`, - options: {historyState: unknown; state?: unknown} = {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(): boolean { - return this.canSetInitialEntry; - } - - /** - * Sets whether to emulate traversals as synchronous rather than - * asynchronous. - */ - setSynchronousTraversalsForTesting(synchronousTraversals: boolean) { - this.synchronousTraversals = synchronousTraversals; - } - - /** Equivalent to `navigation.entries()`. */ - entries(): FakeNavigationHistoryEntry[] { - return this.entriesArr.slice(); - } - - /** Equivalent to `navigation.navigate()`. */ - navigate(url: string, options?: NavigationNavigateOptions): FakeNavigationResult { - const fromUrl = new URL(this.currentEntry.url!); - const toUrl = new URL(url, this.currentEntry.url!); - - let navigationType: NavigationTypeString; - 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: unknown, title: string, url?: string): void { - this.pushOrReplaceState('push', data, title, url); - } - - /** Equivalent to `history.replaceState()`. */ - replaceState(data: unknown, title: string, url?: string): void { - this.pushOrReplaceState('replace', data, title, url); - } - - private pushOrReplaceState( - navigationType: NavigationTypeString, - data: unknown, - _title: string, - url?: string, - ): void { - 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: string, options?: NavigationOptions): FakeNavigationResult { - 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?: NavigationOptions): FakeNavigationResult { - 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?: NavigationOptions): FakeNavigationResult { - 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: number): void { - 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 */ - private runTraversal(traversal: () => void) { - 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: string, - callback: EventListenerOrEventListenerObject, - options?: AddEventListenerOptions | boolean, - ) { - this.eventTarget.addEventListener(type, callback, options); - } - - /** Equivalent to `navigation.removeEventListener()`. */ - removeEventListener( - type: string, - callback: EventListenerOrEventListenerObject, - options?: EventListenerOptions | boolean, - ) { - this.eventTarget.removeEventListener(type, callback, options); - } - - /** Equivalent to `navigation.dispatchEvent()` */ - dispatchEvent(event: Event): boolean { - 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. */ - private userAgentNavigate( - destination: FakeNavigationDestination, - result: InternalNavigationResult, - options: InternalNavigateOptions, - ) { - // 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. */ - private 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. */ - private userAgentPushOrReplace( - destination: FakeNavigationDestination, - {navigationType}: {navigationType: NavigationTypeString}, - ) { - 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. */ - private userAgentTraverse(destination: FakeNavigationDestination) { - this.currentEntryIndex = destination.index; - } - - /** Utility method for finding entries with the given `key`. */ - private findEntry(key: string) { - for (const entry of this.entriesArr) { - if (entry.key === key) return entry; - } - return undefined; - } - - set onnavigate(_handler: ((this: Navigation, ev: NavigateEvent) => any) | null) { - throw new Error('unimplemented'); - } - - get onnavigate(): ((this: Navigation, ev: NavigateEvent) => any) | null { - throw new Error('unimplemented'); - } - - set oncurrententrychange( - _handler: ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) | null, - ) { - throw new Error('unimplemented'); - } - - get oncurrententrychange(): - | ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) - | null { - throw new Error('unimplemented'); - } - - set onnavigatesuccess(_handler: ((this: Navigation, ev: Event) => any) | null) { - throw new Error('unimplemented'); - } - - get onnavigatesuccess(): ((this: Navigation, ev: Event) => any) | null { - throw new Error('unimplemented'); - } - - set onnavigateerror(_handler: ((this: Navigation, ev: ErrorEvent) => any) | null) { - throw new Error('unimplemented'); - } - - get onnavigateerror(): ((this: Navigation, ev: ErrorEvent) => any) | null { - throw new Error('unimplemented'); - } - - get transition(): NavigationTransition | null { - throw new Error('unimplemented'); - } - - updateCurrentEntry(_options: NavigationUpdateCurrentEntryOptions): void { - throw new Error('unimplemented'); - } - - reload(_options?: NavigationReloadOptions): NavigationResult { - throw new Error('unimplemented'); - } -} - -/** - * Fake equivalent of the `NavigationResult` interface with - * `FakeNavigationHistoryEntry`. - */ -interface FakeNavigationResult extends NavigationResult { - readonly committed: Promise; - readonly finished: Promise; -} - -/** - * Fake equivalent of `NavigationHistoryEntry`. - */ -export class FakeNavigationHistoryEntry implements NavigationHistoryEntry { - readonly sameDocument; - - readonly id: string; - readonly key: string; - readonly index: number; - private readonly state: unknown; - private readonly historyState: unknown; - - ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null = null; - - constructor( - readonly url: string | null, - { - id, - key, - index, - sameDocument, - state, - historyState, - }: { - id: string; - key: string; - index: number; - sameDocument: boolean; - historyState: unknown; - state?: unknown; - }, - ) { - this.id = id; - this.key = key; - this.index = index; - this.sameDocument = sameDocument; - this.state = state; - this.historyState = historyState; - } - - getState(): unknown { - // Budget copy. - return this.state ? JSON.parse(JSON.stringify(this.state)) : this.state; - } - - getHistoryState(): unknown { - // Budget copy. - return this.historyState ? JSON.parse(JSON.stringify(this.historyState)) : this.historyState; - } - - addEventListener( - type: string, - callback: EventListenerOrEventListenerObject, - options?: AddEventListenerOptions | boolean, - ) { - throw new Error('unimplemented'); - } - - removeEventListener( - type: string, - callback: EventListenerOrEventListenerObject, - options?: EventListenerOptions | boolean, - ) { - throw new Error('unimplemented'); - } - - dispatchEvent(event: Event): boolean { - throw new Error('unimplemented'); - } -} - -/** `NavigationInterceptOptions` with experimental commit option. */ -export interface ExperimentalNavigationInterceptOptions extends NavigationInterceptOptions { - commit?: 'immediate' | 'after-transition'; -} - -/** `NavigateEvent` with experimental commit function. */ -export interface ExperimentalNavigateEvent extends NavigateEvent { - intercept(options?: ExperimentalNavigationInterceptOptions): void; - - commit(): void; -} - -/** - * Fake equivalent of `NavigateEvent`. - */ -export interface FakeNavigateEvent extends ExperimentalNavigateEvent { - readonly destination: FakeNavigationDestination; -} - -interface InternalFakeNavigateEvent extends FakeNavigateEvent { - readonly sameDocument: boolean; - readonly skipPopState?: boolean; - readonly commitOption: 'after-transition' | 'immediate'; - readonly result: InternalNavigationResult; - - commit(internal?: boolean): void; - cancel(reason: Error): void; - dispatchedNavigateEvent(): void; - userAgentNavigated(entry: FakeNavigationHistoryEntry): void; -} - -/** - * 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, -}: { - cancelable: boolean; - canIntercept: boolean; - userInitiated: boolean; - hashChange: boolean; - navigationType: NavigationTypeString; - signal: AbortSignal; - destination: FakeNavigationDestination; - info: unknown; - sameDocument: boolean; - skipPopState?: boolean; - result: InternalNavigationResult; - userAgentCommit: () => void; -}) { - const event = new Event('navigate', {bubbles: false, cancelable}) as { - -readonly [P in keyof InternalFakeNavigateEvent]: InternalFakeNavigateEvent[P]; - }; - 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: Promise | undefined = undefined; - let interceptCalled = false; - let dispatchedNavigateEvent = false; - let commitCalled = false; - - event.intercept = function ( - this: InternalFakeNavigateEvent, - options?: ExperimentalNavigationInterceptOptions, - ): void { - 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 (this: InternalFakeNavigateEvent): void { - throw new Error('unimplemented'); - }; - - event.commit = function (this: InternalFakeNavigateEvent, 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 (this: InternalFakeNavigateEvent, reason: Error) { - result.committedReject(reason); - result.finishedReject(reason); - }; - - // Internal only. - event.dispatchedNavigateEvent = function (this: InternalFakeNavigateEvent) { - 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 ( - this: InternalFakeNavigateEvent, - entry: FakeNavigationHistoryEntry, - ) { - result.committedResolve(entry); - }; - - return event as InternalFakeNavigateEvent; -} - -/** Fake equivalent of `NavigationCurrentEntryChangeEvent`. */ -export interface FakeNavigationCurrentEntryChangeEvent extends NavigationCurrentEntryChangeEvent { - readonly from: FakeNavigationHistoryEntry; -} - -/** - * Create a fake equivalent of `NavigationCurrentEntryChange`. This does not use - * a class because ES5 transpiled JavaScript cannot extend native Event. - */ -function createFakeNavigationCurrentEntryChangeEvent({ - from, - navigationType, -}: { - from: FakeNavigationHistoryEntry; - navigationType: NavigationTypeString; -}) { - const event = new Event('currententrychange', { - bubbles: false, - cancelable: false, - }) as { - -readonly [P in keyof NavigationCurrentEntryChangeEvent]: NavigationCurrentEntryChangeEvent[P]; - }; - event.from = from; - event.navigationType = navigationType; - return event as FakeNavigationCurrentEntryChangeEvent; -} - -/** - * Create a fake equivalent of `PopStateEvent`. This does not use a class - * because ES5 transpiled JavaScript cannot extend native Event. - */ -function createPopStateEvent({state}: {state: unknown}) { - const event = new Event('popstate', { - bubbles: false, - cancelable: false, - }) as {-readonly [P in keyof PopStateEvent]: PopStateEvent[P]}; - event.state = state; - return event as PopStateEvent; -} - -/** - * Fake equivalent of `NavigationDestination`. - */ -export class FakeNavigationDestination implements NavigationDestination { - readonly url: string; - readonly sameDocument: boolean; - readonly key: string | null; - readonly id: string | null; - readonly index: number; - - private readonly state?: unknown; - private readonly historyState: unknown; - - constructor({ - url, - sameDocument, - historyState, - state, - key = null, - id = null, - index = -1, - }: { - url: string; - sameDocument: boolean; - historyState: unknown; - state?: unknown; - key?: string | null; - id?: string | null; - index?: number; - }) { - this.url = url; - this.sameDocument = sameDocument; - this.state = state; - this.historyState = historyState; - this.key = key; - this.id = id; - this.index = index; - } - - getState(): unknown { - return this.state; - } - - getHistoryState(): unknown { - return this.historyState; - } -} - -/** Utility function to determine whether two UrlLike have the same hash. */ -function isHashChange(from: URL, to: URL): boolean { - 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 { - committedResolve!: (entry: FakeNavigationHistoryEntry) => void; - committedReject!: (reason: Error) => void; - finishedResolve!: (entry: FakeNavigationHistoryEntry) => void; - finishedReject!: (reason: Error) => void; - readonly committed: Promise; - readonly finished: Promise; - get signal(): AbortSignal { - return this.abortController.signal; - } - private readonly abortController = new AbortController(); - - constructor() { - 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: Error) => { - reject(reason); - this.abortController.abort(reason); - }; - }); - // All rejections are handled. - this.committed.catch(() => {}); - this.finished.catch(() => {}); - } -} - -/** Internal options for performing a navigate. */ -interface InternalNavigateOptions { - navigationType: NavigationTypeString; - cancelable: boolean; - canIntercept: boolean; - userInitiated: boolean; - hashChange: boolean; - info?: unknown; - skipPopState?: boolean; -} +export {ɵFakeNavigation as FakeNavigation} from '@angular/core/testing'; diff --git a/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts b/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts index 0ddb4fdfb8c..b790809b8d3 100644 --- a/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts +++ b/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts @@ -6,11 +6,13 @@ * found in the LICENSE file at https://angular.dev/license */ -import {DOCUMENT, PlatformLocation} from '@angular/common'; +import { + DOCUMENT, + PlatformLocation, + ɵPlatformNavigation as PlatformNavigation, +} from '@angular/common'; import {inject, Provider} from '@angular/core'; -// @ng_package: ignore-cross-repo-import -import {PlatformNavigation} from '../../../src/navigation/platform_navigation'; import { FakeNavigationPlatformLocation, MOCK_PLATFORM_LOCATION_CONFIG, diff --git a/packages/common/testing/src/private_export.ts b/packages/common/testing/src/private_export.ts index 749e39149e1..b1f4c653775 100644 --- a/packages/common/testing/src/private_export.ts +++ b/packages/common/testing/src/private_export.ts @@ -7,3 +7,4 @@ */ export {provideFakePlatformNavigation as ɵprovideFakePlatformNavigation} from './navigation/provide_fake_platform_navigation'; +export {FakeNavigation as ɵFakeNavigation} from './navigation/fake_navigation'; diff --git a/packages/core/BUILD.bazel b/packages/core/BUILD.bazel index db4b2d2a6be..10c4d732956 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -30,6 +30,7 @@ ng_module( ), deps = [ "//packages:types", + "//packages/core/primitives/dom-navigation", "//packages/core/primitives/event-dispatch", "//packages/core/primitives/signals", "//packages/core/src/compiler", diff --git a/packages/core/primitives/dom-navigation/BUILD.bazel b/packages/core/primitives/dom-navigation/BUILD.bazel new file mode 100644 index 00000000000..f2c034c851f --- /dev/null +++ b/packages/core/primitives/dom-navigation/BUILD.bazel @@ -0,0 +1,32 @@ +load("//tools:defaults.bzl", "ts_library", "tsec_test") + +package(default_visibility = [ + "//packages:__pkg__", + "//packages/common:__subpackages__", + "//packages/core:__subpackages__", + "//tools/public_api_guard:__pkg__", +]) + +ts_library( + name = "dom-navigation", + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + ), +) + +tsec_test( + name = "tsec_test", + target = "dom-navigation", + tsconfig = "//packages:tsec_config", +) + +filegroup( + name = "files_for_docgen", + srcs = glob([ + "*.ts", + "src/**/*.ts", + ]), +) diff --git a/packages/core/primitives/dom-navigation/index.ts b/packages/core/primitives/dom-navigation/index.ts new file mode 100644 index 00000000000..3e6d512a011 --- /dev/null +++ b/packages/core/primitives/dom-navigation/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './src/navigation_types'; diff --git a/packages/common/testing/src/navigation/navigation_types.ts b/packages/core/primitives/dom-navigation/src/navigation_types.ts similarity index 98% rename from packages/common/testing/src/navigation/navigation_types.ts rename to packages/core/primitives/dom-navigation/src/navigation_types.ts index 0dafd4efe73..7e0d50c2d54 100644 --- a/packages/common/testing/src/navigation/navigation_types.ts +++ b/packages/core/primitives/dom-navigation/src/navigation_types.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ +// TODO: Figure out how to use the types from NPM in the public API + export interface NavigationEventMap { navigate: NavigateEvent; navigatesuccess: Event; diff --git a/packages/core/primitives/dom-navigation/testing/BUILD.bazel b/packages/core/primitives/dom-navigation/testing/BUILD.bazel new file mode 100644 index 00000000000..b1a7413f47b --- /dev/null +++ b/packages/core/primitives/dom-navigation/testing/BUILD.bazel @@ -0,0 +1,34 @@ +load("//tools:defaults.bzl", "ts_library", "tsec_test") + +package(default_visibility = [ + "//packages:__pkg__", + "//packages/common:__subpackages__", + "//packages/core/primitives/dom-navigation/testing:__subpackages__", + "//packages/core/testing:__subpackages__", + "//tools/public_api_guard:__pkg__", +]) + +ts_library( + name = "testing", + srcs = glob( + [ + "**/*.ts", + ], + ), + deps = [ + "//packages/core/primitives/dom-navigation", + ], +) + +tsec_test( + name = "tsec_test", + target = "testing", + tsconfig = "//packages:tsec_config", +) + +filegroup( + name = "files_for_docgen", + srcs = glob([ + "*.ts", + ]), +) diff --git a/packages/core/primitives/dom-navigation/testing/fake_navigation.ts b/packages/core/primitives/dom-navigation/testing/fake_navigation.ts new file mode 100644 index 00000000000..bb00cfe17c0 --- /dev/null +++ b/packages/core/primitives/dom-navigation/testing/fake_navigation.ts @@ -0,0 +1,990 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + NavigationNavigateOptions, + NavigationTypeString, + NavigationOptions, + NavigateEvent, + NavigationCurrentEntryChangeEvent, + NavigationTransition, + NavigationUpdateCurrentEntryOptions, + NavigationReloadOptions, + NavigationResult, + NavigationHistoryEntry, + NavigationInterceptOptions, + NavigationDestination, + Navigation, +} from '../src/navigation_types'; + +/** + * 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. + */ +export class FakeNavigation implements Navigation { + /** + * The fake implementation of an entries array. Only same-document entries + * allowed. + */ + private readonly entriesArr: FakeNavigationHistoryEntry[] = []; + + /** + * The current active entry index into `entriesArr`. + */ + private currentEntryIndex = 0; + + /** + * The current navigate event. + */ + private navigateEvent: InternalFakeNavigateEvent | undefined = undefined; + + /** + * A Map of pending traversals, so that traversals to the same entry can be + * re-used. + */ + private readonly traversalQueue = new Map(); + + /** + * A Promise that resolves when the previous traversals have finished. Used to + * simulate the cross-process communication necessary for traversals. + */ + private nextTraversal = Promise.resolve(); + + /** + * A prospective current active entry index, which includes unresolved + * traversals. Used by `go` to determine where navigations are intended to go. + */ + private prospectiveEntryIndex = 0; + + /** + * A test-only option to make traversals synchronous, rather than emulate + * cross-process communication. + */ + private synchronousTraversals = false; + + /** Whether to allow a call to setInitialEntryForTesting. */ + private canSetInitialEntry = true; + + /** `EventTarget` to dispatch events. */ + private eventTarget: EventTarget; + + /** The next unique id for created entries. Replace recreates this id. */ + private nextId = 0; + + /** The next unique key for created entries. Replace inherits this id. */ + private nextKey = 0; + + /** Whether this fake is disposed. */ + private disposed = false; + + /** Equivalent to `navigation.currentEntry`. */ + get currentEntry(): FakeNavigationHistoryEntry { + return this.entriesArr[this.currentEntryIndex]; + } + + get canGoBack(): boolean { + return this.currentEntryIndex > 0; + } + + get canGoForward(): boolean { + return this.currentEntryIndex < this.entriesArr.length - 1; + } + + constructor( + private readonly window: Window, + startURL: `http${string}`, + ) { + this.eventTarget = this.window.document.createElement('div'); + // First entry. + this.setInitialEntryForTesting(startURL); + } + + /** + * Sets the initial entry. + */ + setInitialEntryForTesting( + url: `http${string}`, + options: {historyState: unknown; state?: unknown} = {historyState: null}, + ): void { + 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(): boolean { + return this.canSetInitialEntry; + } + + /** + * Sets whether to emulate traversals as synchronous rather than + * asynchronous. + */ + setSynchronousTraversalsForTesting(synchronousTraversals: boolean): void { + this.synchronousTraversals = synchronousTraversals; + } + + /** Equivalent to `navigation.entries()`. */ + entries(): FakeNavigationHistoryEntry[] { + return this.entriesArr.slice(); + } + + /** Equivalent to `navigation.navigate()`. */ + navigate(url: string, options?: NavigationNavigateOptions): FakeNavigationResult { + const fromUrl = new URL(this.currentEntry.url!); + const toUrl = new URL(url, this.currentEntry.url!); + + let navigationType: NavigationTypeString; + 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: unknown, title: string, url?: string): void { + this.pushOrReplaceState('push', data, title, url); + } + + /** Equivalent to `history.replaceState()`. */ + replaceState(data: unknown, title: string, url?: string): void { + this.pushOrReplaceState('replace', data, title, url); + } + + private pushOrReplaceState( + navigationType: NavigationTypeString, + data: unknown, + _title: string, + url?: string, + ): void { + 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: string, options?: NavigationOptions): FakeNavigationResult { + 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?: NavigationOptions): FakeNavigationResult { + 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?: NavigationOptions): FakeNavigationResult { + 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: number): void { + 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 */ + private runTraversal(traversal: () => void) { + 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: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void { + this.eventTarget.addEventListener(type, callback, options); + } + + /** Equivalent to `navigation.removeEventListener()`. */ + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: EventListenerOptions | boolean, + ): void { + this.eventTarget.removeEventListener(type, callback, options); + } + + /** Equivalent to `navigation.dispatchEvent()` */ + dispatchEvent(event: Event): boolean { + return this.eventTarget.dispatchEvent(event); + } + + /** Cleans up resources. */ + dispose(): void { + // 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(): boolean { + return this.disposed; + } + + /** Implementation for all navigations and traversals. */ + private userAgentNavigate( + destination: FakeNavigationDestination, + result: InternalNavigationResult, + options: InternalNavigateOptions, + ) { + // 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. */ + private 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. */ + private userAgentPushOrReplace( + destination: FakeNavigationDestination, + {navigationType}: {navigationType: NavigationTypeString}, + ) { + 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. */ + private userAgentTraverse(destination: FakeNavigationDestination) { + this.currentEntryIndex = destination.index; + } + + /** Utility method for finding entries with the given `key`. */ + private findEntry(key: string) { + for (const entry of this.entriesArr) { + if (entry.key === key) return entry; + } + return undefined; + } + + set onnavigate( + // tslint:disable-next-line:no-any + _handler: ((this: Navigation, ev: NavigateEvent) => any) | null, + ) { + throw new Error('unimplemented'); + } + + // tslint:disable-next-line:no-any + get onnavigate(): ((this: Navigation, ev: NavigateEvent) => any) | null { + throw new Error('unimplemented'); + } + + set oncurrententrychange( + _handler: // tslint:disable-next-line:no-any + ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) | null, + ) { + throw new Error('unimplemented'); + } + + get oncurrententrychange(): // tslint:disable-next-line:no-any + ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) | null { + throw new Error('unimplemented'); + } + + set onnavigatesuccess( + // tslint:disable-next-line:no-any + _handler: ((this: Navigation, ev: Event) => any) | null, + ) { + throw new Error('unimplemented'); + } + + // tslint:disable-next-line:no-any + get onnavigatesuccess(): ((this: Navigation, ev: Event) => any) | null { + throw new Error('unimplemented'); + } + + set onnavigateerror( + // tslint:disable-next-line:no-any + _handler: ((this: Navigation, ev: ErrorEvent) => any) | null, + ) { + throw new Error('unimplemented'); + } + + // tslint:disable-next-line:no-any + get onnavigateerror(): ((this: Navigation, ev: ErrorEvent) => any) | null { + throw new Error('unimplemented'); + } + + get transition(): NavigationTransition | null { + throw new Error('unimplemented'); + } + + updateCurrentEntry(_options: NavigationUpdateCurrentEntryOptions): void { + throw new Error('unimplemented'); + } + + reload(_options?: NavigationReloadOptions): NavigationResult { + throw new Error('unimplemented'); + } +} + +/** + * Fake equivalent of the `NavigationResult` interface with + * `FakeNavigationHistoryEntry`. + */ +interface FakeNavigationResult extends NavigationResult { + readonly committed: Promise; + readonly finished: Promise; +} + +/** + * Fake equivalent of `NavigationHistoryEntry`. + */ +export class FakeNavigationHistoryEntry implements NavigationHistoryEntry { + readonly sameDocument: boolean; + + readonly id: string; + readonly key: string; + readonly index: number; + private readonly state: unknown; + private readonly historyState: unknown; + + // tslint:disable-next-line:no-any + ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null = null; + + constructor( + readonly url: string | null, + { + id, + key, + index, + sameDocument, + state, + historyState, + }: { + id: string; + key: string; + index: number; + sameDocument: boolean; + historyState: unknown; + state?: unknown; + }, + ) { + this.id = id; + this.key = key; + this.index = index; + this.sameDocument = sameDocument; + this.state = state; + this.historyState = historyState; + } + + getState(): unknown { + // Budget copy. + return this.state ? (JSON.parse(JSON.stringify(this.state)) as unknown) : this.state; + } + + getHistoryState(): unknown { + // Budget copy. + return this.historyState + ? (JSON.parse(JSON.stringify(this.historyState)) as unknown) + : this.historyState; + } + + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void { + throw new Error('unimplemented'); + } + + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: EventListenerOptions | boolean, + ): void { + throw new Error('unimplemented'); + } + + dispatchEvent(event: Event): boolean { + throw new Error('unimplemented'); + } +} + +/** `NavigationInterceptOptions` with experimental commit option. */ +export interface ExperimentalNavigationInterceptOptions extends NavigationInterceptOptions { + commit?: 'immediate' | 'after-transition'; +} + +/** `NavigateEvent` with experimental commit function. */ +export interface ExperimentalNavigateEvent extends NavigateEvent { + intercept(options?: ExperimentalNavigationInterceptOptions): void; + + commit(): void; +} + +/** + * Fake equivalent of `NavigateEvent`. + */ +export interface FakeNavigateEvent extends ExperimentalNavigateEvent { + readonly destination: FakeNavigationDestination; +} + +interface InternalFakeNavigateEvent extends FakeNavigateEvent { + readonly sameDocument: boolean; + readonly skipPopState?: boolean; + readonly commitOption: 'after-transition' | 'immediate'; + readonly result: InternalNavigationResult; + + commit(internal?: boolean): void; + cancel(reason: Error): void; + dispatchedNavigateEvent(): void; + userAgentNavigated(entry: FakeNavigationHistoryEntry): void; +} + +/** + * 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, +}: { + cancelable: boolean; + canIntercept: boolean; + userInitiated: boolean; + hashChange: boolean; + navigationType: NavigationTypeString; + signal: AbortSignal; + destination: FakeNavigationDestination; + info: unknown; + sameDocument: boolean; + skipPopState?: boolean; + result: InternalNavigationResult; + userAgentCommit: () => void; +}) { + const event = new Event('navigate', {bubbles: false, cancelable}) as { + -readonly [P in keyof InternalFakeNavigateEvent]: InternalFakeNavigateEvent[P]; + }; + 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: Promise | undefined = undefined; + let interceptCalled = false; + let dispatchedNavigateEvent = false; + let commitCalled = false; + + event.intercept = function ( + this: InternalFakeNavigateEvent, + options?: ExperimentalNavigationInterceptOptions, + ): void { + interceptCalled = true; + event.sameDocument = true; + const handler = options?.handler; + if (handler) { + handlerFinished = handler(); + } + if (options?.commit) { + event.commitOption = options.commit; + } + // TODO: handle focus reset and scroll? + }; + + event.scroll = function (this: InternalFakeNavigateEvent): void { + // TODO: handle scroll? + }; + + event.commit = function (this: InternalFakeNavigateEvent, 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 (this: InternalFakeNavigateEvent, reason: Error) { + result.committedReject(reason); + result.finishedReject(reason); + }; + + // Internal only. + event.dispatchedNavigateEvent = function (this: InternalFakeNavigateEvent) { + 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 ( + this: InternalFakeNavigateEvent, + entry: FakeNavigationHistoryEntry, + ) { + result.committedResolve(entry); + }; + + return event as InternalFakeNavigateEvent; +} + +/** Fake equivalent of `NavigationCurrentEntryChangeEvent`. */ +export interface FakeNavigationCurrentEntryChangeEvent extends NavigationCurrentEntryChangeEvent { + readonly from: FakeNavigationHistoryEntry; +} + +/** + * Create a fake equivalent of `NavigationCurrentEntryChange`. This does not use + * a class because ES5 transpiled JavaScript cannot extend native Event. + */ +function createFakeNavigationCurrentEntryChangeEvent({ + from, + navigationType, +}: { + from: FakeNavigationHistoryEntry; + navigationType: NavigationTypeString; +}) { + const event = new Event('currententrychange', { + bubbles: false, + cancelable: false, + }) as { + -readonly [P in keyof NavigationCurrentEntryChangeEvent]: NavigationCurrentEntryChangeEvent[P]; + }; + event.from = from; + event.navigationType = navigationType; + return event as FakeNavigationCurrentEntryChangeEvent; +} + +/** + * Create a fake equivalent of `PopStateEvent`. This does not use a class + * because ES5 transpiled JavaScript cannot extend native Event. + */ +function createPopStateEvent({state}: {state: unknown}) { + const event = new Event('popstate', { + bubbles: false, + cancelable: false, + }) as {-readonly [P in keyof PopStateEvent]: PopStateEvent[P]}; + event.state = state; + return event as PopStateEvent; +} + +/** + * Fake equivalent of `NavigationDestination`. + */ +export class FakeNavigationDestination implements NavigationDestination { + readonly url: string; + readonly sameDocument: boolean; + readonly key: string | null; + readonly id: string | null; + readonly index: number; + + private readonly state?: unknown; + private readonly historyState: unknown; + + constructor({ + url, + sameDocument, + historyState, + state, + key = null, + id = null, + index = -1, + }: { + url: string; + sameDocument: boolean; + historyState: unknown; + state?: unknown; + key?: string | null; + id?: string | null; + index?: number; + }) { + this.url = url; + this.sameDocument = sameDocument; + this.state = state; + this.historyState = historyState; + this.key = key; + this.id = id; + this.index = index; + } + + getState(): unknown { + return this.state; + } + + getHistoryState(): unknown { + return this.historyState; + } +} + +/** Utility function to determine whether two UrlLike have the same hash. */ +function isHashChange(from: URL, to: URL): boolean { + 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 { + committedResolve!: (entry: FakeNavigationHistoryEntry) => void; + committedReject!: (reason: Error) => void; + finishedResolve!: (entry: FakeNavigationHistoryEntry) => void; + finishedReject!: (reason: Error) => void; + readonly committed: Promise; + readonly finished: Promise; + get signal(): AbortSignal { + return this.abortController.signal; + } + private readonly abortController = new AbortController(); + + constructor() { + 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: Error) => { + reject(reason); + this.abortController.abort(reason); + }; + }); + // All rejections are handled. + this.committed.catch(() => {}); + this.finished.catch(() => {}); + } +} + +/** Internal options for performing a navigate. */ +interface InternalNavigateOptions { + navigationType: NavigationTypeString; + cancelable: boolean; + canIntercept: boolean; + userInitiated: boolean; + hashChange: boolean; + info?: unknown; + skipPopState?: boolean; +} diff --git a/packages/core/primitives/dom-navigation/testing/index.ts b/packages/core/primitives/dom-navigation/testing/index.ts new file mode 100644 index 00000000000..6d537170b6f --- /dev/null +++ b/packages/core/primitives/dom-navigation/testing/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './fake_navigation'; diff --git a/packages/core/primitives/dom-navigation/testing/test/BUILD.bazel b/packages/core/primitives/dom-navigation/testing/test/BUILD.bazel new file mode 100644 index 00000000000..ff18b175492 --- /dev/null +++ b/packages/core/primitives/dom-navigation/testing/test/BUILD.bazel @@ -0,0 +1,30 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "karma_web_test_suite", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob( + ["**/*.ts"], + ), + # Visible to //:saucelabs_unit_tests_poc target + visibility = ["//:__pkg__"], + deps = [ + "//packages/core/primitives/dom-navigation/testing", + "//packages/private/testing", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["//tools/testing:node"], + deps = [ + ":test_lib", + ], +) + +karma_web_test_suite( + name = "test_web", + deps = [ + ":test_lib", + ], +) diff --git a/packages/common/test/navigation/fake_platform_navigation.spec.ts b/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts similarity index 99% rename from packages/common/test/navigation/fake_platform_navigation.spec.ts rename to packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts index b69d4556949..2397159729d 100644 --- a/packages/common/test/navigation/fake_platform_navigation.spec.ts +++ b/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts @@ -11,7 +11,10 @@ import { FakeNavigateEvent, FakeNavigation, FakeNavigationCurrentEntryChangeEvent, -} from '../../testing/src/navigation/fake_navigation'; +} from '../fake_navigation'; +import {ensureDocument} from '@angular/private/testing'; + +ensureDocument(); interface Locals { navigation: FakeNavigation; diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 00442e53ec5..f4cd15a6511 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -6,6 +6,21 @@ * found in the LICENSE file at https://angular.dev/license */ +export { + type NavigateEvent as ɵNavigateEvent, + type Navigation as ɵNavigation, + type NavigationCurrentEntryChangeEvent as ɵNavigationCurrentEntryChangeEvent, + type NavigationHistoryEntry as ɵNavigationHistoryEntry, + type NavigationNavigateOptions as ɵNavigationNavigateOptions, + type NavigationOptions as ɵNavigationOptions, + type NavigationReloadOptions as ɵNavigationReloadOptions, + type NavigationResult as ɵNavigationResult, + type NavigationTransition as ɵNavigationTransition, + type NavigationUpdateCurrentEntryOptions as ɵNavigationUpdateCurrentEntryOptions, + type NavigationTypeString as ɵNavigationTypeString, + type NavigationInterceptOptions as ɵNavigationInterceptOptions, + type NavigationDestination as ɵNavigationDestination, +} from '../primitives/dom-navigation'; export {setAlternateWeakRefImpl as ɵsetAlternateWeakRefImpl} from '../primitives/signals'; export {detectChangesInViewIfRequired as ɵdetectChangesInViewIfRequired} from './application/application_ref'; export {INTERNAL_APPLICATION_ERROR_HANDLER as ɵINTERNAL_APPLICATION_ERROR_HANDLER} from './error_handler'; diff --git a/packages/core/testing/BUILD.bazel b/packages/core/testing/BUILD.bazel index 3be96e7e52b..5366d7e6fd1 100644 --- a/packages/core/testing/BUILD.bazel +++ b/packages/core/testing/BUILD.bazel @@ -13,6 +13,7 @@ ng_module( "//packages:types", "//packages/compiler", "//packages/core", + "//packages/core/primitives/dom-navigation/testing", "//packages/localize", "//packages/zone.js/lib:zone_d_ts", "@npm//@types/jasmine", diff --git a/packages/core/testing/public_api.ts b/packages/core/testing/public_api.ts index 7dfefb9c220..2efde47bb3a 100644 --- a/packages/core/testing/public_api.ts +++ b/packages/core/testing/public_api.ts @@ -14,5 +14,6 @@ * Entry point for all public APIs of this package. */ export * from './src/testing'; +export * from './src/testing_private_export'; // This file only reexports content of the `src` folder. Keep it that way. diff --git a/packages/core/testing/src/testing_private_export.ts b/packages/core/testing/src/testing_private_export.ts new file mode 100644 index 00000000000..5a34ee894f7 --- /dev/null +++ b/packages/core/testing/src/testing_private_export.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {FakeNavigation as ɵFakeNavigation} from '../../primitives/dom-navigation/testing';