From fa5ae9228d2e9e7d3babbffb26a6816d0b92ff2b Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 11 Jun 2025 11:25:15 -0700 Subject: [PATCH] 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 --- .../dom-navigation/src/navigation_types.ts | 1 + .../dom-navigation/testing/fake_navigation.ts | 210 ++++++++--- .../dom-navigation/testing/test/BUILD.bazel | 5 +- .../test/fake_platform_navigation.spec.ts | 339 ++++++++++++++++++ 4 files changed, 495 insertions(+), 60 deletions(-) diff --git a/packages/core/primitives/dom-navigation/src/navigation_types.ts b/packages/core/primitives/dom-navigation/src/navigation_types.ts index 7e0d50c2d54..7ed158634e0 100644 --- a/packages/core/primitives/dom-navigation/src/navigation_types.ts +++ b/packages/core/primitives/dom-navigation/src/navigation_types.ts @@ -67,6 +67,7 @@ export declare class NavigationTransition { readonly navigationType: NavigationTypeString; readonly from: NavigationHistoryEntry; readonly finished: Promise; + readonly committed: Promise; } export interface NavigationHistoryEntryEventMap { diff --git a/packages/core/primitives/dom-navigation/testing/fake_navigation.ts b/packages/core/primitives/dom-navigation/testing/fake_navigation.ts index 8cd7f4e71f3..21254b96a73 100644 --- a/packages/core/primitives/dom-navigation/testing/fake_navigation.ts +++ b/packages/core/primitives/dom-navigation/testing/fake_navigation.ts @@ -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> = []; @@ -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; + 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; + readonly committed: Promise; 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((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(async (resolve, reject) => { + this.finished = new Promise((resolve, reject) => { this.finishedResolve = () => { if (this.committedTo === null) { throw new Error( diff --git a/packages/core/primitives/dom-navigation/testing/test/BUILD.bazel b/packages/core/primitives/dom-navigation/testing/test/BUILD.bazel index f851559a5cb..a7d0bea9fcf 100644 --- a/packages/core/primitives/dom-navigation/testing/test/BUILD.bazel +++ b/packages/core/primitives/dom-navigation/testing/test/BUILD.bazel @@ -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", ], diff --git a/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts b/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts index 8924beee7e7..4bbf2d38e04 100644 --- a/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts +++ b/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts @@ -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); + }); + }); });