refactor(core): update FakeNavigation to the latest spec (#62017)

Spec updates are in https://github.com/whatwg/html/pull/10919

For the most part, the updates revolve around the deferred commit
handling (with precommitHandler). Updates to redirect allow more
options. A committed promise now exists on the transition since commits
can be delayed. Tests were made zoneless for easier debugging and
timeouts were reduced.

PR Close #62017
This commit is contained in:
Andrew Scott 2025-06-11 11:25:15 -07:00 committed by Kristiyan Kostadinov
parent 900cd37f68
commit fa5ae9228d
4 changed files with 495 additions and 60 deletions

View file

@ -67,6 +67,7 @@ export declare class NavigationTransition {
readonly navigationType: NavigationTypeString;
readonly from: NavigationHistoryEntry;
readonly finished: Promise<void>;
readonly committed: Promise<void>;
}
export interface NavigationHistoryEntryEventMap {

View file

@ -241,8 +241,9 @@ export class FakeNavigation implements Navigation {
const destination = new FakeNavigationDestination({
url: toUrl.toString(),
sameDocument: true,
sameDocument: true, // history.pushState/replaceState are always same-document
historyState: data,
state: undefined, // No Navigation API state directly from history.pushState
});
const result = new InternalNavigationResult(this);
@ -457,6 +458,17 @@ export class FakeNavigation implements Navigation {
return this.disposed;
}
abortOngoingNavigation(eventToAbort: InternalFakeNavigateEvent, reason?: Error) {
if (this.navigateEvent !== eventToAbort) {
return;
}
if (this.navigateEvent.abortController.signal.aborted) {
return;
}
const abortReason = reason ?? new DOMException('Navigation aborted', 'AbortError');
this.navigateEvent.cancel(abortReason);
}
/**
* Implementation for all navigations and traversals.
* @returns true if the event was intercepted, otherwise false
@ -470,22 +482,27 @@ export class FakeNavigation implements Navigation {
// entry.
this.canSetInitialEntry = false;
if (this.navigateEvent) {
this.navigateEvent.cancel(new DOMException('Navigation was aborted', 'AbortError'));
this.navigateEvent = null;
this.abortOngoingNavigation(
this.navigateEvent,
new DOMException('Navigation superseded by a new navigation.', 'AbortError'),
);
}
return dispatchNavigateEvent({
// TODO(atscott): Disposing doesn't really do much because new requests are still processed
// if (this.disposed) {
// return false;
// }
const dispatchResultIsTrueIfNoInterception = dispatchNavigateEvent({
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,
result,
});
return !dispatchResultIsTrueIfNoInterception;
}
/**
@ -548,7 +565,10 @@ export class FakeNavigation implements Navigation {
}
if (navigationType === 'push' || navigationType === 'replace') {
const index = this.currentEntryIndex;
const key = navigationType === 'push' ? String(this.nextKey++) : this.currentEntry.key;
const key =
navigationType === 'push'
? String(this.nextKey++)
: (oldCurrentNHE?.key ?? String(this.nextKey++));
const newNHE = new FakeNavigationHistoryEntry(this.eventTarget, destination.url, {
id: String(this.nextId++),
key,
@ -742,7 +762,7 @@ export interface ExperimentalNavigationInterceptOptions extends NavigationInterc
}
export interface NavigationPrecommitController {
redirect: (url: string) => void;
redirect: (url: string, options?: NavigationNavigateOptions) => void;
}
export interface ExperimentalNavigateEvent extends NavigateEvent {
@ -765,6 +785,7 @@ interface InternalFakeNavigateEvent extends FakeNavigateEvent {
scrollBehavior: 'after-transition' | 'manual' | null;
focusResetBehavior: 'after-transition' | 'manual' | null;
abortController: AbortController;
cancel(reason: Error): void;
}
@ -780,7 +801,6 @@ function dispatchNavigateEvent({
userInitiated,
hashChange,
navigationType,
signal,
destination,
info,
sameDocument,
@ -791,30 +811,32 @@ function dispatchNavigateEvent({
userInitiated: boolean;
hashChange: boolean;
navigationType: NavigationTypeString;
signal: AbortSignal;
destination: FakeNavigationDestination;
info: unknown;
sameDocument: boolean;
result: InternalNavigationResult;
}) {
const {navigation} = result;
const eventAbortController = new AbortController();
const event = new Event('navigate', {bubbles: false, cancelable}) as {
-readonly [P in keyof InternalFakeNavigateEvent]: InternalFakeNavigateEvent[P];
};
event.focusResetBehavior = null;
event.scrollBehavior = null;
event.interceptionState = 'none';
event.navigationType = navigationType;
event.destination = destination;
event.canIntercept = canIntercept;
event.userInitiated = userInitiated;
event.hashChange = hashChange;
event.navigationType = navigationType;
event.signal = signal;
event.destination = destination;
event.signal = eventAbortController.signal;
event.abortController = eventAbortController;
event.info = info;
event.focusResetBehavior = null;
event.scrollBehavior = null;
event.interceptionState = 'none';
event.downloadRequest = null;
event.formData = null;
event.result = result;
event.sameDocument = sameDocument;
let precommitHandlers: Array<(controller: NavigationPrecommitController) => Promise<void>> = [];
@ -866,7 +888,7 @@ function dispatchNavigateEvent({
};
// https://whatpr.org/html/10919/nav-history-apis.html#dom-navigationprecommitcontroller-redirect
function redirect(url: string) {
function redirect(url: string, options: NavigationNavigateOptions = {}) {
if (event.interceptionState === 'none') {
throw new Error('cannot redirect when event is not intercepted');
}
@ -882,8 +904,17 @@ function dispatchNavigateEvent({
'InvalidStateError',
);
}
const toUrl = new URL(url, navigation.currentEntry.url!);
event.destination.url = toUrl.href;
const destinationUrl = new URL(url, navigation.currentEntry.url!);
if (options.history === 'push' || options.history === 'replace') {
event.navigationType = options.history;
}
if (options.hasOwnProperty('state')) {
event.destination.state = options.state;
}
event.destination.url = destinationUrl.href;
if (options.hasOwnProperty('info')) {
event.info = options.info;
}
}
// https://whatpr.org/html/10919/nav-history-apis.html#inner-navigate-event-firing-algorithm
@ -892,15 +923,9 @@ function dispatchNavigateEvent({
if (result.signal.aborted) {
return;
}
if (event.interceptionState !== 'none') {
(navigation.transition as InternalNavigationTransition)?.committedResolve();
if (event.interceptionState === 'intercepted') {
event.interceptionState = 'committed';
if (!navigation.currentEntry) {
throw new Error('from history entry should not be null');
}
navigation.transition = new InternalNavigationTransition(
navigation.currentEntry,
navigationType,
);
switch (event.navigationType) {
case 'push':
case 'replace': {
@ -929,19 +954,29 @@ function dispatchNavigateEvent({
return;
}
if (event !== navigation.navigateEvent) {
throw new Error("Navigation's ongoing event not equal to resolved event");
if (!result.signal.aborted && result.committedTo) {
result.finishedReject(
new DOMException('Navigation superseded before handler completion', 'AbortError'),
);
}
return;
}
navigation.navigateEvent = null;
finishNavigationEvent(event, true);
const navigatesuccessEvent = new Event('navigatesuccess', {bubbles: false, cancelable});
const navigatesuccessEvent = new Event('navigatesuccess', {
bubbles: false,
cancelable: false,
});
navigation.eventTarget.dispatchEvent(navigatesuccessEvent);
result.finishedResolve();
if (navigation.transition !== null) {
(navigation.transition as InternalNavigationTransition).finishedResolve();
}
(navigation.transition as InternalNavigationTransition)?.finishedResolve();
navigation.transition = null;
})
.catch((reason) => event.cancel(reason));
.catch((reason) => {
if (!event.abortController.signal.aborted) {
event.cancel(reason);
}
});
}
// Internal only.
@ -951,41 +986,90 @@ function dispatchNavigateEvent({
if (result.signal.aborted) {
return;
}
if (event !== navigation.navigateEvent) {
throw new Error("Navigation's ongoing event not equal to resolved event");
this.abortController.abort(reason);
const isCurrentGlobalNavigationEvent = this === navigation.navigateEvent;
if (isCurrentGlobalNavigationEvent) {
navigation.navigateEvent = null;
}
navigation.navigateEvent = null;
if (event.interceptionState !== 'intercepted') {
finishNavigationEvent(event, false);
if (this.interceptionState !== 'intercepted' && this.interceptionState !== 'finished') {
finishNavigationEvent(this, false);
} else if (this.interceptionState === 'intercepted') {
this.interceptionState = 'finished';
}
const navigateerrorEvent = new Event('navigateerror', {bubbles: false, cancelable});
const navigateerrorEvent = new Event('navigateerror', {
bubbles: false,
cancelable,
}) as ErrorEvent;
(navigateerrorEvent as unknown as {error: Error}).error = reason;
navigation.eventTarget.dispatchEvent(navigateerrorEvent);
result.finishedReject(reason);
if (navigation.transition !== null) {
(navigation.transition as InternalNavigationTransition).finishedReject(reason);
if (result.committedTo === null && !result.signal.aborted) {
result.committedReject(reason);
}
result.finishedReject(reason);
const transition = navigation.transition as InternalNavigationTransition | undefined;
transition?.committedReject(reason);
transition?.finishedReject(reason);
navigation.transition = null;
};
function dispatch() {
navigation.navigateEvent = event;
navigation.eventTarget.dispatchEvent(event);
const dispatchResult = navigation.eventTarget.dispatchEvent(event);
if (precommitHandlers.length === 0) {
commit();
if (event.interceptionState === 'intercepted') {
if (!navigation.currentEntry) {
event.cancel(
new DOMException(
'Cannot create transition without a currentEntry for intercepted navigation.',
'InvalidStateError',
),
);
return;
}
const transition = new InternalNavigationTransition(navigation.currentEntry, navigationType);
navigation.transition = transition;
// Mark transition.finished as handled (Spec Step 33.4)
transition.finished.catch(() => {});
transition.committed.catch(() => {});
}
if (!dispatchResult && event.cancelable) {
if (!event.abortController.signal.aborted) {
event.cancel(
new DOMException('Navigation prevented by event.preventDefault()', 'AbortError'),
);
}
} else {
const precommitController: NavigationPrecommitController = {redirect};
const precommitPromisesList = precommitHandlers.map((handler) =>
handler(precommitController),
);
Promise.all(precommitPromisesList)
.then(() => commit())
.catch((reason: Error) => event.cancel(reason));
if (precommitHandlers.length === 0) {
commit();
} else {
const precommitController: NavigationPrecommitController = {redirect};
const precommitPromisesList = precommitHandlers.map((handler) => {
let p: Promise<void>;
try {
p = handler(precommitController);
} catch (e) {
p = Promise.reject(e);
}
p.catch(() => {});
return p;
});
Promise.all(precommitPromisesList)
.then(() => commit())
.catch((reason: Error) => {
if (event.abortController.signal.aborted) {
return;
}
if (navigation.transition) {
(navigation.transition as InternalNavigationTransition).committedReject(reason);
}
event.cancel(reason);
});
}
}
}
dispatch();
return event.interceptionState !== 'none';
return event.interceptionState === 'none';
}
/** https://whatpr.org/html/10919/nav-history-apis.html#navigateevent-finish */
@ -997,7 +1081,6 @@ function finishNavigationEvent(event: InternalFakeNavigateEvent, didFulfill: boo
if (didFulfill === true) {
throw new Error('didFulfill should be false');
}
// assert precommit handlers is not empty
event.interceptionState = 'finished';
return;
}
@ -1016,7 +1099,10 @@ 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');
}
// TODO(atscott): The rest of the steps
if (event.focusResetBehavior === 'manual') {
return;
}
// TODO(atscott): the rest of the steps
}
function potentiallyResetScroll(event: InternalFakeNavigateEvent) {
@ -1098,7 +1184,7 @@ export class FakeNavigationDestination implements NavigationDestination {
readonly id: string | null;
readonly index: number;
private readonly state?: unknown;
state?: unknown;
private readonly historyState: unknown;
constructor({
@ -1148,8 +1234,11 @@ function isHashChange(from: URL, to: URL): boolean {
class InternalNavigationTransition implements NavigationTransition {
readonly finished: Promise<void>;
readonly committed: Promise<void>;
finishedResolve!: () => void;
finishedReject!: (reason: Error) => void;
committedResolve!: () => void;
committedReject!: (reason: Error) => void;
constructor(
readonly from: NavigationHistoryEntry,
readonly navigationType: NavigationTypeString,
@ -1158,8 +1247,13 @@ class InternalNavigationTransition implements NavigationTransition {
this.finishedReject = reject;
this.finishedResolve = resolve;
});
this.committed = new Promise<void>((resolve, reject) => {
this.committedReject = reject;
this.committedResolve = resolve;
});
// All rejections are handled.
this.finished.catch(() => {});
this.committed.catch(() => {});
}
}
@ -1189,7 +1283,7 @@ class InternalNavigationResult {
this.committedReject = reject;
});
this.finished = new Promise<FakeNavigationHistoryEntry>(async (resolve, reject) => {
this.finished = new Promise<FakeNavigationHistoryEntry>((resolve, reject) => {
this.finishedResolve = () => {
if (this.committedTo === null) {
throw new Error(

View file

@ -1,5 +1,5 @@
load("//tools:defaults.bzl", "karma_web_test_suite")
load("//tools:defaults2.bzl", "angular_jasmine_test", "ts_project")
load("//tools:defaults2.bzl", "ts_project", "zoneless_jasmine_test")
ts_project(
name = "test_lib",
@ -17,7 +17,7 @@ ts_project(
],
)
angular_jasmine_test(
zoneless_jasmine_test(
name = "test",
data = [
":test_lib_rjs",
@ -26,6 +26,7 @@ angular_jasmine_test(
karma_web_test_suite(
name = "test_web",
zoneless = True,
deps = [
":test_lib",
],

View file

@ -26,6 +26,8 @@ interface Locals {
setExtraNavigateCallback: (callback: (event: FakeNavigateEvent) => void) => void;
}
jasmine.DEFAULT_TIMEOUT_INTERVAL = 100;
describe('navigation', () => {
let locals: Locals;
@ -494,6 +496,34 @@ describe('navigation', () => {
await expectAsync(finished).toBeResolvedTo(committedEntry);
});
it('precommitHandler rejects', async () => {
const error = new Error('precommitHandler rejected');
locals.pendingInterceptOptions.push({
precommitHandler: () => Promise.reject(error),
});
const {committed, finished} = locals.navigation.navigate('/test-precommit-reject');
await expectAsync(committed).toBeRejectedWith(error);
await expectAsync(finished).toBeRejectedWith(error);
expect(locals.navigation.currentEntry.url).not.toBe('https://test.com/test-precommit-reject');
expect(locals.navigateEvents.length).toBe(1); // navigate event still fires
expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0); // No commit
});
it('precommitHandler throws', async () => {
const error = new Error('precommitHandler threw');
locals.pendingInterceptOptions.push({
precommitHandler: () => {
throw error;
},
});
const {committed, finished} = locals.navigation.navigate('/test-precommit-throw');
await expectAsync(committed).toBeRejectedWith(error);
await expectAsync(finished).toBeRejectedWith(error);
expect(locals.navigation.currentEntry.url).not.toBe('https://test.com/test-precommit-throw');
expect(locals.navigateEvents.length).toBe(1);
expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0);
});
it('deferred commit resolves on finished', async () => {
let handlerFinishedResolve!: () => void;
let precommitHandlerResolve!: () => void;
@ -631,6 +661,253 @@ describe('navigation', () => {
await expectAsync(finished).toBeRejectedWith(error);
expect(navigateEvent.signal.aborted).toBeTrue();
});
describe('precommitHandler with history API', () => {
it('is invoked when pushState triggers a navigation', async () => {
let precommitHandlerCalled = false;
locals.pendingInterceptOptions.push({
precommitHandler: async () => {
precommitHandlerCalled = true;
},
});
locals.navigation.pushState(null, '', '/pushed');
await new Promise((resolve) => setTimeout(resolve));
expect(precommitHandlerCalled).toBeTrue();
expect(locals.navigation.currentEntry.url).toBe('https://test.com/pushed');
});
it('precommitHandler rejects during pushState', async () => {
let precommitHandlerCalled = false;
locals.pendingInterceptOptions.push({
precommitHandler: () => {
precommitHandlerCalled = true;
return Promise.reject(new Error());
},
});
const nextEvent = locals.nextNavigateEvent();
locals.navigation.pushState(null, '', '/pushed-throw');
await new Promise(async (resolve) => {
(await nextEvent).signal.onabort = resolve;
});
expect(precommitHandlerCalled).toBeTrue();
expect(locals.navigation.currentEntry.url).not.toBe('https://test.com/pushed-reject');
expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0);
});
it('precommitHandler throws during pushState', async () => {
locals.pendingInterceptOptions.push({
precommitHandler: () => {
throw new Error();
},
});
locals.navigation.pushState(null, '', '/pushed-throw');
await new Promise((resolve) => setTimeout(resolve));
expect(locals.navigation.currentEntry.url).not.toBe('https://test.com/pushed-throw');
expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0);
});
it('is invoked when replaceState triggers a navigation', async () => {
let precommitHandlerCalled = false;
locals.pendingInterceptOptions.push({
precommitHandler: async () => {
precommitHandlerCalled = true;
},
});
locals.navigation.replaceState(null, '', '/replaced');
await new Promise((resolve) => setTimeout(resolve));
expect(precommitHandlerCalled).toBeTrue();
expect(locals.navigation.currentEntry.url).toBe('https://test.com/replaced');
});
it('precommitHandler rejects during replaceState', async () => {
const error = new Error('precommitHandler rejected for replaceState');
let precommitHandlerCalled = false;
locals.pendingInterceptOptions.push({
precommitHandler: () => {
precommitHandlerCalled = true;
return Promise.reject(error);
},
});
locals.navigation.replaceState(null, '', '/replaced-reject');
await new Promise((resolve) => setTimeout(resolve));
expect(precommitHandlerCalled).toBeTrue();
const navigateEvent = locals.navigateEvents[locals.navigateEvents.length - 1];
expect(navigateEvent.signal.aborted).toBeTrue();
expect(navigateEvent.signal.reason).toBe(error);
expect(locals.navigation.currentEntry.url).not.toBe('https://test.com/replaced-reject');
expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0);
});
it('precommitHandler throws during replaceState', async () => {
const error = new Error('precommitHandler threw for replaceState');
let precommitHandlerCalled = false;
locals.pendingInterceptOptions.push({
precommitHandler: () => {
precommitHandlerCalled = true;
throw error;
},
});
locals.navigation.replaceState(null, '', '/replaced-throw');
await new Promise((resolve) => setTimeout(resolve));
expect(precommitHandlerCalled).toBeTrue();
const navigateEvent = locals.navigateEvents[locals.navigateEvents.length - 1];
expect(navigateEvent.signal.aborted).toBeTrue();
expect(locals.navigation.currentEntry.url).not.toBe('https://test.com/replaced-throw');
expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0);
});
describe('redirect from precommitHandler during pushState', () => {
it('correctly changes URL and replaces history entry by default', async () => {
// First, push a state
locals.navigation.pushState(null, '', '/initial-for-replace-push');
await new Promise((resolve) => setTimeout(resolve));
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected-from-push', {history: 'push'});
},
});
const originalNumEntries = locals.navigation.entries().length;
locals.navigation.pushState(null, '', '/pushed');
await new Promise((resolve) => setTimeout(resolve));
expect(locals.navigation.currentEntry.url).toBe('https://test.com/redirected-from-push');
expect(locals.navigation.entries().length).toBe(originalNumEntries + 1);
});
it('correctly changes URL and replaces history entry when history: "replace"', async () => {
// First, push a state
locals.navigation.pushState(null, '', '/initial-for-replace-push');
await new Promise((resolve) => setTimeout(resolve));
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected-from-push', {history: 'replace'});
},
});
const originalNumEntries = locals.navigation.entries().length;
locals.navigation.pushState(null, '', '/pushed');
await new Promise((resolve) => setTimeout(resolve));
expect(locals.navigation.currentEntry.url).toBe('https://test.com/redirected-from-push');
// pushState (becomes entry) + redirect with replace (replaces that entry)
expect(locals.navigation.entries().length).toBe(originalNumEntries);
});
it('correctly updates info during redirect', async () => {
const redirectInfo = {isRedirected: true};
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected-info', {info: redirectInfo});
},
});
const nextEvent = locals.nextNavigateEvent();
locals.navigation.pushState(null, '', '/pushed-for-info');
const e = await nextEvent;
await new Promise((resolve) => setTimeout(resolve));
expect(e.info).toEqual(redirectInfo);
});
it('correctly updates state during redirect', async () => {
const redirectState = {isRedirected: true};
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected-state', {state: redirectState});
},
});
locals.navigation.pushState(null, '', '/pushed-for-state');
await new Promise((resolve) => setTimeout(resolve));
expect(locals.navigation.currentEntry.getState()).toEqual(redirectState);
});
});
describe('redirect from precommitHandler during replaceState', () => {
it('correctly changes URL and replaces history entry by default', async () => {
// First, push a state
locals.navigation.pushState(null, '', '/initial-for-replace-push');
await new Promise((resolve) => setTimeout(resolve));
locals.navigateEvents.length = 0;
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected-from-replace-push', {history: 'push'});
},
});
const originalNumEntries = locals.navigation.entries().length;
locals.navigation.replaceState(null, '', '/replaced-for-push');
await new Promise((resolve) => setTimeout(resolve));
expect(locals.navigation.currentEntry.url).toBe(
'https://test.com/redirected-from-replace-push',
);
// replaceState (modifies current) + redirect with push (adds new one)
expect(locals.navigation.entries().length).toBe(originalNumEntries + 1);
});
it('correctly changes URL and replaces history entry when history: "replace"', async () => {
// First, push a state
locals.navigation.pushState(null, '', '/initial-for-replace-replace');
await new Promise((resolve) => setTimeout(resolve));
locals.navigateEvents.length = 0;
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected-from-replace-replace', {history: 'replace'});
},
});
const originalNumEntries = locals.navigation.entries().length;
locals.navigation.replaceState(null, '', '/replaced-for-replace');
await new Promise((resolve) => setTimeout(resolve));
expect(locals.navigation.currentEntry.url).toBe(
'https://test.com/redirected-from-replace-replace',
);
// replaceState (modifies current) + redirect with replace (modifies current again)
expect(locals.navigation.entries().length).toBe(originalNumEntries);
});
it('correctly updates info during redirect', async () => {
const redirectInfo = {isRedirectedReplace: true};
// First, push a state
locals.navigation.pushState(null, '', '/initial-for-replace-info');
await new Promise((resolve) => setTimeout(resolve));
locals.navigateEvents.length = 0;
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected-replace-info', {info: redirectInfo});
},
});
const redirectedEvent = locals.nextNavigateEvent(); // Redirected navigation
locals.navigation.replaceState(null, '', '/replaced-for-info');
const e = await redirectedEvent;
await new Promise((resolve) => setTimeout(resolve));
expect(e.info).toEqual(redirectInfo);
});
it('correctly updates state during redirect', async () => {
const redirectState = {isRedirectedReplace: true};
// First, push a state
locals.navigation.pushState(null, '', '/initial-for-replace-state');
await new Promise((resolve) => setTimeout(resolve));
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected-replace-state', {state: redirectState});
},
});
locals.navigation.replaceState(null, '', '/replaced-for-state');
await new Promise((resolve) => setTimeout(resolve));
expect(locals.navigation.currentEntry.getState()).toEqual(redirectState);
});
});
});
});
describe('traversal', () => {
@ -1802,4 +2079,66 @@ describe('navigation', () => {
});
});
});
describe('redirect', () => {
it('correctly changes the destination URL', async () => {
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected');
},
});
const {committed} = locals.navigation.navigate('/initial');
const committedEntry = await committed;
expect(committedEntry.url).toBe('https://test.com/redirected');
});
it('works with history: "push" option', async () => {
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected', {history: 'push'});
},
});
const {committed} = locals.navigation.navigate('/initial');
const committedEntry = await committed;
expect(committedEntry.url).toBe('https://test.com/redirected');
expect(locals.navigation.entries().length).toBe(2); // Initial and redirected
});
it('works with history: "replace" option', async () => {
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected', {history: 'replace'});
},
});
const {committed} = locals.navigation.navigate('/initial');
const committedEntry = await committed;
expect(committedEntry.url).toBe('https://test.com/redirected');
expect(locals.navigation.entries().length).toBe(1); // Original entry replaced
});
it('correctly updates state if provided', async () => {
const state = {redirectState: 'test'};
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
event.redirect('/redirected', {state});
},
});
const {committed} = locals.navigation.navigate('/initial');
const committedEntry = await committed;
expect(committedEntry.getState()).toEqual(state);
});
it('throws an error if navigationType is "traverse"', async () => {
await setUpEntries();
locals.pendingInterceptOptions.push({
precommitHandler: async (event) => {
expect(() => event.redirect('/redirected')).toThrowError();
},
});
// Use back() to trigger a 'traverse' navigation
await locals.navigation.back().finished;
// Check that a navigate event occurred (it should, even if redirect fails)
expect(locals.navigateEvents.length).toBe(1);
});
});
});