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:
Thomas Wilkinson
2023-10-24 20:01:57 -05:00
committed by Jessica Janiuk
parent e2f3ae51df
commit d5fd614836
6 changed files with 2995 additions and 12 deletions
+1 -1
View File
@@ -31,8 +31,8 @@ ng_module(
),
deps = [
"//packages/core",
"@npm//rxjs",
"@npm//@types/dom-navigation",
"@npm//rxjs",
],
)
-9
View File
@@ -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');
}
},
];
}