Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 64 additions & 9 deletions packages/angular/common/src/directives/navigation/tabs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {

Check failure on line 1 in packages/angular/common/src/directives/navigation/tabs.ts

View workflow job for this annotation

GitHub Actions / build-angular

There should be no empty line within import group
AfterContentChecked,
AfterContentInit,
Directive,
Expand All @@ -11,10 +11,54 @@
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',
})
Expand Down Expand Up @@ -103,23 +147,26 @@
*
* 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<boolean> | 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
Expand All @@ -136,6 +183,12 @@
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
Expand All @@ -159,17 +212,19 @@
const navigationExtras = rootView && tabRootUrl === rootView.url && rootView.savedExtras;
return this.navCtrl.navigateRoot(tabRootUrl, {
...navigationExtras,
...hrefExtras,
animated: true,
animationDirection: 'back',
});
} else {
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';

/**
* Verifies that query params on an `<ion-tab-button>` 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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@
Tabs Basic Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/tabs-search-params">
<ion-label>
Tabs Search Params Test
</ion-label>
</ion-item>
</ion-list>

<ion-list>
Expand Down
Original file line number Diff line number Diff line change
@@ -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: `
<p>Tab 1</p>
<p data-testid="tab1-query">{{ queryString$ | async }}</p>
<p data-testid="tab1-foo">{{ foo$ | async }}</p>
`,
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) {}
}
Original file line number Diff line number Diff line change
@@ -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: `
<p>Tab 2</p>
<p data-testid="tab2-query">{{ queryString$ | async }}</p>
<p data-testid="tab2-baz">{{ baz$ | async }}</p>
`,
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) {}
}
Original file line number Diff line number Diff line change
@@ -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: `
<ion-tabs>
<ion-tab-bar slot="bottom">
<ion-tab-button
tab="tab1"
href="/standalone/tabs-search-params/tab1?foo=bar"
data-testid="tab1"
>
<ion-icon name="triangle"></ion-icon>
<ion-label>Tab 1</ion-label>
</ion-tab-button>
<ion-tab-button
tab="tab2"
href="/standalone/tabs-search-params/tab2?baz=qux"
data-testid="tab2"
>
<ion-icon name="square"></ion-icon>
<ion-label>Tab 2</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-tabs>
`,
standalone: true,
imports: [IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs],
})
export class TabsSearchParamsComponent {}
37 changes: 30 additions & 7 deletions packages/vue-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
);
}
};

Expand Down
10 changes: 9 additions & 1 deletion packages/vue/src/components/IonTabBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "/")
);
Expand Down
18 changes: 18 additions & 0 deletions packages/vue/test/base/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,24 @@ const routes: Array<RouteRecordRaw> = [
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'),
Expand Down
Loading
Loading