diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f17a5f9a264..446fac61981 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,7 +15,7 @@ jobs: security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: languages: javascript - - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 3d9b03ea5c4..54990664544 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -28,7 +28,7 @@ jobs: outputs: migrate: ${{ steps.filter.outputs.migrate }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history so the PR base commit is reachable for the diff below. fetch-depth: 0 @@ -63,8 +63,8 @@ jobs: run: working-directory: ./packages/migrate steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x cache: 'npm' diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a3bb0d57a..f152580536f 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. +## [9.0.1](https://github.com/ionic-team/ionic-framework/compare/v9.0.0...v9.0.1) (2026-08-26) + +### Bug Fixes + +* **core:** restore prop defaults when consumers downlevel ([#31375](https://github.com/ionic-team/ionic-framework/issues/31375)) ([b4174f9](https://github.com/ionic-team/ionic-framework/commit/b4174f96f5061ebc93119cc7e1363162ae666ae9)), closes [#31280](https://github.com/ionic-team/ionic-framework/issues/31280) +* **item-sliding, segment-button:** read props after frameworks assign them ([#31391](https://github.com/ionic-team/ionic-framework/issues/31391)) ([7956b97](https://github.com/ionic-team/ionic-framework/commit/7956b973b8bb55e5f0cae844ee992fe9921432cb)), closes [#31388](https://github.com/ionic-team/ionic-framework/issues/31388) +* **overlays:** restore presented state after a DOM move ([#31400](https://github.com/ionic-team/ionic-framework/issues/31400)) ([bfff8e1](https://github.com/ionic-team/ionic-framework/commit/bfff8e1377a8faff3581e61026733ce29b671513)), closes [#31389](https://github.com/ionic-team/ionic-framework/issues/31389) [#31390](https://github.com/ionic-team/ionic-framework/issues/31390) [#31389](https://github.com/ionic-team/ionic-framework/issues/31389) +* **picker-column:** commit value on outside press ([#31397](https://github.com/ionic-team/ionic-framework/issues/31397)) ([95e2224](https://github.com/ionic-team/ionic-framework/commit/95e222488d4e4bac9c6c8c0b42a2154082846767)), closes [#30449](https://github.com/ionic-team/ionic-framework/issues/30449) [#30674](https://github.com/ionic-team/ionic-framework/issues/30674) +* **react-router:** skip page clone when nothing will animate ([#31387](https://github.com/ionic-team/ionic-framework/issues/31387)) ([898001c](https://github.com/ionic-team/ionic-framework/commit/898001c5c6a92a3269dd8e60c4e79da1c634af9d)) +* **refresher:** validate slot after frameworks assign it ([#31377](https://github.com/ionic-team/ionic-framework/issues/31377)) ([d41f258](https://github.com/ionic-team/ionic-framework/commit/d41f25860dcb9a71a40226973cca9ca223b7f7ca)), closes [#31376](https://github.com/ionic-team/ionic-framework/issues/31376) +* **select:** derive option text from all child nodes ([#31382](https://github.com/ionic-team/ionic-framework/issues/31382)) ([7879ec5](https://github.com/ionic-team/ionic-framework/commit/7879ec5763be92b4ecf03c6c58975265166e33ba)), closes [#31381](https://github.com/ionic-team/ionic-framework/issues/31381) + + # [9.0.0](https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0) (2026-08-19) **Full Changelog**: https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0 diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index a0f09f6f6c8..5302cb963d6 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.1](https://github.com/ionic-team/ionic-framework/compare/v9.0.0...v9.0.1) (2026-08-26) + +### Bug Fixes + +* **core:** restore prop defaults when consumers downlevel ([#31375](https://github.com/ionic-team/ionic-framework/issues/31375)) ([b4174f9](https://github.com/ionic-team/ionic-framework/commit/b4174f96f5061ebc93119cc7e1363162ae666ae9)), closes [#31280](https://github.com/ionic-team/ionic-framework/issues/31280) +* **item-sliding, segment-button:** read props after frameworks assign them ([#31391](https://github.com/ionic-team/ionic-framework/issues/31391)) ([7956b97](https://github.com/ionic-team/ionic-framework/commit/7956b973b8bb55e5f0cae844ee992fe9921432cb)), closes [#31388](https://github.com/ionic-team/ionic-framework/issues/31388) +* **overlays:** restore presented state after a DOM move ([#31400](https://github.com/ionic-team/ionic-framework/issues/31400)) ([bfff8e1](https://github.com/ionic-team/ionic-framework/commit/bfff8e1377a8faff3581e61026733ce29b671513)), closes [#31389](https://github.com/ionic-team/ionic-framework/issues/31389) [#31390](https://github.com/ionic-team/ionic-framework/issues/31390) [#31389](https://github.com/ionic-team/ionic-framework/issues/31389) +* **picker-column:** commit value on outside press ([#31397](https://github.com/ionic-team/ionic-framework/issues/31397)) ([95e2224](https://github.com/ionic-team/ionic-framework/commit/95e222488d4e4bac9c6c8c0b42a2154082846767)), closes [#30449](https://github.com/ionic-team/ionic-framework/issues/30449) [#30674](https://github.com/ionic-team/ionic-framework/issues/30674) +* **refresher:** validate slot after frameworks assign it ([#31377](https://github.com/ionic-team/ionic-framework/issues/31377)) ([d41f258](https://github.com/ionic-team/ionic-framework/commit/d41f25860dcb9a71a40226973cca9ca223b7f7ca)), closes [#31376](https://github.com/ionic-team/ionic-framework/issues/31376) +* **select:** derive option text from all child nodes ([#31382](https://github.com/ionic-team/ionic-framework/issues/31382)) ([7879ec5](https://github.com/ionic-team/ionic-framework/commit/7879ec5763be92b4ecf03c6c58975265166e33ba)), closes [#31381](https://github.com/ionic-team/ionic-framework/issues/31381) + + # [9.0.0](https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0) (2026-08-19) **Full Changelog**: https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0 diff --git a/core/package-lock.json b/core/package-lock.json index 93b7f706583..1c7f5128c70 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/core", - "version": "9.0.0", + "version": "9.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "9.0.0", + "version": "9.0.1", "license": "MIT", "dependencies": { - "@stencil/core": "^4.43.5", + "@stencil/core": "^4.44.2", "ionicons": "^8.1.0", "tslib": "^2.1.0" }, @@ -29,7 +29,7 @@ "@stencil/angular-output-target": "^1.4.1", "@stencil/react-output-target": "^1.6.2", "@stencil/sass": "^3.0.9", - "@stencil/vue-output-target": "0.14.1", + "@stencil/vue-output-target": "0.14.2", "@types/jest": "^29.5.6", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -633,9 +633,9 @@ "license": "MIT" }, "node_modules/@capacitor/core": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz", - "integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.0.tgz", + "integrity": "sha512-Ca4krtqH1hothjtBIwf2J2TW7IhYq1ujp8QeItTiJohNsqij8ja2DYYH3DU0l8RmxCWaBAFTGA2TgOgOMCSNsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -663,9 +663,9 @@ } }, "node_modules/@capacitor/status-bar": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz", - "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz", + "integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1839,9 +1839,9 @@ } }, "node_modules/@stencil/core": { - "version": "4.43.5", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.5.tgz", - "integrity": "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.2.tgz", + "integrity": "sha512-TNaYdlyHfy8UF7glvZwaY6pM0AhgCOXuW5Kx3+AO+ITeaipCHWXi5tkZ6P/3HCL+hUySc60lPKSnZ4xdYIFb4g==", "license": "MIT", "bin": { "stencil": "bin/stencil" @@ -1903,9 +1903,9 @@ } }, "node_modules/@stencil/vue-output-target": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@stencil/vue-output-target/-/vue-output-target-0.14.1.tgz", - "integrity": "sha512-mtqpYVsdopTbMK0eAmyNa2+MuuhiZgJcrBt1hihEERyG0vtDqcaYkqNz+sYSvA0iK3tTGs5vOUPOd1HU1w9D7w==", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@stencil/vue-output-target/-/vue-output-target-0.14.2.tgz", + "integrity": "sha512-KF09dlRXvieXYEW7MnvnUoAWnQKm3JLori9UiB4xoTdJQeI987nRx5N5zzci+L3j4Ta+932D6+pJuWvHZJb+jA==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/core/package.json b/core/package.json index a17456f8ef1..fd4ac8c514d 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "9.0.0", + "version": "9.0.1", "description": "Base components for Ionic", "engines": { "node": ">= 16" @@ -66,7 +66,7 @@ "loader/" ], "dependencies": { - "@stencil/core": "^4.43.5", + "@stencil/core": "^4.44.2", "ionicons": "^8.1.0", "tslib": "^2.1.0" }, @@ -86,7 +86,7 @@ "@stencil/angular-output-target": "^1.4.1", "@stencil/react-output-target": "^1.6.2", "@stencil/sass": "^3.0.9", - "@stencil/vue-output-target": "0.14.1", + "@stencil/vue-output-target": "0.14.2", "@types/jest": "^29.5.6", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 2873ac12d68..a6c8a7032d3 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -5168,7 +5168,7 @@ declare global { } } declare namespace LocalJSX { - type OneOf = { [P in K]: PropT } & { [P in `attr:${K}` | `prop:${K}`]?: never } | { [P in `attr:${K}`]: AttrT } & { [P in K | `prop:${K}`]?: never } | { [P in `prop:${K}`]: PropT } & { [P in K | `attr:${K}`]?: never }; + type OneOf = { [P in K]: PropT } & { [P in `attr:${K}`]?: never } | { [P in `attr:${K}`]: AttrT } & { [P in K]?: never }; interface IonAccordion { /** diff --git a/core/src/components/action-sheet/action-sheet.tsx b/core/src/components/action-sheet/action-sheet.tsx index c48e95d862d..3223e56a14f 100644 --- a/core/src/components/action-sheet/action-sheet.tsx +++ b/core/src/components/action-sheet/action-sheet.tsx @@ -14,6 +14,7 @@ import { isCancel, prepareOverlay, present, + restoreRootFocusTrapAccessibility, safeCall, setOverlayId, } from '@utils/overlays'; @@ -455,6 +456,15 @@ export class ActionSheet implements ComponentInterface, OverlayInterface { connectedCallback() { prepareOverlay(this.el); this.triggerChanged(); + + // `componentDidLoad` only fires once per instance, so a reconnect has to + // rebuild the gesture its disconnect destroyed. + this.setupButtonActiveGesture(); + + // Re-apply the root lock if moved without dismiss() being called + if (this.presented) { + restoreRootFocusTrapAccessibility(this.el); + } } disconnectedCallback() { @@ -478,26 +488,36 @@ export class ActionSheet implements ComponentInterface, OverlayInterface { this.buttonsChanged(); } - componentDidLoad() { - /** - * Only create gesture if: - * 1. A gesture does not already exist - * 2. App is running in iOS mode - * 3. A wrapper ref exists - * 4. A group ref exists - */ + /** + * Only create gesture if: + * 1. A gesture does not already exist + * 2. App is running in iOS mode + * 3. A wrapper ref exists + * 4. A group ref exists + * 5. The host is still connected, since a reconnect can schedule this and + * disconnect again before the task runs + */ + private setupButtonActiveGesture() { const { groupEl, wrapperEl } = this; - if (!this.gesture && getIonMode(this) === 'ios' && wrapperEl && groupEl) { - readTask(() => { - const isScrollable = groupEl.scrollHeight > groupEl.clientHeight; - if (!isScrollable) { - this.gesture = createButtonActiveGesture(wrapperEl, (refEl: HTMLElement) => - refEl.classList.contains('action-sheet-button') - ); - this.gesture.enable(true); - } - }); + if (getIonMode(this) !== 'ios' || !wrapperEl || !groupEl) { + return; } + readTask(() => { + // Bail if a call queued ahead of this one already built the gesture + // (a second would orphan the first with its listeners still bound), if + // the host disconnected while this task waited, or if the group scrolls. + if (this.gesture || !this.el.isConnected || groupEl.scrollHeight > groupEl.clientHeight) { + return; + } + this.gesture = createButtonActiveGesture(wrapperEl, (refEl: HTMLElement) => + refEl.classList.contains('action-sheet-button') + ); + this.gesture.enable(true); + }); + } + + componentDidLoad() { + this.setupButtonActiveGesture(); /** * If action sheet was rendered with isOpen="true" diff --git a/core/src/components/action-sheet/test/basic/action-sheet.e2e.ts b/core/src/components/action-sheet/test/basic/action-sheet.e2e.ts index d0921d354da..19368dd652f 100644 --- a/core/src/components/action-sheet/test/basic/action-sheet.e2e.ts +++ b/core/src/components/action-sheet/test/basic/action-sheet.e2e.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { configs, detachAndReattach, test } from '@utils/test/playwright'; import { ActionSheetFixture } from './fixture'; @@ -166,3 +166,56 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +/** + * The button gesture only exists in iOS mode, so these run there. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ config, title }) => { + test.describe(title('action sheet: moved while presented'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/action-sheet/test/basic', config); + }); + + test('should keep the app root locked', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent'); + + await page.click('#basic'); + await ionActionSheetDidPresent.next(); + + await expect(page.locator('body')).toHaveClass(/backdrop-no-scroll/); + + await detachAndReattach(page.locator('ion-action-sheet')); + + await expect(page.locator('body')).toHaveClass(/backdrop-no-scroll/); + }); + + test('should keep activating buttons on press', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent'); + + await page.click('#basic'); + await ionActionSheetDidPresent.next(); + + await detachAndReattach(page.locator('ion-action-sheet')); + + const button = page.locator('ion-action-sheet .action-sheet-button').first(); + const box = (await button.boundingBox())!; + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + + await expect(button).toHaveClass(/ion-activated/); + + await page.mouse.up(); + }); + }); +}); diff --git a/core/src/components/alert/alert.tsx b/core/src/components/alert/alert.tsx index 3d5ed015873..de8d7bd450d 100644 --- a/core/src/components/alert/alert.tsx +++ b/core/src/components/alert/alert.tsx @@ -17,6 +17,7 @@ import { isCancel, prepareOverlay, present, + restoreRootFocusTrapAccessibility, safeCall, setOverlayId, } from '@utils/overlays'; @@ -363,10 +364,16 @@ export class Alert implements ComponentInterface, OverlayInterface { this.triggerChanged(); /** * If the alert was previously connected and is being reattached, the - * ResizeObserver was disconnected. componentDidLoad only fires once per - * instance, so re-establish the observer here on reconnect. + * `ResizeObserver` and the button gesture were torn down. `componentDidLoad` + * only fires once per instance, so re-establish both here on reconnect. */ this.setupButtonGroupResizeObserver(); + this.setupButtonActiveGesture(); + + // Re-apply the root lock if moved without dismiss() being called + if (this.presented) { + restoreRootFocusTrapAccessibility(this.el); + } } componentWillLoad() { @@ -394,20 +401,23 @@ export class Alert implements ComponentInterface, OverlayInterface { this.buttonGroupResizeObserver = undefined; } - componentDidLoad() { - /** - * Only create gesture if: - * 1. A gesture does not already exist - * 2. App is running in iOS mode - * 3. A wrapper ref exists - */ + /** + * Only create gesture if: + * 1. A gesture does not already exist + * 2. App is running in iOS mode + * 3. A wrapper ref exists + */ + private setupButtonActiveGesture() { if (!this.gesture && getIonMode(this) === 'ios' && this.wrapperEl) { this.gesture = createButtonActiveGesture(this.wrapperEl, (refEl: HTMLElement) => refEl.classList.contains('alert-button') ); this.gesture.enable(true); } + } + componentDidLoad() { + this.setupButtonActiveGesture(); this.setupButtonGroupResizeObserver(); /** diff --git a/core/src/components/alert/test/basic/alert.e2e.ts b/core/src/components/alert/test/basic/alert.e2e.ts index 558c13a228b..41076ddae21 100644 --- a/core/src/components/alert/test/basic/alert.e2e.ts +++ b/core/src/components/alert/test/basic/alert.e2e.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import type { Locator } from '@playwright/test'; import type { E2EPage } from '@utils/test/playwright'; -import { configs, test } from '@utils/test/playwright'; +import { configs, detachAndReattach, test } from '@utils/test/playwright'; configs({ directions: ['ltr'] }).forEach(({ config, screenshot, title }) => { test.describe(title('alert: basic'), () => { @@ -203,3 +203,56 @@ class AlertFixture { await expect(this.alert).toHaveScreenshot(screenshotFn(`alert-${modifier}`)); } } + +/** + * The button gesture only exists in iOS mode, so these run there. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ config, title }) => { + test.describe(title('alert: moved while presented'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/alert/test/basic', config); + }); + + test('should keep the app root locked', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent'); + + await page.click('#basic'); + await ionAlertDidPresent.next(); + + await expect(page.locator('body')).toHaveClass(/backdrop-no-scroll/); + + await detachAndReattach(page.locator('ion-alert')); + + await expect(page.locator('body')).toHaveClass(/backdrop-no-scroll/); + }); + + test('should keep activating buttons on press', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent'); + + await page.click('#multipleButtons'); + await ionAlertDidPresent.next(); + + await detachAndReattach(page.locator('ion-alert')); + + const button = page.locator('ion-alert .alert-button').first(); + const box = (await button.boundingBox())!; + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + + await expect(button).toHaveClass(/ion-activated/); + + await page.mouse.up(); + }); + }); +}); diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 4613befccac..3de359a494a 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -1716,8 +1716,6 @@ export class Datetime implements ComponentInterface { private renderCombinedDatePickerColumn() { const { defaultParts, disabled, workingParts, locale, minParts, maxParts, todayParts, isDateEnabled } = this; - const activePart = this.getActivePartsWithFallback(); - /** * By default, generate a range of 3 months: * Previous month, current month, and next month @@ -1801,8 +1799,11 @@ export class Datetime implements ComponentInterface { const { value } = ev.detail; const findPart = parts.find(({ month, day, year }) => value === `${year}-${month}-${day}`); + // Read live so parts a sibling column just committed are included. + const activePart = this.getActivePartsWithFallback(); + this.setWorkingParts({ - ...workingParts, + ...this.workingParts, ...findPart, }); @@ -1908,7 +1909,6 @@ export class Datetime implements ComponentInterface { const { disabled, workingParts } = this; - const activePart = this.getActivePartsWithFallback(); const pickerColumnValue = (workingParts.day !== null ? workingParts.day : this.defaultParts.day) ?? undefined; return ( @@ -1920,8 +1920,11 @@ export class Datetime implements ComponentInterface { disabled={disabled} value={pickerColumnValue} onIonChange={(ev: CustomEvent) => { + // Read live so parts a sibling column just committed are included. + const activePart = this.getActivePartsWithFallback(); + this.setWorkingParts({ - ...workingParts, + ...this.workingParts, day: ev.detail.value, }); @@ -1955,8 +1958,6 @@ export class Datetime implements ComponentInterface { const { disabled, workingParts } = this; - const activePart = this.getActivePartsWithFallback(); - return ( { + // Read live so parts a sibling column just committed are included. + const activePart = this.getActivePartsWithFallback(); + this.setWorkingParts({ - ...workingParts, + ...this.workingParts, month: ev.detail.value, }); @@ -2003,8 +2007,6 @@ export class Datetime implements ComponentInterface { const { disabled, workingParts } = this; - const activePart = this.getActivePartsWithFallback(); - return ( { + // Read live so parts a sibling column just committed are included. + const activePart = this.getActivePartsWithFallback(); + this.setWorkingParts({ - ...workingParts, + ...this.workingParts, year: ev.detail.value, }); @@ -2079,7 +2084,7 @@ export class Datetime implements ComponentInterface { } private renderHourPickerColumn(hoursData: WheelColumnOption[]) { - const { disabled, workingParts } = this; + const { disabled } = this; if (hoursData.length === 0) return []; const activePart = this.getActivePartsWithFallback(); @@ -2093,8 +2098,9 @@ export class Datetime implements ComponentInterface { value={activePart.hour} numericInput onIonChange={(ev: CustomEvent) => { + // Read live so parts a sibling column just committed are included. this.setWorkingParts({ - ...workingParts, + ...this.workingParts, hour: ev.detail.value, }); @@ -2121,7 +2127,7 @@ export class Datetime implements ComponentInterface { ); } private renderMinutePickerColumn(minutesData: WheelColumnOption[]) { - const { disabled, workingParts } = this; + const { disabled } = this; if (minutesData.length === 0) return []; const activePart = this.getActivePartsWithFallback(); @@ -2135,8 +2141,9 @@ export class Datetime implements ComponentInterface { value={activePart.minute} numericInput onIonChange={(ev: CustomEvent) => { + // Read live so parts a sibling column just committed are included. this.setWorkingParts({ - ...workingParts, + ...this.workingParts, minute: ev.detail.value, }); @@ -2163,7 +2170,7 @@ export class Datetime implements ComponentInterface { ); } private renderDayPeriodPickerColumn(dayPeriodData: WheelColumnOption[]) { - const { disabled, workingParts } = this; + const { disabled } = this; if (dayPeriodData.length === 0) { return []; } @@ -2180,10 +2187,12 @@ export class Datetime implements ComponentInterface { disabled={disabled} value={activePart.ampm} onIonChange={(ev: CustomEvent) => { - const hour = calculateHourFromAMPM(workingParts, ev.detail.value); + // Read live so parts a sibling column just committed are included. + const currentParts = this.workingParts; + const hour = calculateHourFromAMPM(currentParts, ev.detail.value); this.setWorkingParts({ - ...workingParts, + ...currentParts, ampm: ev.detail.value, hour, }); diff --git a/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts b/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts index fb1b2e31ebf..edfdd29c1d3 100644 --- a/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts +++ b/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts @@ -688,3 +688,104 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); }); + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + /** + * The column sits in the datetime's Shadow DOM, so a document-level listener + * sees the press retargeted to the datetime host. + */ + test.describe(title('datetime: wheel value'), () => { + test('should give an outside click handler the date the wheel is showing', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30449', + }); + + await page.setContent( + ` + + + `, + config + ); + + await page.locator('.datetime-ready').waitFor(); + + await page.evaluate(() => { + const datetime = document.querySelector('ion-datetime')!; + const column = datetime.shadowRoot!.querySelector('ion-picker-column.year-column')!; + const scrollEl = column.shadowRoot!.querySelector('.picker-opts')!; + const w = window as any; + + w.lastScrollAt = 0; + scrollEl.addEventListener('scroll', () => { + w.lastScrollAt = performance.now(); + }); + + /** + * Stands in for an application's own Save button, which reads the + * datetime's value when it is clicked. + */ + document.querySelector('#save')!.addEventListener('click', () => { + w.onSave = { + datetimeValue: datetime.value, + visibleYear: String(column.querySelector('.option-active')?.value ?? ''), + }; + }); + + w.startScroll = () => scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + + /** + * Presses Save in the same frame the wheel is first seen showing a + * different year while still scrolling. Done in the page so no round trip + * can let the scroll finish first, which would leave nothing to race. + */ + w.pressSaveWhenMidScroll = () => + new Promise((resolve, reject) => { + const deadline = performance.now() + 5000; + + const poll = () => { + const highlighted = column.querySelector('.option-active'); + const isScrolling = performance.now() - w.lastScrollAt < 100; + + if (highlighted !== null && String(highlighted.value) !== '2022' && isScrolling) { + const save = document.querySelector('#save')!; + save.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + save.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + resolve(); + } else if (performance.now() > deadline) { + reject(new Error('the wheel never showed a year other than the one it was reporting')); + } else { + requestAnimationFrame(poll); + } + }; + + requestAnimationFrame(poll); + }); + }); + + /** + * Press the column the way a drag would, so the scroll that follows counts + * as the user's. Pressing dead center lands on the year already selected, + * so the press itself does not change the value. + */ + const column = (await page.locator('.year-column').boundingBox())!; + await page.mouse.move(column.x + column.width / 2, column.y + column.height / 2); + await page.mouse.down(); + await page.mouse.up(); + + await page.evaluate(() => (window as any).startScroll()); + await page.evaluate(() => (window as any).pressSaveWhenMidScroll()); + + const onSave = await page.evaluate(() => (window as any).onSave); + + expect(onSave.visibleYear).not.toBe('2022'); + + // The value the Save button saw is the year the user could see. + expect(onSave.datetimeValue.split('-')[0]).toBe(onSave.visibleYear); + }); + }); +}); diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index ba979164c47..d2bce0e0dfd 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -1,7 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core'; import { findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content'; -import { isEndSide } from '@utils/helpers'; +import { componentOnReady, isEndSide } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import { watchForOptions } from '@utils/watch-options'; @@ -245,24 +245,21 @@ export class ItemSliding implements ComponentInterface { } private async updateOptions() { - const options = this.el.querySelectorAll('ion-item-options'); + const options = Array.from(this.el.querySelectorAll('ion-item-options')); + + /** + * Frameworks that assign element props after inserting the element haven't set + * `side` while `connectedCallback` runs, so reading it any earlier reports every + * option as `end`. + */ + await Promise.all(options.map((option) => new Promise((resolve) => componentOnReady(option, resolve)))); let sides = 0; // Reset left and right options in case they were removed this.leftOptions = this.rightOptions = undefined; - for (let i = 0; i < options.length; i++) { - const item = options.item(i); - - /** - * We cannot use the componentOnReady helper - * util here since we need to wait for all of these items - * to be ready before we set `this.sides` and `this.optsDirty`. - */ - // eslint-disable-next-line custom-rules/no-component-on-ready-method - const option = (item as any).componentOnReady !== undefined ? await item.componentOnReady() : item; - + for (const option of options) { const side = isEndSide(option.side ?? option.getAttribute('side')) ? 'end' : 'start'; if (side === 'start') { diff --git a/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts b/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts index 908155f0ea5..66797b3ad4e 100644 --- a/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts +++ b/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; import { configs, dragElementBy, test } from '@utils/test/playwright'; /** @@ -190,3 +191,76 @@ configs().forEach(({ title, screenshot, config }) => { }); }); }); + +/** + * ion-item-sliding reads `side` off each ion-item-options to decide which way the item + * can open. Frameworks that assign element props after inserting the element haven't set + * it while `connectedCallback` runs. + * + * The shared harness page is used because it loads the custom elements build, which is + * where that ordering applies. + * + * This behavior does not vary across modes or directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('item-sliding: basic'), () => { + const openStartOptions = async (page: E2EPage, lateProps: boolean) => { + await page.goto('/src/utils/test/late-props', config); + await page.waitForFunction(() => (window as any).harnessReady === true); + + await page.evaluate( + (late: boolean) => + (window as any).mountLateProps( + ['ion-content', 'ion-list', 'ion-item', 'ion-item-sliding', 'ion-item-options', 'ion-item-option'], + { + tag: 'ion-content', + children: [ + { + tag: 'ion-list', + children: [ + { + tag: 'ion-item-sliding', + children: [ + { tag: 'ion-item', children: [{ tag: 'p', children: ['No label'] }] }, + { + // Passing `side` as a prop lets `lateProps` control when it can be read. + tag: 'ion-item-options', + props: { side: 'start' }, + children: [{ tag: 'ion-item-option', children: ['Favorite'] }], + }, + ], + }, + ], + }, + ], + }, + late + ), + lateProps + ); + await page.waitForChanges(); + + const slidingItem = page.locator('ion-item-sliding'); + + // A positive drag pulls the item to the right, revealing the start options. + await dragElementBy(slidingItem, page, 150); + await page.waitForChanges(); + + await expect(slidingItem).toHaveClass(/item-sliding-active-options-start/); + await expect(page.locator('ion-item-options')).toBeVisible(); + }; + + test('should open the start options when side is assigned before connecting', async ({ page }) => { + await openStartOptions(page, false); + }); + + test('should open the start options when side is assigned after connecting', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31388', + }); + + await openStartOptions(page, true); + }); + }); +}); diff --git a/core/src/components/loading/loading.tsx b/core/src/components/loading/loading.tsx index 80c774fa090..2d8b6290927 100644 --- a/core/src/components/loading/loading.tsx +++ b/core/src/components/loading/loading.tsx @@ -12,6 +12,7 @@ import { eventMethod, prepareOverlay, present, + restoreRootFocusTrapAccessibility, setOverlayId, } from '@utils/overlays'; import { sanitizeDOMString } from '@utils/sanitization'; @@ -208,6 +209,11 @@ export class Loading implements ComponentInterface, OverlayInterface { connectedCallback() { prepareOverlay(this.el); this.triggerChanged(); + + // Re-apply the root lock if moved without dismiss() being called + if (this.presented) { + restoreRootFocusTrapAccessibility(this.el); + } } componentWillLoad() { diff --git a/core/src/components/loading/test/basic/loading.e2e.ts b/core/src/components/loading/test/basic/loading.e2e.ts index 3f85cfacf13..28984fdd210 100644 --- a/core/src/components/loading/test/basic/loading.e2e.ts +++ b/core/src/components/loading/test/basic/loading.e2e.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test'; import type { E2EPage, ScreenshotFn } from '@utils/test/playwright'; -import { configs, test } from '@utils/test/playwright'; +import { configs, detachAndReattach, test } from '@utils/test/playwright'; const runVisualTest = async (page: E2EPage, selector: string, screenshot: ScreenshotFn, screenshotModifier: string) => { const ionLoadingDidPresent = await page.spyOnEvent('ionLoadingDidPresent'); @@ -100,3 +100,30 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c }); }); }); + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ config, title }) => { + test.describe(title('loading: moved while presented'), () => { + test('should keep the app root locked', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + await page.goto('/src/components/loading/test/basic', config); + const ionLoadingDidPresent = await page.spyOnEvent('ionLoadingDidPresent'); + + // This one carries no duration, so it can't auto-dismiss mid-move. + await page.click('#backdrop-loading'); + await ionLoadingDidPresent.next(); + + await expect(page.locator('body')).toHaveClass(/backdrop-no-scroll/); + + await detachAndReattach(page.locator('ion-loading')); + + await expect(page.locator('body')).toHaveClass(/backdrop-no-scroll/); + }); + }); +}); diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 75900bdb361..5db2f8b6c3e 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -17,6 +17,7 @@ import { GESTURE, prepareOverlay, present, + restoreRootFocusTrapAccessibility, setOverlayId, } from '@utils/overlays'; import { getClassMap } from '@utils/theme'; @@ -113,6 +114,9 @@ export class Modal implements ComponentInterface, OverlayInterface { private viewTransitionAnimation?: Animation; private resizeTimeout?: any; private unsubscribeRootSafeAreaTop?: () => void; + // True from the first safe-area write in `present()` until the enter + // animation settles. A position-based read in that window is not the rest position. + private isPresenting = false; // Mutation observer to watch for parent removal private parentRemovalObserver?: MutationObserver; @@ -460,6 +464,18 @@ export class Modal implements ComponentInterface, OverlayInterface { const { el } = this; prepareOverlay(el); this.triggerChanged(); + + // A disconnect tears down state this presentation still needs and cannot + // tell a re-insert from a removal, so put it back. + if (this.presented || this.isPresenting) { + this.restoreSafeAreaOverrides(); + } + // A relocation from `willPresent` reaches these before `present()` has + // applied the lock, so they apply it instead. Both are idempotent. + if (this.presented) { + restoreRootFocusTrapAccessibility(el); + this.initParentRemovalObserver(); + } } disconnectedCallback() { @@ -666,28 +682,36 @@ export class Modal implements ComponentInterface, OverlayInterface { // bindings (e.g., Angular) may not have been applied when componentWillLoad ran. this.isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined; - // Set initial safe-area overrides before animation - this.setInitialSafeAreaOverrides(); + this.isPresenting = true; const hasCardModal = presentingElement !== undefined; - /** - * We need to change the status bar at the - * start of the animation so that it completes - * by the time the card animation is done. - */ - if (hasCardModal && getIonMode(this) === 'ios') { - // Cache the original status bar color before the modal is presented - this.statusBarStyle = await StatusBar.getStyle(); - setCardStatusBarDark(); - } + // Wrapped so a throw cannot strand `isPresenting`, which would suppress + // the position-based correction for the life of this instance. + try { + // Set initial safe-area overrides before animation + this.setInitialSafeAreaOverrides(); - await present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, { - presentingEl: presentingElement, - currentBreakpoint: this.initialBreakpoint, - backdropBreakpoint: this.backdropBreakpoint, - expandToScroll: this.expandToScroll, - }); + /** + * We need to change the status bar at the + * start of the animation so that it completes + * by the time the card animation is done. + */ + if (hasCardModal && getIonMode(this) === 'ios') { + // Cache the original status bar color before the modal is presented + this.statusBarStyle = await StatusBar.getStyle(); + setCardStatusBarDark(); + } + + await present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, { + presentingEl: presentingElement, + currentBreakpoint: this.initialBreakpoint, + backdropBreakpoint: this.backdropBreakpoint, + expandToScroll: this.expandToScroll, + }); + } finally { + this.isPresenting = false; + } // Update safe-area based on actual position after animation this.updateSafeAreaOverrides(); @@ -1407,6 +1431,10 @@ export class Modal implements ComponentInterface, OverlayInterface { return; } + // Both `present()` and a reconnect call this, so drop any existing + // observer. Below the guards, so a call that bails cannot leave none. + this.cleanupParentRemovalObserver(); + this.parentRemovalObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList' && mutation.removedNodes.length > 0) { @@ -1495,6 +1523,9 @@ export class Modal implements ComponentInterface, OverlayInterface { // Set the internal offset property with the resolved root safe-area-top value if (context.isSheetModal) { this.updateSheetOffsetTop(); + // A restore after a DOM move runs this a second time for the same + // modal, so drop the previous subscription rather than stacking one. + this.unsubscribeRootSafeAreaTop?.(); this.unsubscribeRootSafeAreaTop = onRootSafeAreaTopChange((safeAreaTop) => this.updateSheetOffsetTop(safeAreaTop) ); @@ -1622,6 +1653,33 @@ export class Modal implements ComponentInterface, OverlayInterface { this.clearContentSafeAreaPadding(contentEl); } + /** + * Re-applies what `cleanupSafeAreaOverrides()` removed for a modal moved + * while presented. Anything needing no measurement goes on synchronously so + * the modal is never painted without safe-area. The position-based + * correction waits a frame for the layout pass it reads. + */ + private restoreSafeAreaOverrides(): void { + this.setInitialSafeAreaOverrides(); + this.applyFullscreenSafeArea(); + + raf(() => { + // A dismiss or another move can land first, and a read before the enter + // animation settles is not the rest position. `present()` redoes it. + if (!this.presented || !this.el.isConnected || this.isPresenting) { + return; + } + // A hidden subtree carries `display: none`, so there is no box and the + // read would write the safe-area for a modal touching no edge. The + // prediction applied above holds until the modal is shown again. + const { width, height } = this.el.getBoundingClientRect(); + if (width === 0 && height === 0) { + return; + } + this.updateSafeAreaOverrides(); + }); + } + render() { const { handle, diff --git a/core/src/components/modal/test/inline/modal.e2e.ts b/core/src/components/modal/test/inline/modal.e2e.ts index 2f6ef95070a..3cd8b52284c 100644 --- a/core/src/components/modal/test/inline/modal.e2e.ts +++ b/core/src/components/modal/test/inline/modal.e2e.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { configs, detachAndReattach, test } from '@utils/test/playwright'; configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('modal: inline'), () => { @@ -152,6 +152,35 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await expect(modalContainer).not.toBeAttached(); }); + test('it should still dismiss on parent removal after the modal was moved', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + // Moving a presented modal is a disconnect plus a connect, so the + // observer built at the end of `present()` has to be rebuilt. + await page.goto('/src/components/modal/test/inline', config); + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + const modal = page.locator('ion-modal').first(); + const modalContainer = page.locator('#modal-container'); + + await page.click('#open-inline-modal'); + await ionModalDidPresent.next(); + await expect(modal).toBeVisible(); + + await detachAndReattach(modal); + + await page.click('#remove-modal-container'); + + const dismissEvent = await ionModalDidDismiss.next(); + + expect(dismissEvent.detail.role).toBe('parent-removed'); + await expect(modalContainer).not.toBeAttached(); + }); + test('it should dismiss both parent and child modals when parent container is removed from DOM', async ({ page, }) => { diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts b/core/src/components/modal/test/safe-area/modal.e2e.ts index e247b174956..4905f645aa3 100644 --- a/core/src/components/modal/test/safe-area/modal.e2e.ts +++ b/core/src/components/modal/test/safe-area/modal.e2e.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test'; -import { configs, test, Viewports } from '@utils/test/playwright'; +import type { Locator } from '@playwright/test'; +import { configs, detachAndReattach, test, Viewports } from '@utils/test/playwright'; /** * These tests verify that safe-area CSS custom properties are correctly @@ -443,6 +444,178 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config // Clean up await modal.evaluate((el: HTMLIonModalElement) => el.remove()); }); + + test.describe('moving a presented modal', () => { + const moveModal = (modal: Locator) => detachAndReattach(modal, 'ion-app'); + + test('should keep the safe-area overrides', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#fullscreen-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + await moveModal(modal); + + const overrides = await modal.evaluate((el: HTMLIonModalElement) => ({ + top: el.style.getPropertyValue('--ion-safe-area-top'), + bottom: el.style.getPropertyValue('--ion-safe-area-bottom'), + })); + + expect(overrides.top).toBe('inherit'); + expect(overrides.bottom).toBe('inherit'); + // The detach releases the root lock, so the re-insert has to put it back. + await expect(page.locator('body')).toHaveClass(/backdrop-no-scroll/); + }); + + test('should not measure the modal while it has no layout box', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + // A hidden subtree carries `display: none`, so a move inside that + // window reconnects the modal with no box to measure. + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#fullscreen-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + await modal.evaluate((el: HTMLIonModalElement) => el.style.setProperty('display', 'none', 'important')); + await moveModal(modal); + // The reconnect's read is deferred a frame, so let that frame land + // while there is still no box. Clearing `display` from a separate + // round trip races it and the test stops guarding anything. + await modal.evaluate(async (el: HTMLIonModalElement) => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + el.style.removeProperty('display'); + }); + + const overrides = await modal.evaluate((el: HTMLIonModalElement) => ({ + bottom: el.style.getPropertyValue('--ion-safe-area-bottom'), + right: el.style.getPropertyValue('--ion-safe-area-right'), + })); + + expect(overrides.bottom).toBe('inherit'); + expect(overrides.right).toBe('inherit'); + }); + + test('should keep the sheet offset for a sheet modal', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#sheet-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + await moveModal(modal); + + // The sheet's `--height` formula reads `--ion-modal-offset-top`, so + // losing it grows the sheet by the safe-area-top amount. + const offsetTop = await modal.evaluate((el: HTMLIonModalElement) => + el.style.getPropertyValue('--ion-modal-offset-top') + ); + const safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => + el.style.getPropertyValue('--ion-safe-area-top') + ); + + expect(offsetTop).toBe(`${TEST_SAFE_AREA_TOP}px`); + expect(safeAreaTop).toBe('0px'); + }); + + test('should not read a mid-animation position when moved during willPresent', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + /** + * Relocating from `willPresent` reconnects the overlay just before the + * enter animation starts, where a position-based read measures a + * mid-animation box. So animations stay on and the value is sampled + * every frame: `present()` fixes the end state either way, so only + * samples taken during the animation can catch the regression. + */ + await page.goto('/src/components/modal/test/safe-area?ionic:_testing=false', config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.evaluate(() => { + const samples: string[] = []; + (window as any).safeAreaTopSamples = samples; + + document.addEventListener( + 'ionModalWillPresent', + (ev) => { + const modal = ev.target as HTMLElement; + const holder = document.createElement('div'); + document.querySelector('ion-app')!.appendChild(holder); + holder.appendChild(modal); + + const start = performance.now(); + const sample = () => { + samples.push(modal.style.getPropertyValue('--ion-safe-area-top')); + (window as any).samplingSpanMs = performance.now() - start; + if (!(window as any).stopSampling) { + requestAnimationFrame(sample); + } + }; + requestAnimationFrame(sample); + }, + { once: true } + ); + + document.addEventListener('ionModalDidPresent', () => ((window as any).stopSampling = true), { + once: true, + }); + }); + + await page.click('#fullscreen-modal'); + await ionModalDidPresent.next(); + + const samples = await page.evaluate(() => (window as any).safeAreaTopSamples as string[]); + const samplingSpanMs = await page.evaluate(() => (window as any).samplingSpanMs as number); + + // Elapsed time rather than a frame count, which throttles under CI + // load. A starved queue would otherwise leave only samples that pass. + expect(samplingSpanMs).toBeGreaterThan(100); + // Assert the positive value: an unset override is as wrong as `0px`. + expect(samples.every((sample) => sample === 'inherit')).toBe(true); + }); + + test('should keep the ion-content scroll padding', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#fullscreen-modal-no-footer'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + await moveModal(modal); + + const innerScroll = modal.locator('ion-content .inner-scroll'); + const scrollPaddingBottom = await innerScroll.evaluate((el: Element) => + parseFloat(getComputedStyle(el).paddingBottom) + ); + + expect(scrollPaddingBottom).toBe(TEST_ION_PADDING + TEST_SAFE_AREA_BOTTOM); + }); + }); }); }); diff --git a/core/src/components/picker-column/picker-column.tsx b/core/src/components/picker-column/picker-column.tsx index 80de2c2daee..ea56f0711b2 100644 --- a/core/src/components/picker-column/picker-column.tsx +++ b/core/src/components/picker-column/picker-column.tsx @@ -33,6 +33,19 @@ export class PickerColumn implements ComponentInterface { private canExitInputMode = true; private assistiveFocusable?: HTMLElement; private updateValueTextOnScroll = false; + private scrollEndTimeout?: ReturnType; + private centeredOption?: HTMLIonPickerColumnOptionElement; + private isUserScroll = false; + /** + * Where the column halted itself, so the halt's own scroll events are not + * read as movement. + */ + private haltedAtScrollTop?: number; + /** + * The haptics for the wheel picker are an iOS-only feature. As a result, + * they should be disabled on Android. + */ + private enableHaptics = false; @State() ariaLabel: string | null = null; @@ -135,6 +148,15 @@ export class PickerColumn implements ComponentInterface { } else { this.isColumnVisible = false; + /** + * The pending timer still runs so it resets the scroll state, but the + * option it would have committed is dropped. A column the user cannot + * see should not change its value. + */ + this.stopWatchingForOutsidePress(); + this.centeredOption = undefined; + this.haltedAtScrollTop = undefined; + if (this.destroyScrollListener) { this.destroyScrollListener(); this.destroyScrollListener = undefined; @@ -219,6 +241,14 @@ export class PickerColumn implements ComponentInterface { this.ariaLabel = this.el.getAttribute('aria-label') ?? 'Select a value'; } + disconnectedCallback() { + /** + * A pending settle would otherwise commit a value on a column that is no + * longer in the DOM. + */ + this.discardScroll(); + } + private centerPickerItemInView = (target: HTMLElement, smooth = true, canExitInputMode = true) => { const { isColumnVisible, scrollEl } = this; @@ -236,6 +266,14 @@ export class PickerColumn implements ComponentInterface { */ this.canExitInputMode = canExitInputMode; this.updateValueTextOnScroll = false; + + /** + * This scroll takes over from whatever the user was doing, so an outside + * press must not commit an option the column is only passing through. + */ + this.isUserScroll = false; + this.stopWatchingForOutsidePress(); + scrollEl.scroll({ top, left: 0, @@ -298,6 +336,136 @@ export class PickerColumn implements ComponentInterface { this.isActive = state; }; + private clearScrollEndTimeout = () => { + if (this.scrollEndTimeout) { + clearTimeout(this.scrollEndTimeout); + this.scrollEndTimeout = undefined; + } + }; + + /** + * Resets the idle timer that commits the centered option. + */ + private resetScrollEndTimeout = () => { + this.clearScrollEndTimeout(); + this.scrollEndTimeout = setTimeout(this.settle, SCROLL_END_DELAY); + }; + + /** + * Capture so the column still commits if an application's own pointerdown + * handler stops the event before it reaches the document. + */ + private watchForOutsidePress = () => { + doc?.addEventListener('pointerdown', this.onPointerDownOutside, { capture: true }); + }; + + private stopWatchingForOutsidePress = () => { + doc?.removeEventListener('pointerdown', this.onPointerDownOutside, { capture: true }); + }; + + /** + * Resets everything the current scroll interaction is holding, so nothing + * pending on it can commit later. + */ + private discardScroll = () => { + this.stopWatchingForOutsidePress(); + this.clearScrollEndTimeout(); + this.scrollEndCallback = undefined; + this.centeredOption = undefined; + this.haltedAtScrollTop = undefined; + this.isScrolling = false; + this.isUserScroll = false; + this.canExitInputMode = true; + }; + + /** + * Commits the option that is currently centered under the highlight and ends + * the scroll interaction. + */ + private settle = () => { + const { centeredOption } = this; + + this.clearScrollEndTimeout(); + this.stopWatchingForOutsidePress(); + + this.isUserScroll = false; + this.isScrolling = false; + this.updateValueTextOnScroll = true; + + this.enableHaptics && hapticSelectionEnd(); + + /** + * Certain tasks (such as those that + * cause re-renders) should only be done + * once scrolling has finished, otherwise + * flickering may occur. + */ + const { scrollEndCallback } = this; + if (scrollEndCallback) { + scrollEndCallback(); + this.scrollEndCallback = undefined; + } + + /** + * Reset this flag as the + * next scroll interaction could + * be a scroll from the user. In this + * case, we should exit input mode. + */ + this.canExitInputMode = true; + + if (centeredOption !== undefined) { + // Cleared here so a later scroll that centers nothing cannot fall back on it. + this.centeredOption = undefined; + + if (centeredOption.isConnected) { + this.setValue(centeredOption.value); + } + } + }; + + /** + * Commits the option the user can see when they press anything outside of the + * column during a scroll they started. This runs on pointerdown rather than + * click so the value is up to date by the time an application's own click + * handler (a Save button, for example) reads it. + */ + private onPointerDownOutside = (ev: Event) => { + const { centeredOption, parentEl, scrollEl } = this; + + if (!this.isScrolling) { + return; + } + + /** + * The column can live in another component's Shadow DOM, where a document + * listener sees the target retargeted to the outer host. The composed path + * is the only reliable way to tell a press on the column apart. + */ + const path = ev.composedPath(); + + /** + * A press anywhere in the parent picker counts as inside, so reaching for a + * sibling wheel leaves this column coasting. + */ + if (path.includes(this.el) || (parentEl != null && path.includes(parentEl))) { + return; + } + + /** + * Landing on the centered option halts the in-flight momentum scroll, so the + * committed value and the option the user is left looking at agree. The + * option list can be replaced mid-scroll, and a detached option has no + * offset to center on. + */ + if (centeredOption !== undefined && centeredOption.isConnected) { + this.centerPickerItemInView(centeredOption, false, false); + this.haltedAtScrollTop = scrollEl?.scrollTop; + } + + this.settle(); + }; + /** * When the column scrolls, the component * needs to determine which item is centered @@ -305,31 +473,49 @@ export class PickerColumn implements ComponentInterface { * the item object. */ private initializeScrollListener = () => { - /** - * The haptics for the wheel picker are - * an iOS-only feature. As a result, they should - * be disabled on Android. - */ - const enableHaptics = isPlatform('ios'); + this.enableHaptics = isPlatform('ios'); const { el, scrollEl } = this; - let timeout: ReturnType | undefined; let activeEl: HTMLIonPickerColumnOptionElement | undefined = this.activeItem; const scrollCallback = () => { raf(() => { - if (!scrollEl) return; + /** + * This frame cannot be cancelled, and moving the column disconnects it + * without the observer reporting a change, so this checks live state. + */ + if (!this.el.isConnected || !this.isColumnVisible || !scrollEl) return; + + const { haltedAtScrollTop } = this; + + if (haltedAtScrollTop !== undefined) { + /** + * Still at the halted position, so this frame is the halt landing + * rather than the wheel moving, and re-arming would start a second + * commit cycle. + */ + if (Math.abs(scrollEl.scrollTop - haltedAtScrollTop) <= HALT_TOLERANCE) return; - if (timeout) { - clearTimeout(timeout); - timeout = undefined; + this.haltedAtScrollTop = undefined; } + // Armed before the early returns below, so a scroll never ends uncommitted. + this.resetScrollEndTimeout(); + if (!this.isScrolling) { - enableHaptics && hapticSelectionStart(); + this.enableHaptics && hapticSelectionStart(); this.isScrolling = true; } + /** + * Only a user-driven scroll holds an uncommitted selection. Checked every + * frame because the user can take hold of the wheel mid-scroll, and + * re-adding the same listener is a no-op. + */ + if (this.isUserScroll) { + this.watchForOutsidePress(); + } + /** * Select item in the center of the column * which is the month/year that we want to select @@ -396,20 +582,25 @@ export class PickerColumn implements ComponentInterface { } } - if (activeEl !== undefined) { - this.setPickerItemActiveState(activeEl, false); - } - + /** + * A scroll can land with no selectable option centered, such as an + * overscroll bounce briefly centering the empty padding rows. Keep the + * current selection until an option is centered again. + */ if (newActiveElement === undefined || newActiveElement.disabled) { return; } + if (activeEl !== undefined) { + this.setPickerItemActiveState(activeEl, false); + } + /** * If we are selecting a new value, * we need to run haptics again. */ if (newActiveElement !== activeEl) { - enableHaptics && hapticSelectionChanged(); + this.enableHaptics && hapticSelectionChanged(); if (this.canExitInputMode) { /** @@ -445,34 +636,15 @@ export class PickerColumn implements ComponentInterface { this.assistiveFocusable?.setAttribute('aria-valuetext', this.getOptionValueText(newActiveElement)); } - timeout = setTimeout(() => { - this.isScrolling = false; - this.updateValueTextOnScroll = true; - enableHaptics && hapticSelectionEnd(); - - /** - * Certain tasks (such as those that - * cause re-renders) should only be done - * once scrolling has finished, otherwise - * flickering may occur. - */ - const { scrollEndCallback } = this; - if (scrollEndCallback) { - scrollEndCallback(); - this.scrollEndCallback = undefined; - } + this.centeredOption = newActiveElement; + }); + }; - /** - * Reset this flag as the - * next scroll interaction could - * be a scroll from the user. In this - * case, we should exit input mode. - */ - this.canExitInputMode = true; + const userScrollCallback = () => { + this.isUserScroll = true; - this.setValue(newActiveElement.value); - }, 250); - }); + // Taking hold of the wheel ends the halt even if it has not moved yet. + this.haltedAtScrollTop = undefined; }; /** @@ -483,9 +655,20 @@ export class PickerColumn implements ComponentInterface { if (!scrollEl) return; scrollEl.addEventListener('scroll', scrollCallback); + scrollEl.addEventListener('pointerdown', userScrollCallback); + scrollEl.addEventListener('wheel', userScrollCallback, { passive: true }); + /** + * A `pointerdown` fires once per touch, so a drag that pauses long enough + * to commit would look like a scroll the column started. A `touchmove` + * keeps arriving while the finger moves. + */ + scrollEl.addEventListener('touchmove', userScrollCallback, { passive: true }); this.destroyScrollListener = () => { scrollEl.removeEventListener('scroll', scrollCallback); + scrollEl.removeEventListener('pointerdown', userScrollCallback); + scrollEl.removeEventListener('wheel', userScrollCallback); + scrollEl.removeEventListener('touchmove', userScrollCallback); }; }); }; @@ -707,3 +890,18 @@ export class PickerColumn implements ComponentInterface { } const PICKER_ITEM_ACTIVE_CLASS = 'option-active'; + +/** + * How long the column must be idle before the centered option is treated as + * the user's selection. Replaceable by the `scrollend` event once that is + * supported everywhere (https://caniuse.com/?search=scrollend). + */ +const SCROLL_END_DELAY = 250; + +/** + * How far, in pixels, the column may report from where it was halted and still + * count as having stayed there. Centering targets a position between two snap + * points, so mandatory snapping corrects it by half the column height less + * three option heights: 2px at the default sizes. + */ +const HALT_TOLERANCE = 2; diff --git a/core/src/components/picker-column/test/scroll/index.html b/core/src/components/picker-column/test/scroll/index.html new file mode 100644 index 00000000000..d5727968f73 --- /dev/null +++ b/core/src/components/picker-column/test/scroll/index.html @@ -0,0 +1,74 @@ + + + + + Picker Column - Scroll + + + + + + + + + + + + + Picker Column - Scroll + + + + + + + + + + Save + +
Flick the wheel, then tap Save mid-scroll.
+
+
+ + + + diff --git a/core/src/components/picker-column/test/scroll/picker-column.e2e.ts b/core/src/components/picker-column/test/scroll/picker-column.e2e.ts new file mode 100644 index 00000000000..000b99a5fe6 --- /dev/null +++ b/core/src/components/picker-column/test/scroll/picker-column.e2e.ts @@ -0,0 +1,562 @@ +import { expect } from '@playwright/test'; +import type { E2EPage, E2ELocator } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +/** The idle period the column waits out before it commits the centered option. */ +const SCROLL_END_DELAY = 250; + +/** Long enough that a pending commit has either landed or is never coming. */ +const COMMIT_WINDOW = SCROLL_END_DELAY + 350; + +interface SaveRecord { + value: string; + highlighted: string; +} + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('picker-column: scroll'), () => { + test.beforeEach(async ({ page }) => { + await page.setContent( + ` + + + ${Array.from( + { length: 200 }, + (_, i) => `${i}` + ).join('')} + + + + `, + config + ); + + await page.locator('ion-picker-column-option.option-active').waitFor(); + + await page.evaluate(() => { + const column = document.querySelector('ion-picker-column')!; + const scrollEl = column.shadowRoot!.querySelector('.picker-opts')!; + const w = window as any; + + w.lastScrollAt = 0; + scrollEl.addEventListener('scroll', () => { + w.lastScrollAt = performance.now(); + }); + + /** + * Records the value at the moment the click handler ran, alongside the + * option the user could see under the highlight. + */ + document.querySelector('#save')!.addEventListener('click', () => { + w.onSave = { + value: String(column.value), + highlighted: String(column.querySelector('.option-active')?.value ?? ''), + }; + }); + }); + }); + + const startScroll = (page: E2EPage) => + page.evaluate(() => { + const scrollEl = document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + }); + + /** + * Presses the column the way a drag would, so the scroll that follows counts + * as the user's. Pressing dead center lands on the option already selected, + * so the press itself does not change the value. + */ + const pressColumn = async (page: E2EPage) => { + const box = (await page.locator('ion-picker-column').boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.up(); + }; + + /** + * The race needs the highlight off the committed value, so the column is + * showing one option while reporting another. + */ + const waitForMidScroll = (page: E2EPage) => + page.waitForFunction( + () => { + const highlighted = document.querySelector( + 'ion-picker-column .option-active' + ); + const column = document.querySelector('ion-picker-column')!; + const isScrolling = performance.now() - (window as any).lastScrollAt < 100; + return highlighted !== null && String(highlighted.value) !== String(column.value) && isScrolling; + }, + undefined, + { timeout: 5000 } + ); + + /** Presses an element with `pointerdown` before `click`, the order a tap uses. */ + const press = (page: E2EPage, selector: string) => + page.evaluate((selector) => { + const target = document.querySelector(selector)!; + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + target.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + }, selector); + + /** + * Presses the given element in the same frame the column is first seen + * mid-scroll. The polling and the press share one evaluate so no round trip + * can let the scroll finish first, which would leave nothing to race. + * + * Passing `past` waits for the highlight to get beyond a given option, for + * when the column already reports the option it is traveling towards. + */ + const pressWhenMidScroll = (page: E2EPage, selector: string, past?: number) => + page.evaluate( + ({ selector, past }) => + new Promise((resolve, reject) => { + const column = document.querySelector('ion-picker-column')!; + const deadline = performance.now() + 5000; + + const isMidScroll = () => { + const highlighted = column.querySelector('.option-active'); + + if (highlighted === null || performance.now() - (window as any).lastScrollAt >= 100) { + return false; + } + + return past === undefined + ? String(highlighted.value) !== String(column.value) + : Number(highlighted.value) > past; + }; + + const poll = () => { + if (isMidScroll()) { + const target = document.querySelector(selector)!; + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + target.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + resolve(); + } else if (performance.now() > deadline) { + reject(new Error('the column never reached the expected mid-scroll state')); + } else { + requestAnimationFrame(poll); + } + }; + + requestAnimationFrame(poll); + }), + { selector, past } + ); + + const highlightedValue = (page: E2EPage) => + page.evaluate(() => { + const highlighted = document.querySelector( + 'ion-picker-column .option-active' + ); + return highlighted === null ? null : Number(highlighted.value); + }); + + /** + * Presses Save once the column has visibly moved off the option it is + * reporting but is still scrolling towards its resting place. + */ + const pressSaveMidScroll = async (page: E2EPage): Promise => { + await pressColumn(page); + await startScroll(page); + await pressWhenMidScroll(page, '#save'); + + return await page.evaluate(() => (window as any).onSave); + }; + + /** + * Stands in for an overscroll bounce leaving the empty padding rows under + * the highlight. Scrolling onto the padding cannot produce this reliably, + * because snapping pulls the column straight back. + */ + const hideOptionsFromHitTesting = (page: E2EPage) => + page.evaluate(() => { + document.querySelectorAll('ion-picker-column-option').forEach((option) => { + option.style.pointerEvents = 'none'; + }); + }); + + const dispatchScroll = (page: E2EPage) => + page.evaluate(() => { + const scrollEl = document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.dispatchEvent(new Event('scroll')); + }); + + const waitForColumnIdle = (page: E2EPage) => + page.waitForFunction((delay) => performance.now() - (window as any).lastScrollAt > delay, COMMIT_WINDOW, { + timeout: 10000, + }); + + /** Drains the rAF the column schedules to react to a scroll. */ + const flushAnimationFrames = (page: E2EPage) => + page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + ); + + test('should commit the visible option before an outside click handler runs', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30449', + }); + + const onSave = await pressSaveMidScroll(page); + + expect(onSave.highlighted).not.toBe('5'); + + // The value the Save button saw is the option the user could see. + expect(onSave.value).toBe(onSave.highlighted); + }); + + /** + * Tapping an option part way through a flick replaces the selection, so an + * outside press during the scroll to it must not commit an option on the way. + */ + test('should keep a selection made mid-flick when Save is pressed', async ({ page }) => { + await pressColumn(page); + await startScroll(page); + await waitForMidScroll(page); + + // Stands in for tapping an option while the wheel is still coasting. + await page.locator('ion-picker-column').evaluate((column: HTMLIonPickerColumnElement) => column.setValue('150')); + await pressWhenMidScroll(page, '#save'); + + const onSave: SaveRecord = await page.evaluate(() => (window as any).onSave); + + expect(onSave.value).toBe('150'); + + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', '150'); + }); + + /** + * Taking hold of the wheel mid-scroll does not start a new scroll, so the + * column has to notice the user part way through one it is already running. + */ + test('should commit the visible option when the user takes over a scroll in progress', async ({ page }) => { + await page.locator('ion-picker-column').evaluate((column: HTMLIonPickerColumnElement) => { + column.value = '150'; + }); + + // Wait until the column is genuinely in transit, past 5 but not yet at 150. + await page.waitForFunction( + () => { + const highlighted = document.querySelector( + 'ion-picker-column .option-active' + ); + const value = highlighted === null ? null : Number(highlighted.value); + return value !== null && value > 5 && value < 150; + }, + undefined, + { timeout: 5000 } + ); + + // Stands in for grabbing the wheel while it is still traveling. + await page.evaluate(() => { + const scrollEl = document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + }); + + // Press once the user's own scroll has carried it past where it was headed. + await pressWhenMidScroll(page, '#save', 150); + + const onSave: SaveRecord = await page.evaluate(() => (window as any).onSave); + + expect(onSave.value).toBe(onSave.highlighted); + }); + + test('should not move on past the option it committed to an outside click', async ({ page }) => { + const onSave = await pressSaveMidScroll(page); + + /** + * Waits for the column to stop rather than a fixed window. If the press had + * not halted the momentum, the scroll would run on for about a second and + * commit a later option. + */ + await waitForColumnIdle(page); + + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', onSave.value); + }); + + /** + * The halt is the column scrolling itself, so the scroll events it produces + * must not be read as the wheel still moving and start a second commit. + */ + test('should emit one change for a scroll ended by an outside click', async ({ page }) => { + const ionChange = await page.spyOnEvent('ionChange'); + + const onSave = await pressSaveMidScroll(page); + + await waitForColumnIdle(page); + + expect(ionChange).toHaveReceivedEventTimes(1); + expect(ionChange).toHaveReceivedEventDetail({ value: onSave.value }); + }); + + /** + * A frame with no option centered must leave the highlight where it is. + */ + test('should keep the highlight when a scroll frame has no option centered', async ({ page }) => { + await hideOptionsFromHitTesting(page); + await dispatchScroll(page); + await page.waitForTimeout(COMMIT_WINDOW); + + await expect(page.locator('ion-picker-column-option.option-active')).toHaveCount(1); + await expect(page.locator('ion-picker-column-option.option-active')).toHaveJSProperty('value', '5'); + }); + + /** + * The same uncentered frame arriving part way through a scroll must keep the + * option that was already centered, and must still commit it. + */ + test('should commit the last centered option when a later frame has none', async ({ page }) => { + await pressColumn(page); + await startScroll(page); + await waitForMidScroll(page); + + /** + * From here no frame can center an option, so whatever was centered last is + * what the column falls back on. Read it after the scroll rAF has drained + * so an already queued frame cannot move it afterwards. + */ + await hideOptionsFromHitTesting(page); + await flushAnimationFrames(page); + + const centered = await page + .locator('ion-picker-column-option.option-active') + .evaluate((option: HTMLIonPickerColumnOptionElement) => String(option.value)); + expect(centered).not.toBe('5'); + + await dispatchScroll(page); + + /** + * The scroll is still coasting, and every frame it produces now finds no + * option, so the commit lands once it stops. + */ + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', centered); + + await expect(page.locator('ion-picker-column-option.option-active')).toHaveCount(1); + await expect(page.locator('ion-picker-column-option.option-active')).toHaveJSProperty('value', centered); + }); + + /** + * Once a commit has landed, a later scroll that never centers an option must + * not fall back on it. + */ + test('should not commit an option left over from a finished scroll', async ({ page }) => { + await pressColumn(page); + await startScroll(page); + await waitForColumnIdle(page); + + // The scroll ran to the end of the column, so this is where it settled. + const committed = await highlightedValue(page); + expect(committed).toBe(199); + + // From here the column can never find an option under the highlight again. + await hideOptionsFromHitTesting(page); + + await pressColumn(page); + await page.evaluate(() => { + const scrollEl = document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.scrollTo({ top: 0, behavior: 'smooth' }); + }); + await press(page, '#save'); + + /** + * Falling back on the leftover option would have halted this scroll and + * dragged the column back to the far end, so it would never arrive. + */ + await page.waitForFunction( + () => document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!.scrollTop < 50, + undefined, + { timeout: 5000 } + ); + + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', String(committed)); + }); + + /** + * A keyed list reorder moves a column rather than replacing it, which + * disconnects and reconnects it without the visibility observer reporting a + * change. The column has to keep reacting to scrolls afterwards. + */ + test('should still track scrolling after the column is moved', async ({ page }) => { + await page.evaluate(() => { + const host = document.createElement('div'); + document.body.appendChild(host); + host.appendChild(document.querySelector('ion-picker')!); + }); + + await startScroll(page); + + // The last option, since the scroll runs to the end of the column. + await expect(page.locator('ion-picker-column-option.option-active')).toHaveJSProperty('value', '199'); + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', '199'); + }); + + /** + * The frame in which the column reacts to a scroll cannot be cancelled, so + * it can land after the column has already been torn down. + */ + test('should not commit a value after the column is removed mid-scroll', async ({ page }) => { + /** + * Spied on the column rather than the page because the picker is removed + * before the commit would fire, and an event on a detached element never + * reaches the page. + */ + const ionChange = await (page.locator('ion-picker-column') as E2ELocator).spyOnEvent('ionChange'); + + await page.evaluate(() => { + const column = document.querySelector('ion-picker-column')!; + const scrollEl = column.shadowRoot!.querySelector('.picker-opts')!; + + /** + * The column registered its own scroll listener first, so by the time + * this one runs the column has already queued the frame that reacts to + * this scroll. Removing the picker here leaves that frame pending. Wait + * a few scrolls first so the column has centered an option to commit. + */ + let scrolls = 0; + scrollEl.addEventListener('scroll', () => { + if (++scrolls === 5) { + document.querySelector('ion-picker')!.remove(); + } + }); + + scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + }); + + await page.waitForTimeout(COMMIT_WINDOW); + + expect(ionChange).not.toHaveReceivedEvent(); + }); + + /** + * A scroll the column starts itself is not a selection the user made, so an + * outside press during one must not freeze it. + */ + test('should not commit an option that a programmatic scroll is passing through', async ({ page }) => { + const ionChange = await page.spyOnEvent('ionChange'); + + await page.locator('ion-picker-column').evaluate((column: HTMLIonPickerColumnElement) => { + column.value = '150'; + }); + + await pressWhenMidScroll(page, '#save'); + + await page.waitForTimeout(COMMIT_WINDOW); + + // Setting the `value` property must not emit `ionChange`. + expect(ionChange).not.toHaveReceivedEvent(); + + // The column must still be headed for the option the application asked for. + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', '150'); + }); + + /** + * Selecting an option directly scrolls the column to it. That scroll belongs + * to the selection the user already made, so an outside press during it must + * not redirect the value. + */ + test('should not commit an option that a selected scroll is passing through', async ({ page }) => { + const ionChange = await page.spyOnEvent('ionChange'); + + // A press that selects the option already under the highlight, so nothing scrolls yet. + await pressColumn(page); + + // Stands in for tapping an option far down the column. + await page.locator('ion-picker-column').evaluate((column: HTMLIonPickerColumnElement) => column.setValue('150')); + + await pressWhenMidScroll(page, '#save'); + + await page.waitForTimeout(COMMIT_WINDOW); + + // Only the selection itself is committed, not an option on the way to it. + expect(ionChange).toHaveReceivedEventTimes(1); + expect(ionChange).toHaveReceivedEventDetail({ value: '150' }); + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', '150'); + }); + }); +}); + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('picker-column: sibling columns'), () => { + /** + * A press inside the picker does not read a coasting column's value, so it + * must not stop it on an option the user was only scrolling past. + */ + test('should keep coasting when another column in the picker is pressed', async ({ page }) => { + await page.setContent( + ` + + + ${Array.from( + { length: 200 }, + (_, i) => `${i}` + ).join('')} + + + a + b + + + `, + config + ); + + await page.locator('.first ion-picker-column-option.option-active').waitFor(); + + await page.evaluate(() => { + const scrollEl = document.querySelector('.first')!.shadowRoot!.querySelector('.picker-opts')!; + (window as any).lastScrollAt = 0; + scrollEl.addEventListener('scroll', () => { + (window as any).lastScrollAt = performance.now(); + }); + }); + + const first = page.locator('.first'); + const box = (await first.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.up(); + + // Press the sibling in the same frame the first column is seen mid-flick. + await page.evaluate(() => { + const scrollEl = document.querySelector('.first')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + + return new Promise((resolve, reject) => { + const column = document.querySelector('.first')!; + const deadline = performance.now() + 5000; + + const poll = () => { + const highlighted = column.querySelector('.option-active'); + const isScrolling = performance.now() - (window as any).lastScrollAt < 100; + + if (highlighted !== null && String(highlighted.value) !== String(column.value) && isScrolling) { + const sibling = document.querySelector('.second')!; + sibling.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + resolve(); + } else if (performance.now() > deadline) { + reject(new Error('the first column never reached the expected mid-scroll state')); + } else { + requestAnimationFrame(poll); + } + }; + + requestAnimationFrame(poll); + }); + }); + + // The scroll was headed for the end of the column, so that is where it belongs. + await expect(first).toHaveJSProperty('value', '199'); + await expect(page.locator('.first ion-picker-column-option.option-active')).toHaveJSProperty('value', '199'); + }); + }); +}); diff --git a/core/src/components/popover/popover.tsx b/core/src/components/popover/popover.tsx index e106737a453..ecfcee2c3b8 100644 --- a/core/src/components/popover/popover.tsx +++ b/core/src/components/popover/popover.tsx @@ -13,6 +13,7 @@ import { FOCUS_TRAP_DISABLE_CLASS, prepareOverlay, present, + restoreRootFocusTrapAccessibility, setOverlayId, } from '@utils/overlays'; import { isPlatform } from '@utils/platform'; @@ -355,6 +356,14 @@ export class Popover implements ComponentInterface, PopoverInterface { prepareOverlay(el); configureTriggerInteraction(); + + // Re-apply the root lock if moved without dismiss() being called + if (this.presented) { + restoreRootFocusTrapAccessibility(el); + // The disconnect tore down the header observer, and `present()` is its + // only other caller, so a move mid-layout would lose the recalculation. + this.recalculateContentOnHeaderReady(); + } } disconnectedCallback() { @@ -574,6 +583,13 @@ export class Popover implements ComponentInterface, PopoverInterface { return; } + // Both `present()` and a reconnect call this, so drop any existing + // observer. Below the guards, so a call that bails cannot leave none. + if (this.headerResizeObserver) { + this.headerResizeObserver.disconnect(); + this.headerResizeObserver = undefined; + } + this.headerResizeObserver = new ResizeObserver(async () => { if (header.offsetHeight > 0) { this.headerResizeObserver?.disconnect(); diff --git a/core/src/components/popover/test/basic/popover.e2e.ts b/core/src/components/popover/test/basic/popover.e2e.ts index 92adfe7640b..2ea5a5092a7 100644 --- a/core/src/components/popover/test/basic/popover.e2e.ts +++ b/core/src/components/popover/test/basic/popover.e2e.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { configs, detachAndReattach, test } from '@utils/test/playwright'; import { PopoverFixture } from '../fixture'; @@ -261,3 +261,29 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => }); }); }); + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ config, title }) => { + test.describe(title('popover: moved while presented'), () => { + test('should keep the app root locked', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31389', + }); + + await page.goto('/src/components/popover/test/basic', config); + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await page.click('#basic-popover'); + await ionPopoverDidPresent.next(); + + await expect(page.locator('body')).toHaveClass(/backdrop-no-scroll/); + + await detachAndReattach(page.locator('ion-popover')); + + await expect(page.locator('body')).toHaveClass(/backdrop-no-scroll/); + }); + }); +}); diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index ecd27fd0965..153f4cc6f76 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -512,11 +512,19 @@ export class Refresher implements ComponentInterface { this.checkNativeRefresher(); } - async connectedCallback() { + /** + * Validate the slot attribute before rendering, while the host still reflects the + * developer's original markup. `connectedCallback` is too early: in the custom + * elements build it runs during insertion, before frameworks such as React assign + * the slot. + */ + componentWillLoad() { if (this.el.getAttribute('slot') !== 'fixed') { printIonError('[ion-refresher] - Make sure you use: '); - return; } + } + + async connectedCallback() { const contentEl = this.el.closest(ION_CONTENT_ELEMENT_SELECTOR); if (!contentEl) { printIonContentErrorMsg(this.el); diff --git a/core/src/components/refresher/test/slot-validation/refresher.e2e.ts b/core/src/components/refresher/test/slot-validation/refresher.e2e.ts new file mode 100644 index 00000000000..952c2e4125a --- /dev/null +++ b/core/src/components/refresher/test/slot-validation/refresher.e2e.ts @@ -0,0 +1,96 @@ +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { configs, dragElementByYAxis, test } from '@utils/test/playwright'; + +const collectConsoleErrors = (page: Page) => { + const logs: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'error') { + logs.push(msg.text()); + } + }); + + return logs; +}; + +const SLOT_ERROR = '[Ionic Error]: [ion-refresher] - Make sure you use: '; + +/** + * Rendering puts `slot="fixed"` on the host, so a refresher whose markup omits the + * slot still ends up in the right place and has to work. Frameworks that assign the + * slot after inserting the element start out the same way. + * + * This behavior does not vary across directions. + */ +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('refresher: slot validation'), () => { + test('should still set up the pull-to-refresh gesture when the slot is missing', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31376', + }); + + await page.setContent( + ` + + + + + +
+
+ `, + config + ); + /** + * Gesture setup runs behind a dynamic import, so dragging straight after + * setContent can land before the refresher is listening. + */ + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + const ionRefresh = await page.spyOnEvent('ionRefresh'); + + await dragElementByYAxis(page.locator('body'), page, 320); + + await expect.poll(() => ionRefresh.events.length).toBe(1); + }); + + test('should report an error telling the developer to add the slot', async ({ page }) => { + const logs = collectConsoleErrors(page); + + await page.setContent( + ` + + + + + + `, + config + ); + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain(SLOT_ERROR); + }); + + test('should not report an error when the slot is set', async ({ page }) => { + const logs = collectConsoleErrors(page); + + await page.setContent( + ` + + + + + + `, + config + ); + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + expect(logs).toEqual([]); + }); + }); +}); diff --git a/core/src/components/segment-button/segment-button.tsx b/core/src/components/segment-button/segment-button.tsx index 115040298d3..6601efd5b40 100644 --- a/core/src/components/segment-button/segment-button.tsx +++ b/core/src/components/segment-button/segment-button.tsx @@ -73,14 +73,6 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { addEventListener(segmentEl, 'ionSelect', this.updateState); addEventListener(segmentEl, 'ionStyle', this.updateStyle); } - - // Prevent buttons from being disabled when associated with segment content - if (this.contentId && this.disabled) { - printIonWarning( - `[ion-segment-button] - Segment buttons cannot be disabled when associated with an .` - ); - this.disabled = false; - } } disconnectedCallback() { @@ -100,6 +92,18 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { // Return if there is no contentId defined if (!this.contentId) return; + /** + * Checked here rather than in `connectedCallback` so frameworks that assign element + * props after inserting the element have set `disabled` by now. A disabled ion-segment + * pushes that onto its buttons too, which this guard should not undo. + */ + if (this.disabled && this.segmentEl?.disabled !== true) { + printIonWarning( + `[ion-segment-button] - Segment buttons cannot be disabled when associated with an .` + ); + this.disabled = false; + } + // Attempt to find the Segment Content by its contentId const segmentContent = document.getElementById(this.contentId) as HTMLIonSegmentContentElement | null; diff --git a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts index c7dead8943f..8429f63fbbe 100644 --- a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts +++ b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts @@ -47,3 +47,113 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); }); + +/** + * Frameworks that assign element props after inserting the element have set neither + * `contentId` nor `disabled` while `connectedCallback` runs, so the check that keeps a + * button enabled has to happen later. + * + * The shared harness page is used because it loads the custom elements build, which is + * where that ordering applies. + * + * This behavior does not vary across modes or directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('segment-view: disabled'), () => { + [false, true].forEach((lateProps) => { + const when = lateProps ? 'after connecting' : 'before connecting'; + + test(`should only re-enable the developer-disabled button when props are assigned ${when}`, async ({ page }) => { + const warnings: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'warning') { + warnings.push(msg.text()); + } + }); + + await page.goto('/src/utils/test/late-props', config); + await page.waitForFunction(() => (window as any).harnessReady === true); + + await page.evaluate( + (late: boolean) => + (window as any).mountLateProps( + ['ion-segment', 'ion-segment-button', 'ion-segment-view', 'ion-segment-content', 'ion-label'], + { + tag: 'div', + children: [ + { + // The developer disabled the second button, which has to be forced back on. + tag: 'ion-segment', + props: { value: 'first' }, + children: [ + { + tag: 'ion-segment-button', + props: { value: 'first', contentId: 'first-content' }, + children: [{ tag: 'ion-label', children: ['First'] }], + }, + { + tag: 'ion-segment-button', + props: { value: 'second', contentId: 'second-content', disabled: true }, + children: [{ tag: 'ion-label', children: ['Second'] }], + }, + ], + }, + { + tag: 'ion-segment-view', + children: [ + { tag: 'ion-segment-content', attrs: { id: 'first-content' }, children: ['First'] }, + { tag: 'ion-segment-content', attrs: { id: 'second-content' }, children: ['Second'] }, + ], + }, + { + // This whole segment is disabled, so its buttons stay off. + tag: 'ion-segment', + props: { value: 'third', disabled: true }, + children: [ + { + tag: 'ion-segment-button', + props: { value: 'third', contentId: 'third-content' }, + children: [{ tag: 'ion-label', children: ['Third'] }], + }, + { + tag: 'ion-segment-button', + props: { value: 'fourth', contentId: 'fourth-content' }, + children: [{ tag: 'ion-label', children: ['Fourth'] }], + }, + ], + }, + { + tag: 'ion-segment-view', + children: [ + { tag: 'ion-segment-content', attrs: { id: 'third-content' }, children: ['Third'] }, + { tag: 'ion-segment-content', attrs: { id: 'fourth-content' }, children: ['Fourth'] }, + ], + }, + ], + }, + late + ), + lateProps + ); + await page.waitForChanges(); + + const disabled = await page + .locator('ion-segment') + .evaluateAll((segments: HTMLIonSegmentElement[]) => + segments.map((segment) => + Array.from(segment.querySelectorAll('ion-segment-button')).map((button) => button.disabled) + ) + ); + + expect(disabled).toEqual([ + [false, false], + [true, true], + ]); + expect(warnings.join('\n')).toContain( + '[ion-segment-button] - Segment buttons cannot be disabled when associated with an .' + ); + }); + }); + }); +}); diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index c870291ec4c..7c3f7f7477f 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -15,7 +15,7 @@ import { printIonWarning } from '@utils/logging'; import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays'; import type { OverlaySelect } from '@utils/overlays-interface'; import { isRTL } from '@utils/rtl'; -import { reflectPropertiesToAttributes, sanitizeDOMTree } from '@utils/sanitization'; +import { blockedTags, reflectPropertiesToAttributes, sanitizeDOMTree } from '@utils/sanitization'; import { createSlotMutationController } from '@utils/slot-mutation-controller'; import type { SlotMutationController } from '@utils/slot-mutation-controller'; import { createColorClasses, hostContext } from '@utils/theme'; @@ -1481,33 +1481,14 @@ const textForValue = ( } /** - * When custom HTML is enabled, extract only the default slot content. - * This ensures aria-label and other text-only contexts read only - * the relevant option text. + * Every text-only context reads only the default slot, so the start + * and end slots stay out of the `aria-label` and the overlay labels. + * Both config paths derive that text through the same helper, so they + * cannot disagree about what an option's text is. `null` marks an + * option with no text, which is dropped from the joined text of a + * `multiple` select rather than joined in as an empty entry. */ - if (customHTMLEnabled) { - const content = getOptionContent(selectOpt); - - if (typeof content === 'string') { - return content; - } - - /** - * Elements were found in the default slot, extract and concatenate - * their text content while trimming whitespace. - */ - if (content) { - const texts = Array.from(content.childNodes) - .map((n) => n.textContent?.trim()) - .filter((t) => t); - return texts.join(' ') || null; - } - - // Empty option - return null; - } - - return getDefaultSlotPlainText(selectOpt); + return getDefaultSlotPlainText(selectOpt) || null; }; /** @@ -1570,9 +1551,13 @@ const getOptionContent = ( return null; } - // Return plain text if no elements are found + /** + * Return plain text if no elements are found. This reads the option the + * same way the non-custom-HTML path does, so the two do not disagree + * about what an option's text is. + */ if (!slotName && nodes.every((n) => n.nodeType === Node.TEXT_NODE)) { - return nodes.map((n) => n.textContent?.trim()).join(' ') || null; + return getDefaultSlotPlainText(option) || null; } /** @@ -1636,22 +1621,47 @@ const getOptionDefaultSlot = (option: HTMLIonSelectOptionElement): Node[] | null return defaultSlotNodes; }; +/** + * Concatenates the text a node renders, skipping the subtrees of tags + * whose contents the browser never paints (`script`, `style`, and the + * rest of `blockedTags`). `textContent` includes those, so reading it + * directly would put stylesheet or script source into the select text + * and the `aria-label`. + * + * @param node - The node to read text from. + * @returns The node's rendered text. + */ +const getRenderedTextContent = (node: Node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ''; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + + if (blockedTags.includes((node as Element).tagName.toLowerCase())) { + return ''; + } + + return Array.from(node.childNodes) + .map((child) => getRenderedTextContent(child)) + .join(''); +}; + /** * Extracts plain text from only the default slot of an option, - * excluding content assigned to named slots (start/end). + * excluding content assigned to named slots (start/end). Text is + * concatenated with no separator and collapsible whitespace is + * collapsed, approximating how the browser renders the option. + * NBSP is not collapsible, so it is preserved. + * + * @param option - The `ion-select-option` element to read text from. + * @returns The option's default slot text. */ const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => { - const texts = Array.from(option.childNodes) - .filter((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - return !(node as HTMLElement).hasAttribute('slot'); - } - return node.nodeType === Node.TEXT_NODE; - }) - .filter((node) => node.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter((t) => t); - return texts.join(' '); + const text = (getOptionDefaultSlot(option) ?? []).map((node) => getRenderedTextContent(node)).join(''); + return text.replace(/[ \t\n\r\f]+/g, ' ').replace(/^[ \t\n\r\f]+|[ \t\n\r\f]+$/g, ''); }; /** diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts b/core/src/components/select/test/rich-content-option/select.e2e.ts index 3acf2d806ac..fa29d7e49e5 100644 --- a/core/src/components/select/test/rich-content-option/select.e2e.ts +++ b/core/src/components/select/test/rich-content-option/select.e2e.ts @@ -430,7 +430,7 @@ configs({ modes: ['md'] }).forEach(({ title, config }) => { */ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('select: rich content options'), () => { - test('it should only render text nodes when `innerHTMLTemplatesEnabled` is disabled', async ({ page }) => { + test('should not render markup when `innerHTMLTemplatesEnabled` is disabled', async ({ page }) => { await page.setContent( ` @@ -466,6 +466,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(endContainer).toHaveCount(0); await expect(span).toHaveCount(0); + /** + * The span is not rendered, but the text it wrapped still reads as + * text, so the option is not silently emptied out. + */ + await expect(firstOption).toContainText('Full Content This is a span element'); + // Click on the first option await firstOption.click(); @@ -479,6 +485,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const selectTextSpan = selectText.locator('.span-style'); await expect(selectTextSpan).toHaveCount(0); + + /** + * Only the default slot is read, so the text of the `start` and `end` + * slots stays out of the selected text. + */ + await expect(selectText).toHaveText('Full Content This is a span element'); }); }); }); diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index ad7c0d3d050..f214e289501 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -1,5 +1,6 @@ import { h } from '@stencil/core'; import { newSpecPage } from '@stencil/core/testing'; +import { alertController } from '@utils/overlays'; import { config } from '../../../global/config'; import { SelectOption } from '../../select-option/select-option'; @@ -160,6 +161,259 @@ describe('ion-select: required', () => { }); }); +describe('ion-select: option plain text', () => { + it('should not insert a space between adjacent text nodes in an option', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + + appendAdjacentTextNodes(select.querySelector('ion-select-option')!); + + select.value = 'star'; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('★Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('★Star'); + }); + + it('should read option text that is wrapped in an element', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `A Star`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('A Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('A Star'); + }); + + it('should read option text when the whole option content is wrapped in an element', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `Star`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + /** + * An option with no text node of its own, such as one whose label comes + * from an i18n component, would otherwise render as an empty select with + * an empty accessible name. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); + }); + + it('should ignore content assigned to the start and end slots', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `LeadStarTrail`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); + }); + + it('should not read text the browser never paints', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `Star`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + /** + * `textContent` includes the source of tags the browser does not render, + * and those tags are the same ones the sanitizer strips from the + * custom HTML path, so both paths have to agree to ignore them. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); + }); + + it('should collapse whitespace from the source markup around option text', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ` + + + Star Option + + + `, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star Option'); + }); + + it('should preserve a non-breaking space that indents option text', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `  Star Option`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + /** + * NBSP is not collapsible, so an option indented with ` ` to fake a + * hierarchy keeps its indentation. Trimming has to leave it alone too, + * which rules out `String.prototype.trim`. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('\u00a0\u00a0Star Option'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('\u00a0\u00a0Star Option'); + }); +}); + +/** + * Frameworks render `{icon}{label}` as two sibling text nodes with no + * whitespace between them. The nodes have to be built here rather than in + * markup, because a parser collapses adjacent text into a single node. + */ +const appendAdjacentTextNodes = (option: Element) => { + option.append(document.createTextNode('★'), document.createTextNode('Star')); +}; + +/** + * The overlay interfaces build their labels from the same helper that produces + * the displayed text, so they need the same coverage. `ion-alert` is not + * defined in a spec page, so the created overlay is stubbed and the options + * passed to the controller are asserted instead. + */ +const stubAlertController = () => + jest.spyOn(alertController, 'create').mockImplementation(async () => { + const overlay = document.createElement('div') as any; + overlay.present = () => Promise.resolve(); + // Never resolves, so the select keeps treating the overlay as open. + overlay.onDidDismiss = () => new Promise(() => {}); + return overlay; + }); + +describe('ion-select: overlay option labels', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should label alert inputs with the text the option renders', async () => { + const createAlert = stubAlertController(); + + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ` + + + Star + + `, + }); + + const select = page.body.querySelector('ion-select')!; + + appendAdjacentTextNodes(select.querySelector('ion-select-option[value="adjacent"]')!); + + await page.waitForChanges(); + + await select.open(); + + expect(createAlert).toHaveBeenCalledTimes(1); + const { inputs } = createAlert.mock.calls[0][0]; + expect(inputs!.map((input) => input.label)).toEqual(['★Star', 'Star']); + }); +}); + +describe('ion-select: option plain text with custom HTML enabled', () => { + /** + * With `innerHTMLTemplatesEnabled` on, the option is read through + * `getOptionContent` instead. An option that holds only text still has to + * produce the same text as the default path. + */ + beforeEach(() => { + config.reset({ innerHTMLTemplatesEnabled: true }); + }); + + afterEach(() => { + config.reset({}); + jest.restoreAllMocks(); + }); + + it('should not insert a space between adjacent text nodes in an option', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + appendAdjacentTextNodes(select.querySelector('ion-select-option')!); + + select.value = 'star'; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.innerHTML).toBe('★Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('★Star'); + }); + + it('should not insert a space between adjacent text nodes in an option that also holds an element', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + const option = select.querySelector('ion-select-option')!; + appendAdjacentTextNodes(option); + + const badge = document.createElement('ion-badge'); + badge.textContent = 'NEW'; + option.append(badge); + + select.value = 'star'; + await page.waitForChanges(); + + /** + * An element in the default slot reads the option through a different + * branch than an option that holds only text. The text nodes render as + * one span, so the `aria-label` has to keep them together too. The + * visible separation from the badge comes from `--select-text-gap` + * rather than from a space in the text. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.innerHTML).toBe( + '★StarNEW' + ); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('★StarNEW'); + }); + + it('should label alert inputs with the text the option renders', async () => { + const createAlert = stubAlertController(); + + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + appendAdjacentTextNodes(select.querySelector('ion-select-option')!); + await page.waitForChanges(); + + await select.open(); + + const { inputs } = createAlert.mock.calls[0][0]; + expect(inputs!.map((input) => input.label)).toEqual(['★Star']); + }); +}); + describe('ion-select: option content property reflection', () => { beforeEach(() => { // Cloning rich option content into the select text only happens when diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 2387ebf0322..5149e119c95 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -51,6 +51,16 @@ const isBackdropAlwaysBlocking = (el: OverlayWithFocusTrapProps): boolean => { return el.showBackdrop !== false && !((el.backdropBreakpoint ?? 0) > 0); }; +/** + * Whether the overlay takes the app root out of the accessibility tree and + * blocks body scroll while presented. Toasts never do, modal and popover can + * opt out with `focusTrap={false}`, and a backdrop that does not block + * (`showBackdrop={false}`, or a `backdropBreakpoint` above 0) is excluded. + */ +const locksAppRoot = (el: OverlayWithFocusTrapProps): boolean => { + return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && isBackdropAlwaysBlocking(el); +}; + const createController = (tagName: string) => { return { create(options: Opts): Promise { @@ -467,6 +477,14 @@ export const getPresentedOverlay = ( return (id === undefined ? overlays : overlays.filter((o: HTMLIonOverlayElement) => o.id === id)).slice(-1)[0]; }; +/** + * The element an app nests its views under. Overlays hide this rather than the + * whole root, so the overlay itself (a sibling) stays reachable. + */ +const getViewContainer = () => { + return getAppRoot(document).querySelector('ion-router-outlet, #ion-view-container-root'); +}; + /** * When an overlay is presented, the main * focus is the overlay not the page content. @@ -490,8 +508,7 @@ export const getPresentedOverlay = ( * for main content. */ export const setRootAriaHidden = (hidden = false) => { - const root = getAppRoot(document); - const viewContainer = root.querySelector('ion-router-outlet, #ion-view-container-root'); + const viewContainer = getViewContainer(); if (!viewContainer) { return; @@ -519,10 +536,7 @@ export const cleanupRootFocusTrapAccessibility = () => { } const remainingOverlays = getPresentedOverlays(document); - const hasRemainingLocking = remainingOverlays.some((o) => { - const el = o as OverlayWithFocusTrapProps; - return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && isBackdropAlwaysBlocking(el); - }); + const hasRemainingLocking = remainingOverlays.some((o) => locksAppRoot(o as OverlayWithFocusTrapProps)); if (!hasRemainingLocking) { setRootAriaHidden(false); @@ -530,6 +544,40 @@ export const cleanupRootFocusTrapAccessibility = () => { } }; +/** + * Applies the root lock itself: `aria-hidden` on the view container, and + * `backdrop-no-scroll` on the body. Shared by `present()` and the restore + * below, which have to stay in lockstep. + */ +const applyRootLock = (el: OverlayWithFocusTrapProps) => { + // Hiding the container the overlay now sits in would hide the overlay too. + if (!getViewContainer()?.contains(el)) { + setRootAriaHidden(true); + } + document.body.classList.add(BACKDROP_NO_SCROLL); +}; + +/** + * Re-applies the root lock that `cleanupRootFocusTrapAccessibility()` released. + * Call from `connectedCallback` when the overlay is still presented. + * + * A synchronous move keeps the overlay connected, so the lock survives. A + * detach with a re-insert in a later task releases it, which is the shape a + * framework produces when it takes a subtree out and puts it back. + */ +export const restoreRootFocusTrapAccessibility = (overlayEl: HTMLIonOverlayElement) => { + if (typeof document === 'undefined') { + return; + } + + const el = overlayEl as OverlayWithFocusTrapProps; + if (!locksAppRoot(el)) { + return; + } + + applyRootLock(el); +}; + export const present = async ( overlay: OverlayInterface, name: keyof IonicConfig, @@ -554,15 +602,7 @@ export const present = async ( } /** - * Due to accessibility guidelines, toasts do not have - * focus traps. - * - * All other overlays should have focus traps to prevent - * the keyboard focus from leaving the overlay unless - * developers explicitly opt out (for example, sheet - * modals that should permit background interaction). - * - * Note: Some apps move inline overlays to a specific container + * Some apps move inline overlays to a specific container * during the willPresent lifecycle (e.g., React portals via * onWillPresent). Defer applying aria-hidden/inert to the app * root until after willPresent so we can detect where the @@ -571,21 +611,13 @@ export const present = async ( * to avoid disabling the overlay. */ const overlayEl = overlay.el as OverlayWithFocusTrapProps; - const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false; - const shouldLockRoot = shouldTrapFocus && isBackdropAlwaysBlocking(overlayEl); + const shouldLockRoot = locksAppRoot(overlayEl); overlay.presented = true; overlay.willPresent.emit(); if (shouldLockRoot) { - const root = getAppRoot(document); - const viewContainer = root.querySelector('ion-router-outlet, #ion-view-container-root'); - const overlayInsideViewContainer = viewContainer ? viewContainer.contains(overlayEl) : false; - - if (!overlayInsideViewContainer) { - setRootAriaHidden(true); - } - document.body.classList.add(BACKDROP_NO_SCROLL); + applyRootLock(overlayEl); } overlay.willPresentShorthand?.emit(); @@ -730,13 +762,9 @@ export const dismiss = async ( * from the root element when the last focus-trapping overlay * is dismissed. */ - const overlaysLockingRoot = presentedOverlays.filter((o) => { - const el = o as OverlayWithFocusTrapProps; - return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && isBackdropAlwaysBlocking(el); - }); + const overlaysLockingRoot = presentedOverlays.filter((o) => locksAppRoot(o as OverlayWithFocusTrapProps)); const overlayEl = overlay.el as OverlayWithFocusTrapProps; - const locksRoot = - overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && isBackdropAlwaysBlocking(overlayEl); + const locksRoot = locksAppRoot(overlayEl); /** * If this is the last visible overlay that is trapping focus diff --git a/core/src/utils/test/late-props/index.html b/core/src/utils/test/late-props/index.html new file mode 100644 index 00000000000..395328d21f7 --- /dev/null +++ b/core/src/utils/test/late-props/index.html @@ -0,0 +1,48 @@ + + + + + Late Props + + + + + + + + + + + +
+
+ + diff --git a/core/src/utils/test/late-props/late-props.js b/core/src/utils/test/late-props/late-props.js new file mode 100644 index 00000000000..7d29334b4cf --- /dev/null +++ b/core/src/utils/test/late-props/late-props.js @@ -0,0 +1,69 @@ +/** + * Test helpers for the custom elements build, where `connectedCallback` runs + * synchronously as the element is inserted. Frameworks that assign element props after + * inserting the element leave a window where a component can't read its own props or + * its children's, and it still has to work. + */ + +import { initialize } from '/components/index.js'; + +/** + * Initializes Ionic in the mode the test asked for. + */ +export const initializeIonic = () => { + initialize({ mode: new URLSearchParams(location.search).get('ionic:mode') ?? 'ios' }); +}; + +/** + * Defines the given tags. Safe to call again for tags that are already defined, so each + * test can ask for whatever it needs. + */ +export const defineTags = async (tags) => { + await Promise.all( + tags.map(async (tag) => { + const mod = await import(`/components/${tag}.js`); + mod.defineCustomElement(); + }) + ); +}; + +/** + * Builds the tree described by `spec` and appends it to `root`. A spec node is + * `{ tag, props, attrs, children }`, where `children` holds specs or strings. The + * `attrs` are always set before the element connects, and the `props` are set before + * connecting when `lateProps` is false, or after the whole tree connects when it is true. + */ +export const mount = (root, spec, lateProps) => { + const pending = []; + + const build = (node) => { + if (typeof node === 'string') { + return document.createTextNode(node); + } + + const el = document.createElement(node.tag); + + if (node.attrs) { + Object.entries(node.attrs).forEach(([key, value]) => el.setAttribute(key, String(value))); + } + + if (node.props) { + if (lateProps) { + pending.push([el, node.props]); + } else { + Object.assign(el, node.props); + } + } + + (node.children || []).forEach((child) => el.appendChild(build(child))); + + return el; + }; + + const tree = build(spec); + + root.appendChild(tree); + + // Descendants before ancestors, matching the order framework effects run in. + pending.reverse().forEach(([el, props]) => Object.assign(el, props)); +}; diff --git a/core/src/utils/test/overlays/overlays-root-locking.spec.ts b/core/src/utils/test/overlays/overlays-root-locking.spec.ts new file mode 100644 index 00000000000..9420960bf82 --- /dev/null +++ b/core/src/utils/test/overlays/overlays-root-locking.spec.ts @@ -0,0 +1,180 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import type { HTMLIonOverlayElement } from '../../overlays-interface'; + +import { ActionSheet } from '../../../components/action-sheet/action-sheet'; +import { Alert } from '../../../components/alert/alert'; +import { Loading } from '../../../components/loading/loading'; +import { Modal } from '../../../components/modal/modal'; +import { Popover } from '../../../components/popover/popover'; + +// A presented overlay locks the app root with `aria-hidden` on the view +// container and `backdrop-no-scroll` on the body, and being taken out and put +// back must not drop it. mock-doc's `appendChild` removes before it inserts, so +// the disconnect fires while detached, which a browser only does across tasks. +// Fixes https://github.com/ionic-team/ionic-framework/issues/31389 +describe('overlays: root locking across a move', () => { + const overlays = [ + { tag: 'ion-modal', component: Modal }, + { tag: 'ion-popover', component: Popover }, + { tag: 'ion-alert', component: Alert }, + { tag: 'ion-action-sheet', component: ActionSheet }, + { tag: 'ion-loading', component: Loading }, + ]; + + overlays.forEach(({ tag, component }) => { + it(`should keep the app root locked when a presented ${tag} is moved`, async () => { + const page = await newSpecPage({ + components: [component], + html: ` +
+
+ <${tag}> + `, + }); + + const overlay = page.body.querySelector(tag) as HTMLIonOverlayElement; + const viewContainer = page.body.querySelector('#ion-view-container-root')!; + const destination = page.body.querySelector('#destination')!; + const body = page.doc.querySelector('body')!; + + await overlay.present(); + + expect(viewContainer.getAttribute('aria-hidden')).toBe('true'); + expect(body).toHaveClass('backdrop-no-scroll'); + + destination.appendChild(overlay); + await page.waitForChanges(); + + expect(viewContainer.getAttribute('aria-hidden')).toBe('true'); + expect(body).toHaveClass('backdrop-no-scroll'); + }); + }); + + it('should release the app root when a moved overlay is later dismissed', async () => { + const page = await newSpecPage({ + components: [Modal], + html: ` +
+
+ + `, + }); + + const modal = page.body.querySelector('ion-modal')!; + const viewContainer = page.body.querySelector('#ion-view-container-root')!; + const destination = page.body.querySelector('#destination')!; + const body = page.doc.querySelector('body')!; + + await modal.present(); + + destination.appendChild(modal); + await page.waitForChanges(); + + await modal.dismiss(); + + expect(viewContainer.hasAttribute('aria-hidden')).toBe(false); + expect(body).not.toHaveClass('backdrop-no-scroll'); + }); + + it('should not lock the app root for a moved overlay that opted out of the focus trap', async () => { + // The overlay never locked the root, so a move must not start locking it. + const page = await newSpecPage({ + components: [Modal], + html: ` +
+
+ + `, + }); + + const modal = page.body.querySelector('ion-modal')!; + modal.focusTrap = false; + const viewContainer = page.body.querySelector('#ion-view-container-root')!; + const destination = page.body.querySelector('#destination')!; + const body = page.doc.querySelector('body')!; + + await modal.present(); + + expect(viewContainer.hasAttribute('aria-hidden')).toBe(false); + + destination.appendChild(modal); + await page.waitForChanges(); + + expect(viewContainer.hasAttribute('aria-hidden')).toBe(false); + expect(body).not.toHaveClass('backdrop-no-scroll'); + }); + + it('should not aria-hide the view container an overlay was re-inserted into', async () => { + /** + * Presenting skips `aria-hidden` when the overlay ends up inside the view + * container, since hiding it would hide the overlay too. A re-insert has to + * reach the same conclusion while still restoring the scroll block. + */ + const page = await newSpecPage({ + components: [Modal], + html: ` +
+ + `, + }); + + const modal = page.body.querySelector('ion-modal')!; + const viewContainer = page.body.querySelector('#ion-view-container-root')!; + const body = page.doc.querySelector('body')!; + + await modal.present(); + expect(viewContainer.getAttribute('aria-hidden')).toBe('true'); + + viewContainer.appendChild(modal); + await page.waitForChanges(); + + expect(viewContainer.hasAttribute('aria-hidden')).toBe(false); + // The scroll block still has to come back, or this would pass on the + // teardown alone and never see the restore run. + expect(body).toHaveClass('backdrop-no-scroll'); + }); + + it('should not stack parent-removal observers when a modal is moved while presenting', async () => { + // Both `present()` and the reconnect init the observer, so a move between + // the two must not leave an orphan that nothing ever disconnects. + const NativeMutationObserver = (global as any).MutationObserver; + const live = new Set(); + (global as any).MutationObserver = class { + constructor() { + live.add(this); + } + observe() {} + disconnect() { + live.delete(this); + } + }; + + try { + const page = await newSpecPage({ + components: [Modal], + html: ` +
+
+ `, + }); + + const modal = page.body.querySelector('ion-modal')!; + const destination = page.body.querySelector('#destination')!; + + modal.addEventListener('ionModalWillPresent', () => destination.appendChild(modal), { once: true }); + + await modal.present(); + await page.waitForChanges(); + + expect(live.size).toBe(1); + + await modal.dismiss(); + await page.waitForChanges(); + + expect(live.size).toBe(0); + } finally { + (global as any).MutationObserver = NativeMutationObserver; + } + }); +}); diff --git a/core/src/utils/test/playwright/detach-reattach.ts b/core/src/utils/test/playwright/detach-reattach.ts new file mode 100644 index 00000000000..5df501ffa1c --- /dev/null +++ b/core/src/utils/test/playwright/detach-reattach.ts @@ -0,0 +1,22 @@ +import type { Locator } from '@playwright/test'; + +/** + * Detaches an element and puts it back in a later task, the way a framework + * binding relocates a subtree it owns. + * + * A single `appendChild` won't do it. Custom element reactions run at the end + * of the same task, so `disconnectedCallback` fires with the element already + * back and nothing tears down. The detach has to survive a task before the + * disconnect is observable. + * + * Pass `containerSelector` to hold the element somewhere other than the body. + */ +export const detachAndReattach = async (locator: Locator, containerSelector = 'body') => { + await locator.evaluate(async (el: HTMLElement, selector: string) => { + const holder = document.createElement('div'); + document.querySelector(selector)!.appendChild(holder); + el.remove(); + await new Promise((resolve) => setTimeout(resolve, 0)); + holder.appendChild(el); + }, containerSelector); +}; diff --git a/core/src/utils/test/playwright/index.ts b/core/src/utils/test/playwright/index.ts index 777d35d4816..29f101e096d 100644 --- a/core/src/utils/test/playwright/index.ts +++ b/core/src/utils/test/playwright/index.ts @@ -7,3 +7,4 @@ export * from './apply-keyboard-focus'; export * from './matchers'; export * from './viewports'; export * from './generator'; +export * from './detach-reattach'; diff --git a/core/tsconfig.json b/core/tsconfig.json index 2d8fb861ac0..3b9d1fd5054 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -9,6 +9,10 @@ "dom.iterable", "es2022" ], + // Must stay false. At target es2022 TypeScript defaults it to true, emitting @Prop + // defaults as class fields. Consumers that downlevel those hoist the initializers + // above Stencil's registerInstance(), so its prop setter drops every default. + "useDefineForClassFields": false, "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment", diff --git a/lerna.json b/lerna.json index 03ae39d1230..5a787a8c6ce 100644 --- a/lerna.json +++ b/lerna.json @@ -10,5 +10,5 @@ "packages/vue", "packages/vue-router" ], - "version": "9.0.0" + "version": "9.0.1" } \ No newline at end of file diff --git a/packages/angular-server/CHANGELOG.md b/packages/angular-server/CHANGELOG.md index 847a5d5d049..3ca2794878e 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. +## [9.0.1](https://github.com/ionic-team/ionic-framework/compare/v9.0.0...v9.0.1) (2026-08-26) + +**Note:** Version bump only for package @ionic/angular-server + + + + + # [9.0.0](https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0) (2026-08-19) **Full Changelog**: https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0 diff --git a/packages/angular-server/package-lock.json b/packages/angular-server/package-lock.json index e4ac92a8f71..4d02923dd86 100644 --- a/packages/angular-server/package-lock.json +++ b/packages/angular-server/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular-server", - "version": "9.0.0", + "version": "9.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/angular-server", - "version": "9.0.0", + "version": "9.0.1", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.0" + "@ionic/core": "^9.0.1" }, "devDependencies": { "@angular/animations": "^22.0.0", @@ -1135,12 +1135,12 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.0.tgz", - "integrity": "sha512-ouHmLIdyP0vTV0PK/kj0QNtQTcDjyoWi2vKRaCkwj0xY3oU9qq3lB0N6w3Cm93ol0inBuXgn3/IN71BTp88c+g==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.1.tgz", + "integrity": "sha512-70fIYFxGuBFYOanWIl1PF3/emB+xVQjLQ9OZqgI+9ki8nEZH1y90YkTtAmleLl2jTx6TUpSaJpdUnzkBreIt7w==", "license": "MIT", "dependencies": { - "@stencil/core": "^4.43.5", + "@stencil/core": "^4.44.2", "ionicons": "^8.1.0", "tslib": "^2.1.0" }, @@ -2280,9 +2280,9 @@ "license": "MIT" }, "node_modules/@stencil/core": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.0.tgz", - "integrity": "sha512-A3kArg+80t9zJdul7FLUcxcjqvYrF76nDavfm2L8R7hGKWPbTTGM/tkOSf7MJqMd1ED0ztYoa9X90UlEXY3nLA==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.2.tgz", + "integrity": "sha512-TNaYdlyHfy8UF7glvZwaY6pM0AhgCOXuW5Kx3+AO+ITeaipCHWXi5tkZ6P/3HCL+hUySc60lPKSnZ4xdYIFb4g==", "license": "MIT", "bin": { "stencil": "bin/stencil" @@ -7626,11 +7626,11 @@ "dev": true }, "@ionic/core": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.0.tgz", - "integrity": "sha512-ouHmLIdyP0vTV0PK/kj0QNtQTcDjyoWi2vKRaCkwj0xY3oU9qq3lB0N6w3Cm93ol0inBuXgn3/IN71BTp88c+g==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.1.tgz", + "integrity": "sha512-70fIYFxGuBFYOanWIl1PF3/emB+xVQjLQ9OZqgI+9ki8nEZH1y90YkTtAmleLl2jTx6TUpSaJpdUnzkBreIt7w==", "requires": { - "@stencil/core": "^4.43.5", + "@stencil/core": "^4.44.2", "ionicons": "^8.1.0", "tslib": "^2.1.0" } @@ -8170,9 +8170,9 @@ "dev": true }, "@stencil/core": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.0.tgz", - "integrity": "sha512-A3kArg+80t9zJdul7FLUcxcjqvYrF76nDavfm2L8R7hGKWPbTTGM/tkOSf7MJqMd1ED0ztYoa9X90UlEXY3nLA==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.2.tgz", + "integrity": "sha512-TNaYdlyHfy8UF7glvZwaY6pM0AhgCOXuW5Kx3+AO+ITeaipCHWXi5tkZ6P/3HCL+hUySc60lPKSnZ4xdYIFb4g==", "requires": { "@rollup/rollup-darwin-arm64": "4.44.0", "@rollup/rollup-darwin-x64": "4.44.0", diff --git a/packages/angular-server/package.json b/packages/angular-server/package.json index 3c73d69c537..788144941ad 100644 --- a/packages/angular-server/package.json +++ b/packages/angular-server/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular-server", - "version": "9.0.0", + "version": "9.0.1", "description": "Angular SSR Module for Ionic", "keywords": [ "ionic", @@ -78,6 +78,6 @@ }, "prettier": "@ionic/prettier-config", "dependencies": { - "@ionic/core": "^9.0.0" + "@ionic/core": "^9.0.1" } } diff --git a/packages/angular/CHANGELOG.md b/packages/angular/CHANGELOG.md index 74b89281fb2..a086e011d01 100644 --- a/packages/angular/CHANGELOG.md +++ b/packages/angular/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. +## [9.0.1](https://github.com/ionic-team/ionic-framework/compare/v9.0.0...v9.0.1) (2026-08-26) + +**Note:** Version bump only for package @ionic/angular + + + + + # [9.0.0](https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0) (2026-08-19) **Full Changelog**: https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0 diff --git a/packages/angular/package-lock.json b/packages/angular/package-lock.json index 8cb36983fe9..3318fe793ad 100644 --- a/packages/angular/package-lock.json +++ b/packages/angular/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular", - "version": "9.0.0", + "version": "9.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular", - "version": "9.0.0", + "version": "9.0.1", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.0", + "@ionic/core": "^9.0.1", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" @@ -1579,12 +1579,12 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.0.tgz", - "integrity": "sha512-ouHmLIdyP0vTV0PK/kj0QNtQTcDjyoWi2vKRaCkwj0xY3oU9qq3lB0N6w3Cm93ol0inBuXgn3/IN71BTp88c+g==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.1.tgz", + "integrity": "sha512-70fIYFxGuBFYOanWIl1PF3/emB+xVQjLQ9OZqgI+9ki8nEZH1y90YkTtAmleLl2jTx6TUpSaJpdUnzkBreIt7w==", "license": "MIT", "dependencies": { - "@stencil/core": "^4.43.5", + "@stencil/core": "^4.44.2", "ionicons": "^8.1.0", "tslib": "^2.1.0" }, @@ -2351,9 +2351,9 @@ "license": "MIT" }, "node_modules/@stencil/core": { - "version": "4.43.5", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.5.tgz", - "integrity": "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.2.tgz", + "integrity": "sha512-TNaYdlyHfy8UF7glvZwaY6pM0AhgCOXuW5Kx3+AO+ITeaipCHWXi5tkZ6P/3HCL+hUySc60lPKSnZ4xdYIFb4g==", "license": "MIT", "bin": { "stencil": "bin/stencil" diff --git a/packages/angular/package.json b/packages/angular/package.json index f470ccd78f1..180e5e69201 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular", - "version": "9.0.0", + "version": "9.0.1", "description": "Angular specific wrappers for @ionic/core", "keywords": [ "ionic", @@ -157,7 +157,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.0", + "@ionic/core": "^9.0.1", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" diff --git a/packages/docs/CHANGELOG.md b/packages/docs/CHANGELOG.md index 77b77912a31..e7c9dff728d 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. +## [9.0.1](https://github.com/ionic-team/ionic-framework/compare/v9.0.0...v9.0.1) (2026-08-26) + +**Note:** Version bump only for package @ionic/docs + + + + + # [9.0.0](https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0) (2026-08-19) **Note:** Version bump only for package @ionic/docs diff --git a/packages/docs/package-lock.json b/packages/docs/package-lock.json index 6bb6ed386d1..e015a136762 100644 --- a/packages/docs/package-lock.json +++ b/packages/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/docs", - "version": "9.0.0", + "version": "9.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/docs", - "version": "9.0.0", + "version": "9.0.1", "license": "MIT" } } diff --git a/packages/docs/package.json b/packages/docs/package.json index 3cc1d7a0406..a9625a235f3 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/docs", - "version": "9.0.0", + "version": "9.0.1", "description": "Pre-packaged API documentation for the Ionic docs.", "main": "core.json", "types": "core.d.ts", diff --git a/packages/migrate/package-lock.json b/packages/migrate/package-lock.json index afd370c9a50..53f1b317380 100644 --- a/packages/migrate/package-lock.json +++ b/packages/migrate/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "ts-morph": "^25.0.0" + "ts-morph": "^28.0.0" }, "bin": { "ionic-migrate": "dist/cli.js" @@ -17,7 +17,7 @@ "devDependencies": { "@types/node": "^24.13.3", "typescript": "^5.7.3", - "vitest": "^3.0.0" + "vitest": "^4.0.0" }, "engines": { "node": ">=20.0.0" @@ -472,41 +472,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -896,15 +861,22 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@ts-morph/common": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.26.1.tgz", - "integrity": "sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", "license": "MIT", "dependencies": { - "fast-glob": "^3.3.2", - "minimatch": "^9.0.4", - "path-browserify": "^1.0.1" + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" } }, "node_modules/@types/chai": { @@ -943,39 +915,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -987,42 +960,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1030,28 +1003,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1068,107 +1038,46 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": "20 || >=22" } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, @@ -1234,43 +1143,6 @@ "node": ">=12.0.0" } }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1286,62 +1158,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1352,50 +1168,21 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1415,6 +1202,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1428,16 +1229,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1446,12 +1237,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -1486,36 +1277,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1561,29 +1322,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1609,25 +1347,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1636,17 +1361,19 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -1663,7 +1390,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1677,68 +1403,23 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/ts-morph": { - "version": "25.0.1", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-25.0.1.tgz", - "integrity": "sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ==", + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", "license": "MIT", "dependencies": { - "@ts-morph/common": "~0.26.0", + "@ts-morph/common": "~0.29.0", "code-block-writer": "^13.0.3" } }, @@ -1838,29 +1519,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1879,79 +1537,80 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -1962,21 +1621,18 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "license": "MIT" }, "node_modules/why-is-node-running": { "version": "2.3.0", diff --git a/packages/migrate/package.json b/packages/migrate/package.json index c70a7184664..00855bfdda8 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -41,11 +41,11 @@ "lint": "tsc --noEmit" }, "dependencies": { - "ts-morph": "^25.0.0" + "ts-morph": "^28.0.0" }, "devDependencies": { "@types/node": "^24.13.3", "typescript": "^5.7.3", - "vitest": "^3.0.0" + "vitest": "^4.0.0" } } diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 71c0a42bc94..87c1e26bc5b 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.1](https://github.com/ionic-team/ionic-framework/compare/v9.0.0...v9.0.1) (2026-08-26) + +### Bug Fixes + +* **react-router:** skip page clone when nothing will animate ([#31387](https://github.com/ionic-team/ionic-framework/issues/31387)) ([898001c](https://github.com/ionic-team/ionic-framework/commit/898001c5c6a92a3269dd8e60c4e79da1c634af9d)) + + # [9.0.0](https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0) (2026-08-19) **Full Changelog**: https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0 diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json index 5535d98819f..5802d191549 100644 --- a/packages/react-router/package-lock.json +++ b/packages/react-router/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/react-router", - "version": "9.0.0", + "version": "9.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react-router", - "version": "9.0.0", + "version": "9.0.1", "license": "MIT", "dependencies": { - "@ionic/react": "^9.0.0", + "@ionic/react": "^9.0.1", "tslib": "*" }, "devDependencies": { @@ -256,12 +256,12 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.0.tgz", - "integrity": "sha512-ouHmLIdyP0vTV0PK/kj0QNtQTcDjyoWi2vKRaCkwj0xY3oU9qq3lB0N6w3Cm93ol0inBuXgn3/IN71BTp88c+g==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.1.tgz", + "integrity": "sha512-70fIYFxGuBFYOanWIl1PF3/emB+xVQjLQ9OZqgI+9ki8nEZH1y90YkTtAmleLl2jTx6TUpSaJpdUnzkBreIt7w==", "license": "MIT", "dependencies": { - "@stencil/core": "^4.43.5", + "@stencil/core": "^4.44.2", "ionicons": "^8.1.0", "tslib": "^2.1.0" }, @@ -296,12 +296,12 @@ } }, "node_modules/@ionic/react": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.0.tgz", - "integrity": "sha512-aVOXzCVJTKp9bJYhoKm6ksu7+gEzsl6179JvZNLsanIeCN4SHsmIa7CfjV9K+TwERXQ8Kgpglj4QquU9/QPRgQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.1.tgz", + "integrity": "sha512-dO257Wl1RSz5g2X0uHkHZOKZYGafFPXcU0EYPgROHxKBUVZpM5VimUZVNcAjkyWRDwDyN0Bjz+7TVfCN3DDPUQ==", "license": "MIT", "dependencies": { - "@ionic/core": "9.0.0", + "@ionic/core": "9.0.1", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" @@ -688,9 +688,9 @@ "license": "MIT" }, "node_modules/@stencil/core": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.0.tgz", - "integrity": "sha512-A3kArg+80t9zJdul7FLUcxcjqvYrF76nDavfm2L8R7hGKWPbTTGM/tkOSf7MJqMd1ED0ztYoa9X90UlEXY3nLA==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.2.tgz", + "integrity": "sha512-TNaYdlyHfy8UF7glvZwaY6pM0AhgCOXuW5Kx3+AO+ITeaipCHWXi5tkZ6P/3HCL+hUySc60lPKSnZ4xdYIFb4g==", "license": "MIT", "bin": { "stencil": "bin/stencil" diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 19c17ba0a1b..c5ddad01551 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react-router", - "version": "9.0.0", + "version": "9.0.1", "description": "React Router wrapper for @ionic/react", "keywords": [ "ionic", @@ -37,7 +37,7 @@ "dist/" ], "dependencies": { - "@ionic/react": "^9.0.0", + "@ionic/react": "^9.0.1", "tslib": "*" }, "peerDependencies": { diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index bea1506cb3a..69ca358dc84 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -5,7 +5,7 @@ */ import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react'; -import { IonRoute, RouteManagerContext, StackContext, createDebugLogger, generateId } from '@ionic/react'; +import { IonRoute, RouteManagerContext, StackContext, createDebugLogger, generateId, getConfig } from '@ionic/react'; import React from 'react'; import type { RouteObject } from 'react-router-dom'; import { Route, UNSAFE_RouteContext as RouteContext, matchRoutes } from 'react-router-dom'; @@ -1513,8 +1513,37 @@ export class StackManager extends React.PureComponent { ) { const myGeneration = ++this.transitionGeneration; + const routerOutlet = this.routerOutletElement!; + + const routeInfoFallbackDirection = + routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root' ? undefined : routeInfo.routeDirection; + const directionToUse = direction ?? routeInfoFallbackDirection; + + /** + * The cases where we pass `commit()` a duration of 0. It's a function so each + * caller reads `skipTransition` as of when it runs, since the swipe gesture can + * set it after we get here. + */ + const isInstantCommit = () => this.skipTransition || skipAnimation || directionToUse === undefined; + + /** + * Whether `commit()` will run an animation. Mirrors the check in core's + * `router-outlet.tsx`, so keep the two in sync: an instant commit never + * animates, and otherwise the outlet's `animated` prop and the global + * `animated` config both have to allow it (`ionic:_testing` turns it off). + */ + const willCommitAnimate = () => { + if (isInstantCommit()) { + return false; + } + + const config = getConfig(); + return !!routerOutlet.animated && (config ? config.getBoolean('animated', true) : true); + }; + const runCommit = async (enteringEl: HTMLElement, leavingEl?: HTMLElement) => { const skipTransition = this.skipTransition; + const commitDuration = isInstantCommit() ? 0 : undefined; /** * If the transition was handled @@ -1551,8 +1580,6 @@ export class StackManager extends React.PureComponent { } } - const commitDuration = skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined; - // Race commit against a timeout to recover from hangs const commitPromise = routerOutlet.commit(enteringEl, leavingEl, { duration: commitDuration, @@ -1589,19 +1616,13 @@ export class StackManager extends React.PureComponent { } }; - const routerOutlet = this.routerOutletElement!; - - const routeInfoFallbackDirection = - routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root' ? undefined : routeInfo.routeDirection; - const directionToUse = direction ?? routeInfoFallbackDirection; - if (enteringViewItem && enteringViewItem.ionPageElement && this.routerOutletElement) { this.transitionEnteringElement = enteringViewItem.ionPageElement; if (leavingViewItem && leavingViewItem.ionPageElement && enteringViewItem === leavingViewItem) { // Clone page for same-view transitions (e.g., /user/1 → /user/2) const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname, undefined, this.outletMountPath); - if (match) { + if (match && willCommitAnimate()) { const newLeavingElement = clonePageElement(leavingViewItem.ionPageElement.outerHTML); if (newLeavingElement) { this.routerOutletElement.appendChild(newLeavingElement); @@ -1609,7 +1630,11 @@ export class StackManager extends React.PureComponent { this.routerOutletElement.removeChild(newLeavingElement); } } else { - // Route no longer matches (e.g., /user/1 → /settings) + /** + * Either the route no longer matches (e.g., /user/1 → /settings), or + * nothing will animate, so the clone would duplicate the page in the + * DOM for no benefit. + */ await runCommit(enteringViewItem.ionPageElement, undefined); } } else { diff --git a/packages/react-router/test/base/tests/e2e/playwright/nested-params.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/nested-params.spec.ts index e7ab3ee0713..0071e38dd33 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/nested-params.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/nested-params.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { ionPageVisible, withTestingMode } from './utils/test-utils'; +import { ionPageVisible, trackPeakMatchCount, withTestingMode } from './utils/test-utils'; test.describe('Nested Params', () => { @@ -44,4 +44,23 @@ test.describe('Nested Params', () => { await expect(page.getByText('Layout sees user: 42')).toBeVisible(); }); + // A duplicate details page, even briefly, fails this spec's text assertions on + // a strict mode violation. + test('should not duplicate the details page while switching params', async ({ page }) => { + await page.goto(withTestingMode('/nested-params')); + await ionPageVisible(page, 'nested-params-landing'); + + await page.locator('#go-to-user-99').click(); + await expect(page.getByText('Details view user: 99')).toBeVisible(); + + await page.locator('[data-pageid="nested-params-user-99"]:not(.ion-page-hidden) #back-to-landing').click(); + await ionPageVisible(page, 'nested-params-landing'); + + const peakDetailsPages = await trackPeakMatchCount(page, '[data-testid="user-details-param"]', '42'); + + await page.locator('#go-to-user-42').click(); + await expect(page.getByText('Details view user: 42')).toBeVisible(); + + expect(await peakDetailsPages()).toBe(1); + }); }); diff --git a/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts index f421357cac3..7287d4c75df 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { ionPageVisible, ionTabClick, withTestingMode } from './utils/test-utils'; +import { ionPageVisible, ionTabClick, trackPeakMatchCount, withTestingMode } from './utils/test-utils'; test.describe('Tab Lifecycle Events', () => { test.beforeEach(async ({ page }) => { @@ -70,4 +70,21 @@ test.describe('Tab Lifecycle Events', () => { expect(events).toContain('home:ionViewWillEnter'); expect(events).toContain('home:ionViewDidEnter'); }); + + // A duplicate tab page, even briefly, fails this spec's page assertions on a + // strict mode violation. + test('should not duplicate the tab page in the DOM while returning to the tabs', async ({ page }) => { + await page.goto(withTestingMode('/tab-lifecycle/home')); + await ionPageVisible(page, 'tab-lifecycle-home'); + + await page.locator('#go-outside').click(); + await ionPageVisible(page, 'tab-lifecycle-outside'); + + const peakHomePages = await trackPeakMatchCount(page, 'div.ion-page[data-pageid="tab-lifecycle-home"]'); + + await page.locator('#go-back-to-tabs').click(); + await ionPageVisible(page, 'tab-lifecycle-home'); + + expect(await peakHomePages()).toBe(1); + }); }); diff --git a/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts b/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts index e7543ef88a1..e651cab081c 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts @@ -17,6 +17,52 @@ export function withTestingMode(path: string): string { return `${path}${separator}ionic:_testing=true`; } +let peakCounterId = 0; + +/** + * Start recording the largest number of elements matching `selector` (optionally + * narrowed to those containing `containsText`) that ever coexist. The returned + * function stops recording and resolves with the peak. + * + * Narrowing by text matters because several parameterized layouts can be in the + * DOM at once, so it's what limits the count to the page under test. + * + * Start tracking after the last navigation. The counter lives on `window`, so a + * `page.goto()` in between wipes it and the returned function will throw. + */ +export async function trackPeakMatchCount( + page: Page, + selector: string, + containsText?: string +): Promise<() => Promise> { + const key = `__peakMatchCount${peakCounterId++}`; + + await page.evaluate( + ({ selector, containsText, key }) => { + const state = window as any; + const count = () => { + const matches = Array.from(document.querySelectorAll(selector)); + return containsText ? matches.filter((el) => el.textContent?.includes(containsText)).length : matches.length; + }; + + state[key] = count(); + const observer = new MutationObserver(() => { + state[key] = Math.max(state[key], count()); + }); + observer.observe(document.body, { subtree: true, childList: true, characterData: true }); + state[`${key}Stop`] = () => observer.disconnect(); + }, + { selector, containsText, key } + ); + + return () => + page.evaluate((k) => { + const state = window as any; + state[`${k}Stop`](); + return state[k] as number; + }, key); +} + /** * Assert that a page is visible and not hidden or invisible. * Equivalent to Cypress `cy.ionPageVisible(pageId)`. diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index cf04cd2425b..f69caea942b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.1](https://github.com/ionic-team/ionic-framework/compare/v9.0.0...v9.0.1) (2026-08-26) + +### Bug Fixes + +* **overlays:** restore presented state after a DOM move ([#31400](https://github.com/ionic-team/ionic-framework/issues/31400)) ([bfff8e1](https://github.com/ionic-team/ionic-framework/commit/bfff8e1377a8faff3581e61026733ce29b671513)), closes [#31389](https://github.com/ionic-team/ionic-framework/issues/31389) [#31390](https://github.com/ionic-team/ionic-framework/issues/31390) [#31389](https://github.com/ionic-team/ionic-framework/issues/31389) + + # [9.0.0](https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0) (2026-08-19) **Full Changelog**: https://github.com/ionic-team/ionic-framework/compare/v8.8.19...v9.0.0 diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index 0154f0a9f14..30de19e159a 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/react", - "version": "9.0.0", + "version": "9.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react", - "version": "9.0.0", + "version": "9.0.1", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.0", + "@ionic/core": "^9.0.1", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" @@ -891,12 +891,12 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.0.tgz", - "integrity": "sha512-ouHmLIdyP0vTV0PK/kj0QNtQTcDjyoWi2vKRaCkwj0xY3oU9qq3lB0N6w3Cm93ol0inBuXgn3/IN71BTp88c+g==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.1.tgz", + "integrity": "sha512-70fIYFxGuBFYOanWIl1PF3/emB+xVQjLQ9OZqgI+9ki8nEZH1y90YkTtAmleLl2jTx6TUpSaJpdUnzkBreIt7w==", "license": "MIT", "dependencies": { - "@stencil/core": "^4.43.5", + "@stencil/core": "^4.44.2", "ionicons": "^8.1.0", "tslib": "^2.1.0" }, @@ -1682,9 +1682,9 @@ } }, "node_modules/@stencil/core": { - "version": "4.43.5", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.5.tgz", - "integrity": "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.2.tgz", + "integrity": "sha512-TNaYdlyHfy8UF7glvZwaY6pM0AhgCOXuW5Kx3+AO+ITeaipCHWXi5tkZ6P/3HCL+hUySc60lPKSnZ4xdYIFb4g==", "license": "MIT", "bin": { "stencil": "bin/stencil" diff --git a/packages/react/package.json b/packages/react/package.json index 45ba247b597..311c533d639 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react", - "version": "9.0.0", + "version": "9.0.1", "description": "React specific wrapper for @ionic/core", "keywords": [ "ionic", @@ -41,7 +41,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.0", + "@ionic/core": "^9.0.1", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" diff --git a/packages/react/src/components/__tests__/createInlineOverlayComponent.spec.tsx b/packages/react/src/components/__tests__/createInlineOverlayComponent.spec.tsx index e2cb8176af8..2c7b6c0309b 100644 --- a/packages/react/src/components/__tests__/createInlineOverlayComponent.spec.tsx +++ b/packages/react/src/components/__tests__/createInlineOverlayComponent.spec.tsx @@ -40,9 +40,138 @@ const teleport = (el: HTMLElement) => { dest.appendChild(el); }; -afterEach(() => { +// The unmount teardown is deferred a microtask so it can check whether React +// removed the DOM or only hid it. Let that microtask run. +const flushTeardown = () => act(async () => {}); + +/** + * Tracks `MutationObserver` use. `jest.spyOn` cannot wrap a class constructor + * (its mock is called without `new`), so swap in a subclass. `observersLive` + * counts observing-minus-disconnected, where a missing `disconnect` shows up. + */ +const countMutationObservers = () => { + const Original = global.MutationObserver; + let created = 0; + let live = 0; + global.MutationObserver = class extends Original { + private isObserving = false; + + constructor(callback: MutationCallback) { + super(callback); + created++; + } + + observe(target: Node, options?: MutationObserverInit) { + if (!this.isObserving) { + this.isObserving = true; + live++; + } + return super.observe(target, options); + } + + disconnect() { + if (this.isObserving) { + this.isObserving = false; + live--; + } + return super.disconnect(); + } + }; + + return { + observersCreated: () => created, + observersLive: () => live, + restore: () => { + global.MutationObserver = Original; + }, + }; +}; + +/** + * Suspends while it holds a pending promise. Inside a Suspense boundary that + * hides the boundary's content: React runs `componentWillUnmount` on everything + * in it without unmounting, then remounts the same instances on the reveal. + */ +const Suspender = ({ pending }: { pending: Promise | null }) => { + if (pending) { + throw pending; + } + return null; +}; + +/** + * Renders `children` in a Suspense boundary next to a sibling that suspends on + * demand, which is the reported trigger: the overlay renders fine, another + * child does not, and React hides the whole boundary. Each `hide()` takes a + * fresh promise so a test can drive more than one cycle. `container` places + * the React root elsewhere, which the shadow-root case needs. + */ +const renderWithBoundary = (children: React.ReactNode, container?: HTMLElement) => { + let setPending!: (pending: Promise | null) => void; + let resolvePending: (() => void) | null = null; + + const Boundary = () => { + const [pending, setPendingState] = React.useState | null>(null); + setPending = setPendingState; + + return ( + loading}> + {children} + + + ); + }; + + const result = render(, container ? { container } : undefined); + + return { + ...result, + hide: async () => { + const pending = new Promise((resolve) => { + resolvePending = resolve as () => void; + }); + act(() => { + setPending(pending); + }); + await flushTeardown(); + }, + reveal: async () => { + await act(async () => { + setPending(null); + resolvePending?.(); + resolvePending = null; + }); + await flushTeardown(); + }, + }; +}; + +afterEach(async () => { document.body.innerHTML = ''; mockComponentOnReady = defaultComponentOnReady; + jest.restoreAllMocks(); + // Clearing the body disconnects any marker still being watched. Let the + // shared destroy observer drain so it does not carry into the next test. + await flushTeardown(); +}); + +describe('createInlineOverlayComponent: cachedOriginalParent', () => { + it('redirects cachedOriginalParent for a portaled overlay but not a nested one', () => { + // Core walks up from `cachedOriginalParent` to find the `.ion-page`. A + // portaled host is cached against the portal container, so it has to be + // pointed back at its JSX position. A nested host is already there. + const { container } = render( + + + + ); + + const modal = document.body.querySelector('ion-modal') as any; + const popover = document.body.querySelector('ion-popover') as any; + + expect(modal.cachedOriginalParent).toBe(container); + expect(popover.cachedOriginalParent).toBeUndefined(); + }); }); describe('createInlineOverlayComponent: unmount cleanup', () => { @@ -111,7 +240,7 @@ describe('createInlineOverlayComponent: unmount cleanup', () => { expect(document.querySelectorAll('ion-modal')).toHaveLength(1); }); - it('removes a relocated nested overlay on unmount even when it never opened', () => { + it('removes a relocated nested overlay on unmount even when it never opened', async () => { // keepContentsMounted renders the children (and the nested popover) while // the outer modal is closed, so the popover observes the nested context. const { unmount } = render( @@ -127,11 +256,12 @@ describe('createInlineOverlayComponent: unmount cleanup', () => { teleport(popover); unmount(); + await flushTeardown(); expect(document.querySelector('ion-popover')).toBeNull(); }); - it('removes an open, relocated nested overlay on unmount', () => { + it('removes an open, relocated nested overlay on unmount', async () => { const { unmount } = render( @@ -149,7 +279,645 @@ describe('createInlineOverlayComponent: unmount cleanup', () => { teleport(popover); expect(() => unmount()).not.toThrow(); + await flushTeardown(); expect(document.querySelector('ion-popover')).toBeNull(); }); }); + +// Fixes https://github.com/ionic-team/ionic-framework/issues/31389 +describe('createInlineOverlayComponent: hidden subtree', () => { + it('leaves a relocated nested overlay alone while a Suspense boundary hides it', async () => { + // A hide must leave the host where it is, since React did not remove it + // and will not put it back on the reveal. + const { hide, reveal } = renderWithBoundary( + + + + ); + + const popover = document.body.querySelector('ion-popover') as HTMLElement; + + // CoreDelegate teleports the host out of its `