fix(core): update FakeNavigation to match WHATWG HTML spec

Update FakeNavigation and Navigation API type definitions to match the
latest WHATWG HTML specification
(https://html.spec.whatwg.org/multipage/nav-history-apis.html):
- Add NavigationPrecommitController.addHandler() and support dynamic
  post-commit handler registration
  (https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigationprecommitcontroller-addhandler).
- Include precommitHandler directly in NavigationInterceptOptions
  (https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigateevent-intercept).
- Add hasUAVisualTransition and sourceElement properties to NavigateEvent
  (https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-navigateevent-interface).
- Fix FakeNavigationHistoryEntry dispose event name from 'disposed' to 'dispose'
  and implement ondispose
  (https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigationhistoryentry-ondispose).
- Implement Navigation.prototype.updateCurrentEntry
  (https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-updatecurrententry).
- Implement Navigation.prototype.reload
  (https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-reload)
  and share "performing a non-traverse navigation"
  (https://html.spec.whatwg.org/multipage/nav-history-apis.html#performing-a-non-traverse-navigation)
  logic with navigate().
- Implement standard event handler IDL properties (onnavigate,
  onnavigatesuccess, onnavigateerror, oncurrententrychange).
- Update internal specification comments and links from legacy PR draft URLs
  to official WHATWG multipage URLs.
This commit is contained in:
Andrew Scott
2026-09-11 12:41:15 -07:00
committed by GitHub
parent d0ed76c1d6
commit a8233232f5
4 changed files with 580 additions and 159 deletions
@@ -152,6 +152,7 @@ export declare class NavigateEvent extends Event {
readonly formData: FormData | null;
readonly downloadRequest: string | null;
readonly info?: unknown;
readonly sourceElement: Element | null;
intercept(options?: NavigationInterceptOptions): void;
scroll(): void;
@@ -168,10 +169,22 @@ export interface NavigateEventInit extends EventInit {
formData?: FormData | null;
downloadRequest?: string | null;
info?: unknown;
sourceElement?: Element | null;
}
export type NavigationInterceptHandler = () => PromiseLike<void> | void;
export type NavigationPrecommitHandler = (
controller: NavigationPrecommitController,
) => PromiseLike<void> | void;
export interface NavigationPrecommitController {
redirect: (url: string, options?: NavigationNavigateOptions) => void;
addHandler: (handler: NavigationInterceptHandler) => void;
}
export interface NavigationInterceptOptions {
handler?: () => Promise<void>;
precommitHandler?: NavigationPrecommitHandler;
handler?: NavigationInterceptHandler;
focusReset?: 'after-transition' | 'manual';
scroll?: 'after-transition' | 'manual';
}
@@ -19,8 +19,11 @@ import {
NavigationResult,
NavigationHistoryEntry,
NavigationInterceptOptions,
NavigationInterceptHandler,
NavigationPrecommitHandler,
NavigationDestination,
Navigation,
NavigationPrecommitController,
} from '../src/navigation_types';
// 3p-only-end
@@ -91,15 +94,26 @@ export class FakeNavigation implements Navigation {
/** Whether this fake is disposed. */
private disposed = false;
/** Equivalent to `navigation.currentEntry`. */
/**
* Equivalent to `navigation.currentEntry`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-currententry
*/
get currentEntry(): FakeNavigationHistoryEntry {
return this.entriesArr[this.currentEntryIndex];
}
/**
* Equivalent to `navigation.canGoBack`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-cangoback
*/
get canGoBack(): boolean {
return this.currentEntryIndex > 0;
}
/**
* Equivalent to `navigation.canGoForward`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-cangoforward
*/
get canGoForward(): boolean {
return this.currentEntryIndex < this.entriesArr.length - 1;
}
@@ -144,7 +158,7 @@ export class FakeNavigation implements Navigation {
): void {
if (!this.canSetInitialEntry) {
throw new Error(
'setInitialEntryForTesting can only be called before any ' + 'navigation has occurred',
'setInitialEntryForTesting can only be called before any navigation has occurred',
);
}
const currentInitialEntry = this.entriesArr[0];
@@ -171,12 +185,18 @@ export class FakeNavigation implements Navigation {
this.synchronousTraversals = synchronousTraversals;
}
/** Equivalent to `navigation.entries()`. */
/**
* Equivalent to `navigation.entries()`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-entries
*/
entries(): FakeNavigationHistoryEntry[] {
return this.entriesArr.slice();
}
/** Equivalent to `navigation.navigate()`. */
/**
* Equivalent to `navigation.navigate()`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-navigate
*/
navigate(url: string, options?: NavigationNavigateOptions): FakeNavigationResult {
const fromUrl = new URL(this.currentEntry.url!);
const toUrl = new URL(url, this.currentEntry.url!);
@@ -201,9 +221,8 @@ export class FakeNavigation implements Navigation {
sameDocument: hashChange,
historyState: null,
});
const result = new InternalNavigationResult(this);
const intercepted = this.userAgentNavigate(destination, result, {
return this.performNonTraverseNavigation(destination, {
navigationType,
cancelable: true,
canIntercept: true,
@@ -212,14 +231,6 @@ export class FakeNavigation implements Navigation {
hashChange,
info: options?.info,
});
if (!intercepted) {
this.updateNavigationEntriesForSameDocumentNavigation(this.navigateEvent!);
}
return {
committed: result.committed,
finished: result.finished,
};
}
/** Equivalent to `history.pushState()`. */
@@ -249,9 +260,8 @@ export class FakeNavigation implements Navigation {
historyState: data,
state: undefined, // No Navigation API state directly from history.pushState
});
const result = new InternalNavigationResult(this);
const intercepted = this.userAgentNavigate(destination, result, {
this.performNonTraverseNavigation(destination, {
navigationType,
cancelable: true,
canIntercept: true,
@@ -259,26 +269,17 @@ export class FakeNavigation implements Navigation {
userInitiated: false,
hashChange,
});
if (intercepted) {
return;
}
this.updateNavigationEntriesForSameDocumentNavigation(this.navigateEvent!);
}
/** Equivalent to `navigation.traverseTo()`. */
/**
* Equivalent to `navigation.traverseTo()`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-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,
};
return earlyErrorResult(new DOMException('Invalid key', 'InvalidStateError'));
}
if (entry === this.currentEntry) {
return {
@@ -295,15 +296,7 @@ export class FakeNavigation implements Navigation {
}
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 destination = this.createDestinationFromEntry(entry);
this.propsectiveTraversalDestinations.push(entry.index);
const result = new InternalNavigationResult(this);
this.traversalQueue.set(entry.key, result);
@@ -328,35 +321,25 @@ export class FakeNavigation implements Navigation {
};
}
/** Equivalent to `navigation.back()`. */
/**
* Equivalent to `navigation.back()`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-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,
};
return earlyErrorResult(new DOMException('Cannot go back', 'InvalidStateError'));
}
const entry = this.entriesArr[this.currentEntryIndex - 1];
return this.traverseTo(entry.key, options);
}
/** Equivalent to `navigation.forward()`. */
/**
* Equivalent to `navigation.forward()`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-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,
};
return earlyErrorResult(new DOMException('Cannot go forward', 'InvalidStateError'));
}
const entry = this.entriesArr[this.currentEntryIndex + 1];
return this.traverseTo(entry.key, options);
@@ -385,15 +368,7 @@ export class FakeNavigation implements Navigation {
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 destination = this.createDestinationFromEntry(entry);
const result = new InternalNavigationResult(this);
const intercepted = this.userAgentNavigate(destination, result, {
navigationType: 'traverse',
@@ -409,6 +384,39 @@ export class FakeNavigation implements Navigation {
});
}
/** Creates a FakeNavigationDestination matching a given history entry. */
private createDestinationFromEntry(entry: FakeNavigationHistoryEntry): FakeNavigationDestination {
return new FakeNavigationDestination({
url: entry.url!,
state: entry.getState(),
historyState: entry.getHistoryState(),
key: entry.key,
id: entry.id,
index: entry.index,
sameDocument: entry.sameDocument,
});
}
/**
* Implementation of "performing a non-traverse navigation" from the spec.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-navigate
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-reload
*/
private performNonTraverseNavigation(
destination: FakeNavigationDestination,
options: InternalNavigateOptions,
): FakeNavigationResult {
const result = new InternalNavigationResult(this);
const intercepted = this.userAgentNavigate(destination, result, options);
if (!intercepted) {
this.updateNavigationEntriesForSameDocumentNavigation(this.navigateEvent!);
}
return {
committed: result.committed,
finished: result.finished,
};
}
/** Runs a traversal synchronously or asynchronously */
private runTraversal(traversal: () => void) {
if (this.synchronousTraversals) {
@@ -479,6 +487,7 @@ export class FakeNavigation implements Navigation {
/**
* Implementation for all navigations and traversals.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigate-event-firing
* @returns true if the event was intercepted, otherwise false
*/
private userAgentNavigate(
@@ -507,6 +516,8 @@ export class FakeNavigation implements Navigation {
hashChange: options.hashChange,
destination,
info: options.info,
hasUAVisualTransition: options.hasUAVisualTransition,
sourceElement: options.sourceElement,
sameDocument: destination.sameDocument,
result,
});
@@ -515,8 +526,8 @@ export class FakeNavigation implements Navigation {
/**
* Implementation for a push or replace navigation.
* https://whatpr.org/html/10919/browsing-the-web.html#url-and-history-update-steps
* https://whatpr.org/html/10919/nav-history-apis.html#update-the-navigation-api-entries-for-a-same-document-navigation
* https://html.spec.whatwg.org/multipage/browsing-the-web.html#url-and-history-update-steps
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#update-the-navigation-api-entries-for-a-same-document-navigation
* @internal
*/
urlAndHistoryUpdateSteps(navigateEvent: InternalFakeNavigateEvent) {
@@ -526,18 +537,18 @@ export class FakeNavigation implements Navigation {
/**
* Implementation for a traverse navigation.
*
* https://whatpr.org/html/10919/browsing-the-web.html#apply-the-traverse-history-step
* https://html.spec.whatwg.org/multipage/browsing-the-web.html#apply-the-traverse-history-step
* ...
* > Let updateDocument be an algorithm step which performs update document for history step application given targetEntry's document, targetEntry, changingNavigableContinuation's update-only, scriptHistoryLength, scriptHistoryIndex, navigationType, entriesForNavigationAPI, and previousEntry.
* > If targetEntry's document is equal to displayedDocument, then perform updateDocument.
* https://whatpr.org/html/10919/browsing-the-web.html#update-document-for-history-step-application
* which then goes to https://whatpr.org/html/10919/nav-history-apis.html#update-the-navigation-api-entries-for-a-same-document-navigation
* https://html.spec.whatwg.org/multipage/browsing-the-web.html#update-document-for-history-step-application
* which then goes to https://html.spec.whatwg.org/multipage/nav-history-apis.html#update-the-navigation-api-entries-for-a-same-document-navigation
* @internal
*/
userAgentTraverse(navigateEvent: InternalFakeNavigateEvent) {
const oldUrl = this.currentEntry.url!;
this.updateNavigationEntriesForSameDocumentNavigation(navigateEvent);
// Happens as part of "updating the document" steps https://whatpr.org/html/10919/browsing-the-web.html#updating-the-document
// Happens as part of "updating the document" steps https://html.spec.whatwg.org/multipage/browsing-the-web.html#updating-the-document
const popStateEvent = createPopStateEvent({
state: navigateEvent.destination.getHistoryState(),
hasUAVisualTransition: navigateEvent.hasUAVisualTransition,
@@ -550,7 +561,7 @@ export class FakeNavigation implements Navigation {
}
/**
* https://whatpr.org/html/10919/nav-history-apis.html#update-the-navigation-api-entries-for-a-same-document-navigation
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#update-the-navigation-api-entries-for-a-same-document-navigation
* @internal
*/
updateNavigationEntriesForSameDocumentNavigation({
@@ -587,6 +598,8 @@ export class FakeNavigation implements Navigation {
historyState: destination.getHistoryState(),
});
this.entriesArr[this.currentEntryIndex] = newNHE;
} else if (navigationType === 'reload') {
oldCurrentNHE.setState(destination.getState());
}
result.committedResolve(this.currentEntry);
const currentEntryChangeEvent = createFakeNavigationCurrentEntryChangeEvent({
@@ -607,54 +620,79 @@ export class FakeNavigation implements Navigation {
return undefined;
}
set onnavigate(
// tslint:disable-next-line:no-any
_handler: ((this: Navigation, ev: NavigateEvent) => any) | null,
) {
throw new Error('unimplemented');
}
private _onnavigate: ((this: Navigation, ev: NavigateEvent) => any) | null = null;
/**
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#handler-navigation-onnavigate
*/
// tslint:disable-next-line:no-any
get onnavigate(): ((this: Navigation, ev: NavigateEvent) => any) | null {
throw new Error('unimplemented');
return this._onnavigate;
}
set oncurrententrychange(
_handler:
// tslint:disable-next-line:no-any
((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) | null,
set onnavigate(
// tslint:disable-next-line:no-any
handler: ((this: Navigation, ev: NavigateEvent) => any) | null,
) {
throw new Error('unimplemented');
this._onnavigate = setEventHandler(this, 'navigate', this._onnavigate, handler);
}
private _oncurrententrychange:
((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) | null = null;
/**
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#handler-navigation-oncurrententrychange
*/
get oncurrententrychange():
// tslint:disable-next-line:no-any
((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) | null {
throw new Error('unimplemented');
| // tslint:disable-next-line:no-any
((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)
| null {
return this._oncurrententrychange;
}
set onnavigatesuccess(
// tslint:disable-next-line:no-any
_handler: ((this: Navigation, ev: Event) => any) | null,
set oncurrententrychange(
handler:
| // tslint:disable-next-line:no-any
((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)
| null,
) {
throw new Error('unimplemented');
this._oncurrententrychange = setEventHandler(
this,
'currententrychange',
this._oncurrententrychange,
handler,
);
}
private _onnavigatesuccess: ((this: Navigation, ev: Event) => any) | null = null;
/**
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#handler-navigation-onnavigatesuccess
*/
// tslint:disable-next-line:no-any
get onnavigatesuccess(): ((this: Navigation, ev: Event) => any) | null {
throw new Error('unimplemented');
return this._onnavigatesuccess;
}
set onnavigateerror(
set onnavigatesuccess(
// tslint:disable-next-line:no-any
_handler: ((this: Navigation, ev: ErrorEvent) => any) | null,
handler: ((this: Navigation, ev: Event) => any) | null,
) {
throw new Error('unimplemented');
this._onnavigatesuccess = setEventHandler(
this,
'navigatesuccess',
this._onnavigatesuccess,
handler,
);
}
private _onnavigateerror: ((this: Navigation, ev: ErrorEvent) => any) | null = null;
/**
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#handler-navigation-onnavigateerror
*/
// tslint:disable-next-line:no-any
get onnavigateerror(): ((this: Navigation, ev: ErrorEvent) => any) | null {
throw new Error('unimplemented');
return this._onnavigateerror;
}
set onnavigateerror(
// tslint:disable-next-line:no-any
handler: ((this: Navigation, ev: ErrorEvent) => any) | null,
) {
this._onnavigateerror = setEventHandler(this, 'navigateerror', this._onnavigateerror, handler);
}
private _transition: NavigationTransition | null = null;
@@ -666,12 +704,57 @@ export class FakeNavigation implements Navigation {
return this._transition;
}
updateCurrentEntry(_options: NavigationUpdateCurrentEntryOptions): void {
throw new Error('unimplemented');
/**
* Equivalent to `navigation.updateCurrentEntry()`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-updatecurrententry
*/
updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void {
const current = this.currentEntry;
if (!current) {
throw new DOMException(
'Cannot update current entry when current is null',
'InvalidStateError',
);
}
current.setState(cloneState(options.state));
const currentEntryChangeEvent = createFakeNavigationCurrentEntryChangeEvent({
from: current,
navigationType: null,
});
this.eventTarget.dispatchEvent(currentEntryChangeEvent);
}
reload(_options?: NavigationReloadOptions): NavigationResult {
throw new Error('unimplemented');
/**
* Equivalent to `navigation.reload()`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-reload
*/
reload(options?: NavigationReloadOptions): FakeNavigationResult {
const current = this.currentEntry;
if (!current) {
return earlyErrorResult(
new DOMException('Cannot reload when currentEntry is null', 'InvalidStateError'),
);
}
const state = options && 'state' in options ? options.state : current.getState();
const destination = new FakeNavigationDestination({
url: current.url!,
state,
historyState: current.getHistoryState(),
key: '',
id: '',
index: -1,
sameDocument: current.sameDocument,
});
return this.performNonTraverseNavigation(destination, {
navigationType: 'reload',
cancelable: true,
canIntercept: true,
userInitiated: false,
hashChange: false,
info: options?.info,
});
}
}
@@ -686,6 +769,7 @@ interface FakeNavigationResult extends NavigationResult {
/**
* Fake equivalent of `NavigationHistoryEntry`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-navigationhistoryentry-interface
*/
export class FakeNavigationHistoryEntry implements NavigationHistoryEntry {
readonly sameDocument: boolean;
@@ -693,11 +777,23 @@ export class FakeNavigationHistoryEntry implements NavigationHistoryEntry {
readonly id: string;
readonly key: string;
readonly index: number;
private readonly state: unknown;
private state: unknown;
private readonly historyState: unknown;
private _ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null = null;
/**
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigationhistoryentry-ondispose
*/
// tslint:disable-next-line:no-any
ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null = null;
get ondispose(): ((this: NavigationHistoryEntry, ev: Event) => any) | null {
return this._ondispose;
}
set ondispose(
// tslint:disable-next-line:no-any
handler: ((this: NavigationHistoryEntry, ev: Event) => any) | null,
) {
this._ondispose = setEventHandler(this, 'dispose', this._ondispose, handler);
}
constructor(
private eventTarget: EventTarget,
@@ -726,16 +822,20 @@ export class FakeNavigationHistoryEntry implements NavigationHistoryEntry {
this.historyState = historyState;
}
/**
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigationhistoryentry-getstate
*/
getState(): unknown {
// Budget copy.
return this.state ? (JSON.parse(JSON.stringify(this.state)) as unknown) : this.state;
return cloneState(this.state);
}
/** @internal */
setState(state: unknown) {
this.state = state;
}
getHistoryState(): unknown {
// Budget copy.
return this.historyState
? (JSON.parse(JSON.stringify(this.historyState)) as unknown)
: this.historyState;
return cloneState(this.historyState);
}
addEventListener(
@@ -758,34 +858,23 @@ export class FakeNavigationHistoryEntry implements NavigationHistoryEntry {
return this.eventTarget.dispatchEvent(event);
}
/** internal */
/**
* internal
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigationhistoryentry-ondispose
*/
dispose() {
const disposeEvent = new Event('disposed');
const disposeEvent = new Event('dispose');
this.dispatchEvent(disposeEvent);
// release current listeners
this.eventTarget = null!;
}
}
/** `NavigationInterceptOptions` with experimental commit option. */
export interface ExperimentalNavigationInterceptOptions extends NavigationInterceptOptions {
precommitHandler?: (controller: NavigationPrecommitController) => Promise<void>;
}
export interface NavigationPrecommitController {
redirect: (url: string, options?: NavigationNavigateOptions) => void;
}
export interface ExperimentalNavigateEvent extends NavigateEvent {
intercept(options?: ExperimentalNavigationInterceptOptions): void;
precommitHandler?: () => Promise<void>;
}
/**
* Fake equivalent of `NavigateEvent`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-navigateevent-interface
*/
export interface FakeNavigateEvent extends ExperimentalNavigateEvent {
export interface FakeNavigateEvent extends NavigateEvent {
readonly destination: FakeNavigationDestination;
}
@@ -815,6 +904,7 @@ function dispatchNavigateEvent({
navigationType,
destination,
info,
sourceElement = null,
sameDocument,
result,
}: {
@@ -826,6 +916,7 @@ function dispatchNavigateEvent({
navigationType: NavigationType;
destination: FakeNavigationDestination;
info: unknown;
sourceElement?: Element | null;
sameDocument: boolean;
result: InternalNavigationResult;
}) {
@@ -847,6 +938,7 @@ function dispatchNavigateEvent({
event.signal = eventAbortController.signal;
event.abortController = eventAbortController;
event.info = info;
event.sourceElement = sourceElement;
event.focusResetBehavior = null;
event.scrollBehavior = null;
event.interceptionState = 'none';
@@ -855,13 +947,15 @@ function dispatchNavigateEvent({
event.result = result;
event.sameDocument = sameDocument;
let precommitHandlers: Array<(controller: NavigationPrecommitController) => Promise<void>> = [];
let precommitHandlers: Array<
(controller: NavigationPrecommitController) => PromiseLike<void> | void
> = [];
let handlers: Array<() => PromiseLike<void> | void> = [];
// https://whatpr.org/html/10919/nav-history-apis.html#dom-navigateevent-intercept
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigateevent-intercept
event.intercept = function (
this: InternalFakeNavigateEvent,
options?: ExperimentalNavigationInterceptOptions,
options?: NavigationInterceptOptions,
): void {
if (!this.canIntercept) {
throw new DOMException(`Cannot intercept when canIntercept is 'false'`, 'SecurityError');
@@ -891,7 +985,7 @@ function dispatchNavigateEvent({
event.scrollBehavior = options?.scroll ?? event.scrollBehavior;
};
// https://whatpr.org/html/10919/nav-history-apis.html#dom-navigateevent-scroll
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigateevent-scroll
event.scroll = function (this: InternalFakeNavigateEvent): void {
if (event.interceptionState !== 'committed') {
throw new DOMException(
@@ -903,7 +997,7 @@ function dispatchNavigateEvent({
processScrollBehavior(event);
};
// https://whatpr.org/html/10919/nav-history-apis.html#dom-navigationprecommitcontroller-redirect
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigationprecommitcontroller-redirect
function redirect(url: string, options: NavigationNavigateOptions = {}) {
if (event.interceptionState === 'none') {
throw new Error('cannot redirect when event is not intercepted');
@@ -933,6 +1027,17 @@ function dispatchNavigateEvent({
}
}
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigationprecommitcontroller-addhandler
function addHandler(handler: () => PromiseLike<void> | void) {
if (event.interceptionState !== 'intercepted') {
throw new DOMException(
`cannot addHandler when event is not in 'intercepted' state`,
'InvalidStateError',
);
}
handlers.push(handler);
}
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#process-navigate-event-handler-failure
function processNavigateEventHandlerFailure(reason: any) {
if (event.abortController.signal.aborted) {
@@ -974,10 +1079,13 @@ function dispatchNavigateEvent({
(navigation.transition as InternalNavigationTransition)?.committedResolve();
const promisesList: Array<PromiseLike<unknown>> = [];
for (const handler of handlers) {
const handlerResult = handler();
if (handlerResult) {
promisesList.push(handlerResult);
try {
const handlerResult = handler();
if (handlerResult) {
promisesList.push(handlerResult);
}
} catch (e) {
promisesList.push(Promise.reject(e));
}
}
promisesList.push(result.committed);
@@ -1063,17 +1171,23 @@ function dispatchNavigateEvent({
if (precommitHandlers.length === 0) {
commit();
} else {
const precommitController: NavigationPrecommitController = {redirect};
const precommitPromisesList = precommitHandlers.map((handler) => {
let p: Promise<void>;
const precommitController: NavigationPrecommitController = {redirect, addHandler};
const precommitPromisesList: Array<PromiseLike<unknown>> = [];
for (const handler of precommitHandlers) {
try {
p = handler(precommitController);
const handlerResult = handler(precommitController);
if (handlerResult) {
if (typeof (handlerResult as any).catch === 'function') {
(handlerResult as any).catch(() => {});
}
precommitPromisesList.push(handlerResult);
}
} catch (e) {
p = Promise.reject(e);
const rejected = Promise.reject(e);
rejected.catch(() => {});
precommitPromisesList.push(rejected);
}
p.catch(() => {});
return p;
});
}
Promise.all(precommitPromisesList)
.then(() => commit())
.catch(processNavigateEventHandlerFailure);
@@ -1085,7 +1199,7 @@ function dispatchNavigateEvent({
return event.interceptionState === 'none';
}
/** https://whatpr.org/html/10919/nav-history-apis.html#navigateevent-finish */
/** https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigateevent-finish */
function finishNavigationEvent(event: InternalFakeNavigateEvent, didFulfill: boolean) {
if (event.interceptionState === 'finished') {
throw new Error('Attempting to finish navigation event that was already finished');
@@ -1107,7 +1221,7 @@ function finishNavigationEvent(event: InternalFakeNavigateEvent, didFulfill: boo
event.interceptionState = 'finished';
}
/** https://whatpr.org/html/10919/nav-history-apis.html#potentially-reset-the-focus */
/** https://html.spec.whatwg.org/multipage/nav-history-apis.html#potentially-reset-the-focus */
function potentiallyResetFocus(event: InternalFakeNavigateEvent) {
if (event.interceptionState !== 'committed' && event.interceptionState !== 'scrolled') {
throw new Error('cannot reset focus if navigation event is not committed or scrolled');
@@ -1118,6 +1232,7 @@ function potentiallyResetFocus(event: InternalFakeNavigateEvent) {
// TODO(atscott): the rest of the steps
}
/** https://html.spec.whatwg.org/multipage/nav-history-apis.html#potentially-reset-the-scroll-position */
function potentiallyResetScroll(event: InternalFakeNavigateEvent) {
if (event.interceptionState !== 'committed' && event.interceptionState !== 'scrolled') {
throw new Error('cannot reset scroll if navigation event is not committed or scrolled');
@@ -1128,7 +1243,7 @@ function potentiallyResetScroll(event: InternalFakeNavigateEvent) {
processScrollBehavior(event);
}
/* https://whatpr.org/html/10919/nav-history-apis.html#process-scroll-behavior */
/** https://html.spec.whatwg.org/multipage/nav-history-apis.html#process-scroll-behavior */
function processScrollBehavior(event: InternalFakeNavigateEvent) {
if (event.interceptionState !== 'committed') {
throw new Error('invalid event interception state when processing scroll behavior');
@@ -1137,7 +1252,10 @@ function processScrollBehavior(event: InternalFakeNavigateEvent) {
// TODO(atscott): the rest of the steps
}
/** Fake equivalent of `NavigationCurrentEntryChangeEvent`. */
/**
* Fake equivalent of `NavigationCurrentEntryChangeEvent`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-navigationcurrententrychangeevent-interface
*/
export interface FakeNavigationCurrentEntryChangeEvent extends NavigationCurrentEntryChangeEvent {
readonly from: FakeNavigationHistoryEntry;
}
@@ -1145,13 +1263,14 @@ export interface FakeNavigationCurrentEntryChangeEvent extends NavigationCurrent
/**
* Create a fake equivalent of `NavigationCurrentEntryChange`. This does not use
* a class because ES5 transpiled JavaScript cannot extend native Event.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-navigationcurrententrychangeevent-interface
*/
function createFakeNavigationCurrentEntryChangeEvent({
from,
navigationType,
}: {
from: FakeNavigationHistoryEntry;
navigationType: NavigationType;
navigationType: NavigationType | null;
}) {
const event = new Event('currententrychange', {
bubbles: false,
@@ -1198,6 +1317,7 @@ function createHashChangeEvent(newURL: string, oldURL: string) {
/**
* Fake equivalent of `NavigationDestination`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-navigationdestination-interface
*/
export class FakeNavigationDestination implements NavigationDestination {
url: string;
@@ -1254,6 +1374,48 @@ function isHashChange(from: URL, to: URL): boolean {
);
}
/**
* Sets an IDL event listener attribute, removing the old listener and adding the new one.
*/
function setEventHandler<H extends ((...args: any[]) => any) | null>(
target: EventTarget,
type: string,
current: H,
next: H,
): H {
if (current) {
target.removeEventListener(type, current as EventListener);
}
if (next) {
target.addEventListener(type, next as EventListener);
}
return next;
}
/** Budget structured clone for state objects. */
function cloneState<T>(state: T): T {
return state !== undefined && state !== null ? (JSON.parse(JSON.stringify(state)) as T) : state;
}
/**
* Creates a FakeNavigationResult where both committed and finished promises
* are rejected with the given error and marked as handled.
*/
function earlyErrorResult(error: DOMException): FakeNavigationResult {
const committed = Promise.reject(error);
const finished = Promise.reject(error);
committed.catch(() => {});
finished.catch(() => {});
return {
committed,
finished,
};
}
/**
* Fake equivalent of `NavigationTransition`.
* https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-navigationtransition-interface
*/
class InternalNavigationTransition implements NavigationTransition {
readonly finished: Promise<void>;
readonly committed: Promise<void>;
@@ -1335,4 +1497,6 @@ interface InternalNavigateOptions {
userInitiated: boolean;
hashChange: boolean;
info?: unknown;
hasUAVisualTransition?: boolean;
sourceElement?: Element | null;
}
@@ -8,6 +8,7 @@ ts_project(
),
visibility = ["//:__pkg__"],
deps = [
"//packages/core/primitives/dom-navigation",
"//packages/core/primitives/dom-navigation/testing",
"//packages/private/testing",
],
@@ -7,7 +7,10 @@
*/
import {
ExperimentalNavigationInterceptOptions,
NavigationInterceptOptions,
NavigationPrecommitController,
} from '../../src/navigation_types';
import {
FakeNavigateEvent,
FakeNavigation,
FakeNavigationCurrentEntryChangeEvent,
@@ -21,7 +24,7 @@ interface Locals {
navigateEvents: FakeNavigateEvent[];
navigationCurrentEntryChangeEvents: FakeNavigationCurrentEntryChangeEvent[];
popStateEvents: PopStateEvent[];
pendingInterceptOptions: ExperimentalNavigationInterceptOptions[];
pendingInterceptOptions: NavigationInterceptOptions[];
nextNavigateEvent: () => Promise<FakeNavigateEvent>;
setExtraNavigateCallback: (callback: (event: FakeNavigateEvent) => void) => void;
}
@@ -53,7 +56,7 @@ describe('navigation', () => {
});
const navigationCurrentEntryChangeEvents: FakeNavigationCurrentEntryChangeEvent[] = [];
const popStateEvents: PopStateEvent[] = [];
const pendingInterceptOptions: ExperimentalNavigationInterceptOptions[] = [];
const pendingInterceptOptions: NavigationInterceptOptions[] = [];
let extraNavigateCallback: ((event: FakeNavigateEvent) => void) | undefined = undefined;
navigation.addEventListener('navigate', (event: Event) => {
@@ -2186,4 +2189,244 @@ describe('navigation', () => {
expect(locals.navigateEvents.length).toBe(1);
});
});
describe('addHandler', () => {
it('executes post-commit handlers registered during precommit', async () => {
const order: string[] = [];
locals.pendingInterceptOptions.push({
precommitHandler: (controller) => {
order.push('precommit');
controller.addHandler(() => {
order.push('postcommit-from-precommit');
});
},
handler: () => {
order.push('postcommit');
},
});
await locals.navigation.navigate('/test').finished;
expect(order).toEqual(['precommit', 'postcommit', 'postcommit-from-precommit']);
});
it('waits for async handlers added via addHandler before finished resolves', async () => {
let resolveHandler!: () => void;
const handlerPromise = new Promise<void>((resolve) => {
resolveHandler = resolve;
});
locals.pendingInterceptOptions.push({
precommitHandler: (controller) => {
controller.addHandler(() => handlerPromise);
},
});
const {finished} = locals.navigation.navigate('/test');
await expectAsync(finished).toBePending();
resolveHandler();
await expectAsync(finished).toBeResolved();
});
it('throws InvalidStateError when addHandler is called outside intercepted state', async () => {
let controller!: NavigationPrecommitController;
locals.pendingInterceptOptions.push({
precommitHandler: (c) => {
controller = c;
},
});
await locals.navigation.navigate('/test').finished;
expect(() => controller.addHandler(() => {})).toThrowMatching(
(e: any) => e.name === 'InvalidStateError',
);
});
});
describe('updateCurrentEntry', () => {
it('updates currentEntry state and dispatches currententrychange with null navigationType', () => {
const initialEntry = locals.navigation.currentEntry;
expect(initialEntry.getState()).toBeUndefined();
locals.navigation.updateCurrentEntry({state: {updated: 123}});
expect(locals.navigation.currentEntry).toBe(initialEntry);
expect(locals.navigation.currentEntry.getState()).toEqual({updated: 123});
expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1);
expect(locals.navigationCurrentEntryChangeEvents[0].from).toBe(initialEntry);
expect(locals.navigationCurrentEntryChangeEvents[0].navigationType).toBeNull();
});
it('clones state so modifications do not affect entry state', () => {
const state = {count: 1};
locals.navigation.updateCurrentEntry({state});
state.count = 2;
expect(locals.navigation.currentEntry.getState()).toEqual({count: 1});
const returnedState = locals.navigation.currentEntry.getState() as {count: number};
returnedState.count = 3;
expect(locals.navigation.currentEntry.getState()).toEqual({count: 1});
});
});
describe('reload', () => {
it('reloads the current URL and dispatches navigate event with reload type', async () => {
const initialEntry = locals.navigation.currentEntry;
const result = locals.navigation.reload({info: 'reloadInfo'});
expect(locals.navigateEvents.length).toBe(1);
expect(locals.navigateEvents[0].navigationType).toBe('reload');
expect(locals.navigateEvents[0].info).toBe('reloadInfo');
expect(locals.navigateEvents[0].destination.url).toBe(initialEntry.url!);
expect(locals.navigateEvents[0].destination.key).toBe('');
expect(locals.navigateEvents[0].destination.id).toBe('');
expect(locals.navigateEvents[0].destination.index).toBe(-1);
const reloadedEntry = await result.finished;
expect(reloadedEntry).toBe(initialEntry);
expect(reloadedEntry.url).toBe(initialEntry.url!);
expect(reloadedEntry.key).toBe(initialEntry.key);
expect(reloadedEntry.id).toBe(initialEntry.id);
});
it('allows intercepting reload', async () => {
let intercepted = false;
locals.pendingInterceptOptions.push({
handler: () => {
intercepted = true;
},
});
await locals.navigation.reload().finished;
expect(intercepted).toBeTrue();
expect(locals.navigateEvents[0].navigationType).toBe('reload');
});
it('reloads with custom state when provided', async () => {
const result = locals.navigation.reload({state: {reloaded: true}});
const entry = await result.finished;
expect(entry.getState()).toEqual({reloaded: true});
});
it('preserves existing state when state option is omitted', async () => {
locals.navigation.updateCurrentEntry({state: {preserved: 'yes'}});
const result = locals.navigation.reload();
const entry = await result.finished;
expect(entry.getState()).toEqual({preserved: 'yes'});
});
it('resets state when explicitly passed state: undefined', async () => {
locals.navigation.updateCurrentEntry({state: {toReset: true}});
const result = locals.navigation.reload({state: undefined});
const entry = await result.finished;
expect(entry.getState()).toBeUndefined();
});
it('does not dispose the current entry on reload', async () => {
const initialEntry = locals.navigation.currentEntry;
let disposed = false;
initialEntry.ondispose = () => {
disposed = true;
};
await locals.navigation.reload().finished;
expect(disposed).toBeFalse();
});
});
describe('NavigateEvent properties', () => {
it('has default hasUAVisualTransition and sourceElement values', async () => {
await locals.navigation.navigate('/page').finished;
const event = locals.navigateEvents[0];
expect(event.hasUAVisualTransition).toBeFalsy();
expect(event.sourceElement).toBeNull();
});
});
describe('NavigationHistoryEntry dispose event', () => {
it('fires "dispose" event and triggers ondispose when entry is replaced', async () => {
const firstEntry = locals.navigation.currentEntry;
let disposeEventFired = false;
let ondisposeFired = false;
firstEntry.addEventListener('dispose', () => {
disposeEventFired = true;
});
firstEntry.ondispose = () => {
ondisposeFired = true;
};
await locals.navigation.navigate('/replaced', {history: 'replace'}).finished;
expect(disposeEventFired).toBeTrue();
expect(ondisposeFired).toBeTrue();
});
it('allows removing ondispose handler by setting it to null', async () => {
const firstEntry = locals.navigation.currentEntry;
let ondisposeFired = false;
firstEntry.ondispose = () => {
ondisposeFired = true;
};
firstEntry.ondispose = null;
await locals.navigation.navigate('/replaced', {history: 'replace'}).finished;
expect(ondisposeFired).toBeFalse();
});
});
describe('Navigation event handlers (on*)', () => {
it('handles onnavigate, onnavigatesuccess, and oncurrententrychange properties', async () => {
let onNavigateCalled = false;
let onNavigateSuccessCalled = false;
let onCurrentEntryChangeCalled = false;
locals.navigation.onnavigate = () => {
onNavigateCalled = true;
};
locals.navigation.onnavigatesuccess = () => {
onNavigateSuccessCalled = true;
};
locals.navigation.oncurrententrychange = () => {
onCurrentEntryChangeCalled = true;
};
await locals.navigation.navigate('/test').finished;
expect(onNavigateCalled).toBeTrue();
expect(onNavigateSuccessCalled).toBeTrue();
expect(onCurrentEntryChangeCalled).toBeTrue();
});
it('handles onnavigateerror property when navigation fails', async () => {
let errorEvent: ErrorEvent | undefined;
const expectedError = new Error('handler failure');
locals.navigation.onnavigate = (event) => {
event.intercept({
handler: () => {
throw expectedError;
},
});
};
locals.navigation.onnavigateerror = (event) => {
errorEvent = event;
};
await expectAsync(locals.navigation.navigate('/test').finished).toBeRejectedWith(
expectedError,
);
expect(errorEvent).toBeDefined();
expect(errorEvent?.error).toBe(expectedError);
});
it('removes event listener when handler property is set to null', async () => {
let onNavigateCalled = false;
locals.navigation.onnavigate = () => {
onNavigateCalled = true;
};
locals.navigation.onnavigate = null;
await locals.navigation.navigate('/test').finished;
expect(onNavigateCalled).toBeFalse();
});
});
});