diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef8dae114d..508426b3d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.8](https://github.com/ionic-team/ionic-framework/compare/v8.8.7...v8.8.8) (2026-05-20) + + +### Bug Fixes + +* **react:** bind events properly for overlays rendered within a nav ([#31159](https://github.com/ionic-team/ionic-framework/issues/31159)) ([fa4593d](https://github.com/ionic-team/ionic-framework/commit/fa4593d8a4d61a583dbf6fa551cd846fe258624f)), closes [#27843](https://github.com/ionic-team/ionic-framework/issues/27843) +* **tabs:** preserve query params and fragment from tab button href ([#31154](https://github.com/ionic-team/ionic-framework/issues/31154)) ([0182bba](https://github.com/ionic-team/ionic-framework/commit/0182bba06d6171dd2faf80556fd2131abf03fa93)), closes [#25470](https://github.com/ionic-team/ionic-framework/issues/25470) +* **vue-router:** prevent out-of-bounds index when popping across tabs ([#31148](https://github.com/ionic-team/ionic-framework/issues/31148)) ([c88c0de](https://github.com/ionic-team/ionic-framework/commit/c88c0de3ade92469fa1f37e1b8220911adf113e0)), closes [#29413](https://github.com/ionic-team/ionic-framework/issues/29413) + + + + + ## [8.8.7](https://github.com/ionic-team/ionic-framework/compare/v8.8.6...v8.8.7) (2026-05-13) diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 3b3bfa8d395..e9b40761785 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.8](https://github.com/ionic-team/ionic-framework/compare/v8.8.7...v8.8.8) (2026-05-20) + +**Note:** Version bump only for package @ionic/core + + + + + ## [8.8.7](https://github.com/ionic-team/ionic-framework/compare/v8.8.6...v8.8.7) (2026-05-13) diff --git a/core/package-lock.json b/core/package-lock.json index df01b3af51b..3426611303e 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/core", - "version": "8.8.7", + "version": "8.8.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "8.8.7", + "version": "8.8.8", "license": "MIT", "dependencies": { "@stencil/core": "4.43.0", diff --git a/core/package.json b/core/package.json index 56246c0dc82..13f69f50091 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "8.8.7", + "version": "8.8.8", "description": "Base components for Ionic", "engines": { "node": ">= 16" diff --git a/lerna.json b/lerna.json index 8527f66f2c1..8c524128cbe 100644 --- a/lerna.json +++ b/lerna.json @@ -3,5 +3,5 @@ "core", "packages/*" ], - "version": "8.8.7" + "version": "8.8.8" } \ No newline at end of file diff --git a/packages/angular-server/CHANGELOG.md b/packages/angular-server/CHANGELOG.md index 90ba0e04927..25283000092 100644 --- a/packages/angular-server/CHANGELOG.md +++ b/packages/angular-server/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.8](https://github.com/ionic-team/ionic-framework/compare/v8.8.7...v8.8.8) (2026-05-20) + +**Note:** Version bump only for package @ionic/angular-server + + + + + ## [8.8.7](https://github.com/ionic-team/ionic-framework/compare/v8.8.6...v8.8.7) (2026-05-13) **Note:** Version bump only for package @ionic/angular-server diff --git a/packages/angular-server/package-lock.json b/packages/angular-server/package-lock.json index 0c205e49cec..2861b146830 100644 --- a/packages/angular-server/package-lock.json +++ b/packages/angular-server/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular-server", - "version": "8.8.7", + "version": "8.8.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/angular-server", - "version": "8.8.7", + "version": "8.8.8", "license": "MIT", "dependencies": { - "@ionic/core": "^8.8.7" + "@ionic/core": "^8.8.8" }, "devDependencies": { "@angular-eslint/eslint-plugin": "^16.0.0", @@ -1031,9 +1031,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.8.7", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.7.tgz", - "integrity": "sha512-eHfu6yea7tcog4vUzfT//LavH/poh8zI3cgO/CDXvOehdCi9QONPeeXE+PaCBFkMnD5Q+08SFF/7a/IfD0X0mQ==", + "version": "8.8.8", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.8.tgz", + "integrity": "sha512-GGvYtEzLtn1gBUC1/vb4pvA3gQzYskTNVIsvdTVIgnwLtdt70rwTibrZRSqmkyHeqpjg/u3+9XsM2c0kzc/V3w==", "license": "MIT", "dependencies": { "@stencil/core": "4.43.0", @@ -7309,9 +7309,9 @@ "dev": true }, "@ionic/core": { - "version": "8.8.7", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.7.tgz", - "integrity": "sha512-eHfu6yea7tcog4vUzfT//LavH/poh8zI3cgO/CDXvOehdCi9QONPeeXE+PaCBFkMnD5Q+08SFF/7a/IfD0X0mQ==", + "version": "8.8.8", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.8.tgz", + "integrity": "sha512-GGvYtEzLtn1gBUC1/vb4pvA3gQzYskTNVIsvdTVIgnwLtdt70rwTibrZRSqmkyHeqpjg/u3+9XsM2c0kzc/V3w==", "requires": { "@stencil/core": "4.43.0", "ionicons": "^8.0.13", diff --git a/packages/angular-server/package.json b/packages/angular-server/package.json index d1365fecd85..c4242de7258 100644 --- a/packages/angular-server/package.json +++ b/packages/angular-server/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular-server", - "version": "8.8.7", + "version": "8.8.8", "description": "Angular SSR Module for Ionic", "keywords": [ "ionic", @@ -62,6 +62,6 @@ }, "prettier": "@ionic/prettier-config", "dependencies": { - "@ionic/core": "^8.8.7" + "@ionic/core": "^8.8.8" } } diff --git a/packages/angular/CHANGELOG.md b/packages/angular/CHANGELOG.md index 256799b62ad..ee86d4e033b 100644 --- a/packages/angular/CHANGELOG.md +++ b/packages/angular/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.8](https://github.com/ionic-team/ionic-framework/compare/v8.8.7...v8.8.8) (2026-05-20) + + +### Bug Fixes + +* **tabs:** preserve query params and fragment from tab button href ([#31154](https://github.com/ionic-team/ionic-framework/issues/31154)) ([0182bba](https://github.com/ionic-team/ionic-framework/commit/0182bba06d6171dd2faf80556fd2131abf03fa93)), closes [#25470](https://github.com/ionic-team/ionic-framework/issues/25470) + + + + + ## [8.8.7](https://github.com/ionic-team/ionic-framework/compare/v8.8.6...v8.8.7) (2026-05-13) **Note:** Version bump only for package @ionic/angular diff --git a/packages/angular/common/src/directives/navigation/tabs.ts b/packages/angular/common/src/directives/navigation/tabs.ts index 73e8c0cc777..3d994c38ffc 100644 --- a/packages/angular/common/src/directives/navigation/tabs.ts +++ b/packages/angular/common/src/directives/navigation/tabs.ts @@ -10,11 +10,54 @@ import { AfterViewInit, 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 +146,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 +182,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 +211,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 +219,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/package-lock.json b/packages/angular/package-lock.json index bf0e687e6c3..2f70c809dcf 100644 --- a/packages/angular/package-lock.json +++ b/packages/angular/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular", - "version": "8.8.7", + "version": "8.8.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular", - "version": "8.8.7", + "version": "8.8.8", "license": "MIT", "dependencies": { - "@ionic/core": "^8.8.7", + "@ionic/core": "^8.8.8", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" @@ -1398,9 +1398,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.8.7", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.7.tgz", - "integrity": "sha512-eHfu6yea7tcog4vUzfT//LavH/poh8zI3cgO/CDXvOehdCi9QONPeeXE+PaCBFkMnD5Q+08SFF/7a/IfD0X0mQ==", + "version": "8.8.8", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.8.tgz", + "integrity": "sha512-GGvYtEzLtn1gBUC1/vb4pvA3gQzYskTNVIsvdTVIgnwLtdt70rwTibrZRSqmkyHeqpjg/u3+9XsM2c0kzc/V3w==", "license": "MIT", "dependencies": { "@stencil/core": "4.43.0", diff --git a/packages/angular/package.json b/packages/angular/package.json index 7193fcfc3b0..7600bae5780 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular", - "version": "8.8.7", + "version": "8.8.8", "description": "Angular specific wrappers for @ionic/core", "keywords": [ "ionic", @@ -48,7 +48,7 @@ } }, "dependencies": { - "@ionic/core": "^8.8.7", + "@ionic/core": "^8.8.8", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" 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..9e9cc5a48d2 --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/tabs-search-params.spec.ts @@ -0,0 +1,88 @@ +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'); + }); + + test('should preserve multiple query params and fragment from tab button href', 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="tab3"]').click(); + await expect(page.locator('app-tabs-search-params-tab3')).toBeVisible(); + + const url = new URL(page.url()); + expect(url.pathname).toBe('/standalone/tabs-search-params/tab3'); + expect(url.search).toBe('?x=1&y=2'); + expect(url.hash).toBe('#section'); + await expect(page.locator('[data-testid="tab3-x"]')).toHaveText('1'); + await expect(page.locator('[data-testid="tab3-y"]')).toHaveText('2'); + await expect(page.locator('[data-testid="tab3-fragment"]')).toHaveText('section'); + }); + + test('should preserve URL-encoded query params from tab button href', 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="tab4"]').click(); + await expect(page.locator('app-tabs-search-params-tab4')).toBeVisible(); + + const url = new URL(page.url()); + expect(url.pathname).toBe('/standalone/tabs-search-params/tab4'); + expect(url.searchParams.get('q')).toBe('hello world'); + await expect(page.locator('[data-testid="tab4-q"]')).toHaveText('hello world'); + }); +}); 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..c365195662d 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,17 @@ 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: 'tab3', loadComponent: () => import('../tabs-search-params/tab3.component').then(c => c.TabsSearchParamsTab3Component) }, + { path: 'tab4', loadComponent: () => import('../tabs-search-params/tab4.component').then(c => c.TabsSearchParamsTab4Component) } + ] + }, { 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/tab3.component.ts b/packages/angular/test/base/src/app/standalone/tabs-search-params/tab3.component.ts new file mode 100644 index 00000000000..c67995316d3 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tabs-search-params/tab3.component.ts @@ -0,0 +1,23 @@ +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-tab3', + template: ` +

Tab 3

+

{{ x$ | async }}

+

{{ y$ | async }}

+

{{ fragment$ | async }}

+ `, + standalone: true, + imports: [AsyncPipe], +}) +export class TabsSearchParamsTab3Component { + x$ = this.route.queryParamMap.pipe(map((m) => m.get('x') ?? '')); + y$ = this.route.queryParamMap.pipe(map((m) => m.get('y') ?? '')); + fragment$ = this.route.fragment.pipe(map((f) => f ?? '')); + + constructor(private route: ActivatedRoute) {} +} diff --git a/packages/angular/test/base/src/app/standalone/tabs-search-params/tab4.component.ts b/packages/angular/test/base/src/app/standalone/tabs-search-params/tab4.component.ts new file mode 100644 index 00000000000..19897f882a7 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tabs-search-params/tab4.component.ts @@ -0,0 +1,19 @@ +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-tab4', + template: ` +

Tab 4

+

{{ q$ | async }}

+ `, + standalone: true, + imports: [AsyncPipe], +}) +export class TabsSearchParamsTab4Component { + q$ = this.route.queryParamMap.pipe(map((m) => m.get('q') ?? '')); + + 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..d81f042b7c7 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tabs-search-params/tabs-search-params.component.ts @@ -0,0 +1,51 @@ +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 + + + + Tab 3 + + + + Tab 4 + + + + `, + standalone: true, + imports: [IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs], +}) +export class TabsSearchParamsComponent {} diff --git a/packages/docs/CHANGELOG.md b/packages/docs/CHANGELOG.md index 3b6b4827d54..934a24497b6 100644 --- a/packages/docs/CHANGELOG.md +++ b/packages/docs/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.8](https://github.com/ionic-team/ionic-framework/compare/v8.8.7...v8.8.8) (2026-05-20) + +**Note:** Version bump only for package @ionic/docs + + + + + ## [8.8.7](https://github.com/ionic-team/ionic-framework/compare/v8.8.6...v8.8.7) (2026-05-13) **Note:** Version bump only for package @ionic/docs diff --git a/packages/docs/package-lock.json b/packages/docs/package-lock.json index b0c83521314..7040a4e55bf 100644 --- a/packages/docs/package-lock.json +++ b/packages/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/docs", - "version": "8.8.7", + "version": "8.8.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/docs", - "version": "8.8.7", + "version": "8.8.8", "license": "MIT" } } diff --git a/packages/docs/package.json b/packages/docs/package.json index 8eeb0549de5..7a40bf552b4 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/docs", - "version": "8.8.7", + "version": "8.8.8", "description": "Pre-packaged API documentation for the Ionic docs.", "main": "core.json", "types": "core.d.ts", diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 78751895e0d..8a05c2f3bad 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.8](https://github.com/ionic-team/ionic-framework/compare/v8.8.7...v8.8.8) (2026-05-20) + +**Note:** Version bump only for package @ionic/react-router + + + + + ## [8.8.7](https://github.com/ionic-team/ionic-framework/compare/v8.8.6...v8.8.7) (2026-05-13) **Note:** Version bump only for package @ionic/react-router diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json index 8b222dff987..16116350cf1 100644 --- a/packages/react-router/package-lock.json +++ b/packages/react-router/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/react-router", - "version": "8.8.7", + "version": "8.8.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/react-router", - "version": "8.8.7", + "version": "8.8.8", "license": "MIT", "dependencies": { - "@ionic/react": "^8.8.7", + "@ionic/react": "^8.8.8", "tslib": "*" }, "devDependencies": { @@ -238,9 +238,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.8.7", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.7.tgz", - "integrity": "sha512-eHfu6yea7tcog4vUzfT//LavH/poh8zI3cgO/CDXvOehdCi9QONPeeXE+PaCBFkMnD5Q+08SFF/7a/IfD0X0mQ==", + "version": "8.8.8", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.8.tgz", + "integrity": "sha512-GGvYtEzLtn1gBUC1/vb4pvA3gQzYskTNVIsvdTVIgnwLtdt70rwTibrZRSqmkyHeqpjg/u3+9XsM2c0kzc/V3w==", "license": "MIT", "dependencies": { "@stencil/core": "4.43.0", @@ -418,12 +418,12 @@ } }, "node_modules/@ionic/react": { - "version": "8.8.7", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.8.7.tgz", - "integrity": "sha512-CWB3I7lzdNbY+CB/skL+qlOf0YjcNiMK9m2lqusQdmlqIf4AlBAO0+mbmEF7DZhMGFHXfcX5WCC+tcwIUANReg==", + "version": "8.8.8", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.8.8.tgz", + "integrity": "sha512-RP/AR5HX4PZq2xKuugS5FM1p0cF1XnRqPwU94O9OyO+DgjEJJ6o/q3Eo0JiwJD9M5sue69cDc82Uc/sZ4+p4bA==", "license": "MIT", "dependencies": { - "@ionic/core": "8.8.7", + "@ionic/core": "8.8.8", "ionicons": "^8.0.13", "tslib": "*" }, @@ -4178,9 +4178,9 @@ "dev": true }, "@ionic/core": { - "version": "8.8.7", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.7.tgz", - "integrity": "sha512-eHfu6yea7tcog4vUzfT//LavH/poh8zI3cgO/CDXvOehdCi9QONPeeXE+PaCBFkMnD5Q+08SFF/7a/IfD0X0mQ==", + "version": "8.8.8", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.8.tgz", + "integrity": "sha512-GGvYtEzLtn1gBUC1/vb4pvA3gQzYskTNVIsvdTVIgnwLtdt70rwTibrZRSqmkyHeqpjg/u3+9XsM2c0kzc/V3w==", "requires": { "@stencil/core": "4.43.0", "ionicons": "^8.0.13", @@ -4284,11 +4284,11 @@ "requires": {} }, "@ionic/react": { - "version": "8.8.7", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.8.7.tgz", - "integrity": "sha512-CWB3I7lzdNbY+CB/skL+qlOf0YjcNiMK9m2lqusQdmlqIf4AlBAO0+mbmEF7DZhMGFHXfcX5WCC+tcwIUANReg==", + "version": "8.8.8", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.8.8.tgz", + "integrity": "sha512-RP/AR5HX4PZq2xKuugS5FM1p0cF1XnRqPwU94O9OyO+DgjEJJ6o/q3Eo0JiwJD9M5sue69cDc82Uc/sZ4+p4bA==", "requires": { - "@ionic/core": "8.8.7", + "@ionic/core": "8.8.8", "ionicons": "^8.0.13", "tslib": "*" } diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 0b7f6a85327..a74b620c4a5 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react-router", - "version": "8.8.7", + "version": "8.8.8", "description": "React Router wrapper for @ionic/react", "keywords": [ "ionic", @@ -36,7 +36,7 @@ "dist/" ], "dependencies": { - "@ionic/react": "^8.8.7", + "@ionic/react": "^8.8.8", "tslib": "*" }, "peerDependencies": { diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index aa99d2e48ec..b56b2122fe0 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.8](https://github.com/ionic-team/ionic-framework/compare/v8.8.7...v8.8.8) (2026-05-20) + + +### Bug Fixes + +* **react:** bind events properly for overlays rendered within a nav ([#31159](https://github.com/ionic-team/ionic-framework/issues/31159)) ([fa4593d](https://github.com/ionic-team/ionic-framework/commit/fa4593d8a4d61a583dbf6fa551cd846fe258624f)), closes [#27843](https://github.com/ionic-team/ionic-framework/issues/27843) + + + + + ## [8.8.7](https://github.com/ionic-team/ionic-framework/compare/v8.8.6...v8.8.7) (2026-05-13) **Note:** Version bump only for package @ionic/react diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index 5d62d0d2234..d899bdb3655 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/react", - "version": "8.8.7", + "version": "8.8.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react", - "version": "8.8.7", + "version": "8.8.8", "license": "MIT", "dependencies": { - "@ionic/core": "^8.8.7", + "@ionic/core": "^8.8.8", "ionicons": "^8.0.13", "tslib": "*" }, @@ -736,9 +736,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.8.7", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.7.tgz", - "integrity": "sha512-eHfu6yea7tcog4vUzfT//LavH/poh8zI3cgO/CDXvOehdCi9QONPeeXE+PaCBFkMnD5Q+08SFF/7a/IfD0X0mQ==", + "version": "8.8.8", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.8.tgz", + "integrity": "sha512-GGvYtEzLtn1gBUC1/vb4pvA3gQzYskTNVIsvdTVIgnwLtdt70rwTibrZRSqmkyHeqpjg/u3+9XsM2c0kzc/V3w==", "license": "MIT", "dependencies": { "@stencil/core": "4.43.0", diff --git a/packages/react/package.json b/packages/react/package.json index 14a310f8a99..b3faf4cea76 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react", - "version": "8.8.7", + "version": "8.8.8", "description": "React specific wrapper for @ionic/core", "keywords": [ "ionic", @@ -40,7 +40,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^8.8.7", + "@ionic/core": "^8.8.8", "ionicons": "^8.0.13", "tslib": "*" }, diff --git a/packages/react/src/components/createInlineOverlayComponent.tsx b/packages/react/src/components/createInlineOverlayComponent.tsx index 65f1711cbb7..e70e88fe40b 100644 --- a/packages/react/src/components/createInlineOverlayComponent.tsx +++ b/packages/react/src/components/createInlineOverlayComponent.tsx @@ -1,5 +1,7 @@ import type { HTMLIonOverlayElement, OverlayEventDetail } from '@ionic/core/components'; +import { componentOnReady } from '@ionic/core/components'; import React, { createElement } from 'react'; +import { createPortal } from 'react-dom'; import { attachProps, @@ -17,6 +19,15 @@ type InlineOverlayState = { isOpen: boolean; }; +/** + * Set to `true` when rendering inside another inline overlay. Nested + * overlays render at their JSX position (no portal) so that core's + * `el.closest('ion-popover')`-style nesting detection keeps working, + * and the outer overlay's portal already gives the subtree the correct + * React event-delegation root. + */ +const NestedOverlayContext = React.createContext(false); + interface IonicReactInternalProps extends React.HTMLAttributes { forwardedRef?: React.ForwardedRef; ref?: React.Ref; @@ -36,12 +47,18 @@ export const createInlineOverlayComponent = ( defineCustomElement(); } const displayName = dashToPascalCase(tagName); - const ReactComponent = class extends React.Component, InlineOverlayState> { + + type InternalProps = IonicReactInternalProps & { isNested?: boolean }; + + const ReactComponent = class extends React.Component { ref: React.RefObject; wrapperRef: React.RefObject; + markerRef: React.RefObject; stableMergedRefs: React.RefCallback; + portalTarget: HTMLElement | null; + isUnmounted = false; - constructor(props: IonicReactInternalProps) { + constructor(props: InternalProps) { super(props); // Create a local ref to to attach props to the wrapped element. this.ref = React.createRef(); @@ -51,17 +68,51 @@ export const createInlineOverlayComponent = ( this.state = { isOpen: false }; // Create a local ref to the inner child element. this.wrapperRef = React.createRef(); + // Marker stays at the JSX location so we can recover the immediate + // JSX parent after the overlay has been portaled to ion-app. + this.markerRef = React.createRef(); + /** + * Resolve the portal target to the same container CoreDelegate + * teleports overlays into. Portaling here keeps the overlay inside + * React's tree so React's synthetic events still dispatch to its + * children, even after CoreDelegate moves the DOM node out of the + * declared JSX parent. + */ + this.portalTarget = typeof document !== 'undefined' ? document.querySelector('ion-app') || document.body : null; } componentDidMount() { + // Reset for React 18 StrictMode: the dev-mode unmount/remount cycle + // re-uses this instance and leaves the flag set from the prior + // componentWillUnmount. + this.isUnmounted = false; + this.componentDidUpdate(this.props); this.ref.current?.addEventListener('ionMount', this.handleIonMount); this.ref.current?.addEventListener('willPresent', this.handleWillPresent); this.ref.current?.addEventListener('didDismiss', this.handleDidDismiss); + + /** + * The overlay is portaled to `portalTarget`, so Stencil caches that + * container as `cachedOriginalParent`. Modal features (sheet + * child-route passthrough, parent-removal auto-dismiss) walk up + * from `cachedOriginalParent` to find the enclosing `.ion-page`, + * so we redirect it at the marker's JSX parent. + */ + const overlay = this.ref.current; + if (overlay) { + componentOnReady(overlay as HTMLElement, () => { + if (this.isUnmounted) return; + const markerParent = this.markerRef.current?.parentElement ?? null; + if (markerParent && markerParent !== this.portalTarget) { + (overlay as any).cachedOriginalParent = markerParent; + } + }); + } } - componentDidUpdate(prevProps: IonicReactInternalProps) { + componentDidUpdate(prevProps: InternalProps) { const node = this.ref.current! as HTMLElement; /** * onDidDismiss and onWillPresent have manual implementations that @@ -69,11 +120,12 @@ export const createInlineOverlayComponent = ( * so they don't get attached twice and called twice. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { onDidDismiss, onWillPresent, ...cProps } = this.props; + const { onDidDismiss, onWillPresent, isNested, ...cProps } = this.props; attachProps(node, cProps, prevProps); } componentWillUnmount() { + this.isUnmounted = true; const node = this.ref.current; /** * If the overlay is being unmounted, but is still @@ -97,14 +149,28 @@ export const createInlineOverlayComponent = ( * avoid memory leaks. */ node.removeEventListener('didDismiss', this.handleDidDismiss); - node.remove(); + if (this.props.isNested) { + /** + * Nested overlays render inline (no portal). CoreDelegate may + * have moved the node out of its React parent, so React's + * unmount won't reach it. Remove it directly. + */ + node.remove(); + } else if (node.isConnected && this.portalTarget && node.parentNode !== this.portalTarget) { + /** + * Portaled path: move the overlay back into `portalTarget` so + * React's portal removeChild can find it. CoreDelegate (or user + * code in onWillPresent) may have moved it elsewhere while open. + */ + this.portalTarget.appendChild(node); + } detachProps(node, this.props); } } render() { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { children, forwardedRef, style, className, ref, ...cProps } = this.props; + const { children, forwardedRef, style, className, ref, isNested, ...cProps } = this.props; const propsToPass = Object.keys(cProps).reduce((acc, name) => { if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) { @@ -136,17 +202,16 @@ export const createInlineOverlayComponent = ( return DELEGATE_HOST; }; - return createElement( - 'template', - {}, + const overlayElement = createElement( + tagName, + newProps, + // Children, not the overlay host, observe `isNested = true`. createElement( - tagName, - newProps, + NestedOverlayContext.Provider, + { value: true }, /** - * We only want the inner component - * to be mounted if the overlay is open, - * so conditionally render the component - * based on the isOpen state. + * We only want the inner component to be mounted if the overlay + * is open, so conditionally render based on `isOpen` state. */ this.state.isOpen || this.props.keepContentsMounted ? createElement( @@ -160,6 +225,21 @@ export const createInlineOverlayComponent = ( : null ) ); + + // Top-level overlays portal into `portalTarget` with a marker + // `