From 90d0c19298a9e2c2b5a86a6c5c4c442c85b35600 Mon Sep 17 00:00:00 2001 From: Remco Hendriksen Date: Wed, 14 Oct 2026 12:24:36 +0200 Subject: [PATCH] fix(vue): skip transitions for superseded pages A page that mounted its ion-page after the route had moved on to a newer page ran the transition for that newer page. The newer page had no ion-page yet, so the transition threw a TypeError. Only run the transition from the register callback when the view item for the current route is the entering item, or when that item has no pending register callback. The newer page runs its own transition once it registers. closes #31569 Co-Authored-By: Claude Opus 5.5 --- .../vue/src/components/IonRouterOutlet.ts | 16 +++++- .../vue/test/base/tests/unit/routing.spec.ts | 55 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/vue/src/components/IonRouterOutlet.ts b/packages/vue/src/components/IonRouterOutlet.ts index 81b4e0fc6e1..dc934000fd6 100644 --- a/packages/vue/src/components/IonRouterOutlet.ts +++ b/packages/vue/src/components/IonRouterOutlet.ts @@ -500,7 +500,21 @@ See https://ionicframework.com/docs/vue/navigation#ionpage for more information. if (!enteringViewItem.mount) { enteringViewItem.mount = true; enteringViewItem.registerCallback = () => { - handlePageTransition(); + const currentViewItem = viewStacks.findViewItemByRouteInfo( + ionRouter.getCurrentRouteInfo(), + id + ); + + /** + * If the route moved on while this page was mounting, + * the newer page runs the transition once it registers. + */ + if ( + currentViewItem === enteringViewItem || + !currentViewItem?.registerCallback + ) { + handlePageTransition(); + } enteringViewItem.registerCallback = undefined; }; } else { diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index ffa5985f4ea..2e03aed19ad 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -17,7 +17,7 @@ import { NavigationFailureType, onBeforeRouteLeave } from 'vue-router'; -import { inject } from 'vue'; +import { defineAsyncComponent, inject } from 'vue'; import { waitForRouter } from './utils'; enableAutoUnmount(afterEach); @@ -2323,4 +2323,57 @@ describe('Routing', () => { { id: 'list', hidden: false } ]); }); + + it('should not run the transition of a newer page when a superseded page mounts first', async () => { + const createLoadingPage = (id: string) => { + let load = () => {}; + const page = defineAsyncComponent( + () => new Promise>((resolve) => { + load = () => resolve(createPage(id)); + }) + ); + + return { page, load: () => load() }; + }; + + const list = createLoadingPage('list'); + const detail = createLoadingPage('detail'); + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', component: createPage('home') }, + { path: '/list', component: list.page }, + { path: '/detail', component: detail.page } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + router.push('/list'); + await waitForRouter(); + router.push('/detail'); + await waitForRouter(); + + // /list finishes loading after the route moved on to /detail. + list.load(); + await waitForRouter(); + detail.load(); + await waitForRouter(); + + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('does not have the required component') + ); + expect(viewStack(wrapper)).toContainEqual({ id: 'detail', hidden: false }); + + warnSpy.mockRestore(); + }); });