From 861bb1be1d7d9be15e5df3725d35995be8d01bfc Mon Sep 17 00:00:00 2001 From: ShaneK Date: Fri, 16 Oct 2026 11:06:34 -0700 Subject: [PATCH] fix(vue-router): keep tab stack intact when replacing and going back multiple pages --- packages/vue-router/src/router.ts | 15 ++++- packages/vue/src/hooks/router.ts | 2 +- .../e2e/playwright/replace-direction.spec.ts | 10 ++- .../tests/e2e/playwright/tabs-replace.spec.ts | 63 +++++++++++++++++++ .../base/tests/unit/router-outlet.spec.ts | 4 +- 5 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index 365b8454b40..95443549c9e 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -475,9 +475,15 @@ export const createIonRouter = ( currentHistoryPosition ); } else if (incomingRouteParams.routerAction === "pop") { + /** + * Back can skip several entries, e.g. router.go(-3) after a + * tab switch, so use the delta to find the page we left. A + * back done with router.replace has no delta and keeps the + * one-step lookup. + */ leavingLocationInfo = locationHistory.current( initialHistoryPosition, - currentHistoryPosition + 1 + currentHistoryPosition - (delta ?? -1) ); /** @@ -709,7 +715,12 @@ export const createIonRouter = ( routeInfo.lastPathname = currentRouteInfo?.pathname || routeInfo.lastPathname; - routeInfo.pushedByRoute = pushedByRoute; + /** + * A root replace resets the stack, so keeping pushedByRoute + * would show a back button with nowhere to go. + */ + routeInfo.pushedByRoute = + routeInfo.routerDirection === "root" ? undefined : pushedByRoute; /** * Prefer the direction/animation the caller specified on * the navigate call; fall back to the leaving route's diff --git a/packages/vue/src/hooks/router.ts b/packages/vue/src/hooks/router.ts index ed6af2a66b3..6c50c702c77 100644 --- a/packages/vue/src/hooks/router.ts +++ b/packages/vue/src/hooks/router.ts @@ -44,7 +44,7 @@ export const useIonRouter = (): UseIonRouterResult => { navigate(location, "forward", "push", routerAnimation); const replace = (location: any, routerAnimation?: AnimationBuilder) => - navigate(location, "root", "replace", routerAnimation); + navigate(location, "none", "replace", routerAnimation); const back = (routerAnimation?: AnimationBuilder) => goBack(routerAnimation); diff --git a/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts b/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts index e70a1427de4..92024433f12 100644 --- a/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts +++ b/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts @@ -44,7 +44,13 @@ test.describe('useIonRouter.navigate with routerAction="replace"', () => { expect(await readCurrentDirection(page)).toBe('forward'); }); - test('default useIonRouter.replace() keeps direction "root"', async ({ page }) => { + // The default replace keeps the stack beneath the replaced page. + test('default useIonRouter.replace() should use direction "none"', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30617', + }); + await page.goto('/'); await ionPageVisible(page, 'home'); @@ -52,6 +58,6 @@ test.describe('useIonRouter.navigate with routerAction="replace"', () => { await page.evaluate(() => (window as any).debugIonRouter.replace('/routing')); await ionPageVisible(page, 'routing'); - expect(await readCurrentDirection(page)).toBe('root'); + expect(await readCurrentDirection(page)).toBe('none'); }); }); diff --git a/packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts b/packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts new file mode 100644 index 00000000000..0042e35e099 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from './utils/test-base'; +import { ionBackClick, ionPageVisible, ionPageDoesNotExist, ionRouterNavigate, ionRouterReplace, routerPush, tabClick } from './utils/test-utils'; + +/** + * Replacing a page in a tab stack should keep the pages beneath it. + * + * @see https://github.com/ionic-team/ionic-framework/issues/30617 + */ +test.describe('Tabs: replacing a page inside a tab stack', () => { + test('should return to the tab root on back after a tab switch and a forward replace', async ({ page }) => { + await page.goto('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + await routerPush(page, '/tabs/tab1/view1'); + await ionPageVisible(page, 'tab1view1'); + + await tabClick(page, 'tab2'); + await ionPageVisible(page, 'tab2'); + + await tabClick(page, 'tab1'); + await ionPageVisible(page, 'tab1view1'); + + await ionRouterNavigate(page, '/tabs/tab1/view2', 'forward', 'replace'); + await ionPageVisible(page, 'tab1view2'); + + await ionBackClick(page, 'tab1view2'); + + await expect.poll(() => new URL(page.url()).pathname).toBe('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + await ionPageDoesNotExist(page, 'tab1view2'); + }); + + test('should return to the tab root on back after useIonRouter.replace', async ({ page }) => { + await page.goto('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + await routerPush(page, '/tabs/tab1/view1'); + await ionPageVisible(page, 'tab1view1'); + + await ionRouterReplace(page, '/tabs/tab1/view2'); + await ionPageVisible(page, 'tab1view2'); + + await ionBackClick(page, 'tab1view2'); + + await expect.poll(() => new URL(page.url()).pathname).toBe('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + await ionPageDoesNotExist(page, 'tab1view2'); + }); + + // A root replace resets the stack, so there's nothing to go back to. + test('should not show a back button after a root replace', async ({ page }) => { + await page.goto('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + await routerPush(page, '/tabs/tab1/view1'); + await ionPageVisible(page, 'tab1view1'); + + await ionRouterNavigate(page, '/tabs/tab1/view2', 'root', 'replace'); + await ionPageVisible(page, 'tab1view2'); + + await expect(page.locator('.ion-page[data-pageid="tab1view2"] ion-back-button')).toBeHidden(); + }); +}); diff --git a/packages/vue/test/base/tests/unit/router-outlet.spec.ts b/packages/vue/test/base/tests/unit/router-outlet.spec.ts index 9c3b544faab..507c29c04f5 100644 --- a/packages/vue/test/base/tests/unit/router-outlet.spec.ts +++ b/packages/vue/test/base/tests/unit/router-outlet.spec.ts @@ -80,7 +80,7 @@ describe('Routing', () => { expect.anything(), expect.objectContaining({ // "root" and "none" both resolve to duration=0 via hasRootDirection. - direction: "root", + direction: "none", duration: 0, animationBuilder: undefined }) @@ -142,7 +142,7 @@ describe('Routing', () => { expect.anything(), expect.anything(), expect.objectContaining({ - direction: "root", + direction: "none", duration: undefined, animationBuilder: animation })