From 2b2b99abf5d8e71d6a58154c42627975ec47d564 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 13 May 2026 13:06:45 -0700 Subject: [PATCH 1/2] fix(vue-router): reset deep-loaded tab child to originalHref --- packages/vue-router/src/router.ts | 26 +++++++++- packages/vue/src/components/IonTabBar.ts | 20 +++++--- packages/vue/src/components/IonTabButton.ts | 2 +- .../e2e/playwright/tabs-deep-load.spec.ts | 51 +++++++++++++++++++ 4 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 packages/vue/test/base/tests/e2e/playwright/tabs-deep-load.spec.ts diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index 816b0b04667..8edf1f0b1bc 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -555,7 +555,7 @@ export const createIonRouter = ( router.push(routerLink); }; - const resetTab = (tab: string) => { + const resetTab = (tab: string, originalHref?: string) => { /** * Resetting the tab should go back * to the initial view in the tab stack. @@ -570,7 +570,29 @@ export const createIonRouter = ( */ const routeInfo = locationHistory.getFirstRouteInfoForTab(tab); if (routeInfo) { - router.go(routeInfo.position - currentHistoryPosition); + const delta = routeInfo.position - currentHistoryPosition; + if (delta !== 0) { + router.go(delta); + return; + } + /** + * The first history entry for this tab is the current entry, + * so there's nothing earlier to traverse back to. Happens + * after a deep load onto a tab child or an external navigation + * that reset the SPA history. Replace with `originalHref` so + * no stale child entry stays in browser history. + */ + if (originalHref && routeInfo.pathname !== originalHref) { + handleNavigate(originalHref, "pop", "back", undefined, tab); + } + return; + } + /** + * No routeInfo for this tab yet. Replace the current entry + * with `originalHref` so the tab has a root to reset to. + */ + if (originalHref) { + handleNavigate(originalHref, "pop", "back", undefined, tab); } }; diff --git a/packages/vue/src/components/IonTabBar.ts b/packages/vue/src/components/IonTabBar.ts index 30002f4d858..a05957f5798 100644 --- a/packages/vue/src/components/IonTabBar.ts +++ b/packages/vue/src/components/IonTabBar.ts @@ -182,18 +182,23 @@ export const IonTabBar = defineComponent({ } }); - if (activeTab && prevActiveTab) { - const prevHref = this.$data.tabState.tabs[prevActiveTab].currentHref; + if (activeTab && currentRoute?.pathname) { + const prevHref = prevActiveTab + ? this.$data.tabState.tabs[prevActiveTab].currentHref + : undefined; /** * If the tabs change or the url changes, * update the currentHref for the active tab. - * Ex: url changes from /tabs/tab1 --> /tabs/tab1/child + * Ex: url changes from /tabs/tab1 to /tabs/tab1/child. * If we went to tab2 then back to tab1, we should * land on /tabs/tab1/child instead of /tabs/tab1. + * + * Also runs on initial setup so a deep-loaded tab child + * records its real pathname instead of `originalHref`. */ if ( activeTab !== prevActiveTab || - prevHref !== currentRoute?.pathname + prevHref !== currentRoute.pathname ) { /** * By default the search is `undefined` in Ionic Vue, @@ -201,10 +206,10 @@ export const IonTabBar = defineComponent({ * We check for truthy here because empty string is falsy * and currentRoute.search cannot ever be a boolean. */ - const search = currentRoute?.search ? `?${currentRoute.search}` : ""; + const search = currentRoute.search ? `?${currentRoute.search}` : ""; tabs[activeTab] = { ...tabs[activeTab], - currentHref: currentRoute?.pathname + search, + currentHref: currentRoute.pathname + search, }; } @@ -213,7 +218,8 @@ export const IonTabBar = defineComponent({ * set the previous tab back to its original href. */ if ( - currentRoute?.routerAction === "pop" && + prevActiveTab && + currentRoute.routerAction === "pop" && activeTab !== prevActiveTab ) { tabs[prevActiveTab] = { diff --git a/packages/vue/src/components/IonTabButton.ts b/packages/vue/src/components/IonTabButton.ts index b110a6209e0..bd667c8cdba 100644 --- a/packages/vue/src/components/IonTabButton.ts +++ b/packages/vue/src/components/IonTabButton.ts @@ -75,7 +75,7 @@ export const IonTabButton = /*@__PURE__*/ defineComponent({ if (ionRouter !== null) { if (prevActiveTab === tab) { if (originalHref !== currentHref) { - ionRouter.resetTab(tab); + ionRouter.resetTab(tab, originalHref); } } else { ionRouter.changeTab(tab, currentHref); diff --git a/packages/vue/test/base/tests/e2e/playwright/tabs-deep-load.spec.ts b/packages/vue/test/base/tests/e2e/playwright/tabs-deep-load.spec.ts new file mode 100644 index 00000000000..656f2e20d74 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/tabs-deep-load.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from './utils/test-base'; +import { ionPageVisible, tabClick } from './utils/test-utils'; + +/** + * Tab navigation when the user lands directly on a tab child route + * (e.g. browser refresh or back from an external URL). The tab's + * locationHistory only contains the deep-loaded entry, so resetting or + * pushing within the tab has to fall back to the tab's originalHref + * instead of router.go on a non-existent prior entry. + * + * @see https://github.com/ionic-team/ionic-framework/issues/29705 + */ +test.describe('Tabs: deep-loaded child', () => { + test('child-to-child push from deep-loaded tab child renders the next page', async ({ page }) => { + await page.goto('/tabs/tab1/childone'); + await ionPageVisible(page, 'tab1childone'); + + await page.locator('.ion-page[data-pageid="tab1childone"] #child-two').click(); + + await expect.poll(() => new URL(page.url()).pathname).toBe('/tabs/tab1/childtwo'); + await ionPageVisible(page, 'tab1childtwo'); + }); + + test('clicking tab button on deep-loaded tab child returns to tab root', async ({ page }) => { + await page.goto('/tabs/tab1/childone'); + await ionPageVisible(page, 'tab1childone'); + + await tabClick(page, 'tab1'); + + await expect.poll(() => new URL(page.url()).pathname).toBe('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + /** + * The fallback uses replace semantics, so the deep-loaded child + * is removed from history rather than stacked on top of the tab + * root. A push fallback would leave an extra entry behind. + */ + const historyLength = await page.evaluate(() => window.history.length); + expect(historyLength).toBeLessThanOrEqual(2); + }); + + test('deep-load works on tabs other than tab1', async ({ page }) => { + await page.goto('/tabs/tab2/childone'); + await ionPageVisible(page, 'tab2childone'); + + await tabClick(page, 'tab2'); + + await expect.poll(() => new URL(page.url()).pathname).toBe('/tabs/tab2'); + await ionPageVisible(page, 'tab2'); + }); +}); From ec8a15385f1b4ad89bdf81f790b702d6e08d375f Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 13 May 2026 13:16:35 -0700 Subject: [PATCH 2/2] chore(lint): fixing up tests --- packages/vue/src/components/IonTabBar.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/vue/src/components/IonTabBar.ts b/packages/vue/src/components/IonTabBar.ts index a05957f5798..86ff15e104c 100644 --- a/packages/vue/src/components/IonTabBar.ts +++ b/packages/vue/src/components/IonTabBar.ts @@ -196,10 +196,7 @@ export const IonTabBar = defineComponent({ * Also runs on initial setup so a deep-loaded tab child * records its real pathname instead of `originalHref`. */ - if ( - activeTab !== prevActiveTab || - prevHref !== currentRoute.pathname - ) { + if (activeTab !== prevActiveTab || prevHref !== currentRoute.pathname) { /** * By default the search is `undefined` in Ionic Vue, * but Vue Router can set the search to the empty string.