Skip to content

Commit 0182bba

Browse files
authored
fix(tabs): preserve query params and fragment from tab button href (#31154)
Issue number: resolves #25470 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? When an `<ion-tab-button>` declares an `href` with a query string or fragment, activating the tab drops both. In Angular, `IonTabs.select` navigates to `tabsPrefix/tab` and never reads the button's `href`. In Vue, the router splits the path on `?` and discards everything after it, and `IonTabBar` compares the raw href against the pathname so a tab with query params never shows as selected. In React, this issue has been fixed as part of the RR6 migration. ## What is the new behavior? Tab activation forwards the `href`'s query and fragment as navigation extras. A saved view's previously-captured extras still win when re-selecting a tab with prior history, so mid-stack state isn't clobbered. `IonTabBar`'s selection check now compares pathnames only, so a tab with a query string still highlights when its pathname is active. Added Playwright coverage in `@ionic/angular` and Cypress coverage in `@ionic/vue` for first visit, switching tabs, switching back, and re-clicking the active tab. The Vue tests will cause a conflict on merging into major-9.0, but I volunteer to fix that issue when it comes up. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information Preview pages: - Angular: https://ionic-framework-git-fw-7146-ionic1.vercel.app/angular/standalone/tabs-search-params/tab1?foo=bar - Vue: https://ionic-framework-git-fw-7146-ionic1.vercel.app/vue/tabs-search-params/tab1?foo=bar - React: This was fixed in the RR6 migration in V9. Unfortunately this won't be coming to 8.8 at this time.
1 parent c88c0de commit 0182bba

19 files changed

Lines changed: 653 additions & 17 deletions

File tree

packages/angular/common/src/directives/navigation/tabs.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,54 @@ import {
1010
AfterViewInit,
1111
QueryList,
1212
} from '@angular/core';
13+
import type { Params } from '@angular/router';
1314

1415
import { NavController } from '../../providers/nav-controller';
1516

1617
import { StackDidChangeEvent, StackWillChangeEvent } from './stack-utils';
1718

19+
/**
20+
* Extracts `queryParams` and `fragment` from a tab button's href for use
21+
* as Angular `NavigationExtras`. Returns `undefined` when neither is present.
22+
*/
23+
const parseHrefExtras = (href: string | undefined): { queryParams?: Params; fragment?: string } | undefined => {
24+
if (!href) {
25+
return undefined;
26+
}
27+
28+
const hashIndex = href.indexOf('#');
29+
// Treat a bare `#` (no fragment text) as no fragment.
30+
const fragment = hashIndex >= 0 && hashIndex < href.length - 1 ? href.slice(hashIndex + 1) : undefined;
31+
const beforeHash = hashIndex >= 0 ? href.slice(0, hashIndex) : href;
32+
33+
const queryIndex = beforeHash.indexOf('?');
34+
const search = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : '';
35+
36+
let queryParams: Params | undefined;
37+
if (search) {
38+
const params = new URLSearchParams(search);
39+
queryParams = {};
40+
for (const key of new Set(params.keys())) {
41+
const all = params.getAll(key);
42+
queryParams[key] = all.length > 1 ? all : all[0];
43+
}
44+
}
45+
46+
if (!queryParams && fragment === undefined) {
47+
return undefined;
48+
}
49+
50+
/**
51+
* Build the result with only the populated keys so that a spread of the
52+
* returned object does not overwrite saved `queryParams`/`fragment` with
53+
* `undefined` (which `Object.assign`/spread would copy as a real key).
54+
*/
55+
const extras: { queryParams?: Params; fragment?: string } = {};
56+
if (queryParams) extras.queryParams = queryParams;
57+
if (fragment !== undefined) extras.fragment = fragment;
58+
return extras;
59+
};
60+
1861
@Directive({
1962
selector: 'ion-tabs',
2063
})
@@ -103,23 +146,26 @@ export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterC
103146
*
104147
* a. Get the saved root view from the router outlet. If the saved root view
105148
* matches the tabRootUrl, set the route view to this view including the
106-
* navigation extras.
107-
* b. If the saved root view from the router outlet does
108-
* not match, navigate to the tabRootUrl. No navigation extras are
109-
* included.
149+
* navigation extras. Any `queryParams` or `fragment` declared on the tab
150+
* button's `href` are also forwarded.
151+
* b. If the saved root view from the router outlet does not match, navigate
152+
* to the tabRootUrl, forwarding any `queryParams`/`fragment` declared on
153+
* the tab button's `href`.
110154
*
111155
* 2. If the current tab tab is not currently selected, get the last route
112156
* view from the router outlet.
113157
*
114158
* a. If the last route view exists, navigate to that view including any
115-
* navigation extras
116-
* b. If the last route view doesn't exist, then navigate
117-
* to the default tabRootUrl
159+
* navigation extras.
160+
* b. If the last route view doesn't exist, then navigate to the default
161+
* tabRootUrl, forwarding any `queryParams`/`fragment` declared on the
162+
* tab button's `href`.
118163
*/
119164
@HostListener('ionTabButtonClick', ['$event'])
120165
select(tabOrEvent: string | CustomEvent): Promise<boolean> | undefined {
121166
const isTabString = typeof tabOrEvent === 'string';
122167
const tab = isTabString ? tabOrEvent : (tabOrEvent as CustomEvent).detail.tab;
168+
const href: string | undefined = isTabString ? undefined : (tabOrEvent as CustomEvent).detail.href;
123169

124170
/**
125171
* If the tabs are not using the router, then
@@ -136,6 +182,12 @@ export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterC
136182
const alreadySelected = this.outlet.getActiveStackId() === tab;
137183
const tabRootUrl = `${this.outlet.tabsPrefix}/${tab}`;
138184

185+
/**
186+
* The href pathname is ignored here; tab routing is driven by `tabsPrefix/tab`.
187+
* Only the query and fragment are forwarded as navigation extras.
188+
*/
189+
const hrefExtras = parseHrefExtras(href);
190+
139191
/**
140192
* If this is a nested tab, prevent the event
141193
* from bubbling otherwise the outer tabs
@@ -159,17 +211,19 @@ export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterC
159211
const navigationExtras = rootView && tabRootUrl === rootView.url && rootView.savedExtras;
160212
return this.navCtrl.navigateRoot(tabRootUrl, {
161213
...navigationExtras,
214+
...hrefExtras,
162215
animated: true,
163216
animationDirection: 'back',
164217
});
165218
} else {
166219
const lastRoute = this.outlet.getLastRouteView(tab);
167220
/**
168221
* If there is a lastRoute, goto that, otherwise goto the fallback url of the
169-
* selected tab
222+
* selected tab. When falling back to the tab root, honor query params and
223+
* fragment declared on the tab button's href.
170224
*/
171225
const url = lastRoute?.url || tabRootUrl;
172-
const navigationExtras = lastRoute?.savedExtras;
226+
const navigationExtras = lastRoute?.savedExtras ?? (url === tabRootUrl ? hrefExtras : undefined);
173227

174228
return this.navCtrl.navigateRoot(url, {
175229
...navigationExtras,
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* Verifies that query params on an `<ion-tab-button>` href are preserved when
5+
* the tab is activated (first visit, switching tabs, switching back, and
6+
* re-clicking the already-active tab).
7+
*
8+
* @see https://github.com/ionic-team/ionic-framework/issues/25470
9+
*/
10+
test.describe('Tabs: query params on tab button href', () => {
11+
test('should preserve query params on first visit to a tab', async ({ page }) => {
12+
await page.goto('/standalone/tabs-search-params/tab1?foo=bar');
13+
14+
await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible();
15+
expect(new URL(page.url()).pathname).toBe('/standalone/tabs-search-params/tab1');
16+
expect(new URL(page.url()).search).toBe('?foo=bar');
17+
await expect(page.locator('[data-testid="tab1-foo"]')).toHaveText('bar');
18+
await expect(page.locator('ion-tab-button[data-testid="tab1"]')).toHaveClass(/tab-selected/);
19+
});
20+
21+
test('should preserve href query params when switching to a tab for the first time', async ({ page }) => {
22+
await page.goto('/standalone/tabs-search-params/tab1?foo=bar');
23+
await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible();
24+
25+
await page.locator('ion-tab-button[data-testid="tab2"]').click();
26+
await expect(page.locator('app-tabs-search-params-tab2')).toBeVisible();
27+
28+
expect(new URL(page.url()).pathname).toBe('/standalone/tabs-search-params/tab2');
29+
expect(new URL(page.url()).search).toBe('?baz=qux');
30+
await expect(page.locator('[data-testid="tab2-baz"]')).toHaveText('qux');
31+
await expect(page.locator('ion-tab-button[data-testid="tab2"]')).toHaveClass(/tab-selected/);
32+
});
33+
34+
test('should preserve query params when switching back to a previously visited tab', async ({ page }) => {
35+
await page.goto('/standalone/tabs-search-params/tab1?foo=bar');
36+
await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible();
37+
38+
await page.locator('ion-tab-button[data-testid="tab2"]').click();
39+
await expect(page.locator('app-tabs-search-params-tab2')).toBeVisible();
40+
41+
await page.locator('ion-tab-button[data-testid="tab1"]').click();
42+
await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible();
43+
44+
expect(new URL(page.url()).pathname).toBe('/standalone/tabs-search-params/tab1');
45+
expect(new URL(page.url()).search).toBe('?foo=bar');
46+
await expect(page.locator('[data-testid="tab1-foo"]')).toHaveText('bar');
47+
});
48+
49+
test('should preserve query params when re-clicking the already-active tab', async ({ page }) => {
50+
await page.goto('/standalone/tabs-search-params/tab1?foo=bar');
51+
await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible();
52+
53+
await page.locator('ion-tab-button[data-testid="tab1"]').click();
54+
55+
expect(new URL(page.url()).pathname).toBe('/standalone/tabs-search-params/tab1');
56+
expect(new URL(page.url()).search).toBe('?foo=bar');
57+
await expect(page.locator('[data-testid="tab1-foo"]')).toHaveText('bar');
58+
});
59+
60+
test('should preserve multiple query params and fragment from tab button href', async ({ page }) => {
61+
await page.goto('/standalone/tabs-search-params/tab1?foo=bar');
62+
await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible();
63+
64+
await page.locator('ion-tab-button[data-testid="tab3"]').click();
65+
await expect(page.locator('app-tabs-search-params-tab3')).toBeVisible();
66+
67+
const url = new URL(page.url());
68+
expect(url.pathname).toBe('/standalone/tabs-search-params/tab3');
69+
expect(url.search).toBe('?x=1&y=2');
70+
expect(url.hash).toBe('#section');
71+
await expect(page.locator('[data-testid="tab3-x"]')).toHaveText('1');
72+
await expect(page.locator('[data-testid="tab3-y"]')).toHaveText('2');
73+
await expect(page.locator('[data-testid="tab3-fragment"]')).toHaveText('section');
74+
});
75+
76+
test('should preserve URL-encoded query params from tab button href', async ({ page }) => {
77+
await page.goto('/standalone/tabs-search-params/tab1?foo=bar');
78+
await expect(page.locator('app-tabs-search-params-tab1')).toBeVisible();
79+
80+
await page.locator('ion-tab-button[data-testid="tab4"]').click();
81+
await expect(page.locator('app-tabs-search-params-tab4')).toBeVisible();
82+
83+
const url = new URL(page.url());
84+
expect(url.pathname).toBe('/standalone/tabs-search-params/tab4');
85+
expect(url.searchParams.get('q')).toBe('hello world');
86+
await expect(page.locator('[data-testid="tab4-q"]')).toHaveText('hello world');
87+
});
88+
});

packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ export const routes: Routes = [
5555
]
5656
},
5757
{ path: 'tabs-basic', loadComponent: () => import('../tabs-basic/tabs-basic.component').then(c => c.TabsBasicComponent) },
58+
{ path: 'tabs-search-params', redirectTo: '/standalone/tabs-search-params/tab1?foo=bar', pathMatch: 'full' },
59+
{
60+
path: 'tabs-search-params',
61+
loadComponent: () => import('../tabs-search-params/tabs-search-params.component').then(c => c.TabsSearchParamsComponent),
62+
children: [
63+
{ path: 'tab1', loadComponent: () => import('../tabs-search-params/tab1.component').then(c => c.TabsSearchParamsTab1Component) },
64+
{ path: 'tab2', loadComponent: () => import('../tabs-search-params/tab2.component').then(c => c.TabsSearchParamsTab2Component) },
65+
{ path: 'tab3', loadComponent: () => import('../tabs-search-params/tab3.component').then(c => c.TabsSearchParamsTab3Component) },
66+
{ path: 'tab4', loadComponent: () => import('../tabs-search-params/tab4.component').then(c => c.TabsSearchParamsTab4Component) }
67+
]
68+
},
5869
{
5970
path: 'validation',
6071
children: [

packages/angular/test/base/src/app/standalone/home-page/home-page.component.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@
7979
Tabs Basic Test
8080
</ion-label>
8181
</ion-item>
82+
<ion-item routerLink="/standalone/tabs-search-params">
83+
<ion-label>
84+
Tabs Search Params Test
85+
</ion-label>
86+
</ion-item>
8287
</ion-list>
8388

8489
<ion-list>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { AsyncPipe } from '@angular/common';
2+
import { Component } from '@angular/core';
3+
import { ActivatedRoute } from '@angular/router';
4+
import { map } from 'rxjs/operators';
5+
6+
@Component({
7+
selector: 'app-tabs-search-params-tab1',
8+
template: `
9+
<p>Tab 1</p>
10+
<p data-testid="tab1-query">{{ queryString$ | async }}</p>
11+
<p data-testid="tab1-foo">{{ foo$ | async }}</p>
12+
`,
13+
standalone: true,
14+
imports: [AsyncPipe],
15+
})
16+
export class TabsSearchParamsTab1Component {
17+
queryString$ = this.route.queryParamMap.pipe(
18+
map((m) => m.keys.map((k) => `${k}=${m.get(k)}`).join('&'))
19+
);
20+
21+
foo$ = this.route.queryParamMap.pipe(map((m) => m.get('foo') ?? ''));
22+
23+
constructor(private route: ActivatedRoute) {}
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { AsyncPipe } from '@angular/common';
2+
import { Component } from '@angular/core';
3+
import { ActivatedRoute } from '@angular/router';
4+
import { map } from 'rxjs/operators';
5+
6+
@Component({
7+
selector: 'app-tabs-search-params-tab2',
8+
template: `
9+
<p>Tab 2</p>
10+
<p data-testid="tab2-query">{{ queryString$ | async }}</p>
11+
<p data-testid="tab2-baz">{{ baz$ | async }}</p>
12+
`,
13+
standalone: true,
14+
imports: [AsyncPipe],
15+
})
16+
export class TabsSearchParamsTab2Component {
17+
queryString$ = this.route.queryParamMap.pipe(
18+
map((m) => m.keys.map((k) => `${k}=${m.get(k)}`).join('&'))
19+
);
20+
21+
baz$ = this.route.queryParamMap.pipe(map((m) => m.get('baz') ?? ''));
22+
23+
constructor(private route: ActivatedRoute) {}
24+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { AsyncPipe } from '@angular/common';
2+
import { Component } from '@angular/core';
3+
import { ActivatedRoute } from '@angular/router';
4+
import { map } from 'rxjs/operators';
5+
6+
@Component({
7+
selector: 'app-tabs-search-params-tab3',
8+
template: `
9+
<p>Tab 3</p>
10+
<p data-testid="tab3-x">{{ x$ | async }}</p>
11+
<p data-testid="tab3-y">{{ y$ | async }}</p>
12+
<p data-testid="tab3-fragment">{{ fragment$ | async }}</p>
13+
`,
14+
standalone: true,
15+
imports: [AsyncPipe],
16+
})
17+
export class TabsSearchParamsTab3Component {
18+
x$ = this.route.queryParamMap.pipe(map((m) => m.get('x') ?? ''));
19+
y$ = this.route.queryParamMap.pipe(map((m) => m.get('y') ?? ''));
20+
fragment$ = this.route.fragment.pipe(map((f) => f ?? ''));
21+
22+
constructor(private route: ActivatedRoute) {}
23+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { AsyncPipe } from '@angular/common';
2+
import { Component } from '@angular/core';
3+
import { ActivatedRoute } from '@angular/router';
4+
import { map } from 'rxjs/operators';
5+
6+
@Component({
7+
selector: 'app-tabs-search-params-tab4',
8+
template: `
9+
<p>Tab 4</p>
10+
<p data-testid="tab4-q">{{ q$ | async }}</p>
11+
`,
12+
standalone: true,
13+
imports: [AsyncPipe],
14+
})
15+
export class TabsSearchParamsTab4Component {
16+
q$ = this.route.queryParamMap.pipe(map((m) => m.get('q') ?? ''));
17+
18+
constructor(private route: ActivatedRoute) {}
19+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Component } from '@angular/core';
2+
import { IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs } from '@ionic/angular/standalone';
3+
import { addIcons } from 'ionicons';
4+
import { square, triangle } from 'ionicons/icons';
5+
6+
addIcons({ square, triangle });
7+
8+
@Component({
9+
selector: 'app-tabs-search-params',
10+
template: `
11+
<ion-tabs>
12+
<ion-tab-bar slot="bottom">
13+
<ion-tab-button
14+
tab="tab1"
15+
href="/standalone/tabs-search-params/tab1?foo=bar"
16+
data-testid="tab1"
17+
>
18+
<ion-icon name="triangle"></ion-icon>
19+
<ion-label>Tab 1</ion-label>
20+
</ion-tab-button>
21+
<ion-tab-button
22+
tab="tab2"
23+
href="/standalone/tabs-search-params/tab2?baz=qux"
24+
data-testid="tab2"
25+
>
26+
<ion-icon name="square"></ion-icon>
27+
<ion-label>Tab 2</ion-label>
28+
</ion-tab-button>
29+
<ion-tab-button
30+
tab="tab3"
31+
href="/standalone/tabs-search-params/tab3?x=1&y=2#section"
32+
data-testid="tab3"
33+
>
34+
<ion-icon name="triangle"></ion-icon>
35+
<ion-label>Tab 3</ion-label>
36+
</ion-tab-button>
37+
<ion-tab-button
38+
tab="tab4"
39+
href="/standalone/tabs-search-params/tab4?q=hello%20world"
40+
data-testid="tab4"
41+
>
42+
<ion-icon name="square"></ion-icon>
43+
<ion-label>Tab 4</ion-label>
44+
</ion-tab-button>
45+
</ion-tab-bar>
46+
</ion-tabs>
47+
`,
48+
standalone: true,
49+
imports: [IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs],
50+
})
51+
export class TabsSearchParamsComponent {}

0 commit comments

Comments
 (0)