From ad14bd2c50e78bc1c92d26a9c900cd013a089f56 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 19 May 2026 08:19:46 -0700 Subject: [PATCH] fix(tabs): preserve query params and fragment from tab button href --- .../common/src/directives/navigation/tabs.ts | 73 ++++++++++++++++--- .../src/standalone/tabs-search-params.spec.ts | 59 +++++++++++++++ .../standalone/app-standalone/app.routes.ts | 9 +++ .../home-page/home-page.component.html | 5 ++ .../tabs-search-params/tab1.component.ts | 24 ++++++ .../tabs-search-params/tab2.component.ts | 24 ++++++ .../tabs-search-params.component.ts | 35 +++++++++ packages/vue-router/src/router.ts | 37 ++++++++-- packages/vue/src/components/IonTabBar.ts | 10 ++- packages/vue/test/base/src/router/index.ts | 18 +++++ packages/vue/test/base/src/views/Home.vue | 3 + .../src/views/tabs-search-params/Tab1.vue | 31 ++++++++ .../src/views/tabs-search-params/Tab2.vue | 31 ++++++++ .../tabs-search-params/TabsSearchParams.vue | 58 +++++++++++++++ .../tests/e2e/specs/tabs-search-params.cy.js | 57 +++++++++++++++ 15 files changed, 457 insertions(+), 17 deletions(-) create mode 100644 packages/angular/test/base/e2e/src/standalone/tabs-search-params.spec.ts create mode 100644 packages/angular/test/base/src/app/standalone/tabs-search-params/tab1.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/tabs-search-params/tab2.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/tabs-search-params/tabs-search-params.component.ts create mode 100644 packages/vue/test/base/src/views/tabs-search-params/Tab1.vue create mode 100644 packages/vue/test/base/src/views/tabs-search-params/Tab2.vue create mode 100644 packages/vue/test/base/src/views/tabs-search-params/TabsSearchParams.vue create mode 100644 packages/vue/test/base/tests/e2e/specs/tabs-search-params.cy.js diff --git a/packages/angular/common/src/directives/navigation/tabs.ts b/packages/angular/common/src/directives/navigation/tabs.ts index 73e8c0cc777..8f6e29d359d 100644 --- a/packages/angular/common/src/directives/navigation/tabs.ts +++ b/packages/angular/common/src/directives/navigation/tabs.ts @@ -11,10 +11,54 @@ import { QueryList, } from '@angular/core'; +import type { Params } from '@angular/router'; + import { NavController } from '../../providers/nav-controller'; import { StackDidChangeEvent, StackWillChangeEvent } from './stack-utils'; +/** + * Extracts `queryParams` and `fragment` from a tab button's href for use + * as Angular `NavigationExtras`. Returns `undefined` when neither is present. + */ +const parseHrefExtras = (href: string | undefined): { queryParams?: Params; fragment?: string } | undefined => { + if (!href) { + return undefined; + } + + const hashIndex = href.indexOf('#'); + // Treat a bare `#` (no fragment text) as no fragment. + const fragment = hashIndex >= 0 && hashIndex < href.length - 1 ? href.slice(hashIndex + 1) : undefined; + const beforeHash = hashIndex >= 0 ? href.slice(0, hashIndex) : href; + + const queryIndex = beforeHash.indexOf('?'); + const search = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : ''; + + let queryParams: Params | undefined; + if (search) { + const params = new URLSearchParams(search); + queryParams = {}; + for (const key of new Set(params.keys())) { + const all = params.getAll(key); + queryParams[key] = all.length > 1 ? all : all[0]; + } + } + + if (!queryParams && fragment === undefined) { + return undefined; + } + + /** + * Build the result with only the populated keys so that a spread of the + * returned object does not overwrite saved `queryParams`/`fragment` with + * `undefined` (which `Object.assign`/spread would copy as a real key). + */ + const extras: { queryParams?: Params; fragment?: string } = {}; + if (queryParams) extras.queryParams = queryParams; + if (fragment !== undefined) extras.fragment = fragment; + return extras; +}; + @Directive({ selector: 'ion-tabs', }) @@ -103,23 +147,26 @@ export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterC * * a. Get the saved root view from the router outlet. If the saved root view * matches the tabRootUrl, set the route view to this view including the - * navigation extras. - * b. If the saved root view from the router outlet does - * not match, navigate to the tabRootUrl. No navigation extras are - * included. + * navigation extras. Any `queryParams` or `fragment` declared on the tab + * button's `href` are also forwarded. + * b. If the saved root view from the router outlet does not match, navigate + * to the tabRootUrl, forwarding any `queryParams`/`fragment` declared on + * the tab button's `href`. * * 2. If the current tab tab is not currently selected, get the last route * view from the router outlet. * * a. If the last route view exists, navigate to that view including any - * navigation extras - * b. If the last route view doesn't exist, then navigate - * to the default tabRootUrl + * navigation extras. + * b. If the last route view doesn't exist, then navigate to the default + * tabRootUrl, forwarding any `queryParams`/`fragment` declared on the + * tab button's `href`. */ @HostListener('ionTabButtonClick', ['$event']) select(tabOrEvent: string | CustomEvent): Promise | undefined { const isTabString = typeof tabOrEvent === 'string'; const tab = isTabString ? tabOrEvent : (tabOrEvent as CustomEvent).detail.tab; + const href: string | undefined = isTabString ? undefined : (tabOrEvent as CustomEvent).detail.href; /** * If the tabs are not using the router, then @@ -136,6 +183,12 @@ export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterC const alreadySelected = this.outlet.getActiveStackId() === tab; const tabRootUrl = `${this.outlet.tabsPrefix}/${tab}`; + /** + * The href pathname is ignored here; tab routing is driven by `tabsPrefix/tab`. + * Only the query and fragment are forwarded as navigation extras. + */ + const hrefExtras = parseHrefExtras(href); + /** * If this is a nested tab, prevent the event * from bubbling otherwise the outer tabs @@ -159,6 +212,7 @@ export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterC const navigationExtras = rootView && tabRootUrl === rootView.url && rootView.savedExtras; return this.navCtrl.navigateRoot(tabRootUrl, { ...navigationExtras, + ...hrefExtras, animated: true, animationDirection: 'back', }); @@ -166,10 +220,11 @@ export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterC const lastRoute = this.outlet.getLastRouteView(tab); /** * If there is a lastRoute, goto that, otherwise goto the fallback url of the - * selected tab + * selected tab. When falling back to the tab root, honor query params and + * fragment declared on the tab button's href. */ const url = lastRoute?.url || tabRootUrl; - const navigationExtras = lastRoute?.savedExtras; + const navigationExtras = lastRoute?.savedExtras ?? (url === tabRootUrl ? hrefExtras : undefined); return this.navCtrl.navigateRoot(url, { ...navigationExtras, diff --git a/packages/angular/test/base/e2e/src/standalone/tabs-search-params.spec.ts b/packages/angular/test/base/e2e/src/standalone/tabs-search-params.spec.ts new file mode 100644 index 00000000000..2f408520c33 --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/tabs-search-params.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; + +/** + * Verifies that query params on an `` href are preserved when + * the tab is activated (first visit, switching tabs, switching back, and + * re-clicking the already-active tab). + * + * @see https://github.com/ionic-team/ionic-framework/issues/25470 + */ +test.describe('Tabs: query params on tab button href', () => { + test('should preserve query params on first visit to a tab', async ({ page }) => { + await page.goto('/standalone/tabs-search-params/tab1?foo=bar'); + + await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible(); + expect(new URL(page.url()).pathname).toBe('/standalone/tabs-search-params/tab1'); + expect(new URL(page.url()).search).toBe('?foo=bar'); + await expect(page.locator('[data-testid="tab1-foo"]')).toHaveText('bar'); + await expect(page.locator('ion-tab-button[data-testid="tab1"]')).toHaveClass(/tab-selected/); + }); + + test('should preserve href query params when switching to a tab for the first time', async ({ page }) => { + await page.goto('/standalone/tabs-search-params/tab1?foo=bar'); + await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible(); + + await page.locator('ion-tab-button[data-testid="tab2"]').click(); + await expect(page.locator('app-tabs-search-params-tab2')).toBeVisible(); + + expect(new URL(page.url()).pathname).toBe('/standalone/tabs-search-params/tab2'); + expect(new URL(page.url()).search).toBe('?baz=qux'); + await expect(page.locator('[data-testid="tab2-baz"]')).toHaveText('qux'); + await expect(page.locator('ion-tab-button[data-testid="tab2"]')).toHaveClass(/tab-selected/); + }); + + test('should preserve query params when switching back to a previously visited tab', async ({ page }) => { + await page.goto('/standalone/tabs-search-params/tab1?foo=bar'); + await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible(); + + await page.locator('ion-tab-button[data-testid="tab2"]').click(); + await expect(page.locator('app-tabs-search-params-tab2')).toBeVisible(); + + await page.locator('ion-tab-button[data-testid="tab1"]').click(); + await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible(); + + expect(new URL(page.url()).pathname).toBe('/standalone/tabs-search-params/tab1'); + expect(new URL(page.url()).search).toBe('?foo=bar'); + await expect(page.locator('[data-testid="tab1-foo"]')).toHaveText('bar'); + }); + + test('should preserve query params when re-clicking the already-active tab', async ({ page }) => { + await page.goto('/standalone/tabs-search-params/tab1?foo=bar'); + await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible(); + + await page.locator('ion-tab-button[data-testid="tab1"]').click(); + + expect(new URL(page.url()).pathname).toBe('/standalone/tabs-search-params/tab1'); + expect(new URL(page.url()).search).toBe('?foo=bar'); + await expect(page.locator('[data-testid="tab1-foo"]')).toHaveText('bar'); + }); +}); diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts index dd0ccbb4ac5..1f7d06969d6 100644 --- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts +++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts @@ -55,6 +55,15 @@ export const routes: Routes = [ ] }, { path: 'tabs-basic', loadComponent: () => import('../tabs-basic/tabs-basic.component').then(c => c.TabsBasicComponent) }, + { path: 'tabs-search-params', redirectTo: '/standalone/tabs-search-params/tab1?foo=bar', pathMatch: 'full' }, + { + path: 'tabs-search-params', + loadComponent: () => import('../tabs-search-params/tabs-search-params.component').then(c => c.TabsSearchParamsComponent), + children: [ + { path: 'tab1', loadComponent: () => import('../tabs-search-params/tab1.component').then(c => c.TabsSearchParamsTab1Component) }, + { path: 'tab2', loadComponent: () => import('../tabs-search-params/tab2.component').then(c => c.TabsSearchParamsTab2Component) } + ] + }, { path: 'validation', children: [ diff --git a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html index 00c3bf97452..3ad10254b30 100644 --- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html @@ -79,6 +79,11 @@ Tabs Basic Test + + + Tabs Search Params Test + + diff --git a/packages/angular/test/base/src/app/standalone/tabs-search-params/tab1.component.ts b/packages/angular/test/base/src/app/standalone/tabs-search-params/tab1.component.ts new file mode 100644 index 00000000000..ec472e4e74f --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tabs-search-params/tab1.component.ts @@ -0,0 +1,24 @@ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'app-tabs-search-params-tab1', + template: ` +

Tab 1

+

{{ queryString$ | async }}

+

{{ foo$ | async }}

+ `, + standalone: true, + imports: [AsyncPipe], +}) +export class TabsSearchParamsTab1Component { + queryString$ = this.route.queryParamMap.pipe( + map((m) => m.keys.map((k) => `${k}=${m.get(k)}`).join('&')) + ); + + foo$ = this.route.queryParamMap.pipe(map((m) => m.get('foo') ?? '')); + + constructor(private route: ActivatedRoute) {} +} diff --git a/packages/angular/test/base/src/app/standalone/tabs-search-params/tab2.component.ts b/packages/angular/test/base/src/app/standalone/tabs-search-params/tab2.component.ts new file mode 100644 index 00000000000..09bf2453da6 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tabs-search-params/tab2.component.ts @@ -0,0 +1,24 @@ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'app-tabs-search-params-tab2', + template: ` +

Tab 2

+

{{ queryString$ | async }}

+

{{ baz$ | async }}

+ `, + standalone: true, + imports: [AsyncPipe], +}) +export class TabsSearchParamsTab2Component { + queryString$ = this.route.queryParamMap.pipe( + map((m) => m.keys.map((k) => `${k}=${m.get(k)}`).join('&')) + ); + + baz$ = this.route.queryParamMap.pipe(map((m) => m.get('baz') ?? '')); + + constructor(private route: ActivatedRoute) {} +} diff --git a/packages/angular/test/base/src/app/standalone/tabs-search-params/tabs-search-params.component.ts b/packages/angular/test/base/src/app/standalone/tabs-search-params/tabs-search-params.component.ts new file mode 100644 index 00000000000..30a6fc38b07 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tabs-search-params/tabs-search-params.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { square, triangle } from 'ionicons/icons'; + +addIcons({ square, triangle }); + +@Component({ + selector: 'app-tabs-search-params', + template: ` + + + + + Tab 1 + + + + Tab 2 + + + + `, + standalone: true, + imports: [IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs], +}) +export class TabsSearchParamsComponent {} diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index 35ce3d55a94..dd864e1458b 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -534,7 +534,18 @@ export const createIonRouter = ( if (!path) return; const routeInfo = locationHistory.getCurrentRouteInfoForTab(tab); - const [pathname] = path.split("?"); + /** + * Strip the fragment before parsing the query so that a `#frag` on the + * href cannot leak into the last query value or corrupt the pathname. + */ + const hashIndex = path.indexOf("#"); + const beforeHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path; + const hrefHash = + hashIndex >= 0 && hashIndex < path.length - 1 + ? path.slice(hashIndex) + : ""; + const [pathname, search] = beforeHash.split("?"); + const hrefSearch = search ? `?${search}` : ""; if (routeInfo) { incomingRouteParams = { @@ -550,17 +561,29 @@ export const createIonRouter = ( * for the route info to be incorrect * as the tab you want is not the * tab you are on. + * + * If the incoming href carries its own query string, prefer that over + * the previously-saved search so query params on the tab button href + * are honored when re-selecting the tab. */ + const effectiveSearch = hrefSearch || routeInfo.search; + const push = { + query: parseQuery(effectiveSearch), + ...(hrefHash ? { hash: hrefHash } : {}), + }; if (routeInfo.pathname === pathname) { - router.push({ - path: routeInfo.pathname, - query: parseQuery(routeInfo.search), - }); + router.push({ path: routeInfo.pathname, ...push }); } else { - router.push({ path: pathname, query: parseQuery(routeInfo.search) }); + router.push({ path: pathname, ...push }); } } else { - handleNavigate(pathname, "push", "none", undefined, tab); + handleNavigate( + pathname + hrefSearch + hrefHash, + "push", + "none", + undefined, + tab + ); } }; diff --git a/packages/vue/src/components/IonTabBar.ts b/packages/vue/src/components/IonTabBar.ts index 30002f4d858..f72c1a30b8f 100644 --- a/packages/vue/src/components/IonTabBar.ts +++ b/packages/vue/src/components/IonTabBar.ts @@ -34,8 +34,16 @@ const matchesTab = (pathname: string, href: string | undefined): boolean => { return false; } + /** + * Compare pathnames only; an href like "/tabs/home?foo=bar#section" must + * still match a pathname of "/tabs/home". Strip the fragment first so a + * "#frag" containing "?" cannot leak into the pathname. + */ + const hrefPathname = href.split("#")[0].split("?")[0]; const normalizedHref = - href.endsWith("/") && href !== "/" ? href.slice(0, -1) : href; + hrefPathname.endsWith("/") && hrefPathname !== "/" + ? hrefPathname.slice(0, -1) + : hrefPathname; return ( pathname === normalizedHref || pathname.startsWith(normalizedHref + "/") ); diff --git a/packages/vue/test/base/src/router/index.ts b/packages/vue/test/base/src/router/index.ts index e518550fd01..172e5a4e795 100644 --- a/packages/vue/test/base/src/router/index.ts +++ b/packages/vue/test/base/src/router/index.ts @@ -165,6 +165,24 @@ const routes: Array = [ path: '/tabs-basic', component: () => import('@/views/TabsBasic.vue') }, + { + path: '/tabs-search-params/', + component: () => import('@/views/tabs-search-params/TabsSearchParams.vue'), + children: [ + { + path: '', + redirect: '/tabs-search-params/tab1?foo=bar' + }, + { + path: 'tab1', + component: () => import('@/views/tabs-search-params/Tab1.vue') + }, + { + path: 'tab2', + component: () => import('@/views/tabs-search-params/Tab2.vue') + } + ] + }, { path: '/tabs-similar-prefixes/', component: () => import('@/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue'), diff --git a/packages/vue/test/base/src/views/Home.vue b/packages/vue/test/base/src/views/Home.vue index d37ab45bbea..68535e05231 100644 --- a/packages/vue/test/base/src/views/Home.vue +++ b/packages/vue/test/base/src/views/Home.vue @@ -53,6 +53,9 @@ Tabs with Similar Route Prefixes + + Tabs with Search Params on Href + Lifecycle diff --git a/packages/vue/test/base/src/views/tabs-search-params/Tab1.vue b/packages/vue/test/base/src/views/tabs-search-params/Tab1.vue new file mode 100644 index 00000000000..c64da9d74ea --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-search-params/Tab1.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/vue/test/base/src/views/tabs-search-params/Tab2.vue b/packages/vue/test/base/src/views/tabs-search-params/Tab2.vue new file mode 100644 index 00000000000..8e03fbd2def --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-search-params/Tab2.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/vue/test/base/src/views/tabs-search-params/TabsSearchParams.vue b/packages/vue/test/base/src/views/tabs-search-params/TabsSearchParams.vue new file mode 100644 index 00000000000..741d3e49e07 --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-search-params/TabsSearchParams.vue @@ -0,0 +1,58 @@ + + + diff --git a/packages/vue/test/base/tests/e2e/specs/tabs-search-params.cy.js b/packages/vue/test/base/tests/e2e/specs/tabs-search-params.cy.js new file mode 100644 index 00000000000..9f61f04a90a --- /dev/null +++ b/packages/vue/test/base/tests/e2e/specs/tabs-search-params.cy.js @@ -0,0 +1,57 @@ +/** + * Verifies that query params set on an IonTabButton href are preserved when + * the tab is activated (first visit, switching tabs, switching back, and + * re-clicking the already-active tab). + * + * @see https://github.com/ionic-team/ionic-framework/issues/25470 + */ +describe('Tabs: query params on tab button href', () => { + it('should preserve query params on first visit to a tab', () => { + cy.visit('/tabs-search-params/tab1?foo=bar'); + + cy.ionPageVisible('tabs-search-params-tab1'); + cy.location('pathname').should('eq', '/tabs-search-params/tab1'); + cy.location('search').should('eq', '?foo=bar'); + cy.get('[data-testid="tab1-foo"]').should('have.text', 'bar'); + cy.get('ion-tab-button[data-testid="tab1"]').should('have.class', 'tab-selected'); + }); + + it('should preserve href query params when switching to a tab for the first time', () => { + cy.visit('/tabs-search-params/tab1?foo=bar'); + cy.ionPageVisible('tabs-search-params-tab1'); + + cy.get('ion-tab-button[data-testid="tab2"]').click(); + cy.ionPageVisible('tabs-search-params-tab2'); + + cy.location('pathname').should('eq', '/tabs-search-params/tab2'); + cy.location('search').should('eq', '?baz=qux'); + cy.get('[data-testid="tab2-baz"]').should('have.text', 'qux'); + cy.get('ion-tab-button[data-testid="tab2"]').should('have.class', 'tab-selected'); + }); + + it('should preserve query params when switching back to a previously visited tab', () => { + cy.visit('/tabs-search-params/tab1?foo=bar'); + cy.ionPageVisible('tabs-search-params-tab1'); + + cy.get('ion-tab-button[data-testid="tab2"]').click(); + cy.ionPageVisible('tabs-search-params-tab2'); + + cy.get('ion-tab-button[data-testid="tab1"]').click(); + cy.ionPageVisible('tabs-search-params-tab1'); + + cy.location('pathname').should('eq', '/tabs-search-params/tab1'); + cy.location('search').should('eq', '?foo=bar'); + cy.get('[data-testid="tab1-foo"]').should('have.text', 'bar'); + }); + + it('should preserve query params when re-clicking the already-active tab', () => { + cy.visit('/tabs-search-params/tab1?foo=bar'); + cy.ionPageVisible('tabs-search-params-tab1'); + + cy.get('ion-tab-button[data-testid="tab1"]').click(); + + cy.location('pathname').should('eq', '/tabs-search-params/tab1'); + cy.location('search').should('eq', '?foo=bar'); + cy.get('[data-testid="tab1-foo"]').should('have.text', 'bar'); + }); +});