mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
900cd37f68
commit
fa5ae9228d
4 changed files with 495 additions and 60 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue