mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
refactor(core): Add fake navigation to primitives for code sharing (#59857)
This moves the `FakeNavigation` implementation to the primitives folder so its implementation can be shared with Wiz. This class was initially copied directly from the Wiz implementation, with some small modifications. There will still need to be some work done to align the implementations and fix anything internally that needs adjusting. PR Close #59857
This commit is contained in:
parent
8c1b196ecd
commit
8657a0e4cc
20 changed files with 1158 additions and 1167 deletions
|
|
@ -30,6 +30,7 @@ ng_module(
|
|||
),
|
||||
deps = [
|
||||
"//packages/core",
|
||||
"//packages/core/primitives/dom-navigation",
|
||||
"@npm//rxjs",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<NavigationHistoryEntry>;
|
||||
finished: Promise<NavigationHistoryEntry>;
|
||||
}
|
||||
|
||||
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<K extends keyof NavigationEventMap>(
|
||||
type: K,
|
||||
listener: (this: Navigation, ev: NavigationEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
removeEventListener<K extends keyof NavigationEventMap>(
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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<K extends keyof NavigationHistoryEntryEventMap>(
|
||||
type: K,
|
||||
listener: (this: NavigationHistoryEntry, ev: NavigationHistoryEntryEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
removeEventListener<K extends keyof NavigationHistoryEntryEventMap>(
|
||||
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<void>;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ ng_module(
|
|||
deps = [
|
||||
"//packages/common",
|
||||
"//packages/core",
|
||||
"//packages/core/testing",
|
||||
"@npm//rxjs",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<string, InternalNavigationResult>();
|
||||
|
||||
/**
|
||||
* 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<void>((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<FakeNavigationHistoryEntry>;
|
||||
readonly finished: Promise<FakeNavigationHistoryEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> | 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<FakeNavigationHistoryEntry>;
|
||||
readonly finished: Promise<FakeNavigationHistoryEntry>;
|
||||
get signal(): AbortSignal {
|
||||
return this.abortController.signal;
|
||||
}
|
||||
private readonly abortController = new AbortController();
|
||||
|
||||
constructor() {
|
||||
this.committed = new Promise<FakeNavigationHistoryEntry>((resolve, reject) => {
|
||||
this.committedResolve = resolve;
|
||||
this.committedReject = reject;
|
||||
});
|
||||
|
||||
this.finished = new Promise<FakeNavigationHistoryEntry>(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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@
|
|||
*/
|
||||
|
||||
export {provideFakePlatformNavigation as ɵprovideFakePlatformNavigation} from './navigation/provide_fake_platform_navigation';
|
||||
export {FakeNavigation as ɵFakeNavigation} from './navigation/fake_navigation';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
32
packages/core/primitives/dom-navigation/BUILD.bazel
Normal file
32
packages/core/primitives/dom-navigation/BUILD.bazel
Normal file
|
|
@ -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",
|
||||
]),
|
||||
)
|
||||
9
packages/core/primitives/dom-navigation/index.ts
Normal file
9
packages/core/primitives/dom-navigation/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
34
packages/core/primitives/dom-navigation/testing/BUILD.bazel
Normal file
34
packages/core/primitives/dom-navigation/testing/BUILD.bazel
Normal file
|
|
@ -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",
|
||||
]),
|
||||
)
|
||||
|
|
@ -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<string, InternalNavigationResult>();
|
||||
|
||||
/**
|
||||
* 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<void>((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<FakeNavigationHistoryEntry>;
|
||||
readonly finished: Promise<FakeNavigationHistoryEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> | 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<FakeNavigationHistoryEntry>;
|
||||
readonly finished: Promise<FakeNavigationHistoryEntry>;
|
||||
get signal(): AbortSignal {
|
||||
return this.abortController.signal;
|
||||
}
|
||||
private readonly abortController = new AbortController();
|
||||
|
||||
constructor() {
|
||||
this.committed = new Promise<FakeNavigationHistoryEntry>((resolve, reject) => {
|
||||
this.committedResolve = resolve;
|
||||
this.committedReject = reject;
|
||||
});
|
||||
|
||||
this.finished = new Promise<FakeNavigationHistoryEntry>(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;
|
||||
}
|
||||
9
packages/core/primitives/dom-navigation/testing/index.ts
Normal file
9
packages/core/primitives/dom-navigation/testing/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
9
packages/core/testing/src/testing_private_export.ts
Normal file
9
packages/core/testing/src/testing_private_export.ts
Normal file
|
|
@ -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';
|
||||
Loading…
Reference in a new issue