mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
refactor(common): Add fake implementation of PlatformNavigation. (#52363)
This implementation does most, but not all, of the things the native Navigation API does. Also adds a spec that tests all the currently supported behaviors. PR Close #52363
This commit is contained in:
committed by
Jessica Janiuk
parent
e2f3ae51df
commit
d5fd614836
@@ -31,8 +31,8 @@ ng_module(
|
||||
),
|
||||
deps = [
|
||||
"//packages/core",
|
||||
"@npm//rxjs",
|
||||
"@npm//@types/dom-navigation",
|
||||
"@npm//rxjs",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +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.io/license
|
||||
*/
|
||||
|
||||
export {PlatformNavigation} from './platform_navigation';
|
||||
@@ -11,8 +11,6 @@ import {Injectable} from '@angular/core';
|
||||
/**
|
||||
* This class wraps the platform Navigation API which allows server-specific and test
|
||||
* implementations.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@Injectable({providedIn: 'platform', useFactory: () => window.navigation})
|
||||
export abstract class PlatformNavigation extends Navigation {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,969 @@
|
||||
/**
|
||||
* @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.io/license
|
||||
*/
|
||||
|
||||
// Prevents deletion of `Event` from `globalThis` during module loading.
|
||||
const Event = globalThis.Event;
|
||||
|
||||
/**
|
||||
* 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 = this.window.document.createElement('div');
|
||||
|
||||
/** 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, private readonly baseURI: string) {
|
||||
// First entry.
|
||||
this.setInitialEntryForTesting('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the initial entry.
|
||||
*/
|
||||
setInitialEntryForTesting(
|
||||
url: string,
|
||||
options: {
|
||||
historyState: unknown;
|
||||
// Allows setting the URL without resolving it against the base.
|
||||
absoluteUrl?: boolean;
|
||||
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(
|
||||
options.absoluteUrl ? url : new URL(url, this.baseURI).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!, this.baseURI);
|
||||
const toUrl = new URL(url, this.baseURI);
|
||||
|
||||
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!, this.baseURI);
|
||||
const toUrl = url ? new URL(url, this.baseURI) : 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!, this.baseURI);
|
||||
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.baseURI));
|
||||
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!, this.baseURI);
|
||||
const entry = this.entriesArr[targetIndex];
|
||||
const hashChange = isHashChange(fromUrl, new URL(entry.url!, this.baseURI));
|
||||
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;
|
||||
|
||||
// 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)) : 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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @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.io/license
|
||||
*/
|
||||
|
||||
import {Provider} from '@angular/core';
|
||||
|
||||
// @ng_package: ignore-cross-repo-import
|
||||
import {PlatformNavigation} from '../../../src/navigation/platform_navigation';
|
||||
|
||||
import {FakeNavigation} from './fake_navigation';
|
||||
|
||||
/**
|
||||
* Return a provider for the `FakeNavigation` in place of the real Navigation API.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function provideFakePlatformNavigation(): Provider[] {
|
||||
return [
|
||||
{
|
||||
provide: PlatformNavigation,
|
||||
useFactory: () => {
|
||||
return new FakeNavigation(window, 'https://test.com');
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user