From e0236096ea5b80e4f3c97a170b7c8bafcb2ce015 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 18 Aug 2026 13:03:09 -0700 Subject: [PATCH 01/12] fix(vue-router): clear navigation info when a guard aborts navigation Co-authored-by: zhiqiang.guo --- packages/vue-router/src/router.ts | 29 ++++- .../vue/test/base/tests/unit/routing.spec.ts | 103 ++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index dd864e1458b..55b058f571a 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -5,7 +5,11 @@ import type { NavigationFailure, RouteLocationRaw, } from "vue-router"; -import { parseQuery } from "vue-router"; +import { + isNavigationFailure, + NavigationFailureType, + parseQuery, +} from "vue-router"; import { createLocationHistory } from "./locationHistory"; import type { @@ -46,7 +50,28 @@ export const createIonRouter = ( _: RouteLocationNormalized, failure?: NavigationFailure ) => { - if (failure) return; + if (failure) { + /* + * vue-router reverts the history entry for aborted and duplicated + * navigations, so the staged navigation info describes a history event + * that no longer happened. Clearing it prevents a stale delta from + * leaking into the next navigation, where it would be mistaken for + * history traversal and stop the incoming route from being added. + * + * A cancelled navigation is superseded by another one and keeps its + * history entry, so its info is still accurate and stays in place for + * the superseding navigation to consume. + */ + if (!isNavigationFailure(failure, NavigationFailureType.cancelled)) { + currentNavigationInfo = { + direction: undefined, + action: undefined, + delta: undefined, + }; + } + + return; + } const { direction, action, delta } = currentNavigationInfo; diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index 3d1f50a428f..b33136f101d 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -689,4 +689,107 @@ describe('Routing', () => { expect(wrapper.findComponent(Page2).exists()).toBe(false); expect(wrapper.findComponent(Page3).exists()).toBe(false); }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should keep the view stack intact after a navigation guard blocks going back', async () => { + const createPage = (id: string) => ({ + components: { IonPage }, + name: id, + template: `` + }); + + const Home = createPage('home'); + const Register = createPage('register'); + const Profile = createPage('profile'); + + let isLoggedIn = false; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/register', component: Register }, + { path: '/profile', component: Profile } + ] + }); + + /* + * Leaving the authenticated route while still logged in is blocked, which + * aborts the navigation. An aborted back navigation used to leave stale + * navigation info behind, which then made the next navigation look like + * history traversal. + */ + router.beforeEach((to, from) => { + if (from.path === '/profile' && to.path !== '/profile' && isLoggedIn) { + return false; + } + + return true; + }); + + router.push('/home'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + /* + * Ionic keeps previously visited pages mounted so they can be animated back + * to, hiding the inactive ones with `ion-page-hidden`. Asserting on the + * whole stack therefore catches both a wrong visible page and a page that + * was destroyed when it should have been kept. + */ + const viewStack = () => + wrapper.findAll('.ion-page').map((page) => ({ + id: page.attributes('data-page'), + hidden: page.classes('ion-page-hidden') + })); + + router.push('/register'); + await waitForRouter(); + + isLoggedIn = true; + router.replace('/profile'); + await waitForRouter(); + + expect(viewStack()).toEqual([ + { id: 'home', hidden: true }, + { id: 'profile', hidden: false } + ]); + + // The guard blocks this, so the stack should be untouched. + router.back(); + await waitForRouter(); + + expect(viewStack()).toEqual([ + { id: 'home', hidden: true }, + { id: 'profile', hidden: false } + ]); + + /* + * Logging out is a push, so Profile stays in the stack behind Home. Before + * the fix the stale delta from the blocked back navigation made this look + * like history traversal, which destroyed the Profile view. + */ + isLoggedIn = false; + router.push('/home'); + await waitForRouter(); + + expect(viewStack()).toEqual([ + { id: 'home', hidden: false }, + { id: 'profile', hidden: true } + ]); + + isLoggedIn = true; + router.push('/profile'); + await waitForRouter(); + + expect(viewStack()).toEqual([ + { id: 'home', hidden: true }, + { id: 'profile', hidden: false } + ]); + }); }); From 6d5c199fa4e1c6d0181533d8b19802d881b5cf37 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 18 Aug 2026 15:14:11 -0700 Subject: [PATCH 02/12] fix(vue-router): clear staged route params when a guard aborts navigation Co-authored-by: ShaneK <561207+ShaneK@users.noreply.github.com> --- packages/vue-router/src/router.ts | 16 ++-- .../vue/test/base/tests/unit/routing.spec.ts | 78 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index 55b058f571a..cbdfe0d2007 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -53,13 +53,17 @@ export const createIonRouter = ( if (failure) { /* * vue-router reverts the history entry for aborted and duplicated - * navigations, so the staged navigation info describes a history event - * that no longer happened. Clearing it prevents a stale delta from - * leaking into the next navigation, where it would be mistaken for - * history traversal and stop the incoming route from being added. + * navigations, so any state staged for that navigation describes + * something that no longer happened. Both pieces are normally consumed + * by handleHistoryChange, which does not run when the navigation fails. + * + * A stale delta makes the next navigation look like history traversal, + * which stops the incoming route from being added. Stale route params + * are left behind by handleNavigateBack and apply the previous route's + * id and pop action to whatever is navigated to next. * * A cancelled navigation is superseded by another one and keeps its - * history entry, so its info is still accurate and stays in place for + * history entry, so its state is still accurate and stays in place for * the superseding navigation to consume. */ if (!isNavigationFailure(failure, NavigationFailureType.cancelled)) { @@ -68,6 +72,8 @@ export const createIonRouter = ( action: undefined, delta: undefined, }; + + incomingRouteParams = undefined; } return; diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index b33136f101d..6cda86a6d80 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -792,4 +792,82 @@ describe('Routing', () => { { id: 'profile', hidden: false } ]); }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should keep canGoBack accurate after a guard blocks a back button navigation', async () => { + const createPage = (id: string) => ({ + components: { IonPage }, + name: id, + template: `` + }); + + const Home = createPage('home'); + const Profile = createPage('profile'); + const Settings = createPage('settings'); + + const AppWithInject = { + components: { IonApp, IonRouterOutlet }, + name: 'AppWithInject', + template: '', + setup() { + const ionRouter = useIonRouter(); + return { ionRouter }; + } + }; + + let isLoggedIn = false; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/profile', component: Profile }, + { path: '/settings', component: Settings } + ] + }); + + router.beforeEach((to, from) => { + if (from.path === '/profile' && to.path !== '/profile' && isLoggedIn) { + return false; + } + + return true; + }); + + router.push('/home'); + await router.isReady(); + const wrapper = mount(AppWithInject, { + global: { + plugins: [router, IonicVue] + } + }); + + const ionRouter = wrapper.vm.ionRouter; + + router.push('/profile'); + await waitForRouter(); + + expect(ionRouter.canGoBack()).toEqual(true); + + /* + * Going back through the back button stages route params before handing off + * to the router. The guard blocks the navigation, so those params used to be + * left behind and then applied to the next route instead. + */ + isLoggedIn = true; + ionRouter.back(); + await waitForRouter(); + + /* + * Navigating with vue-router rather than useIonRouter matters here. The + * useIonRouter helpers stage their own route params, which would overwrite + * the leftovers and hide the problem. + */ + isLoggedIn = false; + router.push('/settings'); + await waitForRouter(); + + expect(ionRouter.canGoBack()).toEqual(true); + }); }); From 7ae3b1646abfc102258c77c373f7a9ed1772cec9 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 18 Aug 2026 15:26:31 -0700 Subject: [PATCH 03/12] docs(vue-router): clarify which navigation failures clear staged state --- packages/vue-router/src/router.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index cbdfe0d2007..ab05480cd08 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -65,6 +65,11 @@ export const createIonRouter = ( * A cancelled navigation is superseded by another one and keeps its * history entry, so its state is still accurate and stays in place for * the superseding navigation to consume. + * + * This only covers navigations that fail. A guard that returns a + * location redirects rather than fails, so vue-router neither reverts + * the history entry nor calls afterEach for the original navigation, + * and the staged state still reaches the redirect target. */ if (!isNavigationFailure(failure, NavigationFailureType.cancelled)) { currentNavigationInfo = { From b6c6e7345628ebaf61120284421fee8ea25cdaf4 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 18 Aug 2026 16:09:35 -0700 Subject: [PATCH 04/12] fix(vue-router): clear staged state when a navigation is cancelled --- packages/vue-router/src/router.ts | 27 ++-- .../vue/test/base/tests/unit/routing.spec.ts | 143 +++++++++++++++++- 2 files changed, 148 insertions(+), 22 deletions(-) diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index ab05480cd08..ec05d15a674 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -5,11 +5,7 @@ import type { NavigationFailure, RouteLocationRaw, } from "vue-router"; -import { - isNavigationFailure, - NavigationFailureType, - parseQuery, -} from "vue-router"; +import { parseQuery } from "vue-router"; import { createLocationHistory } from "./locationHistory"; import type { @@ -62,24 +58,23 @@ export const createIonRouter = ( * are left behind by handleNavigateBack and apply the previous route's * id and pop action to whatever is navigated to next. * - * A cancelled navigation is superseded by another one and keeps its - * history entry, so its state is still accurate and stays in place for - * the superseding navigation to consume. + * This applies to cancelled navigations too. Their history entry is + * not reverted, but the state describes the navigation that was + * replaced rather than the one that replaced it, so leaving it in + * place hands a back navigation's delta to an unrelated push. * * This only covers navigations that fail. A guard that returns a * location redirects rather than fails, so vue-router neither reverts * the history entry nor calls afterEach for the original navigation, * and the staged state still reaches the redirect target. */ - if (!isNavigationFailure(failure, NavigationFailureType.cancelled)) { - currentNavigationInfo = { - direction: undefined, - action: undefined, - delta: undefined, - }; + currentNavigationInfo = { + direction: undefined, + action: undefined, + delta: undefined, + }; - incomingRouteParams = undefined; - } + incomingRouteParams = undefined; return; } diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index 6cda86a6d80..5c3c7499182 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -1,4 +1,4 @@ -import { enableAutoUnmount, mount } from '@vue/test-utils'; +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createRouter, createWebHistory } from '@ionic/vue-router'; import { @@ -12,7 +12,11 @@ import { IonLabel, useIonRouter } from '@ionic/vue'; -import { onBeforeRouteLeave } from 'vue-router'; +import { + isNavigationFailure, + NavigationFailureType, + onBeforeRouteLeave +} from 'vue-router'; import { waitForRouter } from './utils'; enableAutoUnmount(afterEach); @@ -691,7 +695,7 @@ describe('Routing', () => { }); // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 - it('should keep the view stack intact after a navigation guard blocks going back', async () => { + it('should keep the previous page when pushing after a guard blocks going back', async () => { const createPage = (id: string) => ({ components: { IonPage }, name: id, @@ -748,6 +752,20 @@ describe('Routing', () => { hidden: page.classes('ion-page-hidden') })); + /* + * The router is provided to the app by the Ionic Vue router plugin, so it + * can be read without wrapping the outlet in another component. + */ + const currentRoute = () => { + const routeInfo = wrapper.vm.$.appContext.provides.navManager.getCurrentRouteInfo(); + + return { + pathname: routeInfo.pathname, + routerAction: routeInfo.routerAction, + routerDirection: routeInfo.routerDirection + }; + }; + router.push('/register'); await waitForRouter(); @@ -770,9 +788,10 @@ describe('Routing', () => { ]); /* - * Logging out is a push, so Profile stays in the stack behind Home. Before - * the fix the stale delta from the blocked back navigation made this look - * like history traversal, which destroyed the Profile view. + * Logging out is a push, so Profile stays in the stack behind Home and the + * route is recorded as a forward push. The stale delta from the blocked + * back navigation used to make this look like history traversal, which + * recorded it as a pop going back and destroyed the Profile view. */ isLoggedIn = false; router.push('/home'); @@ -782,6 +801,11 @@ describe('Routing', () => { { id: 'home', hidden: false }, { id: 'profile', hidden: true } ]); + expect(currentRoute()).toEqual({ + pathname: '/home', + routerAction: 'push', + routerDirection: 'forward' + }); isLoggedIn = true; router.push('/profile'); @@ -870,4 +894,111 @@ describe('Routing', () => { expect(ionRouter.canGoBack()).toEqual(true); }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should not apply a cancelled back navigation to the navigation that replaced it', async () => { + const createPage = (id: string) => ({ + components: { IonPage }, + name: id, + template: `` + }); + + const Home = createPage('home'); + const Profile = createPage('profile'); + const Settings = createPage('settings'); + + let racing = false; + let releaseBack: () => void; + let releasePush: () => void; + let backReachedGuard: () => void; + let cancelReported: () => void; + + const backStarted = new Promise((resolve) => { + backReachedGuard = resolve as () => void; + }); + const backCancelled = new Promise((resolve) => { + cancelReported = resolve as () => void; + }); + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/home', component: Home }, + { path: '/profile', component: Profile }, + { path: '/settings', component: Settings } + ] + }); + + /* + * Both navigations are held inside their guards so the test controls the + * order they finish in, rather than relying on timing. The back navigation + * is released first so it reports its cancellation before the push that + * replaced it completes. + */ + router.beforeEach(async (to) => { + if (!racing) { + return true; + } + + if (to.path === '/home') { + backReachedGuard(); + await new Promise((resolve) => { + releaseBack = resolve as () => void; + }); + } + + if (to.path === '/settings') { + await new Promise((resolve) => { + releasePush = resolve as () => void; + }); + } + + return true; + }); + + router.afterEach((_to, _from, failure) => { + if (isNavigationFailure(failure, NavigationFailureType.cancelled)) { + cancelReported(); + } + }); + + router.push('/home'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + router.push('/profile'); + await waitForRouter(); + + racing = true; + + // Start going back, and wait until it is actually in flight. + router.back(); + await backStarted; + + // Replace it with a push while it is still in flight. + router.push('/settings'); + await flushPromises(); + + // Let the back navigation finish, which reports it as cancelled. + releaseBack(); + await backCancelled; + + // Only now let the push finish, so it is the one reading any staged state. + releasePush(); + await waitForRouter(); + + const navManager = wrapper.vm.$.appContext.provides.navManager; + const routeInfo = navManager.getCurrentRouteInfo(); + + expect( + wrapper.findAll('.ion-page').map((page) => page.attributes('data-page')) + ).toEqual(['home', 'profile', 'settings']); + expect(routeInfo.pathname).toEqual('/settings'); + expect(routeInfo.routerAction).toEqual('push'); + expect(routeInfo.routerDirection).toEqual('forward'); + }); }); From d4b89b7a4910c15ea3aae3a7e2901fe15c2c405c Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 18 Aug 2026 16:12:49 -0700 Subject: [PATCH 05/12] test(vue-router): use data-pageid in routing specs --- packages/vue/test/base/tests/unit/routing.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index 5c3c7499182..63bd05ecc38 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -699,7 +699,7 @@ describe('Routing', () => { const createPage = (id: string) => ({ components: { IonPage }, name: id, - template: `` + template: `` }); const Home = createPage('home'); @@ -748,7 +748,7 @@ describe('Routing', () => { */ const viewStack = () => wrapper.findAll('.ion-page').map((page) => ({ - id: page.attributes('data-page'), + id: page.attributes('data-pageid'), hidden: page.classes('ion-page-hidden') })); @@ -822,7 +822,7 @@ describe('Routing', () => { const createPage = (id: string) => ({ components: { IonPage }, name: id, - template: `` + template: `` }); const Home = createPage('home'); @@ -900,7 +900,7 @@ describe('Routing', () => { const createPage = (id: string) => ({ components: { IonPage }, name: id, - template: `` + template: `` }); const Home = createPage('home'); @@ -995,7 +995,7 @@ describe('Routing', () => { const routeInfo = navManager.getCurrentRouteInfo(); expect( - wrapper.findAll('.ion-page').map((page) => page.attributes('data-page')) + wrapper.findAll('.ion-page').map((page) => page.attributes('data-pageid')) ).toEqual(['home', 'profile', 'settings']); expect(routeInfo.pathname).toEqual('/settings'); expect(routeInfo.routerAction).toEqual('push'); From 4bbcc7a30266b0d7619f235fae8607cf1bdb6a68 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 18 Aug 2026 16:17:37 -0700 Subject: [PATCH 06/12] test(vue-router): align routing spec setup with the rest of the file --- packages/vue/test/base/tests/unit/routing.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index 63bd05ecc38..270801b2b63 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -732,7 +732,7 @@ describe('Routing', () => { return true; }); - router.push('/home'); + router.push('/'); await router.isReady(); const wrapper = mount(IonRouterOutlet, { global: { @@ -859,7 +859,7 @@ describe('Routing', () => { return true; }); - router.push('/home'); + router.push('/'); await router.isReady(); const wrapper = mount(AppWithInject, { global: { From 2dc6efdb199ffc8797cb9eaa95b6778645de4221 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 18 Aug 2026 16:21:12 -0700 Subject: [PATCH 07/12] test(vue-router): tidy the navigation guard specs --- packages/vue/test/base/tests/unit/routing.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index 270801b2b63..075fcd78668 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -807,7 +807,6 @@ describe('Routing', () => { routerDirection: 'forward' }); - isLoggedIn = true; router.push('/profile'); await waitForRouter(); From 7ff2c5e2f4fe53a1d648b2880514d568c55c5749 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Thu, 20 Aug 2026 16:37:05 -0700 Subject: [PATCH 08/12] fix(vue-router): only clear staged state from the failed navigation --- packages/vue-router/src/router.ts | 47 +++++--- packages/vue-router/src/types.ts | 6 + .../vue/test/base/tests/unit/routing.spec.ts | 111 ++++++++++++++++++ 3 files changed, 148 insertions(+), 16 deletions(-) diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index ec05d15a674..aee791269de 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -29,6 +29,7 @@ export const createIonRouter = ( direction: undefined, action: undefined, delta: undefined, + to: undefined, }; /** @@ -48,33 +49,40 @@ export const createIonRouter = ( ) => { if (failure) { /* - * vue-router reverts the history entry for aborted and duplicated - * navigations, so any state staged for that navigation describes - * something that no longer happened. Both pieces are normally consumed - * by handleHistoryChange, which does not run when the navigation fails. + * State staged for a navigation that failed describes something that + * did not happen. handleHistoryChange normally consumes it, but it does + * not run when the navigation fails, so it has to be cleared here or + * the next navigation picks it up instead. * - * A stale delta makes the next navigation look like history traversal, + * A stale delta makes that navigation look like history traversal, * which stops the incoming route from being added. Stale route params * are left behind by handleNavigateBack and apply the previous route's * id and pop action to whatever is navigated to next. * - * This applies to cancelled navigations too. Their history entry is - * not reverted, but the state describes the navigation that was - * replaced rather than the one that replaced it, so leaving it in - * place hands a back navigation's delta to an unrelated push. + * Only clear state that belongs to this navigation. A second history + * navigation can replace this one and stage its own information first, + * in which case clearing would strip the delta from the navigation that + * is still running. * * This only covers navigations that fail. A guard that returns a * location redirects rather than fails, so vue-router neither reverts * the history entry nor calls afterEach for the original navigation, * and the staged state still reaches the redirect target. */ - currentNavigationInfo = { - direction: undefined, - action: undefined, - delta: undefined, - }; + const staysWithThisNavigation = + currentNavigationInfo.to === undefined || + currentNavigationInfo.to === to.fullPath; + + if (staysWithThisNavigation) { + currentNavigationInfo = { + direction: undefined, + action: undefined, + delta: undefined, + to: undefined, + }; - incomingRouteParams = undefined; + incomingRouteParams = undefined; + } return; } @@ -99,6 +107,7 @@ export const createIonRouter = ( direction: undefined, action: undefined, delta: undefined, + to: undefined, }; } ); @@ -128,7 +137,7 @@ export const createIonRouter = ( }); } - opts.history.listen((_: any, _x: any, info: any) => { + opts.history.listen((to: any, _x: any, info: any) => { /** * history.listen only fires on certain * event such as when the user clicks the @@ -150,6 +159,12 @@ export const createIonRouter = ( */ action: info.type === "pop" && info.delta >= 1 ? "push" : info.type, direction: info.direction === "" ? "forward" : info.direction, + + /** + * Recorded so that a failed navigation can tell whether this + * information is its own before clearing it. + */ + to, }; }); diff --git a/packages/vue-router/src/types.ts b/packages/vue-router/src/types.ts index e1cddbfde58..7f502d548e4 100644 --- a/packages/vue-router/src/types.ts +++ b/packages/vue-router/src/types.ts @@ -91,4 +91,10 @@ export interface NavigationInformation { action?: RouteAction; direction?: RouteDirection; delta?: number; + /** + * The location the browser moved to when this information was staged. Used to + * tell whether the information belongs to a particular navigation, since a + * second history navigation can stage its own before the first one settles. + */ + to?: string; } diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index 075fcd78668..f4a4bb3af53 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -1000,4 +1000,115 @@ describe('Routing', () => { expect(routeInfo.routerAction).toEqual('push'); expect(routeInfo.routerDirection).toEqual('forward'); }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should keep the delta of a back navigation that replaced a cancelled one', async () => { + const createPage = (id: string) => ({ + components: { IonPage }, + name: id, + template: `` + }); + + const Home = createPage('home'); + const First = createPage('first'); + const Second = createPage('second'); + + let racing = false; + let releaseFirstBack: () => void; + let releaseSecondBack: () => void; + let firstBackReachedGuard: () => void; + let secondBackReachedGuard: () => void; + let cancelReported: () => void; + + const firstBackStarted = new Promise((resolve) => { + firstBackReachedGuard = resolve as () => void; + }); + const secondBackStarted = new Promise((resolve) => { + secondBackReachedGuard = resolve as () => void; + }); + const firstBackCancelled = new Promise((resolve) => { + cancelReported = resolve as () => void; + }); + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/first', component: First }, + { path: '/second', component: Second } + ] + }); + + /* + * Both back navigations are held inside their guards so the test controls + * which one settles first. The second one stages its own navigation info as + * soon as its popstate lands, which is why the first one must not clear it. + */ + router.beforeEach(async (to) => { + if (!racing) { + return true; + } + + if (to.path === '/first') { + firstBackReachedGuard(); + await new Promise((resolve) => { + releaseFirstBack = resolve as () => void; + }); + } + + if (to.path === '/home') { + secondBackReachedGuard(); + await new Promise((resolve) => { + releaseSecondBack = resolve as () => void; + }); + } + + return true; + }); + + router.afterEach((_to, _from, failure) => { + if (isNavigationFailure(failure, NavigationFailureType.cancelled)) { + cancelReported(); + } + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + router.push('/first'); + await waitForRouter(); + router.push('/second'); + await waitForRouter(); + + racing = true; + + // First back, held until the second one is also in flight. + router.back(); + await firstBackStarted; + + // Second back, which replaces the first and stages its own info. + router.back(); + await secondBackStarted; + + // Let the first back finish, which reports it as cancelled. + releaseFirstBack(); + await firstBackCancelled; + + // Only now let the second back finish. + releaseSecondBack(); + await waitForRouter(); + + const navManager = wrapper.vm.$.appContext.provides.navManager; + const routeInfo = navManager.getCurrentRouteInfo(); + + expect(routeInfo.pathname).toEqual('/home'); + expect(routeInfo.routerAction).toEqual('pop'); + expect(routeInfo.routerDirection).toEqual('back'); + }); }); From 1b50d950e8c5c9e24e68f09f88988c04ef589798 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 21 Aug 2026 11:09:48 -0700 Subject: [PATCH 09/12] docs(vue-router): correct what stages route params and when entries revert --- packages/vue-router/src/router.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index bfd7e667ea6..5f4818a4a7c 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -54,10 +54,12 @@ export const createIonRouter = ( * not run when the navigation fails, so it has to be cleared here or * the next navigation picks it up instead. * - * A stale delta makes that navigation look like history traversal, - * which stops the incoming route from being added. Stale route params - * are left behind by handleNavigateBack and apply the previous route's - * id and pop action to whatever is navigated to next. + * A delta is only staged for a history navigation, and a stale one + * makes the next navigation look like traversal, which stops the + * incoming route from being added. Route params are staged by any of + * the navigation helpers, and a stale set carries a pop action into + * whatever runs next. Only handleNavigateBack stages the previous + * route's id alongside them. * * Only clear state that belongs to this navigation. A second history * navigation can replace this one and stage its own information first, @@ -65,9 +67,9 @@ export const createIonRouter = ( * is still running. * * This only covers navigations that fail. A guard that returns a - * location redirects rather than fails, so vue-router neither reverts - * the history entry nor calls afterEach for the original navigation, - * and the staged state still reaches the redirect target. + * location redirects rather than fails, so afterEach is never called + * for the original navigation and its staged state reaches the redirect + * target instead. */ const staysWithThisNavigation = currentNavigationInfo.to === undefined || From 101143ea4889d792a47e1f5cba323be3d57f5883 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 21 Aug 2026 11:21:11 -0700 Subject: [PATCH 10/12] test(vue-router): cover the back button path and fix a misleading spec name --- .../vue/test/base/tests/unit/routing.spec.ts | 75 ++++++++++++++++++- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index ee789c661ab..51821c4c5d1 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -861,7 +861,7 @@ describe('Routing', () => { }); // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 - it('should keep canGoBack accurate after a guard blocks a back button navigation', async () => { + it('should keep canGoBack accurate after a guard blocks a programmatic back', async () => { const createPage = (id: string) => ({ components: { IonPage }, name: id, @@ -918,9 +918,9 @@ describe('Routing', () => { expect(ionRouter.canGoBack()).toEqual(true); /* - * Going back through the back button stages route params before handing off - * to the router. The guard blocks the navigation, so those params used to be - * left behind and then applied to the next route instead. + * useIonRouter's back() stages route params before handing off to the + * router. The guard blocks the navigation, so those params used to be left + * behind and then applied to the next route instead. */ isLoggedIn = true; ionRouter.back(); @@ -1045,6 +1045,73 @@ describe('Routing', () => { expect(routeInfo.routerDirection).toEqual('forward'); }); + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should not reuse the previous route after a guard blocks a back button navigation', async () => { + const createPage = (id: string) => ({ + components: { IonPage }, + name: id, + template: `` + }); + + const Home = createPage('home'); + const Profile = createPage('profile'); + const Settings = createPage('settings'); + + let isLoggedIn = false; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/profile', component: Profile }, + { path: '/settings', component: Settings } + ] + }); + + router.beforeEach((to, from) => { + if (from.path === '/profile' && to.path !== '/profile' && isLoggedIn) { + return false; + } + + return true; + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + const navManager = wrapper.vm.$.appContext.provides.navManager; + + router.push('/profile'); + await waitForRouter(); + + /* + * ion-back-button calls handleNavigateBack, which stages the whole previous + * route rather than just an action and direction. Those params carry an id, + * and a staged id makes handleHistoryChange reuse the params wholesale, so + * a stale set would report the previous route's pathname for whatever is + * navigated to next. + */ + isLoggedIn = true; + navManager.handleNavigateBack(); + await waitForRouter(); + + isLoggedIn = false; + router.push('/settings'); + await waitForRouter(); + + const routeInfo = navManager.getCurrentRouteInfo(); + + expect(routeInfo.pathname).toEqual('/settings'); + expect(routeInfo.routerAction).toEqual('push'); + expect(routeInfo.routerDirection).toEqual('forward'); + }); + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 it('should keep the delta of a back navigation that replaced a cancelled one', async () => { const createPage = (id: string) => ({ From 64a77a6877c4e9ef66c03c89dddcfb170927bdb6 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 21 Aug 2026 13:47:39 -0700 Subject: [PATCH 11/12] test(vue-router): read the router through inject instead of the Vue instance --- .../vue/test/base/tests/unit/routing.spec.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index 51821c4c5d1..2879de4a737 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -17,6 +17,7 @@ import { NavigationFailureType, onBeforeRouteLeave } from 'vue-router'; +import { inject } from 'vue'; import { waitForRouter } from './utils'; enableAutoUnmount(afterEach); @@ -740,10 +741,19 @@ describe('Routing', () => { // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 it('should keep the previous page when pushing after a guard blocks going back', async () => { + /* + * The pages are rendered inside the outlet, so injecting from one of them + * reaches the router the same way useIonRouter does, without wrapping the + * outlet in another component. + */ + let navManager: any; const createPage = (id: string) => ({ components: { IonPage }, name: id, - template: `` + template: ``, + setup() { + navManager = inject('navManager'); + } }); const Home = createPage('home'); @@ -796,12 +806,8 @@ describe('Routing', () => { hidden: page.classes('ion-page-hidden') })); - /* - * The router is provided to the app by the Ionic Vue router plugin, so it - * can be read without wrapping the outlet in another component. - */ const currentRoute = () => { - const routeInfo = wrapper.vm.$.appContext.provides.navManager.getCurrentRouteInfo(); + const routeInfo = navManager.getCurrentRouteInfo(); return { pathname: routeInfo.pathname, @@ -940,10 +946,14 @@ describe('Routing', () => { // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 it('should not apply a cancelled back navigation to the navigation that replaced it', async () => { + let navManager: any; const createPage = (id: string) => ({ components: { IonPage }, name: id, - template: `` + template: ``, + setup() { + navManager = inject('navManager'); + } }); const Home = createPage('home'); @@ -1034,7 +1044,6 @@ describe('Routing', () => { releasePush(); await waitForRouter(); - const navManager = wrapper.vm.$.appContext.provides.navManager; const routeInfo = navManager.getCurrentRouteInfo(); expect( @@ -1047,10 +1056,14 @@ describe('Routing', () => { // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 it('should not reuse the previous route after a guard blocks a back button navigation', async () => { + let navManager: any; const createPage = (id: string) => ({ components: { IonPage }, name: id, - template: `` + template: ``, + setup() { + navManager = inject('navManager'); + } }); const Home = createPage('home'); @@ -1085,8 +1098,6 @@ describe('Routing', () => { } }); - const navManager = wrapper.vm.$.appContext.provides.navManager; - router.push('/profile'); await waitForRouter(); @@ -1114,10 +1125,14 @@ describe('Routing', () => { // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 it('should keep the delta of a back navigation that replaced a cancelled one', async () => { + let navManager: any; const createPage = (id: string) => ({ components: { IonPage }, name: id, - template: `` + template: ``, + setup() { + navManager = inject('navManager'); + } }); const Home = createPage('home'); @@ -1215,7 +1230,6 @@ describe('Routing', () => { releaseSecondBack(); await waitForRouter(); - const navManager = wrapper.vm.$.appContext.provides.navManager; const routeInfo = navManager.getCurrentRouteInfo(); expect(routeInfo.pathname).toEqual('/home'); From 398cb367e1e734957388a2df04f17269d97d4908 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 21 Aug 2026 13:48:31 -0700 Subject: [PATCH 12/12] test(vue-router): add exclamation Co-authored-by: Shane --- packages/vue/test/base/tests/unit/routing.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index 2879de4a737..70d0b2c2acc 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -961,8 +961,8 @@ describe('Routing', () => { const Settings = createPage('settings'); let racing = false; - let releaseBack: () => void; - let releasePush: () => void; + let releaseBack!: () => void; + let releasePush!: () => void; let backReachedGuard: () => void; let cancelReported: () => void;