diff --git a/.github/actions/publish-npm/action.yml b/.github/actions/publish-npm/action.yml index 6913b493ef1..cce9c2c235a 100644 --- a/.github/actions/publish-npm/action.yml +++ b/.github/actions/publish-npm/action.yml @@ -22,7 +22,7 @@ runs: using: 'composite' steps: - name: ๐ŸŸข Configure Node for Publish - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ inputs.node-version }} registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/actions/build-angular-server/action.yml b/.github/workflows/actions/build-angular-server/action.yml index 70c65316883..283a9db0244 100644 --- a/.github/workflows/actions/build-angular-server/action.yml +++ b/.github/workflows/actions/build-angular-server/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic Angular Server' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-angular/action.yml b/.github/workflows/actions/build-angular/action.yml index 121fa6f7da9..7771cb0e879 100644 --- a/.github/workflows/actions/build-angular/action.yml +++ b/.github/workflows/actions/build-angular/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic Angular' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-core-stencil-prerelease/action.yml b/.github/workflows/actions/build-core-stencil-prerelease/action.yml index f1e33c8b55e..0bd78e4adfe 100644 --- a/.github/workflows/actions/build-core-stencil-prerelease/action.yml +++ b/.github/workflows/actions/build-core-stencil-prerelease/action.yml @@ -13,7 +13,7 @@ runs: using: 'composite' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x diff --git a/.github/workflows/actions/build-core/action.yml b/.github/workflows/actions/build-core/action.yml index 068eab5c471..d155d64d4f1 100644 --- a/.github/workflows/actions/build-core/action.yml +++ b/.github/workflows/actions/build-core/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - name: ๐Ÿ•ธ๏ธ Install Dependencies diff --git a/.github/workflows/actions/build-react-router/action.yml b/.github/workflows/actions/build-react-router/action.yml index 0ce63a1ab75..c61c962cc03 100644 --- a/.github/workflows/actions/build-react-router/action.yml +++ b/.github/workflows/actions/build-react-router/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic React Router' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-react/action.yml b/.github/workflows/actions/build-react/action.yml index b7d3ac6b99a..5e615935ec7 100644 --- a/.github/workflows/actions/build-react/action.yml +++ b/.github/workflows/actions/build-react/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic React' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive @@ -43,7 +43,7 @@ runs: output: packages/react/ReactBuild.zip paths: packages/react/dist packages/react/css packages/react/package.json - name: Clean package.json updates - run: git checkout ./core/package.json ./packages/react/package.json + run: git checkout ./core/package.json ./packages/react/package.json ./packages/react/package-lock.json shell: bash - name: ๐Ÿ” Check Diff run: git diff --exit-code diff --git a/.github/workflows/actions/build-vue-router/action.yml b/.github/workflows/actions/build-vue-router/action.yml index 68972ef2324..bc068447da3 100644 --- a/.github/workflows/actions/build-vue-router/action.yml +++ b/.github/workflows/actions/build-vue-router/action.yml @@ -3,7 +3,7 @@ description: 'Builds Ionic Vue Router' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-vue/action.yml b/.github/workflows/actions/build-vue/action.yml index 22c731e99ad..69a55fb0727 100644 --- a/.github/workflows/actions/build-vue/action.yml +++ b/.github/workflows/actions/build-vue/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic Vue' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/download-archive/action.yml b/.github/workflows/actions/download-archive/action.yml index 97f07f8627a..c4da9dd9bca 100644 --- a/.github/workflows/actions/download-archive/action.yml +++ b/.github/workflows/actions/download-archive/action.yml @@ -10,7 +10,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: ${{ inputs.name }} path: ${{ inputs.path }} diff --git a/.github/workflows/actions/test-angular-e2e/action.yml b/.github/workflows/actions/test-angular-e2e/action.yml index 8a15bfb5037..d3cbf772ad4 100644 --- a/.github/workflows/actions/test-angular-e2e/action.yml +++ b/.github/workflows/actions/test-angular-e2e/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/test-angular-package/action.yml b/.github/workflows/actions/test-angular-package/action.yml index 4f6181a2115..296a34baa56 100644 --- a/.github/workflows/actions/test-angular-package/action.yml +++ b/.github/workflows/actions/test-angular-package/action.yml @@ -3,7 +3,7 @@ description: 'Test Ionic Angular Package' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/test-core-clean-build/action.yml b/.github/workflows/actions/test-core-clean-build/action.yml index f6275acbee5..169bca12c5c 100644 --- a/.github/workflows/actions/test-core-clean-build/action.yml +++ b/.github/workflows/actions/test-core-clean-build/action.yml @@ -3,7 +3,7 @@ description: 'Test Core Clean Build' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x diff --git a/.github/workflows/actions/test-core-lint/action.yml b/.github/workflows/actions/test-core-lint/action.yml index 66a3036ef04..d9435780f44 100644 --- a/.github/workflows/actions/test-core-lint/action.yml +++ b/.github/workflows/actions/test-core-lint/action.yml @@ -3,7 +3,7 @@ description: 'Test Core Lint' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - name: ๐Ÿ•ธ๏ธ Install Dependencies diff --git a/.github/workflows/actions/test-core-screenshot/action.yml b/.github/workflows/actions/test-core-screenshot/action.yml index 379ce934edc..ced133401d1 100644 --- a/.github/workflows/actions/test-core-screenshot/action.yml +++ b/.github/workflows/actions/test-core-screenshot/action.yml @@ -13,7 +13,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive @@ -66,7 +66,7 @@ runs: working-directory: ./core - name: ๐Ÿ“ฆ Archive Updated Screenshots if: inputs.update == 'true' && steps.test-and-update.outputs.hasUpdatedScreenshots == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: updated-screenshots-${{ inputs.shard }}-${{ inputs.totalShards }} path: UpdatedScreenshots-${{ inputs.shard }}-${{ inputs.totalShards }}.zip diff --git a/.github/workflows/actions/test-core-spec/action.yml b/.github/workflows/actions/test-core-spec/action.yml index f33ab0aa3e3..e2d55586215 100644 --- a/.github/workflows/actions/test-core-spec/action.yml +++ b/.github/workflows/actions/test-core-spec/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - name: ๐Ÿ•ธ๏ธ Install Dependencies diff --git a/.github/workflows/actions/test-react-e2e/action.yml b/.github/workflows/actions/test-react-e2e/action.yml index af6100bdb07..5c1e96e4f88 100644 --- a/.github/workflows/actions/test-react-e2e/action.yml +++ b/.github/workflows/actions/test-react-e2e/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/test-react-router-e2e/action.yml b/.github/workflows/actions/test-react-router-e2e/action.yml index beea150b549..13e6ef48ebe 100644 --- a/.github/workflows/actions/test-react-router-e2e/action.yml +++ b/.github/workflows/actions/test-react-router-e2e/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/test-vue-e2e/action.yml b/.github/workflows/actions/test-vue-e2e/action.yml index 2316e019a46..c57ba40064f 100644 --- a/.github/workflows/actions/test-vue-e2e/action.yml +++ b/.github/workflows/actions/test-vue-e2e/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/update-reference-screenshots/action.yml b/.github/workflows/actions/update-reference-screenshots/action.yml index 844f4e6d2ac..c1c8df73ddb 100644 --- a/.github/workflows/actions/update-reference-screenshots/action.yml +++ b/.github/workflows/actions/update-reference-screenshots/action.yml @@ -7,10 +7,10 @@ on: runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: ./artifacts - name: ๐Ÿ”Ž Extract Archives diff --git a/.github/workflows/actions/upload-archive/action.yml b/.github/workflows/actions/upload-archive/action.yml index 3cfff8853fc..5245f7a48c3 100644 --- a/.github/workflows/actions/upload-archive/action.yml +++ b/.github/workflows/actions/upload-archive/action.yml @@ -13,7 +13,7 @@ runs: - name: ๐Ÿ—„๏ธ Create Archive run: zip -q -r ${{ inputs.output }} ${{ inputs.paths }} shell: bash - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.name }} path: ${{ inputs.output }} diff --git a/.github/workflows/assign-issues.yml b/.github/workflows/assign-issues.yml index fc254b78976..6d0d03e4a3b 100644 --- a/.github/workflows/assign-issues.yml +++ b/.github/workflows/assign-issues.yml @@ -11,7 +11,7 @@ jobs: issues: write steps: - name: 'Auto-assign issue' - uses: pozil/auto-assign-issue@af6beea6bdf1e8eb373f061c5bc168681fc6d011 # v4.0.1 + uses: pozil/auto-assign-issue@d6e511896e5f1d2408e74db74416647d8ff80c8c # v4.1.0 with: assignees: brandyscarney, thetaPC, ShaneK numOfAssignee: 1 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index bdb408b3eb9..4decf54d203 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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: javascript - - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 54990664544..5cd5df3ecb4 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -64,7 +64,7 @@ jobs: working-directory: ./packages/migrate steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x cache: 'npm' diff --git a/.github/workflows/release-migrate.yml b/.github/workflows/release-migrate.yml index d119e6830de..0c083240609 100644 --- a/.github/workflows/release-migrate.yml +++ b/.github/workflows/release-migrate.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: ๐ŸŸข Configure Node for Publish - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x registry-url: 'https://registry.npmjs.org' diff --git a/CHANGELOG.md b/CHANGELOG.md index f017d422729..a1615d78b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,39 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **angular:** import tab proxies directly in standalone tabs ([#31596](https://github.com/ionic-team/ionic-framework/issues/31596)) ([cb963f8](https://github.com/ionic-team/ionic-framework/commit/cb963f886ee08ce3abf25d6cb4e33c5cc3ac0003)), closes [#31594](https://github.com/ionic-team/ionic-framework/issues/31594) +* **datetime:** discard unconfirmed selection on cancel ([#31558](https://github.com/ionic-team/ionic-framework/issues/31558)) ([cecd3af](https://github.com/ionic-team/ionic-framework/commit/cecd3afdaea91a2e5f4ada6cd4aaedf6f436e6c9)), closes [#30777](https://github.com/ionic-team/ionic-framework/issues/30777) [#27975](https://github.com/ionic-team/ionic-framework/issues/27975) [#27976](https://github.com/ionic-team/ionic-framework/issues/27976) +* **input, select, textarea:** prevent floating label from overflowing while it floats ([#31567](https://github.com/ionic-team/ionic-framework/issues/31567)) ([ecd6f5a](https://github.com/ionic-team/ionic-framework/commit/ecd6f5a7c0ed987f4357b7a0e3954d9291be170d)) +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) +* **vue-router:** keep back working after replacing a page in tabs ([#31583](https://github.com/ionic-team/ionic-framework/issues/31583)) ([86390f6](https://github.com/ionic-team/ionic-framework/commit/86390f6deab23b58498c3b9ae782f9e0fc5a4e29)), closes [#30617](https://github.com/ionic-team/ionic-framework/issues/30617) [#31142](https://github.com/ionic-team/ionic-framework/issues/31142) + + +## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) + +### Bug Fixes + +* **angular, vue:** fire lifecycle events on nested pages ([#31532](https://github.com/ionic-team/ionic-framework/issues/31532)) ([277097e](https://github.com/ionic-team/ionic-framework/commit/277097e1dfa13d8b34a6826aca462843810fc8f1)), closes [#30831](https://github.com/ionic-team/ionic-framework/issues/30831) [#16834](https://github.com/ionic-team/ionic-framework/issues/16834) +* **angular:** update route snapshot on query param navigation ([#31534](https://github.com/ionic-team/ionic-framework/issues/31534)) ([6b5afec](https://github.com/ionic-team/ionic-framework/commit/6b5afec5b8d2a8df79c014120b5be5b4fbb2a2cb)), closes [#24587](https://github.com/ionic-team/ionic-framework/issues/24587) +* **modal:** stop sheets dismissing on upward drags ([#31553](https://github.com/ionic-team/ionic-framework/issues/31553)) ([3e3544e](https://github.com/ionic-team/ionic-framework/commit/3e3544e4303d9dd49eb834cddddacd54bbe01fe4)) +* **react-router:** stop hidden views from re-rendering on every navigation ([#31541](https://github.com/ionic-team/ionic-framework/issues/31541)) ([4ae0196](https://github.com/ionic-team/ionic-framework/commit/4ae01961e8ed65e26369e09d478fabeffba5ed6d)), closes [#31528](https://github.com/ionic-team/ionic-framework/issues/31528) + + +## [9.0.7](https://github.com/ionic-team/ionic-framework/compare/v9.0.6...v9.0.7) (2026-10-07) + +### Bug Fixes + +* **close-watcher:** allow back navigation when nothing is open ([#31520](https://github.com/ionic-team/ionic-framework/issues/31520)) ([6532bcb](https://github.com/ionic-team/ionic-framework/commit/6532bcbcf5b69f33724a66ab76720ec645adb00b)), closes [#29648](https://github.com/ionic-team/ionic-framework/issues/29648) +* **css:** stop emitting :host-context selectors in global stylesheets ([#31516](https://github.com/ionic-team/ionic-framework/issues/31516)) ([35bd2fa](https://github.com/ionic-team/ionic-framework/commit/35bd2fa9f1046b3f5fd3cd6b3778b0b5945be99c)), closes [#30024](https://github.com/ionic-team/ionic-framework/issues/30024) +* **input-otp:** sync value when length changes ([#31485](https://github.com/ionic-team/ionic-framework/issues/31485)) ([e425825](https://github.com/ionic-team/ionic-framework/commit/e425825815d3c0ab39800912558624b9de72dc6f)), closes [#31484](https://github.com/ionic-team/ionic-framework/issues/31484) [#31484](https://github.com/ionic-team/ionic-framework/issues/31484) +* **react:** prevent error when an outlet unmounts before it's ready ([#31517](https://github.com/ionic-team/ionic-framework/issues/31517)) ([d5bab2e](https://github.com/ionic-team/ionic-framework/commit/d5bab2e89548311b2e3f62b5fe2be477685cb06d)), closes [#31513](https://github.com/ionic-team/ionic-framework/issues/31513) +* **vue-router:** render the redirect target when a guard redirects ([#31510](https://github.com/ionic-team/ionic-framework/issues/31510)) ([643584b](https://github.com/ionic-team/ionic-framework/commit/643584b3ac2ba1ca263cbf53960ccde3be2a96fc)) +* **vue-router:** reset tab with memory history ([#31511](https://github.com/ionic-team/ionic-framework/issues/31511)) ([edbc304](https://github.com/ionic-team/ionic-framework/commit/edbc304fbc4179a668d0e170241abd78311fdb62)), closes [#29785](https://github.com/ionic-team/ionic-framework/issues/29785) + + ## [9.0.6](https://github.com/ionic-team/ionic-framework/compare/v9.0.5...v9.0.6) (2026-09-30) ### Bug Fixes diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 7532826ba38..57a6c7960d3 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,31 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **datetime:** discard unconfirmed selection on cancel ([#31558](https://github.com/ionic-team/ionic-framework/issues/31558)) ([cecd3af](https://github.com/ionic-team/ionic-framework/commit/cecd3afdaea91a2e5f4ada6cd4aaedf6f436e6c9)), closes [#30777](https://github.com/ionic-team/ionic-framework/issues/30777) [#27975](https://github.com/ionic-team/ionic-framework/issues/27975) [#27976](https://github.com/ionic-team/ionic-framework/issues/27976) +* **input, select, textarea:** prevent floating label from overflowing while it floats ([#31567](https://github.com/ionic-team/ionic-framework/issues/31567)) ([ecd6f5a](https://github.com/ionic-team/ionic-framework/commit/ecd6f5a7c0ed987f4357b7a0e3954d9291be170d)) +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) + + +## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) + +### Bug Fixes + +* **modal:** stop sheets dismissing on upward drags ([#31553](https://github.com/ionic-team/ionic-framework/issues/31553)) ([3e3544e](https://github.com/ionic-team/ionic-framework/commit/3e3544e4303d9dd49eb834cddddacd54bbe01fe4)) + + +## [9.0.7](https://github.com/ionic-team/ionic-framework/compare/v9.0.6...v9.0.7) (2026-10-07) + +### Bug Fixes + +* **close-watcher:** allow back navigation when nothing is open ([#31520](https://github.com/ionic-team/ionic-framework/issues/31520)) ([6532bcb](https://github.com/ionic-team/ionic-framework/commit/6532bcbcf5b69f33724a66ab76720ec645adb00b)), closes [#29648](https://github.com/ionic-team/ionic-framework/issues/29648) +* **css:** stop emitting :host-context selectors in global stylesheets ([#31516](https://github.com/ionic-team/ionic-framework/issues/31516)) ([35bd2fa](https://github.com/ionic-team/ionic-framework/commit/35bd2fa9f1046b3f5fd3cd6b3778b0b5945be99c)), closes [#30024](https://github.com/ionic-team/ionic-framework/issues/30024) +* **input-otp:** sync value when length changes ([#31485](https://github.com/ionic-team/ionic-framework/issues/31485)) ([e425825](https://github.com/ionic-team/ionic-framework/commit/e425825815d3c0ab39800912558624b9de72dc6f)), closes [#31484](https://github.com/ionic-team/ionic-framework/issues/31484) [#31484](https://github.com/ionic-team/ionic-framework/issues/31484) + + ## [9.0.6](https://github.com/ionic-team/ionic-framework/compare/v9.0.5...v9.0.6) (2026-09-30) **Note:** Version bump only for package @ionic/core diff --git a/core/Dockerfile b/core/Dockerfile index 702746752ba..966a39a352e 100644 --- a/core/Dockerfile +++ b/core/Dockerfile @@ -1,5 +1,5 @@ # Get Playwright -FROM mcr.microsoft.com/playwright:v1.63.0 +FROM mcr.microsoft.com/playwright:v1.64.0 # Set the working directory WORKDIR /ionic diff --git a/core/package-lock.json b/core/package-lock.json index 7d84539b209..9efcb746452 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/core", - "version": "9.0.6", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "9.0.6", + "version": "9.0.9", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -23,13 +23,13 @@ "@eslint/js": "^9.0.0", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", "@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.2", + "@stencil/vue-output-target": "0.14.6", "@types/jest": "^29.5.6", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -45,7 +45,7 @@ "fs-extra": "^9.0.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", - "playwright-core": "^1.63.0", + "playwright-core": "^1.64.0", "prettier": "^2.6.1", "rollup": "^2.26.4", "sass": "^1.33.0", @@ -633,9 +633,9 @@ "license": "MIT" }, "node_modules/@capacitor/core": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz", - "integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.3.tgz", + "integrity": "sha512-oBE1oGuYE4ZzYYrulC75OuHdSsD6zLUy7d5o1tFwDLG4govGoubY15cxpiI5l0+q87Lm5p0bhbehMdzJg4aFag==", "dev": true, "license": "MIT", "dependencies": { @@ -653,9 +653,9 @@ } }, "node_modules/@capacitor/keyboard": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.5.tgz", - "integrity": "sha512-oFXygC4eKYA5l2MdpTR06L2M/4x6e2SLD5yS1T9+UBDKTkzyvhWKEhbYLUaTIBPpLKqlfGudJw1X73S1H9eUzQ==", + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.6.tgz", + "integrity": "sha512-Bbe+9V0qaT6dzEFJEK7BUWR01l2+vZE8FM5ITF/WgKtx+G6eGZ6d5+YiSMRJBAK0VGjdO59v8yiQ10Doa5KyvQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -663,9 +663,9 @@ } }, "node_modules/@capacitor/status-bar": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz", - "integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.4.tgz", + "integrity": "sha512-ZMzBUyHBzwDUxcHadEhvOvmoPmMQGMZgaMRJu8ohELeI6IaxoVH9ZhdF/OHnY3/yykv3Glev8Tnbh4aZY/0B0w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1637,13 +1637,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -1829,19 +1829,19 @@ } }, "node_modules/@stencil/angular-output-target": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@stencil/angular-output-target/-/angular-output-target-1.4.1.tgz", - "integrity": "sha512-sy+k9B5jcT6hpaVU9n+/ZUjz3qVr9PAdPgy2c3LFQouW1aFSa3Ng0O+Ia3wk7LMeL3vDetCn2X+p2ewdHGc1vA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@stencil/angular-output-target/-/angular-output-target-1.5.2.tgz", + "integrity": "sha512-QzVH3d26mHgEbyVoFOzE5vAOKWSBl7t7VV2npK0LhF0JtdorYqEVWkV7sRyV80j/xodptB50wbWZZ5hQA1VVwg==", "dev": true, "license": "MIT", "peerDependencies": { - "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-alpha.17 <5.0.0-next.0" + "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-beta.18" } }, "node_modules/@stencil/core": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.45.1.tgz", - "integrity": "sha512-/b8CItmSwOzZwafxXwomJnN9X/Ic/Fxum9ka/RDHmADVf4DA4152uY7zitzLV+vk8JdXxKA7FKyLT3GKNo9R3A==", + "version": "4.45.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.45.2.tgz", + "integrity": "sha512-CNkPVZsqyNMjFMX9bEkMJsvBbQxjLw480OaoamoqiLPdQCFjJQReeOjnWX1A5ivSn3UYEIJ6MoN5VXDkaHGt8Q==", "license": "MIT", "bin": { "stencil": "bin/stencil" @@ -1903,13 +1903,13 @@ } }, "node_modules/@stencil/vue-output-target": { - "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==", + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/@stencil/vue-output-target/-/vue-output-target-0.14.6.tgz", + "integrity": "sha512-IkgRf6UjoZg83CWguSxiRMqNLIRI29C0SBDj3m6OuB5ShVDqt2idV+kROdHhzR9DQFEPbKZqp7XNJIfrYN1zhg==", "dev": true, "license": "MIT", "peerDependencies": { - "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-alpha.17 <5.0.0-next.0", + "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-beta.18", "vue": "^3.4.38", "vue-router": "^4.5.0 || ^5.0.0" }, @@ -9221,13 +9221,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -9237,9 +9237,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/core/package.json b/core/package.json index 9e43d9b115f..51a57c9994b 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "9.0.6", + "version": "9.0.9", "description": "Base components for Ionic", "engines": { "node": ">= 16" @@ -80,13 +80,13 @@ "@eslint/js": "^9.0.0", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", "@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.2", + "@stencil/vue-output-target": "0.14.6", "@types/jest": "^29.5.6", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -102,7 +102,7 @@ "fs-extra": "^9.0.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", - "playwright-core": "^1.63.0", + "playwright-core": "^1.64.0", "prettier": "^2.6.1", "rollup": "^2.26.4", "sass": "^1.33.0", diff --git a/core/src/components.d.ts b/core/src/components.d.ts index afddade5171..22e751460f0 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -392,7 +392,7 @@ export namespace Components { */ "tappable": boolean; /** - * If `true`, the backdrop will be visible. + * If `true`, the backdrop will be visible. This property controls whether or not the backdrop's default background is shown. It does not control whether or not the backdrop blocks interaction, and it does not override a custom background set on `ion-backdrop`. * @default true */ "visible": boolean; @@ -912,7 +912,7 @@ export namespace Components { } interface IonDatetime { /** - * Emits the ionCancel event and optionally closes the popover or modal that the datetime was presented in. + * Emits the ionCancel event, discards any unconfirmed selection, and optionally closes the popover or modal that the datetime was presented in. * @param closeOverlay If `true`, closes the parent overlay. Defaults to `false`. */ "cancel": (closeOverlay?: boolean) => Promise; @@ -5512,7 +5512,7 @@ declare namespace LocalJSX { */ "tappable"?: boolean; /** - * If `true`, the backdrop will be visible. + * If `true`, the backdrop will be visible. This property controls whether or not the backdrop's default background is shown. It does not control whether or not the backdrop blocks interaction, and it does not override a custom background set on `ion-backdrop`. * @default true */ "visible"?: boolean; diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Chrome-linux.png index d9c803dfa7c..4e77dd36c94 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png index b8c706fb344..b2b830ef953 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Safari-linux.png index 6d11e95bba2..31a3feced83 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Chrome-linux.png index ade964c7b27..b7881eb34b4 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png index 59c03881daf..e98c0ede885 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Safari-linux.png index 4db55bcfcdc..2a3a4d09722 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Chrome-linux.png index e79d6e3c871..f19384016b4 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Firefox-linux.png index fab77e40ddc..65a04b6f10a 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Safari-linux.png index 1076d64e884..44fa87eb3d5 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Chrome-linux.png index 76e841e42fc..e3357e6a193 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Firefox-linux.png index b58450b4c15..8fcbef1cab1 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Safari-linux.png index 2eb01e783c9..c5da390317f 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/index.html b/core/src/components/alert/test/basic/index.html index e9ec5413a04..8c163252e46 100644 --- a/core/src/components/alert/test/basic/index.html +++ b/core/src/components/alert/test/basic/index.html @@ -190,11 +190,17 @@ type: 'date', min: '2017-03-01', max: '2018-01-12', + // WebKit renders today's date in empty date inputs, so set + // a value to keep the screenshot from changing every day. + value: '2017-06-15', }, // input date without min nor max { name: 'name5', type: 'date', + // WebKit renders today's date in empty date inputs, so set + // a value to keep the screenshot from changing every day. + value: '2020-09-24', }, { name: 'name6', diff --git a/core/src/components/backdrop/backdrop.tsx b/core/src/components/backdrop/backdrop.tsx index 9468f02526c..8a3ff04c837 100644 --- a/core/src/components/backdrop/backdrop.tsx +++ b/core/src/components/backdrop/backdrop.tsx @@ -14,6 +14,10 @@ import { getIonMode } from '../../global/ionic-global'; export class Backdrop implements ComponentInterface { /** * If `true`, the backdrop will be visible. + * This property controls whether or not the backdrop's + * default background is shown. It does not control whether + * or not the backdrop blocks interaction, and it does not + * override a custom background set on `ion-backdrop`. */ @Prop() visible = true; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index e9bb5d47086..4851f70df70 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -129,6 +129,14 @@ export class Datetime implements ComponentInterface { private destroyCalendarListener?: () => void; private destroyKeyboardMO?: () => void; + private destroyOverlayPresentListener?: () => void; + /** + * Set when the user selects a value, cleared when it's confirmed + * or `processValue` runs (`reset()`, or `value` being set to a + * non-empty value). The overlay present listener only discards a + * selection while this is set. + */ + private hasUnconfirmedSelection = false; // TODO(FW-2832): types (DatetimeParts causes some errors that need untangling) private minParts?: any; @@ -565,6 +573,8 @@ export class Datetime implements ComponentInterface { } } + this.hasUnconfirmedSelection = false; + if (closeOverlay) { this.closeParentOverlay(CONFIRM_ROLE); } @@ -583,9 +593,10 @@ export class Datetime implements ComponentInterface { } /** - * Emits the ionCancel event and - * optionally closes the popover - * or modal that the datetime was + * Emits the ionCancel event, discards + * any unconfirmed selection, and + * optionally closes the popover or + * modal that the datetime was * presented in. * * @param closeOverlay If `true`, closes the parent overlay. Defaults to `false`. @@ -594,8 +605,12 @@ export class Datetime implements ComponentInterface { async cancel(closeOverlay = false) { this.ionCancel.emit(); - if (closeOverlay) { - this.closeParentOverlay(CANCEL_ROLE); + /** + * When a parent overlay closes, the present listener discards + * the selection on the next open. Otherwise discard it now. + */ + if (!closeOverlay || !this.closeParentOverlay(CANCEL_ROLE)) { + this.processValue(this.value); } } @@ -665,6 +680,8 @@ export class Datetime implements ComponentInterface { if (popoverOrModal) { popoverOrModal.dismiss(undefined, role); } + + return popoverOrModal !== null; }; private setWorkingParts = (parts: DatetimeParts) => { @@ -682,6 +699,8 @@ export class Datetime implements ComponentInterface { return; } + this.hasUnconfirmedSelection = true; + const { multiple, minParts, maxParts, activeParts } = this; /** @@ -1102,6 +1121,7 @@ export class Datetime implements ComponentInterface { this.loadTimeout = setTimeout(() => { this.ensureReadyIfVisible(); }, 100); + this.initializeOverlayPresentListener(); } disconnectedCallback() { @@ -1110,6 +1130,39 @@ export class Datetime implements ComponentInterface { this.clearFocusVisible = undefined; } this.loadTimeoutCleanup(); + + if (this.destroyOverlayPresentListener) { + this.destroyOverlayPresentListener(); + this.destroyOverlayPresentListener = undefined; + } + } + + /** + * A datetime in a modal or popover stays mounted between + * presentations, so an unconfirmed selection would still show when + * it reopens. Discarding on present rather than dismiss lets app + * code that runs on dismiss still confirm or reset the selection, + * whatever order its listeners were added in. + */ + private initializeOverlayPresentListener() { + const overlay = this.el.closest('ion-modal, ion-popover'); + if (!overlay) { + return; + } + + const eventName = overlay.tagName === 'ION-MODAL' ? 'ionModalWillPresent' : 'ionPopoverWillPresent'; + const onPresent = (ev: Event) => { + // Ignore nested overlays and skip when there's nothing to discard. + if (ev.target !== overlay || !this.hasUnconfirmedSelection) { + return; + } + + // The overlay is still hidden, so skip the month animation. + this.processValue(this.value, false); + }; + + overlay.addEventListener(eventName, onPresent); + this.destroyOverlayPresentListener = () => overlay.removeEventListener(eventName, onPresent); } /** @@ -1363,7 +1416,9 @@ export class Datetime implements ComponentInterface { }); } - private processValue = (value?: string | string[] | null) => { + private processValue = (value?: string | string[] | null, animated = true) => { + this.hasUnconfirmedSelection = false; + const hasValue = value !== null && value !== undefined && value !== '' && (!Array.isArray(value) || value.length > 0); const valueToProcess = hasValue ? parseDate(value) : this.defaultParts; @@ -1435,13 +1490,14 @@ export class Datetime implements ComponentInterface { const bodyIsVisible = el.classList.contains('datetime-ready'); const { isGridStyle, showMonthAndYear } = this; - if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) { + if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear && animated) { /** * Only animate if: * 1. We're using grid style (wheel style pickers should just jump to new value) * 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to) * 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example) * 4. The month/year picker is not open (since you wouldn't see the animation anyway) + * 5. The caller did not opt out (e.g. the parent overlay is still hidden) */ this.animateToDate(targetValue); } else { diff --git a/core/src/components/datetime/test/cancel/datetime.e2e.ts b/core/src/components/datetime/test/cancel/datetime.e2e.ts new file mode 100644 index 00000000000..f6b68dc6b2c --- /dev/null +++ b/core/src/components/datetime/test/cancel/datetime.e2e.ts @@ -0,0 +1,327 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +const getDay = (datetime: Locator, day: number, month = 5) => + datetime.locator(`.calendar-day[data-day="${day}"][data-month="${month}"]`); + +const expectSelectedDay = async (datetime: Locator, selected: number, unselected: number) => { + await expect(getDay(datetime, selected)).toHaveClass(/calendar-day-active/); + await expect(getDay(datetime, unselected)).not.toHaveClass(/calendar-day-active/); +}; + +const openDatetime = async (page: E2EPage, didPresent: string) => { + const ionDidPresent = await page.spyOnEvent(didPresent); + await page.locator('ion-datetime-button #date-button').click(); + await ionDidPresent.next(); + await page.locator('ion-datetime.datetime-ready').waitFor(); +}; + +const dismissDatetime = async (page: E2EPage, didDismiss: string, dismiss: () => Promise) => { + const ionDidDismiss = await page.spyOnEvent(didDismiss); + await dismiss(); + await ionDidDismiss.next(); +}; + +const dismissWithRole = (page: E2EPage, tag: string, role: string) => + page + .locator(tag) + .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement, role: string) => el.dismiss(undefined, role), role); + +const confirmOnDismiss = (page: E2EPage, tag: string, didDismiss: string) => + page + .locator(tag) + .evaluate( + (el: HTMLElement, eventName: string) => + el.addEventListener(eventName, () => el.querySelector('ion-datetime')!.confirm()), + didDismiss + ); + +const overlays = [ + { tag: 'ion-modal', didPresent: 'ionModalDidPresent', didDismiss: 'ionModalDidDismiss' }, + { tag: 'ion-popover', didPresent: 'ionPopoverDidPresent', didDismiss: 'ionPopoverDidDismiss' }, +]; + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { + test.describe(title('datetime: cancel'), () => { + overlays.forEach(({ tag, didPresent, didDismiss }) => { + test.describe(`in an ${tag}`, () => { + let datetime: Locator; + + test.beforeEach(async ({ page }) => { + await page.setContent( + ` + + <${tag}> + + + `, + config + ); + + datetime = page.locator('ion-datetime'); + }); + + test('should show the confirmed date when reopened after cancelling a new selection', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed date when reopened after dismissing without confirming', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed month when reopened after cancelling a selection in another month', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + const monthYear = datetime.locator('.calendar-month-year-toggle'); + const nextMonthButton = datetime.locator('.calendar-next-prev ion-button').nth(1); + + await openDatetime(page, didPresent); + await nextMonthButton.click(); + await expect(monthYear).toContainText('June 2022'); + await getDay(datetime, 10, 6).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + await openDatetime(page, didPresent); + + await expect(monthYear).toContainText('May 2022'); + await expect(getDay(datetime, 3)).toHaveClass(/calendar-day-active/); + + await nextMonthButton.click(); + await expect(monthYear).toContainText('June 2022'); + await expect(getDay(datetime, 10, 6)).not.toHaveClass(/calendar-day-active/); + }); + + test('should keep the new date when reopened after confirming a selection', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#confirm-button').click()); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should commit the selection when a dismiss handler confirms it after a custom role', async ({ page }) => { + await confirmOnDismiss(page, tag, didDismiss); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'my-custom-role')); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should commit the selection when confirmed after awaiting onDidDismiss with a custom role', async ({ + page, + }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + + await page.locator(tag).evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => { + el.onDidDismiss().then(() => el.querySelector('ion-datetime')!.confirm()); + }); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'my-custom-role')); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should keep the selection set by reset() after cancelling', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-05-20')); + await openDatetime(page, didPresent); + + await expectSelectedDay(datetime, 20, 3); + }); + + test('should keep the selection set by reset() before dismissing without confirming', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-05-20')); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await openDatetime(page, didPresent); + + await expectSelectedDay(datetime, 20, 3); + }); + + test('should show the value set after dismissing without confirming', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2022-05-20')); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-20'); + await expectSelectedDay(datetime, 20, 10); + }); + + test('should clear the selection when the value is cleared after dismissing without confirming', async ({ + page, + }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = null)); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', null); + await expect(datetime.locator('.calendar-day-active')).toHaveCount(0); + }); + + /** + * Cancel defers discarding the selection until the overlay + * presents again, so an app that confirms on every dismiss + * commits the cancelled selection. This is intended. + */ + test('should commit a cancelled selection when a dismiss handler confirms it', async ({ page }) => { + await confirmOnDismiss(page, tag, didDismiss); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + }); + }); + + test('should restore the confirmed date when the cancel button is clicked on an inline datetime', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await page.setContent( + '', + config + ); + + const datetime = page.locator('ion-datetime'); + await page.locator('ion-datetime.datetime-ready').waitFor(); + + await getDay(datetime, 10).click(); + await datetime.locator('#cancel-button').click(); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed date in the wheel picker when reopened after cancelling', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await page.setContent( + ` + + + + + `, + config + ); + + const datetime = page.locator('ion-datetime'); + const dayOptions = datetime.locator('.day-column ion-picker-column-option'); + const activeDay = datetime.locator('.day-column ion-picker-column-option.option-active'); + + await openDatetime(page, 'ionModalDidPresent'); + await dayOptions.filter({ hasText: /^10$/ }).click(); + await expect(activeDay).toHaveText('10'); + await dismissDatetime(page, 'ionModalDidDismiss', () => datetime.locator('#cancel-button').click()); + await openDatetime(page, 'ionModalDidPresent'); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expect(activeDay).toHaveText('3'); + }); + + test('should keep the selection when the time picker inside a popover is dismissed', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); + const parentPopover = page.locator('#parent-popover'); + const datetime = page.locator('ion-datetime'); + + await parentPopover.evaluate((el: HTMLIonPopoverElement) => el.present()); + await ionPopoverDidPresent.next(); + await page.locator('ion-datetime.datetime-ready').waitFor(); + + await getDay(datetime, 10).click(); + + /** + * The time picker opens its own popover. Dismissing it + * shouldn't discard the selection in the parent popover. + */ + await datetime.locator('.time-body').click(); + await ionPopoverDidPresent.next(); + await page.keyboard.press('Escape'); + await ionPopoverDidDismiss.next(); + await page.waitForChanges(); + + await expect(parentPopover).not.toHaveClass(/overlay-hidden/); + await expectSelectedDay(datetime, 10, 3); + }); + }); +}); diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 7c46ca59d42..1c84b3c9a42 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -43,6 +43,12 @@ export class InputOTP implements ComponentInterface { */ private isKeyboardNavigation = false; + /** + * The `length` watcher runs before new inputs render, so + * `componentDidRender` updates their tab indexes instead. + */ + private updateTabIndexesAfterRender = false; + @Element() el!: HTMLIonInputOtpElement; @State() private inputValues: string[] = []; @@ -197,6 +203,12 @@ export class InputOTP implements ComponentInterface { this.updateTabIndexes(); } + @Watch('length') + lengthChanged() { + this.initializeValues(); + this.updateTabIndexesAfterRender = true; + } + /** * Processes the separators prop into an array of numbers. * @@ -272,6 +284,13 @@ export class InputOTP implements ComponentInterface { this.updateTabIndexes(); } + componentDidRender() { + if (this.updateTabIndexesAfterRender) { + this.updateTabIndexesAfterRender = false; + this.updateTabIndexes(); + } + } + /** * Get the regex pattern for allowed characters. * If a pattern is provided, use it to create a regex pattern @@ -851,7 +870,14 @@ export class InputOTP implements ComponentInterface { tabIndex={index === tabbableIndex ? 0 : -1} value={inputValues[index] || ''} autocomplete="one-time-code" - ref={(el) => (inputRefs[index] = el as HTMLInputElement)} + ref={(el) => { + if (el) { + inputRefs[index] = el as HTMLInputElement; + } else { + // The input was removed, so drop its ref and any after it. + inputRefs.splice(index); + } + }} onInput={this.onInput(index)} onBlur={this.onBlur} onFocus={this.onFocus(index)} diff --git a/core/src/components/input-otp/test/a11y/input-otp.e2e.ts b/core/src/components/input-otp/test/a11y/input-otp.e2e.ts index 88b0be04800..7cc5374d8ef 100644 --- a/core/src/components/input-otp/test/a11y/input-otp.e2e.ts +++ b/core/src/components/input-otp/test/a11y/input-otp.e2e.ts @@ -46,6 +46,27 @@ configs().forEach(({ title, config }) => { await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'true'); }); + test('should update aria-hidden when length increases', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31484', + }); + + await page.setContent(``, config); + + const inputOtp = page.locator('ion-input-otp'); + await inputOtp.evaluate((el: HTMLIonInputOtpElement) => { + el.length = 4; + }); + + const inputBoxes = page.locator('ion-input-otp input'); + + await expect(inputBoxes.nth(0)).toHaveAttribute('aria-hidden', 'false'); + await expect(inputBoxes.nth(1)).toHaveAttribute('aria-hidden', 'true'); + await expect(inputBoxes.nth(2)).toHaveAttribute('aria-hidden', 'true'); + await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'true'); + }); + test('should update aria-hidden when typing a value', async ({ page }) => { await page.setContent(``, config); diff --git a/core/src/components/input-otp/test/basic/input-otp.e2e.ts b/core/src/components/input-otp/test/basic/input-otp.e2e.ts index 306e72f873d..a135f502f0a 100644 --- a/core/src/components/input-otp/test/basic/input-otp.e2e.ts +++ b/core/src/components/input-otp/test/basic/input-otp.e2e.ts @@ -71,6 +71,39 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => { await verifyInputValues(inputOtp, ['1', '2', '3', '4', '5', '6', '7', '8']); }); + test('should synchronize the value when length changes', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31484', + }); + + await page.setContent(`Description`, config); + + const inputOtp = page.locator('ion-input-otp'); + await inputOtp.evaluate((el) => el.setAttribute('length', '2')); + + await verifyInputValues(inputOtp, ['1', '2']); + }); + + test('should add empty input boxes when length increases', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31484', + }); + + await page.setContent(`Description`, config); + + const inputOtp = page.locator('ion-input-otp'); + await inputOtp.evaluate((el: HTMLIonInputOtpElement) => { + el.length = 4; + }); + + const inputBoxes = page.locator('ion-input-otp input'); + await expect(inputBoxes).toHaveCount(4); + + await verifyInputValues(inputOtp, ['1', '2', '', '']); + }); + test('should accept numbers only by default', async ({ page }) => { await page.setContent(`Description`, config); @@ -1033,6 +1066,36 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await expect(ionChange).toHaveReceivedEventTimes(1); }); + test('should emit ionChange event when blurring with a new value after length decreases', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31484', + }); + + await page.setContent(`Description`, config); + + const ionChange = await page.spyOnEvent('ionChange'); + + const inputOtp = page.locator('ion-input-otp'); + await inputOtp.evaluate((el: HTMLIonInputOtpElement) => { + el.length = 2; + }); + await page.waitForChanges(); + + const firstInput = page.locator('ion-input-otp input').first(); + await firstInput.focus(); + + await page.keyboard.type('12'); + + // Click outside the input to trigger the blur event + await page.mouse.click(0, 0); + + await ionChange.next(); + await expect(ionChange).toHaveReceivedEventDetail({ value: '12', event: { isTrusted: true } }); + }); + test('should not emit ionChange event when blurring with the same value', async ({ page }) => { await page.setContent(`Description`, config); diff --git a/core/src/components/input/input.md.outline.scss b/core/src/components/input/input.md.outline.scss index d6cda674e58..bbd4360f8e1 100644 --- a/core/src/components/input/input.md.outline.scss +++ b/core/src/components/input/input.md.outline.scss @@ -11,13 +11,22 @@ /** * The measured width of the start container, as a negative offset - * in LTR and a positive one in RTL. Written to the host by the - * start container controller, so renaming it here requires renaming + * in LTR and a positive one in RTL. Written to the host by the start + * and end container controller, so renaming it here requires renaming * it there too. Declared with a default so the label is positioned * correctly before the first measurement runs. */ --internal-start-container-adjustment: 0px; + /** + * The measured combined width of the start and end containers. + * Written to the host by the start and end container controller, so + * renaming it here requires renaming it there too. Declared with a + * default so the floating label falls back to the width of the + * control before the first measurement runs. + */ + --internal-start-end-container-width: 0px; + min-height: 56px; } @@ -74,11 +83,12 @@ } /** - * The resting label is positioned relative to the control so it can size to - * the space between the start and end slots. When floating, the label needs - * to escape the control and position itself relative to the wrapper instead. + * The label is positioned relative to the control whether it is resting + * over the input or floating in the notch, so its max-width always resolves + * against the same width. This lets the max-width animate between the + * resting and floating values without jumping. */ -:host(.input-fill-outline:not(.label-floating)) .input-control { +:host(.input-fill-outline) .input-control { position: relative; } @@ -89,9 +99,8 @@ position: absolute; /** - * While resting this is the width of the text input, which already - * excludes the start and end slots. Once the label floats the - * containing block becomes the wrapper and the rule below takes over. + * This is the width of the text input, which already + * excludes the start and end slots. */ max-width: 100%; } @@ -113,10 +122,11 @@ @include margin(0); /** - * Label text should not extend - * beyond the bounds of the input. + * Label text should not extend beyond the bounds of the input. + * The control excludes the start and end slots, but the notch spans + * the full width of the outline, so their width is added back. */ - max-width: calc((100% - var(--padding-start) - var(--padding-end) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); + max-width: calc((100% + var(--internal-start-end-container-width) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); } // Input Fill: Outline Outline Container diff --git a/core/src/components/input/input.scss b/core/src/components/input/input.scss index f5d02058228..dbf93e7cfe3 100644 --- a/core/src/components/input/input.scss +++ b/core/src/components/input/input.scss @@ -466,7 +466,11 @@ */ max-width: 200px; - transition: color 150ms cubic-bezier(.4, 0, .2, 1), transform 150ms cubic-bezier(.4, 0, .2, 1); + /** + * The max-width animates with the transform so the label does not + * reveal its full floating width while it is still at full size. + */ + transition: color 150ms #{$form-control-label-transition-easing}, transform 150ms #{$form-control-label-transition-easing}, max-width 150ms #{$form-control-label-transition-easing}; /** * This ensures that double tapping this text @@ -481,8 +485,8 @@ * disable the label transition to prevent the floating label * from animating as its position is adjusted. * - * This class is applied by the start container controller, so - * renaming it here requires renaming it there too. + * This class is applied by the start and end container controller, + * so renaming it here requires renaming it there too. */ :host(.skip-label-transition) .label-text-wrapper { transition: none; diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 2dc919e41b1..9e8103432e6 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -13,11 +13,11 @@ import { forceUpdate, h, } from '@stencil/core'; -import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartEndContainerController } from '@utils/forms'; import { createClickController, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; @@ -59,8 +59,9 @@ export class Input implements ComponentInterface { private slotMutationController?: SlotMutationController; private notchController?: NotchController; private notchSpacerEl: HTMLElement | undefined; - private startContainerController?: StartContainerController; + private startEndContainerController?: StartEndContainerController; private startContainerEl: HTMLElement | undefined; + private endContainerEl: HTMLElement | undefined; private clickController?: ClickController; private originalIonInput?: EventEmitter; @@ -412,7 +413,7 @@ export class Input implements ComponentInterface { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); this.setSlottedLabelId(); forceUpdate(this); @@ -425,15 +426,16 @@ export class Input implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonMode(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); this.clickController = createClickController(el, () => this.nativeInput); @@ -482,7 +484,7 @@ export class Input implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } disconnectedCallback() { @@ -504,9 +506,9 @@ export class Input implements ComponentInterface { this.notchController = undefined; } - if (this.startContainerController) { - this.startContainerController.destroy(); - this.startContainerController = undefined; + if (this.startEndContainerController) { + this.startEndContainerController.destroy(); + this.startEndContainerController = undefined; } // Clean up validation observer to prevent memory leaks @@ -946,7 +948,7 @@ export class Input implements ComponentInterface { /> -
+
(this.endContainerEl = el)}> {this.clearInput && !readonly && !disabled && ( +
-
+
(this.endContainerEl = el)}> {/** * The icon is rendered in the end container when the * select has a floating or stacked label so it is diff --git a/core/src/components/select/test/slot/select.e2e.ts b/core/src/components/select/test/slot/select.e2e.ts index 3f12c760117..dae11c5a79c 100644 --- a/core/src/components/select/test/slot/select.e2e.ts +++ b/core/src/components/select/test/slot/select.e2e.ts @@ -513,6 +513,90 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co expect(box!.width).toBeGreaterThanOrEqual(twoCharacterWidth); }); }); + + /** + * The label truncates beside the wide slot and has room for its full + * text once it floats into the notch. It must not reveal that full text + * while it is still at its resting size, or it overflows the select + * partway through the transition. + */ + test('should keep the label inside the select while it floats above a wide end slot', async ({ page }) => { + /** + * The label is longer than the select, so revealing all of it at its + * resting size pushes it past the border, not only over the slot. + */ + await page.setContent( + ` + + ${endSlot} + 100 + + `, + config + ); + + const select = page.locator('ion-select'); + + /** + * The first measurement of the start container disables the label + * transition for a frame. Wait for it to finish so the label is not + * floated while its transitions are turned off. + */ + await expect(select).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(select).not.toHaveClass(/skip-label-transition/); + + /** + * Pauses the label's transitions and steps through them so every + * point of the animation is checked regardless of frame timing. + */ + const overflow = await select.evaluate(async (el: HTMLIonSelectElement) => { + const label = el.shadowRoot!.querySelector('.label-text-wrapper')!; + const wrapper = el.shadowRoot!.querySelector('.select-wrapper')!; + const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); + + /** + * The class that starts the transitions is added on the next render. + * A mutation observer runs right after that render, before the next + * frame, so the transitions are caught at their start. Waiting for a + * frame or using `page.waitForChanges()` can let the 150ms transitions + * finish on a slow machine, at which point the browser discards them + * and there is nothing to step through. + */ + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (el.classList.contains('label-floating')) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(el, { attributeFilter: ['class'] }); + + el.value = '100'; + }); + + // Without this a label that already finished floating would pass + const transitions = label.getAnimations(); + if (transitions.length === 0) { + throw new Error('The label did not transition'); + } + transitions.forEach((transition) => transition.pause()); + + // Step through the 150ms label transition in 15ms increments + const overflows = Array.from({ length: 11 }, (_, step) => { + transitions.forEach((transition) => (transition.currentTime = step * 15)); + return label.getBoundingClientRect().right - maxRight; + }); + + return Math.max(...overflows); + }); + + expect(overflow).toBeLessThanOrEqual(1); + }); }); }); diff --git a/core/src/components/split-pane/test/nested-menu/split-pane.e2e.ts b/core/src/components/split-pane/test/nested-menu/split-pane.e2e.ts new file mode 100644 index 00000000000..15e0c5cba44 --- /dev/null +++ b/core/src/components/split-pane/test/nested-menu/split-pane.e2e.ts @@ -0,0 +1,111 @@ +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test, Viewports } from '@utils/test/playwright'; + +/** + * This behavior does not vary across modes/directions. + */ +configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => { + test.describe(title('split-pane: nested menu'), () => { + const setup = async (page: E2EPage) => { + await page.setContent( + ` + + + + Side Menu + +
+ + Page Menu + +
+ Main Content +
+
+
+
+ `, + config + ); + }; + + test('should open a menu inside the main content when the split pane is visible', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30591', + }); + + await page.setViewportSize(Viewports.large); + await setup(page); + + const sideMenu = page.locator('#side-menu'); + const pageMenu = page.locator('#page-menu'); + + await expect(sideMenu).toBeVisible(); + await expect(sideMenu).toHaveClass(/split-pane-side/); + await expect(pageMenu).not.toHaveClass(/split-pane-side/); + + const didOpen = await pageMenu.evaluate((el: HTMLIonMenuElement) => el.open()); + + expect(didOpen).toBe(true); + await expect(pageMenu).toHaveClass(/show-menu/); + }); + + test('should open a menu inside the main content after the split pane becomes visible', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30591', + }); + + await page.setViewportSize(Viewports.tablet.portrait); + await setup(page); + + const sideMenu = page.locator('#side-menu'); + const pageMenu = page.locator('#page-menu'); + + await expect(sideMenu).toBeHidden(); + + await page.setViewportSize(Viewports.large); + await expect(sideMenu).toBeVisible(); + + const didOpen = await pageMenu.evaluate((el: HTMLIonMenuElement) => el.open()); + + expect(didOpen).toBe(true); + await expect(pageMenu).toHaveClass(/show-menu/); + }); + + test('should treat a menu as a side pane of a split pane that is the main content of another split pane', async ({ + page, + }) => { + await page.setViewportSize(Viewports.large); + await page.setContent( + ` + + + + Outer Menu + + + + Inner Menu + +
+ Main Content +
+
+
+
+ `, + config + ); + + const innerMenu = page.locator('#inner-menu'); + + await expect(innerMenu).toHaveClass(/split-pane-side/); + await expect(innerMenu).toHaveClass(/menu-pane-visible/); + }); + }); +}); diff --git a/core/src/components/textarea/test/slot/textarea.e2e.ts b/core/src/components/textarea/test/slot/textarea.e2e.ts index 87c15eca153..14e30727d1b 100644 --- a/core/src/components/textarea/test/slot/textarea.e2e.ts +++ b/core/src/components/textarea/test/slot/textarea.e2e.ts @@ -343,6 +343,89 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co expect(box!.width).toBeGreaterThanOrEqual(twoCharacterWidth); }); }); + + /** + * The label truncates beside the wide slot and has room for its full + * text once it floats into the notch. It must not reveal that full text + * while it is still at its resting size, or it overflows the textarea + * partway through the transition. + */ + test('should keep the label inside the textarea while it floats above a wide end slot', async ({ page }) => { + /** + * The label is longer than the textarea, so revealing all of it at its + * resting size pushes it past the border, not only over the slot. + */ + await page.setContent( + ` + + ${endSlot} + + `, + config + ); + + const textarea = page.locator('ion-textarea'); + + /** + * The first measurement of the start container disables the label + * transition for a frame. Wait for it to finish so the label is not + * floated while its transitions are turned off. + */ + await expect(textarea).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(textarea).not.toHaveClass(/skip-label-transition/); + + /** + * Pauses the label's transitions and steps through them so every + * point of the animation is checked regardless of frame timing. + */ + const overflow = await textarea.evaluate(async (el: HTMLIonTextareaElement) => { + const label = el.querySelector('.label-text-wrapper')!; + const wrapper = el.querySelector('.textarea-wrapper')!; + const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); + + /** + * The class that starts the transitions is added on the next render. + * A mutation observer runs right after that render, before the next + * frame, so the transitions are caught at their start. Waiting for a + * frame or using `page.waitForChanges()` can let the 150ms transitions + * finish on a slow machine, at which point the browser discards them + * and there is nothing to step through. + */ + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (el.classList.contains('label-floating')) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(el, { attributeFilter: ['class'] }); + + el.value = '100'; + }); + + // Without this a label that already finished floating would pass + const transitions = label.getAnimations(); + if (transitions.length === 0) { + throw new Error('The label did not transition'); + } + transitions.forEach((transition) => transition.pause()); + + // Step through the 150ms label transition in 15ms increments + const overflows = Array.from({ length: 11 }, (_, step) => { + transitions.forEach((transition) => (transition.currentTime = step * 15)); + return label.getBoundingClientRect().right - maxRight; + }); + + return Math.max(...overflows); + }); + + expect(overflow).toBeLessThanOrEqual(1); + }); }); }); diff --git a/core/src/components/textarea/textarea.md.outline.scss b/core/src/components/textarea/textarea.md.outline.scss index 6452987a2b2..00d79728edd 100644 --- a/core/src/components/textarea/textarea.md.outline.scss +++ b/core/src/components/textarea/textarea.md.outline.scss @@ -11,12 +11,21 @@ /** * The measured width of the start container, as a negative offset - * in LTR and a positive one in RTL. Written to the host by the - * start container controller, so renaming it here requires renaming + * in LTR and a positive one in RTL. Written to the host by the start + * and end container controller, so renaming it here requires renaming * it there too. Declared with a default so the label is positioned * correctly before the first measurement runs. */ --internal-start-container-adjustment: 0px; + + /** + * The measured combined width of the start and end containers. + * Written to the host by the start and end container controller, so + * renaming it here requires renaming it there too. Declared with a + * default so the floating label falls back to the width of the + * control before the first measurement runs. + */ + --internal-start-end-container-width: 0px; } :host(.textarea-fill-outline.textarea-shape-round) { @@ -72,11 +81,12 @@ } /** - * The resting label is positioned relative to the control so it can size to - * the space between the start and end slots. When floating, the label needs - * to escape the control and position itself relative to the wrapper instead. + * The label is positioned relative to the control whether it is resting + * over the textarea or floating in the notch, so its max-width always resolves + * against the same width. This lets the max-width animate between the + * resting and floating values without jumping. */ -:host(.textarea-fill-outline:not(.label-floating)) .textarea-control { +:host(.textarea-fill-outline) .textarea-control { position: relative; } @@ -87,9 +97,8 @@ position: absolute; /** - * While resting this is the width of the textarea, which already - * excludes the start and end slots. Once the label floats the - * containing block becomes the wrapper and the rule below takes over. + * This is the width of the textarea, which already + * excludes the start and end slots. */ max-width: 100%; } @@ -111,12 +120,11 @@ @include margin(0); /** - * Label text should not extend - * beyond the bounds of the textarea. + * Label text should not extend beyond the bounds of the textarea. + * The control excludes the start and end slots, but the notch spans + * the full width of the outline, so their width is added back. */ - max-width: calc( - (100% - var(--padding-start) - var(--padding-end) - #{$textarea-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale} - ); + max-width: calc((100% + var(--internal-start-end-container-width) - #{$textarea-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); } /** diff --git a/core/src/components/textarea/textarea.scss b/core/src/components/textarea/textarea.scss index 7db3f21df17..84ad244d67f 100644 --- a/core/src/components/textarea/textarea.scss +++ b/core/src/components/textarea/textarea.scss @@ -496,7 +496,11 @@ */ max-width: 200px; - transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + /** + * The max-width animates with the transform so the label does not + * reveal its full floating width while it is still at full size. + */ + transition: color 150ms #{$form-control-label-transition-easing}, transform 150ms #{$form-control-label-transition-easing}, max-width 150ms #{$form-control-label-transition-easing}; /** * This ensures that double tapping this text @@ -511,8 +515,8 @@ * disable the label transition to prevent the floating label * from animating as its position is adjusted. * - * This class is applied by the start container controller, so - * renaming it here requires renaming it there too. + * This class is applied by the start and end container controller, + * so renaming it here requires renaming it there too. */ :host(.skip-label-transition) .label-text-wrapper { transition: none; diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index c893a652144..297b5cdebc3 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -14,11 +14,11 @@ import { h, writeTask, } from '@stencil/core'; -import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartEndContainerController } from '@utils/forms'; import { createClickController, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; @@ -68,8 +68,9 @@ export class Textarea implements ComponentInterface { private notchController?: NotchController; private notchSpacerEl: HTMLElement | undefined; - private startContainerController?: StartContainerController; + private startEndContainerController?: StartEndContainerController; private startContainerEl: HTMLElement | undefined; + private endContainerEl: HTMLElement | undefined; private clickController?: ClickController; /** @@ -343,7 +344,7 @@ export class Textarea implements ComponentInterface { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); forceUpdate(this); }); @@ -354,15 +355,16 @@ export class Textarea implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonMode(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); this.clickController = createClickController(el, () => this.nativeInput); @@ -415,9 +417,9 @@ export class Textarea implements ComponentInterface { this.notchController = undefined; } - if (this.startContainerController) { - this.startContainerController.destroy(); - this.startContainerController = undefined; + if (this.startEndContainerController) { + this.startEndContainerController.destroy(); + this.startEndContainerController = undefined; } // Clean up validation observer to prevent memory leaks @@ -441,7 +443,7 @@ export class Textarea implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } /** @@ -815,7 +817,7 @@ export class Textarea implements ComponentInterface {
-
+
(this.endContainerEl = el)}>
{shouldRenderHighlight &&
} diff --git a/core/src/css/float-elements.scss b/core/src/css/float-elements.scss index 0e543e3404e..78147a3798a 100644 --- a/core/src/css/float-elements.scss +++ b/core/src/css/float-elements.scss @@ -1,3 +1,6 @@ +// Global stylesheet: `:host-context()` never matches outside a shadow root +$rtl-use-host-context: false; + @import "../themes/ionic.globals"; @import "../themes/ionic.mixins"; diff --git a/core/src/css/test/global-stylesheets.spec.ts b/core/src/css/test/global-stylesheets.spec.ts new file mode 100644 index 00000000000..430e3702de1 --- /dev/null +++ b/core/src/css/test/global-stylesheets.spec.ts @@ -0,0 +1,38 @@ +import { execFileSync } from 'child_process'; +import { readdirSync } from 'fs'; +import { dirname, join } from 'path'; + +const cssDir = join(__dirname, '..'); +const sassCli = join(dirname(require.resolve('sass')), 'sass.js'); + +/** + * Compiles through the sass CLI, the same way `npm run css.sass` builds + * the published stylesheets. The sass JS API can't be imported here + * because it detects the spec window and never populates its exports. + */ +const compileStylesheet = (file: string) => + execFileSync(process.execPath, [sassCli, '--style=compressed', '--no-source-map', join(cssDir, file)], { + encoding: 'utf8', + }); + +const globalStylesheets = ['.', 'palettes'].flatMap((dir) => + readdirSync(join(cssDir, dir)) + .filter((file) => file.endsWith('.scss')) + .map((file) => join(dir, file)) +); + +describe('global stylesheets', () => { + // https://github.com/ionic-team/ionic-framework/issues/30024 + it.each(globalStylesheets)('%s should not contain :host-context', (file) => { + expect(compileStylesheet(file)).not.toContain(':host-context'); + }); + + it('float-elements.scss should flip start and end floats for an ancestor dir=rtl', () => { + const css = compileStylesheet('float-elements.scss'); + + expect(css).toContain('[dir=rtl] .ion-float-start{float:right !important}'); + expect(css).toContain('[dir=rtl] .ion-float-end{float:left !important}'); + expect(css).toContain('.ion-float-start:dir(rtl){float:right !important}'); + expect(css).toContain('.ion-float-end:dir(rtl){float:left !important}'); + }); +}); diff --git a/core/src/themes/ionic.globals.scss b/core/src/themes/ionic.globals.scss index 1d5789607f1..ae1e28cb7e7 100644 --- a/core/src/themes/ionic.globals.scss +++ b/core/src/themes/ionic.globals.scss @@ -53,6 +53,10 @@ $form-control-min-width: 2ch; /// iOS does not have a floating label design spec, so we standardize on 0.75. $form-control-label-stacked-scale: 0.75; +// Easing for the color, transform, and max-width transitions of a form +// control label as it moves between resting and floating. +$form-control-label-transition-easing: cubic-bezier(.4, 0, .2, 1); + // Z-Index // -------------------------------------------------- diff --git a/core/src/themes/ionic.mixins.scss b/core/src/themes/ionic.mixins.scss index da174046be7..aa25b62abe6 100644 --- a/core/src/themes/ionic.mixins.scss +++ b/core/src/themes/ionic.mixins.scss @@ -226,6 +226,11 @@ // } } +// Whether `rtl()` emits `:host-context()` selectors. Global stylesheets +// set this to false: they apply to the document, where `:host-context()` +// never matches, and Lightning CSS warns about every occurrence. +$rtl-use-host-context: true !default; + @mixin rtl() { $root: #{&}; @@ -254,7 +259,7 @@ } // Supported by Chrome. - @if length($hostContextSelectors) > 0 { + @if $rtl-use-host-context and length($hostContextSelectors) > 0 { @at-root #{$hostContextSelectors} { @content; } diff --git a/core/src/utils/config.ts b/core/src/utils/config.ts index cca58752ee7..6a841a3fcf2 100644 --- a/core/src/utils/config.ts +++ b/core/src/utils/config.ts @@ -206,7 +206,10 @@ export interface IonicConfig { /** * @experimental * If `true`, the [CloseWatcher API](https://github.com/WICG/close-watcher) will be used to handle - * all Escape key and hardware back button presses to dismiss menus and overlays and to navigate. + * Escape key and Android back button presses that dismiss menus and overlays. It's only active + * while a menu or an overlay other than a toast is open, so back navigation works as usual + * otherwise. Hybrid apps still handle the hardware back button through the native `backbutton` + * event. * Note that the `hardwareBackButton` config option must also be `true`. */ experimentalCloseWatcher?: boolean; diff --git a/core/src/utils/forms/index.ts b/core/src/utils/forms/index.ts index 31732efac24..ef17d70a583 100644 --- a/core/src/utils/forms/index.ts +++ b/core/src/utils/forms/index.ts @@ -1,5 +1,5 @@ export * from './notch-controller'; -export * from './start-container-controller'; +export * from './start-end-container-controller'; export * from './compare-with-utils'; export * from './item-multiple-inputs'; export * from './click-controller'; diff --git a/core/src/utils/forms/start-container-controller.ts b/core/src/utils/forms/start-container-controller.ts deleted file mode 100644 index 3038f470162..00000000000 --- a/core/src/utils/forms/start-container-controller.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { raf } from '@utils/helpers'; -import { isRTL } from '@utils/rtl'; - -/** - * Applied to the host while the label is being repositioned. The - * component stylesheets style this class, so it must stay in sync with - * the `skip-label-transition` selector in those files. Renaming it here - * requires renaming it there too. - */ -export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition'; - -/** - * Set on the host as an inline style. The component stylesheets declare - * this property and read it with `var()`, so it must stay in sync with - * the `--internal-start-container-adjustment` references in those files. - * Renaming it here requires renaming it there too. - */ -export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment'; - -/** - * A utility to measure and update the start container width adjustment - * for form components (input, textarea, select) with `fill="outline"`. - * This adjustment compensates for the start container width when - * positioning floating/stacked labels within the outline border. - * - * Measurement is deferred with raf() to avoid forcing layout during - * component updates, and a ResizeObserver watches for dynamic width - * changes (e.g., when web fonts load or async content changes). - * - * @internal - * @param el - The host element (ion-input, ion-textarea, or ion-select). - * @param getStartContainer - A callback that returns the start container element. - * @param shouldApplyAdjustment - A callback that determines whether the adjustment should be applied. - */ -export const createStartContainerController = ( - el: HTMLElement, - getStartContainer: () => HTMLElement | undefined, - shouldApplyAdjustment: () => boolean -): StartContainerController => { - let resizeObserver: ResizeObserver | undefined; - let observedContainer: HTMLElement | undefined; - let measurementRaf: number | undefined; - let transitionRaf: number | undefined; - let appliedAdjustment: string | undefined; - - const calculateAdjustment = (): string => { - const startSlot = getStartContainer(); - if (!startSlot || !shouldApplyAdjustment()) { - return ''; - } - - /* - * Round to one decimal place so fractional widths produce a stable - * label offset when the start container is added, removed, or resized. - */ - const startContainerWidth = startSlot.getBoundingClientRect().width; - const roundedWidth = Math.round(startContainerWidth * 10) / 10; - const sign = isRTL(el) ? '' : '-'; - return roundedWidth ? `${sign}${roundedWidth}px` : '0px'; - }; - - const scheduleAdjustmentUpdate = () => { - // Return if there is already a measurement queued to avoid a flicker - if (measurementRaf !== undefined) { - return; - } - - /* - * Defer the measurement until the next animation frame so layout - * is measured after the current update has been applied. - */ - measurementRaf = raf(() => { - measurementRaf = undefined; - - const adjustment = calculateAdjustment(); - - // Only suppress the label transition when the label has to move - if (adjustment !== appliedAdjustment) { - // Discard a removal queued by an earlier change - if (transitionRaf !== undefined) { - cancelAnimationFrame(transitionRaf); - } - - el.classList.add(SKIP_LABEL_TRANSITION_CLASS); - el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment); - appliedAdjustment = adjustment; - - // Re-enable the transition once the new offset has been painted - transitionRaf = raf(() => { - transitionRaf = undefined; - el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); - }); - } - - setupResizeObserver(); - }); - }; - - const disconnectResizeObserver = () => { - if (resizeObserver) { - resizeObserver.disconnect(); - resizeObserver = undefined; - observedContainer = undefined; - } - }; - - const setupResizeObserver = () => { - const startSlot = getStartContainer(); - - // Stop watching once the adjustment no longer applies - if (!startSlot || !shouldApplyAdjustment()) { - disconnectResizeObserver(); - return; - } - - // Return when prerendering or already watching this container - if (typeof ResizeObserver !== 'function' || (resizeObserver && observedContainer === startSlot)) { - return; - } - - disconnectResizeObserver(); - - resizeObserver = new ResizeObserver(() => { - scheduleAdjustmentUpdate(); - }); - - observedContainer = startSlot; - resizeObserver.observe(startSlot); - }; - - const calculateStartContainerWidth = () => { - scheduleAdjustmentUpdate(); - }; - - const destroy = () => { - disconnectResizeObserver(); - - if (measurementRaf !== undefined) { - cancelAnimationFrame(measurementRaf); - measurementRaf = undefined; - } - - // Tearing down mid-toggle would leave the transition disabled - if (transitionRaf !== undefined) { - cancelAnimationFrame(transitionRaf); - transitionRaf = undefined; - el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); - } - - // Re-apply from scratch if the element is connected again - appliedAdjustment = undefined; - }; - - return { - calculateStartContainerWidth, - destroy, - }; -}; - -export type StartContainerController = { - calculateStartContainerWidth: () => void; - destroy: () => void; -}; diff --git a/core/src/utils/forms/start-end-container-controller.ts b/core/src/utils/forms/start-end-container-controller.ts new file mode 100644 index 00000000000..9a297dbf388 --- /dev/null +++ b/core/src/utils/forms/start-end-container-controller.ts @@ -0,0 +1,205 @@ +import { raf } from '@utils/helpers'; +import { isRTL } from '@utils/rtl'; + +/** + * Applied to the host while the label is being repositioned. The + * component stylesheets style this class, so it must stay in sync with + * the `skip-label-transition` selector in those files. Renaming it here + * requires renaming it there too. + */ +export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition'; + +/** + * Set on the host as an inline style. The component stylesheets declare + * this property and read it with `var()`, so it must stay in sync with + * the `--internal-start-container-adjustment` references in those files. + * Renaming it here requires renaming it there too. + */ +export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment'; + +/** + * Set on the host as an inline style. The component stylesheets declare + * this property and read it with `var()`, so it must stay in sync with + * the `--internal-start-end-container-width` references in those files. + * Renaming it here requires renaming it there too. + */ +export const START_END_CONTAINER_WIDTH_VAR = '--internal-start-end-container-width'; + +/** + * A utility to measure the start and end containers of form components + * (input, textarea, select) with `fill="outline"` and write the results + * to the host. The start container width offsets floating/stacked labels + * so they line up with the outline border. The combined start and end + * container width lets the floating label size itself to the full width + * of the outline while still being positioned relative to the control. + * + * Measurement is deferred with raf() to avoid forcing layout during + * component updates, and a ResizeObserver watches for dynamic width + * changes (e.g., when web fonts load or async content changes). + * + * @internal + * @param el - The host element (ion-input, ion-textarea, or ion-select). + * @param getStartContainer - A callback that returns the start container element. + * @param getEndContainer - A callback that returns the end container element. + * @param shouldMeasure - A callback that determines whether the containers should be measured. + */ +export const createStartEndContainerController = ( + el: HTMLElement, + getStartContainer: () => HTMLElement | undefined, + getEndContainer: () => HTMLElement | undefined, + shouldMeasure: () => boolean +): StartEndContainerController => { + let resizeObserver: ResizeObserver | undefined; + let observedStartContainer: HTMLElement | undefined; + let observedEndContainer: HTMLElement | undefined; + let measurementRaf: number | undefined; + let transitionRaf: number | undefined; + let appliedAdjustment: string | undefined; + let appliedStartEndWidth: string | undefined; + + /* + * Round to one decimal place so fractional widths produce a stable + * label offset and width when a container is added, removed, or resized. + */ + const roundWidth = (width: number) => Math.round(width * 10) / 10; + + const measureContainers = (): { adjustment: string; startEndWidth: string } => { + const startContainer = getStartContainer(); + if (!startContainer || !shouldMeasure()) { + return { adjustment: '', startEndWidth: '' }; + } + + const startContainerWidth = roundWidth(startContainer.getBoundingClientRect().width); + const endContainerWidth = roundWidth(getEndContainer()?.getBoundingClientRect().width ?? 0); + const sign = isRTL(el) ? '' : '-'; + + return { + adjustment: startContainerWidth ? `${sign}${startContainerWidth}px` : '0px', + startEndWidth: `${roundWidth(startContainerWidth + endContainerWidth)}px`, + }; + }; + + const scheduleMeasurement = () => { + // Return if there is already a measurement queued to avoid a flicker + if (measurementRaf !== undefined) { + return; + } + + /* + * Defer the measurement until the next animation frame so layout + * is measured after the current update has been applied. + */ + measurementRaf = raf(() => { + measurementRaf = undefined; + + const { adjustment, startEndWidth } = measureContainers(); + + /* + * The width only changes how much of the label fits, so it can + * animate. The transition is not suppressed here because the end + * container can change size while the label is floating, such as + * when async content loads, and that should not cut the float short. + */ + if (startEndWidth !== appliedStartEndWidth) { + el.style.setProperty(START_END_CONTAINER_WIDTH_VAR, startEndWidth); + appliedStartEndWidth = startEndWidth; + } + + // Only suppress the label transition when the label has to move + if (adjustment !== appliedAdjustment) { + // Discard a removal queued by an earlier change + if (transitionRaf !== undefined) { + cancelAnimationFrame(transitionRaf); + } + + el.classList.add(SKIP_LABEL_TRANSITION_CLASS); + el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment); + appliedAdjustment = adjustment; + + // Re-enable the transition once the new offset has been painted + transitionRaf = raf(() => { + transitionRaf = undefined; + el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); + }); + } + + setupResizeObserver(); + }); + }; + + const disconnectResizeObserver = () => { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = undefined; + observedStartContainer = undefined; + observedEndContainer = undefined; + } + }; + + const setupResizeObserver = () => { + const startContainer = getStartContainer(); + const endContainer = getEndContainer(); + + // Stop watching once the measurements no longer apply + if (!startContainer || !shouldMeasure()) { + disconnectResizeObserver(); + return; + } + + // Return when prerendering or already watching these containers + if ( + typeof ResizeObserver !== 'function' || + (resizeObserver && observedStartContainer === startContainer && observedEndContainer === endContainer) + ) { + return; + } + + disconnectResizeObserver(); + + resizeObserver = new ResizeObserver(() => { + scheduleMeasurement(); + }); + + observedStartContainer = startContainer; + resizeObserver.observe(startContainer); + + if (endContainer) { + observedEndContainer = endContainer; + resizeObserver.observe(endContainer); + } + }; + + const calculateContainerWidths = () => { + scheduleMeasurement(); + }; + + const destroy = () => { + disconnectResizeObserver(); + + if (measurementRaf !== undefined) { + cancelAnimationFrame(measurementRaf); + measurementRaf = undefined; + } + + // Tearing down mid-toggle would leave the transition disabled + if (transitionRaf !== undefined) { + cancelAnimationFrame(transitionRaf); + transitionRaf = undefined; + el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); + } + + // Re-apply from scratch if the element is connected again + appliedAdjustment = undefined; + appliedStartEndWidth = undefined; + }; + + return { + calculateContainerWidths, + destroy, + }; +}; + +export type StartEndContainerController = { + calculateContainerWidths: () => void; + destroy: () => void; +}; diff --git a/core/src/utils/forms/test/start-container-controller.spec.ts b/core/src/utils/forms/test/start-end-container-controller.spec.ts similarity index 71% rename from core/src/utils/forms/test/start-container-controller.spec.ts rename to core/src/utils/forms/test/start-end-container-controller.spec.ts index e5c474356ba..cbe34c685b9 100644 --- a/core/src/utils/forms/test/start-container-controller.spec.ts +++ b/core/src/utils/forms/test/start-end-container-controller.spec.ts @@ -1,9 +1,10 @@ -import type { StartContainerController } from '../start-container-controller'; +import type { StartEndContainerController } from '../start-end-container-controller'; import { SKIP_LABEL_TRANSITION_CLASS, START_CONTAINER_ADJUSTMENT_VAR, - createStartContainerController, -} from '../start-container-controller'; + START_END_CONTAINER_WIDTH_VAR, + createStartEndContainerController, +} from '../start-end-container-controller'; /** * The controller defers every measurement to an animation frame, so the tests @@ -91,18 +92,25 @@ const restoreGlobals = () => { globalOverrides.clear(); }; -describe('Start Container Controller', () => { +describe('Start and End Container Controller', () => { let host: HTMLElement; let startContainer: HTMLElement; - let controller: StartContainerController; + let endContainer: HTMLElement; + let controller: StartEndContainerController; let shouldApply: boolean; const setStartWidth = (width: number) => { startContainer.getBoundingClientRect = () => ({ width } as DOMRect); }; + const setEndWidth = (width: number) => { + endContainer.getBoundingClientRect = () => ({ width } as DOMRect); + }; + const getAdjustment = () => host.style.getPropertyValue(START_CONTAINER_ADJUSTMENT_VAR); + const getStartEndWidth = () => host.style.getPropertyValue(START_END_CONTAINER_WIDTH_VAR); + const hasSkipClass = () => host.classList.contains(SKIP_LABEL_TRANSITION_CLASS); /** @@ -110,7 +118,7 @@ describe('Start Container Controller', () => { * measurement frame and the follow-up transition frame run. */ const measure = () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); flushFrames(); }; @@ -127,14 +135,18 @@ describe('Start Container Controller', () => { host = document.createElement('div'); startContainer = document.createElement('div'); + endContainer = document.createElement('div'); host.appendChild(startContainer); + host.appendChild(endContainer); document.body.appendChild(host); setStartWidth(32); + setEndWidth(0); - controller = createStartContainerController( + controller = createStartEndContainerController( host, () => startContainer, + () => endContainer, () => shouldApply ); }); @@ -161,7 +173,7 @@ describe('Start Container Controller', () => { }); it('should not measure until the next frame', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); expect(getAdjustment()).toBe(''); }); @@ -173,8 +185,8 @@ describe('Start Container Controller', () => { * the offset into the next frame, leaving one painted frame where the * content has changed but the label has not moved. */ - requestAnimationFrame(() => controller.calculateStartContainerWidth()); - controller.calculateStartContainerWidth(); + requestAnimationFrame(() => controller.calculateContainerWidths()); + controller.calculateContainerWidths(); flushFrames(); @@ -206,11 +218,47 @@ describe('Start Container Controller', () => { expect(getAdjustment()).toBe('32px'); }); + + it('should set the width to the combined start and end container widths', () => { + setEndWidth(48); + + measure(); + + expect(getStartEndWidth()).toBe('80px'); + }); + + it('should not negate the start and end width in rtl', () => { + host.dir = 'rtl'; + setEndWidth(48); + + measure(); + + expect(getStartEndWidth()).toBe('80px'); + }); + + it('should round the combined start and end width to one decimal place', () => { + setStartWidth(32.06); + setEndWidth(48.02); + + measure(); + + expect(getStartEndWidth()).toBe('80.1px'); + }); + + it('should clear the start and end width when it no longer applies', () => { + measure(); + expect(getStartEndWidth()).toBe('32px'); + + shouldApply = false; + measure(); + + expect(getStartEndWidth()).toBe(''); + }); }); describe('label transition', () => { it('should suppress the transition for one frame when the offset changes', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(hasSkipClass()).toBe(true); @@ -222,14 +270,29 @@ describe('Start Container Controller', () => { it('should not suppress the transition when the offset is unchanged', () => { measure(); - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(hasSkipClass()).toBe(false); }); + it('should not suppress the transition when only the end container changes', () => { + /** + * The end container can resize while the label is floating. Cutting + * the transition short would snap the label into place. + */ + measure(); + + setEndWidth(48); + controller.calculateContainerWidths(); + flushFrames(); + + expect(getStartEndWidth()).toBe('80px'); + expect(hasSkipClass()).toBe(false); + }); + it('should keep the transition suppressed while consecutive changes are applied', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); /** @@ -237,7 +300,7 @@ describe('Start Container Controller', () => { * frame that applies the second one. */ setStartWidth(64); - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(getAdjustment()).toBe('-64px'); @@ -246,18 +309,18 @@ describe('Start Container Controller', () => { }); describe('resize observer', () => { - it('should observe the start container after measuring', () => { + it('should observe the start and end containers after measuring', () => { measure(); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); - it('should not rebind the observer when the container is unchanged', () => { + it('should not rebind the observer when the containers are unchanged', () => { measure(); measure(); expect(MockResizeObserver.instances).toHaveLength(1); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); it('should rebind when the start container is replaced', () => { @@ -269,7 +332,20 @@ describe('Start Container Controller', () => { measure(); expect(firstObserver?.disconnected).toBe(true); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); + }); + + it('should rebind when the end container is replaced', () => { + measure(); + const firstObserver = activeObserver(); + + endContainer = document.createElement('div'); + setEndWidth(48); + measure(); + + expect(firstObserver?.disconnected).toBe(true); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); + expect(getStartEndWidth()).toBe('80px'); }); it('should re-measure when the container resizes', () => { @@ -282,6 +358,16 @@ describe('Start Container Controller', () => { expect(getAdjustment()).toBe('-64px'); }); + it('should re-measure when the end container resizes', () => { + measure(); + + setEndWidth(48); + activeObserver()?.callback(); + flushFrames(); + + expect(getStartEndWidth()).toBe('80px'); + }); + it('should disconnect when the adjustment stops applying', () => { measure(); const observer = activeObserver(); @@ -302,7 +388,7 @@ describe('Start Container Controller', () => { measure(); expect(getAdjustment()).toBe('-32px'); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); }); @@ -317,7 +403,7 @@ describe('Start Container Controller', () => { }); it('should not measure after a pending frame is cancelled', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); controller.destroy(); flushFrames(); @@ -326,7 +412,7 @@ describe('Start Container Controller', () => { }); it('should not leave the transition suppressed when torn down mid-toggle', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); // The class is applied, but the frame that removes it has not run yet flushFrames(); @@ -341,15 +427,16 @@ describe('Start Container Controller', () => { measure(); controller.destroy(); - controller = createStartContainerController( + controller = createStartEndContainerController( host, () => startContainer, + () => endContainer, () => shouldApply ); measure(); expect(getAdjustment()).toBe('-32px'); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); }); }); diff --git a/core/src/utils/hardware-back-button.ts b/core/src/utils/hardware-back-button.ts index 96da99979fb..46b4c19c5c0 100644 --- a/core/src/utils/hardware-back-button.ts +++ b/core/src/utils/hardware-back-button.ts @@ -48,6 +48,26 @@ export const blockHardwareBackButton = () => { document.addEventListener('backbutton', () => {}); }; +const closeWatcherConditions: (() => boolean)[] = []; +let syncCloseWatcher: (() => void) | undefined; + +/** + * Registers a check for whether the back button + * has something to close. The CloseWatcher is only + * active while one of these checks passes. + */ +export const addCloseWatcherCondition = (condition: () => boolean) => { + closeWatcherConditions.push(condition); +}; + +/** + * Call this whenever something the back button + * can close opens or closes. + */ +export const updateCloseWatcher = () => { + syncCloseWatcher?.(); +}; + export const startHardwareBackButton = () => { const doc = document; let busy = false; @@ -105,16 +125,32 @@ export const startHardwareBackButton = () => { }; /** - * If the CloseWatcher is defined then - * we don't want to also listen for the native - * backbutton event otherwise we may get duplicate - * events firing. + * Android WebView never sends the hardware back + * button to a CloseWatcher, so hybrid apps still + * need this. Browsers never fire backbutton. */ + doc.addEventListener('backbutton', backButtonCallback); + if (shouldUseCloseWatcher()) { let watcher: CloseWatcher | undefined; - const configureWatcher = () => { - watcher?.destroy(); + /** + * An active CloseWatcher stops the back button from + * navigating, so only keep one while there's something to close. + */ + syncCloseWatcher = () => { + const canClose = closeWatcherConditions.some((condition) => condition()); + + if (!canClose) { + watcher?.destroy(); + watcher = undefined; + return; + } + + if (watcher !== undefined) { + return; + } + watcher = new win!.CloseWatcher!(); /** @@ -122,17 +158,18 @@ export const startHardwareBackButton = () => { * the watcher gets destroyed. * As a result, we need to re-configure * the watcher so we can respond to other - * close requests. + * close requests, including after one that + * closed nothing, like a modal whose canDismiss + * returned false. */ watcher!.onclose = () => { + watcher = undefined; backButtonCallback(); - configureWatcher(); + syncCloseWatcher?.(); }; }; - configureWatcher(); - } else { - doc.addEventListener('backbutton', backButtonCallback); + syncCloseWatcher(); } }; diff --git a/core/src/utils/menu-controller/index.ts b/core/src/utils/menu-controller/index.ts index 5d170112340..6d90e958e87 100644 --- a/core/src/utils/menu-controller/index.ts +++ b/core/src/utils/menu-controller/index.ts @@ -1,6 +1,6 @@ import { doc } from '@utils/browser'; import type { BackButtonEvent } from '@utils/hardware-back-button'; -import { MENU_BACK_BUTTON_PRIORITY } from '@utils/hardware-back-button'; +import { MENU_BACK_BUTTON_PRIORITY, addCloseWatcherCondition } from '@utils/hardware-back-button'; import { printIonWarning } from '@utils/logging'; import type { MenuI, MenuControllerI } from '../../components/menu/menu-interface'; @@ -229,6 +229,7 @@ const createMenuController = (): MenuControllerI => { registerAnimation('push', menuPushAnimation); registerAnimation('overlay', menuOverlayAnimation); + addCloseWatcherCondition(() => _getOpenSync() !== undefined); doc?.addEventListener('ionBackButton', (ev: BackButtonEvent) => { const openMenu = _getOpenSync(); if (openMenu) { diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 40bbb7b88fc..7a1dd014bd3 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -1,7 +1,7 @@ import { doc } from '@utils/browser'; import { focusFirstDescendant, focusLastDescendant, focusableQueryString } from '@utils/focus-trap'; import type { BackButtonEvent } from '@utils/hardware-back-button'; -import { shouldUseCloseWatcher } from '@utils/hardware-back-button'; +import { addCloseWatcherCondition, shouldUseCloseWatcher, updateCloseWatcher } from '@utils/hardware-back-button'; import { printIonError, printIonWarning } from '@utils/logging'; import { config } from '../global/config'; @@ -35,6 +35,12 @@ import { let lastOverlayIndex = 0; let lastId = 0; +/** + * Every overlay except toast, which doesn't trap + * focus or close on back. + */ +const NON_TOAST_OVERLAYS = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-popover'; + export const activeAnimations = new WeakMap(); type OverlayWithFocusTrapProps = HTMLIonOverlayElement & { @@ -186,7 +192,7 @@ const focusElementInOverlay = (hostToFocus: HTMLElement | null | undefined, over * Should NOT include: Toast */ const trapKeyboardFocus = (ev: Event, doc: Document) => { - const lastOverlay = getPresentedOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-popover'); + const lastOverlay = getPresentedOverlay(doc, NON_TOAST_OVERLAYS); const target = ev.target as HTMLElement | null; /** @@ -383,6 +389,12 @@ const connectListeners = (doc: Document) => { true ); + /** + * Whether the overlay actually dismisses is checked at press time, + * since `backdropDismiss` can change while it's open. + */ + addCloseWatcherCondition(() => getPresentedOverlay(doc, NON_TOAST_OVERLAYS) !== undefined); + // handle back-button click doc.addEventListener('ionBackButton', (ev) => { const lastOverlay = getPresentedOverlay(doc); @@ -525,7 +537,8 @@ export const setRootAriaHidden = (hidden = false) => { * Cleans up root `aria-hidden` and `backdrop-no-scroll` when * an overlay is removed from the DOM without going through * the `dismiss()` flow (e.g., when a framework unmounts the - * overlay during a route change). + * overlay during a route change). Also releases the + * CloseWatcher if nothing is left to close. * * Should be called from an overlay's `disconnectedCallback` * when the overlay was still presented at the time of removal. @@ -535,6 +548,8 @@ export const cleanupRootFocusTrapAccessibility = () => { return; } + updateCloseWatcher(); + const remainingOverlays = getPresentedOverlays(document); const hasRemainingLocking = remainingOverlays.some((o) => locksAppRoot(o as OverlayWithFocusTrapProps)); @@ -559,7 +574,9 @@ const applyRootLock = (el: OverlayWithFocusTrapProps) => { /** * Re-applies the root lock that `cleanupRootFocusTrapAccessibility()` released. - * Call from `connectedCallback` when the overlay is still presented. + * Call from `connectedCallback` when the overlay is still presented. Also + * re-arms the CloseWatcher, which every overlay needs, so that happens + * before the `locksAppRoot` check. * * 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 @@ -570,6 +587,8 @@ export const restoreRootFocusTrapAccessibility = (overlayEl: HTMLIonOverlayEleme return; } + updateCloseWatcher(); + const el = overlayEl as OverlayWithFocusTrapProps; if (!locksAppRoot(el)) { return; @@ -828,6 +847,12 @@ export const dismiss = async ( overlay.el.remove(); + /** + * Release the back button if nothing + * else is left to close. + */ + updateCloseWatcher(); + return true; }; @@ -844,6 +869,13 @@ const overlayAnimation = async ( // Make overlay visible in case it's hidden baseEl.classList.remove('overlay-hidden'); + /** + * Removing `overlay-hidden` makes the overlay count + * as presented, so back can dismiss it while it + * animates in. + */ + updateCloseWatcher(); + const aniRoot = overlay.el; const animation = animationBuilder(aniRoot, opts); diff --git a/core/src/utils/test/hardware-back-button.spec.ts b/core/src/utils/test/hardware-back-button.spec.ts index 87f27d407cc..b195996c20d 100644 --- a/core/src/utils/test/hardware-back-button.spec.ts +++ b/core/src/utils/test/hardware-back-button.spec.ts @@ -1,7 +1,8 @@ +import { newSpecPage } from '@stencil/core/testing'; + import type { BackButtonEvent } from '../../../src/interface'; import { startHardwareBackButton } from '../hardware-back-button'; import { config } from '../../global/config'; -import { win } from '@utils/browser'; describe('Hardware Back Button', () => { beforeEach(() => startHardwareBackButton()); @@ -58,52 +59,216 @@ describe('Hardware Back Button', () => { describe('Experimental Close Watcher', () => { test('should not use the Close Watcher API when available', () => { - const mockAPI = mockCloseWatcher(); + const closeWatchers = mockCloseWatcher(); config.reset({ experimentalCloseWatcher: false }); startHardwareBackButton(); - expect(mockAPI.mock.calls).toHaveLength(0); + expect(closeWatchers.created).toBe(0); }); - test('should use the Close Watcher API when available', () => { - const mockAPI = mockCloseWatcher(); - config.reset({ experimentalCloseWatcher: true }); + test('should still handle the native back button event', async () => { + await newCloseWatcherPage(``); - startHardwareBackButton(); + const cbSpy = jest.fn(); + document.addEventListener('ionBackButton', (ev) => { + (ev as BackButtonEvent).detail.register(0, cbSpy); + }); + + dispatchBackButtonEvent(); - expect(mockAPI.mock.calls).toHaveLength(1); + expect(cbSpy).toHaveBeenCalled(); }); - test('Close Watcher should dispatch ionBackButton events', () => { - const mockAPI = mockCloseWatcher(); - config.reset({ experimentalCloseWatcher: true }); + // Fixes https://github.com/ionic-team/ionic-framework/issues/29648 + test('should not intercept the back button while nothing is open', async () => { + const { closeWatchers } = await newCloseWatcherPage(``); - startHardwareBackButton(); + expect(closeWatchers.active()).toBeUndefined(); + }); - const cbSpy = jest.fn(); - document.addEventListener('ionBackButton', cbSpy); + test('should not intercept the back button while only a toast is shown', async () => { + const { page, closeWatchers } = await newCloseWatcherPage(``); - // Call onclose on Ionic's instance of CloseWatcher - mockAPI.getMockImplementation()!().onclose(); + await page.body.querySelector('ion-toast')!.present(); - expect(cbSpy).toHaveBeenCalled(); + expect(closeWatchers.active()).toBeUndefined(); + }); + + test('should dismiss a presented modal when the back button is pressed', async () => { + const { page, closeWatchers } = await newCloseWatcherPage(``); + + const modal = page.body.querySelector('ion-modal')!; + await modal.present(); + + expect(closeWatchers.active()).toBeDefined(); + + const dismissed = modal.onDidDismiss(); + closeWatchers.requestClose(); + + await expect(dismissed).resolves.toEqual(expect.objectContaining({ role: 'backdrop' })); + }); + + // Fixes https://github.com/ionic-team/ionic-framework/issues/29648 + test('should stop intercepting the back button once the modal is dismissed', async () => { + const { page, closeWatchers } = await newCloseWatcherPage(``); + + const modal = page.body.querySelector('ion-modal')!; + await modal.present(); + await modal.dismiss(); + + expect(closeWatchers.active()).toBeUndefined(); + }); + + test('should stop intercepting the back button when a presented modal is removed without dismissing', async () => { + const { page, closeWatchers } = await newCloseWatcherPage(``); + + const modal = page.body.querySelector('ion-modal')!; + await modal.present(); + modal.remove(); + + expect(closeWatchers.active()).toBeUndefined(); + }); + + test('should keep intercepting the back button when a presented modal is moved', async () => { + const { page, closeWatchers } = await newCloseWatcherPage(`
`); + + const modal = page.body.querySelector('ion-modal')!; + await modal.present(); + page.body.querySelector('#target')!.appendChild(modal); + + expect(closeWatchers.active()).toBeDefined(); + }); + + test('should keep intercepting the back button while a modal is still open underneath', async () => { + const { page, closeWatchers } = await newCloseWatcherPage(` + + + `); + + const bottom = page.body.querySelector('#bottom')!; + const top = page.body.querySelector('#top')!; + await bottom.present(); + await top.present(); + + const topDismissed = top.onDidDismiss(); + closeWatchers.requestClose(); + await topDismissed; + + const bottomDismissed = bottom.onDidDismiss(); + closeWatchers.requestClose(); + + await expect(bottomDismissed).resolves.toEqual(expect.objectContaining({ role: 'backdrop' })); + expect(closeWatchers.active()).toBeUndefined(); + }); + + test('should keep intercepting the back button when the modal refuses to dismiss', async () => { + const { page, closeWatchers } = await newCloseWatcherPage(``); + + const modal = page.body.querySelector('ion-modal')!; + let dismissAttempts = 0; + modal.canDismiss = () => { + dismissAttempts++; + return Promise.resolve(false); + }; + await modal.present(); + + closeWatchers.requestClose(); + await page.waitForChanges(); + closeWatchers.requestClose(); + await page.waitForChanges(); + + expect(dismissAttempts).toBe(2); + }); + + test('should dismiss a modal that allows backdrop dismissal only after it was presented', async () => { + const { page, closeWatchers } = await newCloseWatcherPage(``); + + const modal = page.body.querySelector('ion-modal')!; + modal.backdropDismiss = false; + await modal.present(); + modal.backdropDismiss = true; + + const dismissed = modal.onDidDismiss(); + closeWatchers.requestClose(); + + await expect(dismissed).resolves.toEqual(expect.objectContaining({ role: 'backdrop' })); + }); + + test('should keep intercepting the back button while a toast is shown over a modal', async () => { + const { page, closeWatchers } = await newCloseWatcherPage(` + + + `); + + await page.body.querySelector('ion-modal')!.present(); + await page.body.querySelector('ion-toast')!.present(); + + expect(closeWatchers.active()).toBeDefined(); }); }); +/** + * Modules are reset first because overlays attach their back button + * listener once per module load, and every spec page gets a new document. + */ +const newCloseWatcherPage = async (html: string) => { + jest.resetModules(); + const { Modal } = await import('../../components/modal/modal'); + const { Toast } = await import('../../components/toast/toast'); + const { startHardwareBackButton } = await import('../hardware-back-button'); + const { config } = await import('../../global/config'); + + const page = await newSpecPage({ components: [Modal, Toast], html }); + + // Install the mock after newSpecPage, which resets the window + const closeWatchers = mockCloseWatcher(); + config.reset({ experimentalCloseWatcher: true }); + startHardwareBackButton(); + + return { page, closeWatchers }; +}; + +/** + * Like the browser, only the newest watcher that hasn't + * been destroyed gets close requests. + */ const mockCloseWatcher = () => { - const mockCloseWatcher = jest.fn(); - mockCloseWatcher.mockReturnValue({ - requestClose: () => null, - close: () => null, - destroy: () => null, - oncancel: () => null, - onclose: () => null, - }); - (win as any).CloseWatcher = mockCloseWatcher; + const watchers: { destroyed: boolean; onclose: (() => void) | null; destroy: () => void }[] = []; + + (window as any).CloseWatcher = class { + destroyed = false; + onclose: (() => void) | null = null; + + constructor() { + watchers.push(this); + } + + destroy() { + this.destroyed = true; + } + }; + + const active = () => watchers.filter((w) => !w.destroyed).slice(-1)[0]; - return mockCloseWatcher; + return { + get created() { + return watchers.length; + }, + active, + /** + * Presses back. Like the browser, this destroys + * the watcher before firing `close`. + */ + requestClose() { + const watcher = active(); + if (watcher) { + watcher.destroyed = true; + watcher.onclose?.(); + } + }, + }; }; const dispatchBackButtonEvent = () => { diff --git a/core/src/utils/transition/index.ts b/core/src/utils/transition/index.ts index e74e7318c82..e9d6598a6e9 100644 --- a/core/src/utils/transition/index.ts +++ b/core/src/utils/transition/index.ts @@ -282,6 +282,19 @@ export const setPageHidden = (el: HTMLElement, hidden: boolean) => { } }; +/** + * Blocks clicks on `el` during a swipe to go back without animations, + * so releasing the pointer doesn't click whatever is under it. Returns + * a function that unblocks it after the release click. + */ +export const blockSwipeClicks = (el: HTMLElement) => { + el.style.setProperty('pointer-events', 'none'); + + return () => { + setTimeout(() => el.style.removeProperty('pointer-events')); + }; +}; + const setZIndex = ( enteringEl: HTMLElement | undefined, leavingEl: HTMLElement | undefined, diff --git a/lerna.json b/lerna.json index 3574ffe91d1..b55d0f68f59 100644 --- a/lerna.json +++ b/lerna.json @@ -10,5 +10,5 @@ "packages/vue", "packages/vue-router" ], - "version": "9.0.6" + "version": "9.0.9" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d071e527ba9..8ec921c6bb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3653,13 +3653,14 @@ "dev": true }, "node_modules/handlebars": { - "version": "4.7.7", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", - "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, + "license": "MIT", "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.0", + "minimist": "^1.2.8", + "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, @@ -5015,10 +5016,14 @@ } }, "node_modules/minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", - "dev": true + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/minimist-options": { "version": "4.1.0", @@ -10689,13 +10694,13 @@ "dev": true }, "handlebars": { - "version": "4.7.7", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", - "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "requires": { - "minimist": "^1.2.5", - "neo-async": "^2.6.0", + "minimist": "^1.2.8", + "neo-async": "^2.6.2", "source-map": "^0.6.1", "uglify-js": "^3.1.4", "wordwrap": "^1.0.0" @@ -11735,9 +11740,9 @@ } }, "minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true }, "minimist-options": { diff --git a/packages/angular-server/CHANGELOG.md b/packages/angular-server/CHANGELOG.md index bc1aabd84af..28845030c40 100644 --- a/packages/angular-server/CHANGELOG.md +++ b/packages/angular-server/CHANGELOG.md @@ -3,6 +3,30 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +**Note:** Version bump only for package @ionic/angular-server + + + + + +## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) + +**Note:** Version bump only for package @ionic/angular-server + + + + + +## [9.0.7](https://github.com/ionic-team/ionic-framework/compare/v9.0.6...v9.0.7) (2026-10-07) + +**Note:** Version bump only for package @ionic/angular-server + + + + + ## [9.0.6](https://github.com/ionic-team/ionic-framework/compare/v9.0.5...v9.0.6) (2026-09-30) **Note:** Version bump only for package @ionic/angular-server diff --git a/packages/angular-server/package-lock.json b/packages/angular-server/package-lock.json index 3df144c5282..db1c8718df5 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.6", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular-server", - "version": "9.0.6", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.6" + "@ionic/core": "^9.0.9" }, "devDependencies": { "@angular/animations": "^22.0.0", @@ -1097,9 +1097,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.6.tgz", - "integrity": "sha512-WWxjOjwKPU75BuEhc42t3n2Tn89YEDe5Pce9kA61w/TlWVM3tqiAYC+qfR9MXmER4pQcST01oM2gftheMzcV6g==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", diff --git a/packages/angular-server/package.json b/packages/angular-server/package.json index 77424e5e919..0f16a94182d 100644 --- a/packages/angular-server/package.json +++ b/packages/angular-server/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular-server", - "version": "9.0.6", + "version": "9.0.9", "description": "Angular SSR Module for Ionic", "keywords": [ "ionic", @@ -78,6 +78,6 @@ }, "prettier": "@ionic/prettier-config", "dependencies": { - "@ionic/core": "^9.0.6" + "@ionic/core": "^9.0.9" } } diff --git a/packages/angular/.prettierignore b/packages/angular/.prettierignore index daee675330e..3fbe2f57cab 100644 --- a/packages/angular/.prettierignore +++ b/packages/angular/.prettierignore @@ -5,3 +5,4 @@ proxies.ts src/lazy/directives/proxies-list.ts src/standalone/directives/ion-*.ts **/*/angular-component-lib/utils.ts +**/*/angular-component-lib/boolean-attribute.ts diff --git a/packages/angular/CHANGELOG.md b/packages/angular/CHANGELOG.md index b95833b97e7..1d3e8740831 100644 --- a/packages/angular/CHANGELOG.md +++ b/packages/angular/CHANGELOG.md @@ -3,6 +3,30 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **angular:** import tab proxies directly in standalone tabs ([#31596](https://github.com/ionic-team/ionic-framework/issues/31596)) ([cb963f8](https://github.com/ionic-team/ionic-framework/commit/cb963f886ee08ce3abf25d6cb4e33c5cc3ac0003)), closes [#31594](https://github.com/ionic-team/ionic-framework/issues/31594) +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) + + +## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) + +### Bug Fixes + +* **angular, vue:** fire lifecycle events on nested pages ([#31532](https://github.com/ionic-team/ionic-framework/issues/31532)) ([277097e](https://github.com/ionic-team/ionic-framework/commit/277097e1dfa13d8b34a6826aca462843810fc8f1)), closes [#30831](https://github.com/ionic-team/ionic-framework/issues/30831) [#16834](https://github.com/ionic-team/ionic-framework/issues/16834) +* **angular:** update route snapshot on query param navigation ([#31534](https://github.com/ionic-team/ionic-framework/issues/31534)) ([6b5afec](https://github.com/ionic-team/ionic-framework/commit/6b5afec5b8d2a8df79c014120b5be5b4fbb2a2cb)), closes [#24587](https://github.com/ionic-team/ionic-framework/issues/24587) + + +## [9.0.7](https://github.com/ionic-team/ionic-framework/compare/v9.0.6...v9.0.7) (2026-10-07) + +**Note:** Version bump only for package @ionic/angular + + + + + ## [9.0.6](https://github.com/ionic-team/ionic-framework/compare/v9.0.5...v9.0.6) (2026-09-30) **Note:** Version bump only for package @ionic/angular diff --git a/packages/angular/eslint.config.js b/packages/angular/eslint.config.js index b5f03c846a2..a3a5fe9c179 100644 --- a/packages/angular/eslint.config.js +++ b/packages/angular/eslint.config.js @@ -20,7 +20,16 @@ const tsConfigs = [...compat.extends('@ionic/eslint-config/recommended'), ...ang module.exports = [ { - ignores: ['dist/**', 'build/**', 'scripts/**', '**/proxies.ts', 'test/**', 'src/standalone/directives/ion-*.ts'], + ignores: [ + 'dist/**', + 'build/**', + 'scripts/**', + '**/proxies.ts', + // Generated by @stencil/angular-output-target alongside the proxies + '**/angular-component-lib/boolean-attribute.ts', + 'test/**', + 'src/standalone/directives/ion-*.ts', + ], }, ...tsConfigs, { diff --git a/packages/angular/package-lock.json b/packages/angular/package-lock.json index 88671477585..61484bc67cb 100644 --- a/packages/angular/package-lock.json +++ b/packages/angular/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular", - "version": "9.0.6", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular", - "version": "9.0.6", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.6", + "@ionic/core": "^9.0.9", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" @@ -1580,9 +1580,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.6.tgz", - "integrity": "sha512-WWxjOjwKPU75BuEhc42t3n2Tn89YEDe5Pce9kA61w/TlWVM3tqiAYC+qfR9MXmER4pQcST01oM2gftheMzcV6g==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", diff --git a/packages/angular/package.json b/packages/angular/package.json index 52fcb205f52..820f510ed44 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular", - "version": "9.0.6", + "version": "9.0.9", "description": "Angular specific wrappers for @ionic/core", "keywords": [ "ionic", @@ -168,7 +168,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.6", + "@ionic/core": "^9.0.9", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" diff --git a/packages/angular/src/common/directives/navigation/router-outlet.ts b/packages/angular/src/common/directives/navigation/router-outlet.ts index 71b60f5fdf5..016f7ca6578 100644 --- a/packages/angular/src/common/directives/navigation/router-outlet.ts +++ b/packages/angular/src/common/directives/navigation/router-outlet.ts @@ -22,7 +22,13 @@ import { } from '@angular/core'; import type { Provider } from '@angular/core'; import { OutletContext, Router, ActivatedRoute, ChildrenOutletContexts, PRIMARY_OUTLET, Data } from '@angular/router'; -import { componentOnReady } from '@ionic/core/components'; +import { + LIFECYCLE_DID_ENTER, + LIFECYCLE_DID_LEAVE, + LIFECYCLE_WILL_ENTER, + LIFECYCLE_WILL_LEAVE, + componentOnReady, +} from '@ionic/core/components'; import type { AnimationBuilder } from '@ionic/core/components'; import { Observable, BehaviorSubject, Subscription, combineLatest, of } from 'rxjs'; import { distinctUntilChanged, filter, switchMap } from 'rxjs/operators'; @@ -33,6 +39,26 @@ import { NavController } from '../../providers/nav-controller'; import { StackController } from './stack-controller'; import { RouteView, StackDidChangeEvent, StackWillChangeEvent, getUrl, isTabSwitch } from './stack-utils'; +/** + * A page that left along with its parent page is still the active view of its + * outlet. If the user comes back to a different page in that outlet, the + * outlet's own transition fires leave on it a second time, so swallow leave + * events on it until it enters again. Unblock on `ionViewDidEnter`, since a + * cancelled swipe back only fires `ionViewWillEnter`. + */ +const blockRepeatLeave = (pageEl: HTMLElement) => { + // Capture so this runs before the page's own lifecycle listeners. + const block = (ev: Event) => ev.stopImmediatePropagation(); + const unblock = () => { + pageEl.removeEventListener(LIFECYCLE_WILL_LEAVE, block, true); + pageEl.removeEventListener(LIFECYCLE_DID_LEAVE, block, true); + }; + + pageEl.addEventListener(LIFECYCLE_WILL_LEAVE, block, true); + pageEl.addEventListener(LIFECYCLE_DID_LEAVE, block, true); + pageEl.addEventListener(LIFECYCLE_DID_ENTER, unblock, { capture: true, once: true }); +}; + // TODO(FW-2827): types @Directive({ @@ -57,6 +83,7 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit { private currentActivatedRoute$ = new BehaviorSubject<{ component: any; activatedRoute: ActivatedRoute } | null>(null); private activated: ComponentRef | null = null; + private unbindParentLifecycle?: () => void; /** @internal */ get activatedComponentRef(): ComponentRef | null { return this.activated; @@ -129,6 +156,7 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit { } ngOnDestroy(): void { + this.unbindParentLifecycle?.(); this.stackCtrl.destroy(); this.inputBinder?.unsubscribeFromRouteData(this); } @@ -139,6 +167,7 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit { ngOnInit(): void { this.initializeOutletWithName(); + this.bindParentLifecycle(); } // Note: Ionic deviates from the Angular Router implementation here @@ -159,6 +188,105 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit { }); } + /** + * Core doesn't bubble lifecycle events, so forward the ones on the page + * hosting this outlet (such as a page containing `ion-tabs`) to the page + * active in this outlet. Enter events only go to a page that left with the + * parent page. + */ + private bindParentLifecycle() { + const parentPageEl = this.getParentPageElement(); + if (!parentPageEl) { + return; + } + + let leftWithParent: RouteView | undefined; + + /** + * Apps using the workaround from + * https://github.com/ionic-team/ionic-framework/issues/16834 already + * re-dispatch the parent's lifecycle events onto the active tab, so track + * whether the active page got the event to avoid firing it twice. + */ + let parentEventType: string | undefined; + let activeViewReceived = false; + const onParentEventStart = (ev: Event) => { + if (ev.target === parentPageEl) { + parentEventType = ev.type; + activeViewReceived = false; + } + }; + const onOutletEvent = (ev: Event) => { + if (ev.type === parentEventType && ev.target === this.stackCtrl.getActiveView()?.element) { + activeViewReceived = true; + } + }; + + const forward = (ev: Event) => { + const alreadyReceived = activeViewReceived; + parentEventType = undefined; + activeViewReceived = false; + + const activeView = this.stackCtrl.getActiveView(); + if (!activeView) { + return; + } + + const isEnter = ev.type === LIFECYCLE_WILL_ENTER || ev.type === LIFECYCLE_DID_ENTER; + if (isEnter && activeView !== leftWithParent) { + return; + } + + if (!alreadyReceived) { + activeView.element.dispatchEvent(new CustomEvent(ev.type, { bubbles: false, cancelable: false })); + } + + if (ev.type === LIFECYCLE_DID_LEAVE) { + leftWithParent = activeView; + blockRepeatLeave(activeView.element); + } else if (ev.type === LIFECYCLE_DID_ENTER) { + leftWithParent = undefined; + } + }; + + /** + * Capture listeners see the event before the parent page's own hooks run, + * and see events dispatched on pages in this outlet even though they + * don't bubble. The bubble-phase `forward` listener runs after the parent + * page's own hooks because those are bound when the page is created, + * before this outlet inits. + */ + const events = [LIFECYCLE_WILL_ENTER, LIFECYCLE_DID_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_LEAVE]; + events.forEach((eventName) => { + parentPageEl.addEventListener(eventName, onParentEventStart, true); + parentPageEl.addEventListener(eventName, forward); + this.nativeEl.addEventListener(eventName, onOutletEvent, true); + }); + this.unbindParentLifecycle = () => { + events.forEach((eventName) => { + parentPageEl.removeEventListener(eventName, onParentEventStart, true); + parentPageEl.removeEventListener(eventName, forward); + this.nativeEl.removeEventListener(eventName, onOutletEvent, true); + }); + }; + } + + /** + * The page in the parent outlet that contains this outlet, if any. + */ + private getParentPageElement(): HTMLElement | undefined { + const parentOutletEl = this.parentOutlet?.nativeEl; + if (!parentOutletEl) { + return undefined; + } + + let el: HTMLElement | null = this.nativeEl; + while (el && el.parentElement !== parentOutletEl) { + el = el.parentElement; + } + return el ?? undefined; + } + get isActivated(): boolean { return !!this.activated; } @@ -389,7 +517,7 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit { proxy._futureSnapshot = (activatedRoute as any)._futureSnapshot; proxy._routerState = (activatedRoute as any)._routerState; - proxy.snapshot = activatedRoute.snapshot; + bindProxySnapshot(proxy, activatedRoute); proxy.outlet = activatedRoute.outlet; proxy.component = activatedRoute.component; @@ -433,7 +561,7 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit { (proxy as any)._futureSnapshot = (activatedRoute as any)._futureSnapshot; (proxy as any)._routerState = (activatedRoute as any)._routerState; - proxy.snapshot = activatedRoute.snapshot; + bindProxySnapshot(proxy, activatedRoute); proxy.outlet = activatedRoute.outlet; proxy.component = activatedRoute.component; @@ -441,6 +569,18 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit { } } +/** + * A query params change reuses the page and updates the route's snapshot without + * re-activating the outlet, so the proxy reads it live instead of copying it once. + * It's configurable because `updateActivatedRouteProxy` rebinds it. + */ +const bindProxySnapshot = (proxy: ActivatedRoute, activatedRoute: ActivatedRoute): void => { + Object.defineProperty(proxy, 'snapshot', { + get: () => activatedRoute.snapshot, + configurable: true, + }); +}; + class OutletInjector implements Injector { constructor(private route: ActivatedRoute, private childContexts: ChildrenOutletContexts, private parent: Injector) {} diff --git a/packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts b/packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts new file mode 100644 index 00000000000..c116dd5bc7f --- /dev/null +++ b/packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Transforms a value to a boolean so that boolean properties can be set by attribute presence, + * e.g. `` instead of ``. + * + * Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare + * attribute) becomes `true` and `'false'` becomes `false`. + * + * Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than + * coerced to `false`. Components frequently treat them as a state distinct from `false`: + * + * ```tsx + * // `undefined` means "decide based on the mode", which is not the same as `false` + * const showDetail = detail !== undefined ? detail : mode === 'ios'; + * + * // a strict comparison also behaves differently for `null` than it does for `false` + * const showHandle = handle !== false; + * ``` + * + * Both values reach inputs routinely in Angular templates, from the `async` pipe before its + * first emission and from form control values, so coercing them would change the behavior of + * bindings that work today. + * + * This is implemented here rather than imported from `@angular/core` so that consumers on + * Angular versions without `booleanAttribute` are unaffected by this file being generated. + * + * The parameter type is what Angular derives `ngAcceptInputType_*` from, so it decides which + * template bindings compile. Widening it to `unknown` would let any expression through. + * + * Declared as a function rather than an arrow constant because Angular has to resolve input + * transforms statically when compiling a library in partial compilation mode. + * + * This lives in its own file, separate from `utils.ts`, so that it carries no runtime imports. + * That keeps it independently type-checkable without pulling `rxjs` in for `proxyOutputs`. + */ +export function nullableBooleanAttribute(value: boolean | string | null | undefined): boolean | null | undefined { + if (value === null || value === undefined) { + return value; + } + return typeof value === 'boolean' ? value : value !== 'false'; +} diff --git a/packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts b/packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts new file mode 100644 index 00000000000..c116dd5bc7f --- /dev/null +++ b/packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Transforms a value to a boolean so that boolean properties can be set by attribute presence, + * e.g. `` instead of ``. + * + * Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare + * attribute) becomes `true` and `'false'` becomes `false`. + * + * Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than + * coerced to `false`. Components frequently treat them as a state distinct from `false`: + * + * ```tsx + * // `undefined` means "decide based on the mode", which is not the same as `false` + * const showDetail = detail !== undefined ? detail : mode === 'ios'; + * + * // a strict comparison also behaves differently for `null` than it does for `false` + * const showHandle = handle !== false; + * ``` + * + * Both values reach inputs routinely in Angular templates, from the `async` pipe before its + * first emission and from form control values, so coercing them would change the behavior of + * bindings that work today. + * + * This is implemented here rather than imported from `@angular/core` so that consumers on + * Angular versions without `booleanAttribute` are unaffected by this file being generated. + * + * The parameter type is what Angular derives `ngAcceptInputType_*` from, so it decides which + * template bindings compile. Widening it to `unknown` would let any expression through. + * + * Declared as a function rather than an arrow constant because Angular has to resolve input + * transforms statically when compiling a library in partial compilation mode. + * + * This lives in its own file, separate from `utils.ts`, so that it carries no runtime imports. + * That keeps it independently type-checkable without pulling `rxjs` in for `proxyOutputs`. + */ +export function nullableBooleanAttribute(value: boolean | string | null | undefined): boolean | null | undefined { + if (value === null || value === undefined) { + return value; + } + return typeof value === 'boolean' ? value : value !== 'false'; +} diff --git a/packages/angular/src/standalone/navigation/tabs.ts b/packages/angular/src/standalone/navigation/tabs.ts index 486e7afe64b..ccce63094c7 100644 --- a/packages/angular/src/standalone/navigation/tabs.ts +++ b/packages/angular/src/standalone/navigation/tabs.ts @@ -2,7 +2,8 @@ import { NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, ContentChild, ContentChildren, ViewChild, QueryList } from '@angular/core'; import { IonTabs as IonTabsBase } from '@ionic/angular/common'; -import { IonTabBar, IonTab } from '../directives/proxies'; +import { IonTab } from '../directives/ion-tab'; +import { IonTabBar } from '../directives/ion-tab-bar'; import { IonRouterOutlet } from './router-outlet'; diff --git a/packages/angular/test/apps/ng19/package-lock.json b/packages/angular/test/apps/ng19/package-lock.json index 63feeda6270..731a58c8eb4 100644 --- a/packages/angular/test/apps/ng19/package-lock.json +++ b/packages/angular/test/apps/ng19/package-lock.json @@ -51,7 +51,7 @@ "ts-node": "^8.3.0", "typescript": "^5.6.3", "wait-on": "^8.0.1", - "webpack": "^5.61.0", + "webpack": "^5.111.1", "webpack-cli": "^4.9.2" }, "engines": { @@ -270,167 +270,6 @@ } } }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, "node_modules/@angular-devkit/build-angular/node_modules/enhanced-resolve": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", @@ -4117,9 +3956,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -6725,73 +6564,73 @@ } }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", - "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", - "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", - "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", - "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", - "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", - "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", - "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", - "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "license": "MIT", "dependencies": { @@ -6799,9 +6638,9 @@ } }, "node_modules/@webassemblyjs/leb128": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", - "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6809,79 +6648,79 @@ } }, "node_modules/@webassemblyjs/utf8": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", - "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", - "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/helper-wasm-section": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-opt": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "@webassemblyjs/wast-printer": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", - "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", - "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", - "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", - "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, @@ -6969,9 +6808,9 @@ } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -6980,17 +6819,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "deprecated": "package has been renamed to acorn-import-attributes", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -7403,6 +7231,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -7556,9 +7397,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", - "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -7576,10 +7417,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -7779,6 +7621,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -7789,9 +7644,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001684", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001684.tgz", - "integrity": "sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "dev": true, "funding": [ { @@ -8128,6 +7983,13 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/common-path-prefix": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", @@ -8861,12 +8723,26 @@ "dev": true, "license": "BSD-2-Clause", "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "engines": { + "node": ">= 0.4" } }, "node_modules/eastasianwidth": { @@ -8883,9 +8759,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.65", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.65.tgz", - "integrity": "sha512-PWVzBjghx7/wop6n22vS2MLU8tKGd4Q91aCEGhG/TYmW6PP5OcSXcdnxTe1NNt0T66N8D6jxh4kC8UsdzOGaIw==", + "version": "1.5.451", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.451.tgz", + "integrity": "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg==", "dev": true, "license": "ISC" }, @@ -9044,13 +8920,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -9065,12 +8938,40 @@ } }, "node_modules/es-module-lexer": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", + "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" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", @@ -9769,15 +9670,17 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -9892,16 +9795,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -9910,6 +9818,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -10006,12 +9927,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10058,10 +9979,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -10070,11 +9991,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { "node": ">= 0.4" }, @@ -10083,9 +10008,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10963,13 +10888,6 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "license": "MIT" }, - "node_modules/json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true, - "license": "MIT" - }, "node_modules/json-parse-even-better-errors": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", @@ -11774,6 +11692,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -12015,6 +11942,98 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.13.0.tgz", + "integrity": "sha512-OO1NF2zHj6sf4CNv2AsUWYcS8WEwkVKQDXxxKzlOxx1Xl+WlqYtVC+oNlGNwGkLAOnpj277BSHypWwRtd9w4JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.3", + "terser": "^5.51.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@napi-rs/image": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "imagemin": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "sharp": { + "optional": true + }, + "svgo": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -12445,11 +12464,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "7.2.1", @@ -14283,41 +14305,40 @@ "optional": true }, "node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", "dev": true, "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 10.13.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" } }, - "node_modules/schema-utils/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/select-hose": { @@ -15004,13 +15025,17 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar": { @@ -15181,13 +15206,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -15731,9 +15749,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -15752,7 +15770,7 @@ "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -16379,36 +16397,29 @@ "optional": true }, "node_modules/webpack": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.61.0.tgz", - "integrity": "sha512-fPdTuaYZ/GMGFm4WrPi2KRCqS1vDp773kj9S0iI5Uc//5cszsFEDgHNaX4Rj1vobUiU1dFIV3mA9k1eHeluFpw==", + "version": "5.111.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.111.1.tgz", + "integrity": "sha512-cNypaz0RP+S4cvQVi1M/3bRUkvNP0xZpL0TjFx+c6jg64VbxD+2weWDgY9UnjRI6dhZgtaj8DU76GGFcJ79xPQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/eslint-scope": "^3.7.0", - "@types/estree": "^0.0.50", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.4.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.8.3", - "es-module-lexer": "^0.9.0", - "eslint-scope": "5.1.1", + "enhanced-resolve": "^5.25.0", + "es-module-lexer": "^2.1.0", "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.4", - "json-parse-better-errors": "^1.0.2", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.2.0", - "webpack-sources": "^3.2.0" + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.7.0", + "schema-utils": "^4.5.0", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" }, "bin": { "webpack": "bin/webpack.js" @@ -16916,9 +16927,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.6.0.tgz", + "integrity": "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==", "dev": true, "license": "MIT", "engines": { @@ -16948,101 +16959,47 @@ } }, "node_modules/webpack/node_modules/@types/estree": { - "version": "0.0.50", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz", - "integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/webpack/node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">= 0.6" } }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "node_modules/webpack/node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "graceful-fs": "^4.1.2" }, "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">=10.13.0" } }, "node_modules/websocket-driver": { diff --git a/packages/angular/test/apps/ng19/package.json b/packages/angular/test/apps/ng19/package.json index c24dd67b4e2..a417471fb4d 100644 --- a/packages/angular/test/apps/ng19/package.json +++ b/packages/angular/test/apps/ng19/package.json @@ -59,7 +59,7 @@ "ts-node": "^8.3.0", "typescript": "^5.6.3", "wait-on": "^8.0.1", - "webpack": "^5.61.0", + "webpack": "^5.111.1", "webpack-cli": "^4.9.2" }, "overrides": { diff --git a/packages/angular/test/base/e2e/src/standalone/router-outlet-query-params.spec.ts b/packages/angular/test/base/e2e/src/standalone/router-outlet-query-params.spec.ts new file mode 100644 index 00000000000..5bcfc5d67b5 --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/router-outlet-query-params.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Router Outlet: query params', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/standalone/router-outlet-query-params'); + }); + + test('should resolve relative links after navigating to the same page with query params', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/24587', + }); + + await page.locator('#set-query-params').click(); + await expect(page).toHaveURL(/\/standalone\/router-outlet-query-params\?foo=bar$/); + + await page.locator('#go-to-details').click(); + + await expect(page).toHaveURL(/\/standalone\/router-outlet-query-params\/details$/); + await expect(page.locator('app-router-outlet-query-params-details')).toBeVisible(); + }); + + test('should update the route snapshot after navigating to the same page with query params', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/24587', + }); + + await page.locator('#set-query-params').click(); + await expect(page).toHaveURL(/\/standalone\/router-outlet-query-params\?foo=bar$/); + + await page.locator('#read-snapshot').click(); + + await expect(page.locator('#snapshot-foo')).toHaveText('bar'); + }); +}); diff --git a/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts b/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts new file mode 100644 index 00000000000..dd2365ae92b --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts @@ -0,0 +1,123 @@ +import { expect, test, type Page } from '@playwright/test'; +import { ionSwipeToGoBack } from '../../utils/drag-utils'; +import { ionPageVisible, ionPageHidden, ionPageDoesNotExist } from '../../utils/test-utils'; + +/** + * Starts a swipe to go back and lets go partway across, so the user + * should stay on the current page. + */ +const abandonSwipeBack = async (page: Page) => { + const viewport = page.viewportSize()!; + const y = viewport.height / 2; + + await page.mouse.move(5, y); + await page.mouse.down(); + await page.mouse.move(35, y, { steps: 5 }); + await page.mouse.move(65, y, { steps: 5 }); + + // A quick drag is released with enough velocity to complete the swipe, so slow down before letting go. + for (let i = 1; i <= 3; i++) { + await page.mouse.move(65 + i, y); + await page.waitForTimeout(50); + } + + await page.mouse.up(); + + // Let a back transition started by the swipe settle before asserting. + await page.waitForTimeout(500); +}; + +test.describe('Swipe Animated Disabled', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/standalone/swipe-animated-disabled?ionic:mode=ios'); + }); + + test('should not fire enter lifecycle events on the previous page when an abandoned swipe starts on a non-animated outlet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await abandonSwipeBack(page); + + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + }); + + test('should go back when a swipe completes on a non-animated outlet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await ionPageDoesNotExist(page, 'app-swipe-animated-disabled-details'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('2'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('2'); + }); + + test('should swipe back once the outlet becomes animated', async ({ page }) => { + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + await page.locator('#toggle-animated').click(); + await expect(page.locator('#animated-value')).toHaveText('true'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await ionPageDoesNotExist(page, 'app-swipe-animated-disabled-details'); + }); +}); + +test.describe('Swipe Animated Disabled: global config', () => { + test.beforeEach(async ({ page }) => { + // Testing mode sets the global `animated` config to false. + await page.goto('/standalone/swipe-animated-disabled?ionic:mode=ios&ionic:_testing=true'); + }); + + test('should not fire enter lifecycle events on the previous page when an abandoned swipe starts with animations disabled globally', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + // Animate the outlet so the global config is the only thing disabling animations. + await page.locator('#toggle-animated').click(); + await expect(page.locator('#animated-value')).toHaveText('true'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await abandonSwipeBack(page); + + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + }); +}); diff --git a/packages/angular/test/base/e2e/src/standalone/tab-lifecycle.spec.ts b/packages/angular/test/base/e2e/src/standalone/tab-lifecycle.spec.ts new file mode 100644 index 00000000000..ce0d5715094 --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/tab-lifecycle.spec.ts @@ -0,0 +1,254 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { ionSwipeToGoBack } from '../../utils/drag-utils'; +import { ionPageHidden, ionPageVisible } from '../../utils/test-utils'; + +const getLifecycleEvents = async (page: Page, pageName?: string): Promise => { + const events: string[] = await page.evaluate(() => (window as any).lifecycleEvents ?? []); + return pageName ? events.filter((event) => event.startsWith(`${pageName}:`)) : events; +}; + +const clearLifecycleEvents = (page: Page) => page.evaluate(() => ((window as any).lifecycleEvents = [])); + +/** + * Drags far enough from the left edge to start the swipe back gesture, then + * returns to the edge before releasing so the gesture is cancelled. + * Calling `ionSwipeToGoBack(page, false)` won't work because it doesn't move at all. + */ +const cancelledSwipeBack = async (page: Page) => { + const box = (await page.locator('ion-router-outlet').first().boundingBox())!; + const y = box.y + box.height / 2; + + await page.mouse.move(box.x + 2, y); + await page.mouse.down(); + for (const x of [10, 20, 40, 60, 80, 100, 60, 30, 10, 2]) { + await page.mouse.move(box.x + x, y); + await page.waitForTimeout(25); + } + await page.mouse.up(); +}; + +/** + * Verifies that the active tab's page, and a page in an outlet nested inside + * it, get leave events when navigating out of the tabs, get enter events when + * coming back, and never get leave events twice. + */ +test.describe('Tabs: active tab lifecycle when leaving and returning to the tabs', () => { + test('should fire ionViewWillLeave and ionViewDidLeave on the active tab page', async ({ page }, testInfo) => { + testInfo.annotations.push({ type: 'issue', description: 'FW-7148' }); + + await page.goto('/standalone/tab-lifecycle/home'); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + await expect.poll(() => getLifecycleEvents(page)).toEqual(['home:ionViewWillEnter', 'home:ionViewDidEnter']); + await clearLifecycleEvents(page); + + await page.locator('#go-outside-home').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + + await expect.poll(() => getLifecycleEvents(page)).toEqual(['home:ionViewWillLeave', 'home:ionViewDidLeave']); + }); + + test('should fire leave events on the active tab page each time the tabs are left', async ({ page }) => { + await page.goto('/standalone/tab-lifecycle/home'); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + + await page.locator('#go-outside-home').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + await expect.poll(() => getLifecycleEvents(page)).toContain('home:ionViewDidLeave'); + await clearLifecycleEvents(page); + + await page.locator('app-tab-lifecycle-outside ion-back-button').click(); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + await expect.poll(() => getLifecycleEvents(page)).toEqual(['home:ionViewWillEnter', 'home:ionViewDidEnter']); + await clearLifecycleEvents(page); + + await page.locator('#go-outside-home').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + + await expect.poll(() => getLifecycleEvents(page)).toEqual(['home:ionViewWillLeave', 'home:ionViewDidLeave']); + }); + + test('should fire each event once on the active tab page when the tabs page also re-dispatches them', async ({ + page, + }) => { + await page.goto('/standalone/tab-lifecycle/home?propagate=true'); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + await expect.poll(() => getLifecycleEvents(page)).toContain('tabs:ionViewDidEnter'); + await clearLifecycleEvents(page); + + await page.locator('#go-outside-home').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + await expect.poll(() => getLifecycleEvents(page)).toContain('tabs:ionViewDidLeave'); + expect(await getLifecycleEvents(page, 'home')).toEqual(['home:ionViewWillLeave', 'home:ionViewDidLeave']); + await clearLifecycleEvents(page); + + await page.locator('app-tab-lifecycle-outside ion-back-button').click(); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + await expect.poll(() => getLifecycleEvents(page)).toContain('tabs:ionViewDidEnter'); + expect(await getLifecycleEvents(page, 'home')).toEqual(['home:ionViewWillEnter', 'home:ionViewDidEnter']); + }); + + test('should fire ionViewWillLeave and ionViewDidLeave on the active tab page when a swipe back from the tabs completes', async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await page.goto('/standalone?ionic:mode=ios'); + + await page.locator('ion-item[routerLink="/standalone/tab-lifecycle"]').click(); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + await ionPageHidden(page, 'app-home-page'); + await expect.poll(() => getLifecycleEvents(page)).toContain('home:ionViewDidEnter'); + await clearLifecycleEvents(page); + + await ionSwipeToGoBack(page, true); + await ionPageVisible(page, 'app-home-page'); + + await expect.poll(() => getLifecycleEvents(page, 'home')).toEqual(['home:ionViewWillLeave', 'home:ionViewDidLeave']); + }); + + test('should fire ionViewWillLeave on the active tab page when a swipe back from the tabs is cancelled', async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await page.goto('/standalone?ionic:mode=ios'); + + await page.locator('ion-item[routerLink="/standalone/tab-lifecycle"]').click(); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + await ionPageHidden(page, 'app-home-page'); + await expect.poll(() => getLifecycleEvents(page)).toContain('home:ionViewDidEnter'); + await clearLifecycleEvents(page); + + await cancelledSwipeBack(page); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + await page.waitForFunction(() => document.getAnimations().length === 0); + + expect(await getLifecycleEvents(page, 'home')).toEqual(['home:ionViewWillLeave']); + }); + + test('should not fire leave events on a previously visited tab that is hidden', async ({ page }) => { + await page.goto('/standalone/tab-lifecycle/home'); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + + await page.locator('ion-tab-button[tab="settings"]').click(); + await ionPageVisible(page, 'app-tab-lifecycle-settings'); + await expect + .poll(() => getLifecycleEvents(page, 'settings')) + .toEqual(['settings:ionViewWillEnter', 'settings:ionViewDidEnter']); + await clearLifecycleEvents(page); + + await page.locator('#go-outside-settings').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + + await expect.poll(() => getLifecycleEvents(page)).toEqual(['settings:ionViewWillLeave', 'settings:ionViewDidLeave']); + }); + + test('should fire ionViewWillEnter and ionViewDidEnter on the active tab page when going back to the tabs', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ type: 'issue', description: 'FW-7148' }); + + await page.goto('/standalone/tab-lifecycle/home'); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + + await page.locator('#go-outside-home').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + await expect.poll(() => getLifecycleEvents(page)).toContain('home:ionViewDidLeave'); + await clearLifecycleEvents(page); + + await page.locator('app-tab-lifecycle-outside ion-back-button').click(); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + + await expect.poll(() => getLifecycleEvents(page)).toEqual(['home:ionViewWillEnter', 'home:ionViewDidEnter']); + }); + + test('should not fire leave events again on the tab that was left when returning to a different tab', async ({ + page, + }) => { + await page.goto('/standalone/tab-lifecycle/home'); + await ionPageVisible(page, 'app-tab-lifecycle-home'); + + await page.locator('#go-outside-home').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + await expect.poll(() => getLifecycleEvents(page)).toContain('home:ionViewDidLeave'); + await clearLifecycleEvents(page); + + await page.locator('#go-to-settings').click(); + await ionPageVisible(page, 'app-tab-lifecycle-settings'); + await expect.poll(() => getLifecycleEvents(page)).toEqual(['settings:ionViewWillEnter', 'settings:ionViewDidEnter']); + + expect(await getLifecycleEvents(page, 'home')).toEqual([]); + }); + + test('should fire ionViewWillLeave and ionViewDidLeave on a page in an outlet nested inside the active tab', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ type: 'issue', description: 'FW-7148' }); + + await page.goto('/standalone/tab-lifecycle/nested'); + await ionPageVisible(page, 'app-tab-lifecycle-inner'); + await expect.poll(() => getLifecycleEvents(page)).toContain('inner:ionViewDidEnter'); + await clearLifecycleEvents(page); + + await page.locator('#go-outside-inner').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + + await expect.poll(() => getLifecycleEvents(page)).toEqual(['inner:ionViewWillLeave', 'inner:ionViewDidLeave']); + }); + + test('should fire ionViewWillEnter and ionViewDidEnter on a page in a nested outlet when going back to the tabs', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ type: 'issue', description: 'FW-7148' }); + + await page.goto('/standalone/tab-lifecycle/nested'); + await ionPageVisible(page, 'app-tab-lifecycle-inner'); + + await page.locator('#go-outside-inner').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + await expect.poll(() => getLifecycleEvents(page)).toContain('inner:ionViewDidLeave'); + await clearLifecycleEvents(page); + + await page.locator('app-tab-lifecycle-outside ion-back-button').click(); + await ionPageVisible(page, 'app-tab-lifecycle-inner'); + + await expect.poll(() => getLifecycleEvents(page, 'inner')).toEqual(['inner:ionViewWillEnter', 'inner:ionViewDidEnter']); + }); + + test('should not fire leave events again on a page in a nested outlet when returning to a different tab', async ({ + page, + }) => { + await page.goto('/standalone/tab-lifecycle/nested'); + await ionPageVisible(page, 'app-tab-lifecycle-inner'); + + await page.locator('#go-outside-inner').click(); + await ionPageVisible(page, 'app-tab-lifecycle-outside'); + await expect.poll(() => getLifecycleEvents(page)).toContain('inner:ionViewDidLeave'); + await clearLifecycleEvents(page); + + await page.locator('#go-to-settings').click(); + await ionPageVisible(page, 'app-tab-lifecycle-settings'); + await expect.poll(() => getLifecycleEvents(page)).toEqual(['settings:ionViewWillEnter', 'settings:ionViewDidEnter']); + + expect(await getLifecycleEvents(page, 'inner')).toEqual([]); + }); + + test('should fire leave and enter events on a page in a nested outlet when switching tabs', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ type: 'issue', description: 'FW-7148' }); + + await page.goto('/standalone/tab-lifecycle/nested'); + await ionPageVisible(page, 'app-tab-lifecycle-inner'); + await expect.poll(() => getLifecycleEvents(page)).toContain('inner:ionViewDidEnter'); + await clearLifecycleEvents(page); + + await page.locator('ion-tab-button[tab="settings"]').click(); + await ionPageVisible(page, 'app-tab-lifecycle-settings'); + await expect.poll(() => getLifecycleEvents(page, 'inner')).toEqual(['inner:ionViewWillLeave', 'inner:ionViewDidLeave']); + await clearLifecycleEvents(page); + + await page.locator('ion-tab-button[tab="nested"]').click(); + await ionPageVisible(page, 'app-tab-lifecycle-inner'); + await expect.poll(() => getLifecycleEvents(page, 'inner')).toEqual(['inner:ionViewWillEnter', 'inner:ionViewDidEnter']); + }); +}); diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts index 337b57b8149..37a2bae6314 100644 --- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts +++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts @@ -36,6 +36,14 @@ export const routes: Routes = [ { path: 'modal-options-generic', loadComponent: () => import('../modal-options-generic/modal-options-generic.component').then(c => c.ModalOptionsGenericComponent) }, { path: 'popover-custom-injector', loadComponent: () => import('../popover-custom-injector/popover-custom-injector.component').then(c => c.PopoverCustomInjectorComponent) }, { path: 'router-outlet', loadComponent: () => import('../router-outlet/router-outlet.component').then(c => c.RouterOutletComponent) }, + { + path: 'router-outlet-query-params', + loadComponent: () => import('../router-outlet-query-params/router-outlet-query-params-parent.component').then(c => c.RouterOutletQueryParamsParentComponent), + children: [ + { path: '', loadComponent: () => import('../router-outlet-query-params/router-outlet-query-params.component').then(c => c.RouterOutletQueryParamsComponent) }, + { path: 'details', loadComponent: () => import('../router-outlet-query-params/router-outlet-query-params-details.component').then(c => c.RouterOutletQueryParamsDetailsComponent) }, + ] + }, { path: 'back-button', loadComponent: () => import('../back-button/back-button.component').then(c => c.BackButtonComponent) }, { path: 'router-link', loadComponent: () => import('../router-link/router-link.component').then(c => c.RouterLinkComponent) }, { path: 'nav', loadComponent: () => import('../nav/nav.component').then(c => c.NavComponent) }, @@ -76,6 +84,23 @@ export const routes: Routes = [ { path: 'tab4', loadComponent: () => import('../tabs-search-params/tab4.component').then(c => c.TabsSearchParamsTab4Component) } ] }, + { path: 'tab-lifecycle', redirectTo: '/standalone/tab-lifecycle/home', pathMatch: 'full' }, + { + path: 'tab-lifecycle', + loadComponent: () => import('../tab-lifecycle/tab-lifecycle.component').then(c => c.TabLifecycleComponent), + children: [ + { path: 'home', loadComponent: () => import('../tab-lifecycle/tab-lifecycle-home.component').then(c => c.TabLifecycleHomeComponent) }, + { path: 'settings', loadComponent: () => import('../tab-lifecycle/tab-lifecycle-settings.component').then(c => c.TabLifecycleSettingsComponent) }, + { + path: 'nested', + loadComponent: () => import('../tab-lifecycle/tab-lifecycle-nested.component').then(c => c.TabLifecycleNestedComponent), + children: [ + { path: '', loadComponent: () => import('../tab-lifecycle/tab-lifecycle-inner.component').then(c => c.TabLifecycleInnerComponent) } + ] + } + ] + }, + { path: 'tab-lifecycle-outside', loadComponent: () => import('../tab-lifecycle/tab-lifecycle-outside.component').then(c => c.TabLifecycleOutsideComponent) }, { path: 'swipe-gesture-disabled', loadComponent: () => import('../swipe-gesture-disabled/swipe-gesture-disabled.component').then(c => c.SwipeGestureDisabledComponent), @@ -84,6 +109,14 @@ export const routes: Routes = [ { path: 'details', loadComponent: () => import('../swipe-gesture-disabled/swipe-gesture-disabled-details.component').then(c => c.SwipeGestureDisabledDetailsComponent) } ] }, + { + path: 'swipe-animated-disabled', + loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled.component').then(c => c.SwipeAnimatedDisabledComponent), + children: [ + { path: '', loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled-main.component').then(c => c.SwipeAnimatedDisabledMainComponent) }, + { path: 'details', loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled-details.component').then(c => c.SwipeAnimatedDisabledDetailsComponent) } + ] + }, { path: 'validation', children: [ diff --git a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html index 0adb4664e58..9c48d60f5fb 100644 --- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html @@ -79,6 +79,11 @@ Router Outlet Test + + + Router Outlet Query Params Test + + Split Pane Test @@ -99,11 +104,21 @@ Swipe Gesture Disabled Test + + + Swipe Animated Disabled Test + + Tabs Search Params Test + + + Tab Lifecycle Test + + diff --git a/packages/angular/test/base/src/app/standalone/router-outlet-query-params/router-outlet-query-params-details.component.ts b/packages/angular/test/base/src/app/standalone/router-outlet-query-params/router-outlet-query-params-details.component.ts new file mode 100644 index 00000000000..e6bc2b0cfa3 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/router-outlet-query-params/router-outlet-query-params-details.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { IonContent } from '@ionic/angular'; + +@Component({ + selector: 'app-router-outlet-query-params-details', + template: ` + +

Details

+
+ `, + standalone: true, + imports: [IonContent], +}) +export class RouterOutletQueryParamsDetailsComponent {} diff --git a/packages/angular/test/base/src/app/standalone/router-outlet-query-params/router-outlet-query-params-parent.component.ts b/packages/angular/test/base/src/app/standalone/router-outlet-query-params/router-outlet-query-params-parent.component.ts new file mode 100644 index 00000000000..54bff8f83dc --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/router-outlet-query-params/router-outlet-query-params-parent.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { IonRouterOutlet } from '@ionic/angular'; + +@Component({ + selector: 'app-router-outlet-query-params-parent', + template: ``, + standalone: true, + imports: [IonRouterOutlet], +}) +export class RouterOutletQueryParamsParentComponent {} diff --git a/packages/angular/test/base/src/app/standalone/router-outlet-query-params/router-outlet-query-params.component.ts b/packages/angular/test/base/src/app/standalone/router-outlet-query-params/router-outlet-query-params.component.ts new file mode 100644 index 00000000000..1c34f92c8ed --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/router-outlet-query-params/router-outlet-query-params.component.ts @@ -0,0 +1,27 @@ +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { IonContent, IonRouterLinkWithHref } from '@ionic/angular'; + +@Component({ + selector: 'app-router-outlet-query-params', + template: ` + +

Page One

+ Stay with query params
+ Go to details
+ +

{{ snapshotFoo }}

+
+ `, + standalone: true, + imports: [RouterLink, IonContent, IonRouterLinkWithHref], +}) +export class RouterOutletQueryParamsComponent { + private route = inject(ActivatedRoute); + + snapshotFoo = ''; + + readSnapshot() { + this.snapshotFoo = this.route.snapshot.queryParamMap.get('foo') ?? 'none'; + } +} diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts new file mode 100644 index 00000000000..668487e26eb --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { IonContent } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled-details', + template: ` + +
Details (animated disabled)
+
+ `, + standalone: true, + imports: [IonContent] +}) +export class SwipeAnimatedDisabledDetailsComponent {} diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts new file mode 100644 index 00000000000..06d030fb709 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts @@ -0,0 +1,30 @@ +import { Component, signal } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { IonContent, IonItem, IonLabel, IonRouterLink } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled-main', + template: ` + + + Details + +

ionViewWillEnter: {{ willEnter() }}

+

ionViewDidEnter: {{ didEnter() }}

+
+ `, + standalone: true, + imports: [IonContent, IonItem, IonLabel, IonRouterLink, RouterModule] +}) +export class SwipeAnimatedDisabledMainComponent { + willEnter = signal(0); + didEnter = signal(0); + + ionViewWillEnter() { + this.willEnter.update((n) => n + 1); + } + + ionViewDidEnter() { + this.didEnter.update((n) => n + 1); + } +} diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts new file mode 100644 index 00000000000..1c00d0444bb --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts @@ -0,0 +1,29 @@ +import { Component, signal } from '@angular/core'; +import { IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled', + template: ` + + + + + + Swipe Animated Disabled + + + animated: {{ animated() }} + + + + + + + + `, + standalone: true, + imports: [IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar] +}) +export class SwipeAnimatedDisabledComponent { + animated = signal(false); +} diff --git a/packages/angular/test/base/src/app/standalone/tab-lifecycle/lifecycle-log.ts b/packages/angular/test/base/src/app/standalone/tab-lifecycle/lifecycle-log.ts new file mode 100644 index 00000000000..a2a9a09dddf --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-lifecycle/lifecycle-log.ts @@ -0,0 +1,5 @@ +export const logLifecycle = (event: string) => { + const win = window as any; + win.lifecycleEvents = win.lifecycleEvents || []; + win.lifecycleEvents.push(event); +}; diff --git a/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-home.component.ts b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-home.component.ts new file mode 100644 index 00000000000..3a614785f01 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-home.component.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + IonButton, + IonContent, + IonHeader, + IonRouterLink, + IonTitle, + IonToolbar, + ViewDidEnter, + ViewDidLeave, + ViewWillEnter, + ViewWillLeave, +} from '@ionic/angular'; + +import { logLifecycle } from './lifecycle-log'; + +@Component({ + selector: 'app-tab-lifecycle-home', + template: ` + + + Home Tab + + + + Go Outside Tabs + + `, + standalone: true, + imports: [RouterLink, IonButton, IonContent, IonHeader, IonRouterLink, IonTitle, IonToolbar], +}) +export class TabLifecycleHomeComponent implements ViewWillEnter, ViewDidEnter, ViewWillLeave, ViewDidLeave { + ionViewWillEnter() { + logLifecycle('home:ionViewWillEnter'); + } + + ionViewDidEnter() { + logLifecycle('home:ionViewDidEnter'); + } + + ionViewWillLeave() { + logLifecycle('home:ionViewWillLeave'); + } + + ionViewDidLeave() { + logLifecycle('home:ionViewDidLeave'); + } +} diff --git a/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-inner.component.ts b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-inner.component.ts new file mode 100644 index 00000000000..e6875326fb9 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-inner.component.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + IonButton, + IonContent, + IonHeader, + IonRouterLink, + IonTitle, + IonToolbar, + ViewDidEnter, + ViewDidLeave, + ViewWillEnter, + ViewWillLeave, +} from '@ionic/angular'; + +import { logLifecycle } from './lifecycle-log'; + +@Component({ + selector: 'app-tab-lifecycle-inner', + template: ` + + + Inner Page + + + + Go Outside Tabs + + `, + standalone: true, + imports: [RouterLink, IonButton, IonContent, IonHeader, IonRouterLink, IonTitle, IonToolbar], +}) +export class TabLifecycleInnerComponent implements ViewWillEnter, ViewDidEnter, ViewWillLeave, ViewDidLeave { + ionViewWillEnter() { + logLifecycle('inner:ionViewWillEnter'); + } + + ionViewDidEnter() { + logLifecycle('inner:ionViewDidEnter'); + } + + ionViewWillLeave() { + logLifecycle('inner:ionViewWillLeave'); + } + + ionViewDidLeave() { + logLifecycle('inner:ionViewDidLeave'); + } +} diff --git a/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-nested.component.ts b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-nested.component.ts new file mode 100644 index 00000000000..ed74cceab6c --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-nested.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { IonRouterOutlet } from '@ionic/angular'; + +@Component({ + selector: 'app-tab-lifecycle-nested', + template: ``, + standalone: true, + imports: [IonRouterOutlet], +}) +export class TabLifecycleNestedComponent {} diff --git a/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-outside.component.ts b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-outside.component.ts new file mode 100644 index 00000000000..18ed12e0d87 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-outside.component.ts @@ -0,0 +1,33 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonRouterLink, + IonTitle, + IonToolbar, +} from '@ionic/angular'; + +@Component({ + selector: 'app-tab-lifecycle-outside', + template: ` + + + + + + Outside Tabs + + + +

Outside the tabs

+ Go to Settings Tab +
+ `, + standalone: true, + imports: [RouterLink, IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonRouterLink, IonTitle, IonToolbar], +}) +export class TabLifecycleOutsideComponent {} diff --git a/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-settings.component.ts b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-settings.component.ts new file mode 100644 index 00000000000..067b3d4d0fc --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle-settings.component.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + IonButton, + IonContent, + IonHeader, + IonRouterLink, + IonTitle, + IonToolbar, + ViewDidEnter, + ViewDidLeave, + ViewWillEnter, + ViewWillLeave, +} from '@ionic/angular'; + +import { logLifecycle } from './lifecycle-log'; + +@Component({ + selector: 'app-tab-lifecycle-settings', + template: ` + + + Settings Tab + + + + Go Outside Tabs + + `, + standalone: true, + imports: [RouterLink, IonButton, IonContent, IonHeader, IonRouterLink, IonTitle, IonToolbar], +}) +export class TabLifecycleSettingsComponent implements ViewWillEnter, ViewDidEnter, ViewWillLeave, ViewDidLeave { + ionViewWillEnter() { + logLifecycle('settings:ionViewWillEnter'); + } + + ionViewDidEnter() { + logLifecycle('settings:ionViewDidEnter'); + } + + ionViewWillLeave() { + logLifecycle('settings:ionViewWillLeave'); + } + + ionViewDidLeave() { + logLifecycle('settings:ionViewDidLeave'); + } +} diff --git a/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle.component.ts b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle.component.ts new file mode 100644 index 00000000000..1d488c24ec6 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-lifecycle/tab-lifecycle.component.ts @@ -0,0 +1,80 @@ +import { Component, ViewChild } from '@angular/core'; +import { + IonIcon, + IonLabel, + IonTabBar, + IonTabButton, + IonTabs, + ViewDidEnter, + ViewDidLeave, + ViewWillEnter, + ViewWillLeave, +} from '@ionic/angular'; +import { addIcons } from 'ionicons'; +import { square, triangle } from 'ionicons/icons'; + +import { logLifecycle } from './lifecycle-log'; + +addIcons({ square, triangle }); + +@Component({ + selector: 'app-tab-lifecycle', + template: ` + + + + + Home + + + + Settings + + + + Nested + + + + `, + standalone: true, + imports: [IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs], +}) +export class TabLifecycleComponent implements ViewWillEnter, ViewDidEnter, ViewWillLeave, ViewDidLeave { + @ViewChild(IonTabs) tabs!: IonTabs; + + /** + * The `?propagate` param turns on the workaround from + * https://github.com/ionic-team/ionic-framework/issues/16834, where the tabs + * page re-dispatches its lifecycle events onto the active tab. + */ + private propagate = new URLSearchParams(window.location.search).has('propagate'); + private activeTab?: HTMLElement; + + tabChange() { + this.activeTab = this.tabs.outlet.activatedView?.element; + } + + ionViewWillEnter() { + this.propagateToActiveTab('ionViewWillEnter'); + } + + ionViewDidEnter() { + this.propagateToActiveTab('ionViewDidEnter'); + } + + ionViewWillLeave() { + this.propagateToActiveTab('ionViewWillLeave'); + } + + ionViewDidLeave() { + this.propagateToActiveTab('ionViewDidLeave'); + } + + private propagateToActiveTab(eventName: string) { + if (this.propagate) { + this.activeTab?.dispatchEvent(new CustomEvent(eventName)); + logLifecycle(`tabs:${eventName}`); + } + } +} diff --git a/packages/docs/CHANGELOG.md b/packages/docs/CHANGELOG.md index c41faa00af0..fd259332472 100644 --- a/packages/docs/CHANGELOG.md +++ b/packages/docs/CHANGELOG.md @@ -3,6 +3,30 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +**Note:** Version bump only for package @ionic/docs + + + + + +## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) + +**Note:** Version bump only for package @ionic/docs + + + + + +## [9.0.7](https://github.com/ionic-team/ionic-framework/compare/v9.0.6...v9.0.7) (2026-10-07) + +**Note:** Version bump only for package @ionic/docs + + + + + ## [9.0.6](https://github.com/ionic-team/ionic-framework/compare/v9.0.5...v9.0.6) (2026-09-30) **Note:** Version bump only for package @ionic/docs diff --git a/packages/docs/package-lock.json b/packages/docs/package-lock.json index 0b4c32b1c40..86294ffba1a 100644 --- a/packages/docs/package-lock.json +++ b/packages/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/docs", - "version": "9.0.6", + "version": "9.0.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/docs", - "version": "9.0.6", + "version": "9.0.9", "license": "MIT" } } diff --git a/packages/docs/package.json b/packages/docs/package.json index 073f240f010..ca1de91c4d7 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/docs", - "version": "9.0.6", + "version": "9.0.9", "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 4714971f7aa..81569b6c43b 100644 --- a/packages/migrate/package-lock.json +++ b/packages/migrate/package-lock.json @@ -970,9 +970,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", - "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -980,22 +980,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", - "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.2", + "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -1008,9 +1008,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", - "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { @@ -1049,9 +1049,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -1151,9 +1151,9 @@ } }, "node_modules/magic-string": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", - "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1196,9 +1196,9 @@ } }, "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==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -1323,16 +1323,16 @@ } }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", "engines": { @@ -1340,9 +1340,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -1509,25 +1509,25 @@ } }, "node_modules/vitest": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", - "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.2", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", - "why-is-node-running": "^3.2.1" + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -1542,12 +1542,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.2", - "@vitest/browser-preview": "5.0.2", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.2", - "@vitest/coverage-v8": "5.0.2", - "@vitest/ui": "5.0.2", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -1592,9 +1592,9 @@ } }, "node_modules/why-is-node-running": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", - "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", "bin": { diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index a9b898792a1..c9243f09c62 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -3,6 +3,27 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) + + +## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) + +### Bug Fixes + +* **react-router:** stop hidden views from re-rendering on every navigation ([#31541](https://github.com/ionic-team/ionic-framework/issues/31541)) ([4ae0196](https://github.com/ionic-team/ionic-framework/commit/4ae01961e8ed65e26369e09d478fabeffba5ed6d)), closes [#31528](https://github.com/ionic-team/ionic-framework/issues/31528) + + +## [9.0.7](https://github.com/ionic-team/ionic-framework/compare/v9.0.6...v9.0.7) (2026-10-07) + +### Bug Fixes + +* **react:** prevent error when an outlet unmounts before it's ready ([#31517](https://github.com/ionic-team/ionic-framework/issues/31517)) ([d5bab2e](https://github.com/ionic-team/ionic-framework/commit/d5bab2e89548311b2e3f62b5fe2be477685cb06d)), closes [#31513](https://github.com/ionic-team/ionic-framework/issues/31513) + + ## [9.0.6](https://github.com/ionic-team/ionic-framework/compare/v9.0.5...v9.0.6) (2026-09-30) ### Bug Fixes diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json index 68900c81769..fec794417da 100644 --- a/packages/react-router/package-lock.json +++ b/packages/react-router/package-lock.json @@ -1,16 +1,16 @@ { "name": "@ionic/react-router", - "version": "9.0.6", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react-router", - "version": "9.0.6", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.6", - "@ionic/react": "^9.0.6", + "@ionic/core": "^9.0.9", + "@ionic/react": "^9.0.9", "tslib": "*" }, "devDependencies": { @@ -252,9 +252,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.6.tgz", - "integrity": "sha512-WWxjOjwKPU75BuEhc42t3n2Tn89YEDe5Pce9kA61w/TlWVM3tqiAYC+qfR9MXmER4pQcST01oM2gftheMzcV6g==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -292,12 +292,12 @@ } }, "node_modules/@ionic/react": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.6.tgz", - "integrity": "sha512-WAcbe7ZDIDqQaXvcAF7+AxpYW3Y3HFvZbDnpwQtoSUo8RTAw+AQQOTTSlp1eHOjwqpVqYceBFAdhfNbzBfw/1w==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.9.tgz", + "integrity": "sha512-bnfGMECWnaFNIuljq1Z18YmQUu8Tk9UhPPLqkbKBmzO9MTKPqOGTrcKWOuYaPGHELTQLVENYHt43igpIhWpHVg==", "license": "MIT", "dependencies": { - "@ionic/core": "9.0.6", + "@ionic/core": "9.0.9", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" diff --git a/packages/react-router/package.json b/packages/react-router/package.json index b542f52c525..ba800449943 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react-router", - "version": "9.0.6", + "version": "9.0.9", "description": "React Router wrapper for @ionic/react", "keywords": [ "ionic", @@ -39,8 +39,8 @@ "dist/" ], "dependencies": { - "@ionic/core": "^9.0.6", - "@ionic/react": "^9.0.6", + "@ionic/core": "^9.0.9", + "@ionic/react": "^9.0.9", "tslib": "*" }, "peerDependencies": { diff --git a/packages/react-router/src/ReactRouter/IonRouter.tsx b/packages/react-router/src/ReactRouter/IonRouter.tsx index 6c266218639..ac9e46d04b8 100644 --- a/packages/react-router/src/ReactRouter/IonRouter.tsx +++ b/packages/react-router/src/ReactRouter/IonRouter.tsx @@ -23,6 +23,8 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { IonRouteInner } from './IonRouteInner'; import { ReactRouterViewStack } from './ReactRouterViewStack'; import StackManager from './StackManager'; +import { areParamsEqual } from './utils/areParamsEqual'; +import type { RouteParams } from './utils/areParamsEqual'; export interface LocationState { direction?: RouterDirection; @@ -33,7 +35,6 @@ interface IonRouterProps { registerHistoryListener: (cb: (location: HistoryLocation, action: HistoryAction) => void) => void; } -type RouteParams = Record; type SafeRouteParams = Record; const filterUndefinedParams = (params: RouteParams): SafeRouteParams => { @@ -70,29 +71,6 @@ const checkIsMultiStepBack = ( return false; }; -const areParamsEqual = (a?: RouteParams, b?: RouteParams) => { - const paramsA = a || {}; - const paramsB = b || {}; - const keysA = Object.keys(paramsA); - const keysB = Object.keys(paramsB); - - if (keysA.length !== keysB.length) { - return false; - } - - return keysA.every((key) => { - const valueA = paramsA[key]; - const valueB = paramsB[key]; - if (Array.isArray(valueA) && Array.isArray(valueB)) { - if (valueA.length !== valueB.length) { - return false; - } - return valueA.every((entry, idx) => entry === valueB[idx]); - } - return valueA === valueB; - }); -}; - export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildren) => { const location = useLocation(); const navigate = useNavigate(); diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index dd2740250ef..49989deee26 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -11,6 +11,7 @@ import React from 'react'; import type { PathMatch } from 'react-router-dom'; import { Navigate, UNSAFE_RouteContext as RouteContext } from 'react-router-dom'; +import { areParamsEqual } from './utils/areParamsEqual'; import { analyzeRouteChildren, computeParentPath } from './utils/computeParentPath'; import { derivePathnameToMatch, matchPath } from './utils/pathMatching'; import { normalizePathnameForComparison } from './utils/pathNormalization'; @@ -218,6 +219,54 @@ const resolveIndexRouteMatch = ( return normalizedPathname === normalizedBase ? previousMatch : null; }; +const shallowEqual = (a: object, b: object): boolean => { + const aRecord = a as Record; + const bRecord = b as Record; + const aKeys = Object.keys(aRecord); + return aKeys.length === Object.keys(bRecord).length && aKeys.every((key) => aRecord[key] === bRecord[key]); +}; + +const isSameMatch = (a: RouteContextMatch, b: RouteContextMatch): boolean => + a === b || + (a.pathname === b.pathname && + a.pathnameBase === b.pathnameBase && + areParamsEqual(a.params, b.params) && + (a.route === b.route || shallowEqual(a.route, b.route))); + +/** + * The outlet rebuilds a view's `RouteContext` several times per navigation, so + * this compares by content to tell whether anything actually changed. + */ +const isSameRouteContext = ( + a: React.ContextType, + b: React.ContextType +): boolean => { + const { matches: aMatches, ...aRest } = a; + const { matches: bMatches, ...bRest } = b; + return ( + aMatches.length === bMatches.length && + shallowEqual(aRest, bRest) && + aMatches.every((match, i) => isSameMatch(match as RouteContextMatch, bMatches[i] as RouteContextMatch)) + ); +}; + +/** + * Reuses the last `RouteContext` while its content is the same or `hold` is set, + * so hidden pages that only call `useParams` don't re-render. It lives in + * component state because outlets with the same id share view items. + */ +const ViewRouteContextProvider: React.FC<{ + value: React.ContextType; + hold: boolean; + children: React.ReactNode; +}> = ({ value, hold, children }) => { + const [heldValue, setHeldValue] = React.useState(value); + if (!hold && heldValue !== value && !isSameRouteContext(heldValue, value)) { + setHeldValue(value); + } + return {children}; +}; + export class ReactRouterViewStack extends ViewStacks { /** * Stores the computed parent path for each outlet. @@ -452,6 +501,9 @@ export class ReactRouterViewStack extends ViewStacks { viewItem.routeData.match = match; } + // A more specific sibling owns the pathname, so this view is hidden even though it matches. + let hiddenBySpecificMatch = false; + // Deactivate wildcard (catch-all) and empty-path (default) routes when a more-specific route matches. // This prevents "Not found" or fallback pages from showing alongside valid routes. if (routePath === '*' || routePath === '/*' || routePath === '') { @@ -511,6 +563,7 @@ export class ReactRouterViewStack extends ViewStacks { } if (hasSpecificMatch && !isSwipeRevealed(viewItem)) { + hiddenBySpecificMatch = true; // A splat can be the outlet's container page rather than a "not found" fallback. // Pushed over it is the page underneath, so unmounting it destroys its state and // leaves nothing for back to reveal. Hiding it below covers that. A view with no @@ -534,6 +587,11 @@ export class ReactRouterViewStack extends ViewStacks { viewItem.routeData.match = match; } const routeMatch = shouldSkipForDifferentParam ? viewItem.routeData?.match : match || viewItem.routeData?.match; + // Hold the context when a sibling owns the pathname, the view is parked on another param + // instance, or it doesn't match. A pathless default only strictly matches at "/", so no match + // there doesn't mean it's hidden. + const isPathlessDefault = !routePath && !viewItem.routeData?.childProps?.index; + const holdRouteContext = hiddenBySpecificMatch || !!shouldSkipForDifferentParam || (!match && !isPathlessDefault); return ( @@ -579,7 +637,9 @@ export class ReactRouterViewStack extends ViewStacks { mount={viewItem.mount} removeView={() => this.remove(viewItem)} > - {componentElement} + + {componentElement} + ); }} diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 6848c4941bb..456f14f2b8c 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -1460,7 +1460,7 @@ export class StackManager extends React.PureComponent { }; const onStart = async () => { - // Core only calls onEnd when a progress animation exists, so with animations off a + // Core skips onEnd when an animated swipe ends without a progress animation, so a // previous gesture can leave its mark behind. Drop any stale one before marking. this.clearSwipeRevealedView(); @@ -1494,9 +1494,9 @@ export class StackManager extends React.PureComponent { // When the gesture starts, kick off a transition controlled via swipe gesture if (enteringViewItem && leavingViewItem) { - // Without a leaving view there is no progress animation, and core only calls - // swipeHandler.onEnd when one exists, so nothing would clear the mark until the - // next transition. + // Without a leaving view there is no progress animation, and core skips + // swipeHandler.onEnd for an animated swipe without one, so nothing would clear the + // mark until the next transition. markSwipeRevealed(enteringViewItem); this.swipeRevealedViewItem = enteringViewItem; diff --git a/packages/react-router/src/ReactRouter/utils/areParamsEqual.ts b/packages/react-router/src/ReactRouter/utils/areParamsEqual.ts new file mode 100644 index 00000000000..030e5a47d17 --- /dev/null +++ b/packages/react-router/src/ReactRouter/utils/areParamsEqual.ts @@ -0,0 +1,27 @@ +export type RouteParams = Record; + +/** + * Compares two route param objects by value, treating a missing object as empty. + */ +export const areParamsEqual = (a?: RouteParams, b?: RouteParams): boolean => { + const paramsA = a || {}; + const paramsB = b || {}; + const keysA = Object.keys(paramsA); + const keysB = Object.keys(paramsB); + + if (keysA.length !== keysB.length) { + return false; + } + + return keysA.every((key) => { + const valueA = paramsA[key]; + const valueB = paramsB[key]; + if (Array.isArray(valueA) && Array.isArray(valueB)) { + if (valueA.length !== valueB.length) { + return false; + } + return valueA.every((entry, idx) => entry === valueB[idx]); + } + return valueA === valueB; + }); +}; diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index cb00fb495d9..5ca1be3b7c5 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -49,6 +49,7 @@ import DirectionNoneBack from './pages/direction-none-back/DirectionNoneBack'; import TabSearchParams from './pages/tab-search-params/TabSearchParams'; import { Step1, Step2, Step3, Step4 } from './pages/replace-params/ReplaceParams'; import { ParamSwipeBack, ParamSwipeBackB } from './pages/param-swipe-back/ParamSwipeBack'; +import HiddenViewRenders from './pages/hidden-view-renders/HiddenViewRenders'; import TabLifecycle from './pages/tab-lifecycle/TabLifecycle'; import TabLifecycleOutside from './pages/tab-lifecycle/TabLifecycleOutside'; import { @@ -57,6 +58,7 @@ import { } from './pages/router-link-modifier-click/RouterLinkModifierClick'; import { NavigateRootPageA, NavigateRootPageB, NavigateRootPageC } from './pages/navigate-root/NavigateRoot'; import SuspenseOutlet from './pages/suspense-outlet/SuspenseOutlet'; +import OutletUnmountBeforeReady from './pages/outlet-unmount-before-ready/OutletUnmountBeforeReady'; import { PropsUpdateDirect, PropsUpdateRoutesWrapper } from './pages/props-update/PropsUpdate'; import DisabledButton from './pages/disabled-button/DisabledButton'; import SplatSibling from './pages/splat-sibling/SplatSibling'; @@ -122,12 +124,14 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx index edec002ce8b..6b8d8177f65 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -58,6 +58,9 @@ const Main: React.FC = () => { Suspense Outlet + + Outlet Unmount Before Ready + @@ -91,6 +94,9 @@ const Main: React.FC = () => { Tab Lifecycle + + Hidden View Renders + diff --git a/packages/react-router/test/base/src/pages/hidden-view-renders/HiddenViewRenders.tsx b/packages/react-router/test/base/src/pages/hidden-view-renders/HiddenViewRenders.tsx new file mode 100644 index 00000000000..494969512f2 --- /dev/null +++ b/packages/react-router/test/base/src/pages/hidden-view-renders/HiddenViewRenders.tsx @@ -0,0 +1,114 @@ +import { + IonTabs, + IonRouterOutlet, + IonTabBar, + IonTabButton, + IonLabel, + IonPage, + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonButton, + useIonRouter, +} from '@ionic/react'; +import React, { useEffect } from 'react'; +import { Route, Navigate, useParams } from 'react-router-dom'; + +import TestDescription from '../../components/TestDescription'; +import { countRender } from '../../utils'; + +/** + * Counts committed renders. An effect with no dependency list runs once per + * commit, so a render that bails out isn't counted. + */ +const useCommitCounter = (id: string) => { + useEffect(() => countRender(id)); +}; + +const ParamsReader: React.FC<{ tab: string }> = ({ tab }) => { + useParams(); + useCommitCounter(`${tab}:params`); + return null; +}; + +const IonRouterReader: React.FC<{ tab: string }> = ({ tab }) => { + useIonRouter(); + useCommitCounter(`${tab}:ionRouter`); + return null; +}; + +const TabPage: React.FC<{ tab: string }> = ({ tab }) => { + return ( + + + + Tab {tab.toUpperCase()} + + + + + + + Switch between Tabs A, B and C a few times. Hidden pages that read useParams should not re-render, and ones + that read useIonRouter should re-render once per switch. Counts are in window.renderCounts. + + + + ); +}; + +const SplatPage: React.FC = () => { + const params = useParams(); + useCommitCounter('d:params'); + + return ( + + + + Tab D + + + +
{params['*']}
+ + Go to D Two + + + Tap Go to D Two. The splat param above should change to "two", and window.renderCounts['d:params'] should go + up by 1. + +
+
+ ); +}; + +const HiddenViewRenders: React.FC = () => { + return ( + + + } /> + } /> + } /> + } /> + } /> + + + + Tab A + + + Tab B + + + Tab C + + + Tab D + + + + ); +}; + +export default HiddenViewRenders; diff --git a/packages/react-router/test/base/src/pages/outlet-unmount-before-ready/OutletUnmountBeforeReady.tsx b/packages/react-router/test/base/src/pages/outlet-unmount-before-ready/OutletUnmountBeforeReady.tsx new file mode 100644 index 00000000000..f8bb19ff958 --- /dev/null +++ b/packages/react-router/test/base/src/pages/outlet-unmount-before-ready/OutletUnmountBeforeReady.tsx @@ -0,0 +1,52 @@ +import { IonButton, IonContent, IonHeader, IonPage, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/react'; +import React, { useLayoutEffect, useState } from 'react'; +import { Route } from 'react-router-dom'; + +import TestDescription from '../../components/TestDescription'; + +/** + * Unmounting from a layout effect removes the outlet before it's ready. + */ +const TransientOutlet: React.FC<{ onMounted: () => void }> = ({ onMounted }) => { + useLayoutEffect(onMounted, [onMounted]); + + return ( + + Transient page} /> + + ); +}; + +const OutletUnmountBeforeReady: React.FC = () => { + const [mounted, setMounted] = useState(false); + const [attempts, setAttempts] = useState(0); + + return ( + + + + Outlet Unmount Before Ready + + + + + Tap the button a few times. Each tap mounts a nested outlet and removes it before it's ready, so the counter + should go up without any errors in the console. + + { + setAttempts((count) => count + 1); + setMounted(true); + }} + > + Mount and unmount outlet + +

{attempts}

+ {mounted && setMounted(false)} />} +
+
+ ); +}; + +export default OutletUnmountBeforeReady; diff --git a/packages/react-router/test/base/src/utils/index.ts b/packages/react-router/test/base/src/utils/index.ts index 45db8939265..94b5a23cc04 100644 --- a/packages/react-router/test/base/src/utils/index.ts +++ b/packages/react-router/test/base/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './generateId'; export * from './lifecycleEvents'; export * from './dev'; +export * from './renderCounts'; diff --git a/packages/react-router/test/base/src/utils/renderCounts.ts b/packages/react-router/test/base/src/utils/renderCounts.ts new file mode 100644 index 00000000000..fc932c673ad --- /dev/null +++ b/packages/react-router/test/base/src/utils/renderCounts.ts @@ -0,0 +1,5 @@ +/** Records a committed render under `id` on `window.renderCounts` for a spec to assert on. */ +export const countRender = (id: string) => { + (window as any).renderCounts = (window as any).renderCounts || {}; + (window as any).renderCounts[id] = ((window as any).renderCounts[id] ?? 0) + 1; +}; diff --git a/packages/react-router/test/base/tests/e2e/playwright/hidden-view-renders.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/hidden-view-renders.spec.ts new file mode 100644 index 00000000000..7a4aea9061d --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/hidden-view-renders.spec.ts @@ -0,0 +1,76 @@ +import { test, expect, type Page } from '@playwright/test'; +import { ionPageVisible, ionTabClick, settledRenderCounts, withTestingMode } from './utils/test-utils'; + +const showTab = async (page: Page, tab: 'b' | 'c') => { + await ionTabClick(page, `Tab ${tab.toUpperCase()}`); + await ionPageVisible(page, `hidden-view-renders-${tab}`); +}; + +/** + * Visits Tabs A, B and C, then switches to B and C again. Returns the render + * counts from before and after that second round. + */ +const switchTabsAfterVisitingAll = async (page: Page) => { + await page.goto(withTestingMode('/hidden-view-renders/a')); + await ionPageVisible(page, 'hidden-view-renders-a'); + await showTab(page, 'b'); + await showTab(page, 'c'); + + const before = await settledRenderCounts(page); + + await showTab(page, 'b'); + await showTab(page, 'c'); + + const after = await settledRenderCounts(page); + + return { before, after }; +}; + +test.describe('Hidden View Renders', () => { + test('should not re-render pages that read useParams when switching tabs', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31528', + }); + + const { before, after } = await switchTabsAfterVisitingAll(page); + + // Tab A stayed hidden for both switches. + expect(after['a:params']).toBe(before['a:params']); + // B and C were shown again at the same pathname. + expect(after['b:params']).toBe(before['b:params']); + expect(after['c:params']).toBe(before['c:params']); + }); + + test('should re-render a hidden page that reads useIonRouter once per navigation', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31528', + }); + + const { before, after } = await switchTabsAfterVisitingAll(page); + + // Since `useIonRouter` exposes the live `routeInfo`, it re-renders once per switch. + expect(after['a:ionRouter'] - before['a:ionRouter']).toBe(2); + }); + + test('should render the shown page once when its route match changes', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31528', + }); + + await page.goto(withTestingMode('/hidden-view-renders/d/one')); + await ionPageVisible(page, 'hidden-view-renders-d'); + await expect(page.locator('#splat-param')).toHaveText('one'); + + const before = await settledRenderCounts(page); + + await page.locator('#go-to-d-two').click(); + await expect(page.locator('#splat-param')).toHaveText('two'); + + const after = await settledRenderCounts(page); + + expect(after['d:params'] - before['d:params']).toBe(1); + }); +}); diff --git a/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts new file mode 100644 index 00000000000..b3c14a5dbbd --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; +import { ionPageVisible, withTestingMode } from './utils/test-utils'; + +test.describe('Outlet Unmount Before Ready', () => { + test('should not throw when an ionPage outlet unmounts before it is ready', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31513', + }); + + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(withTestingMode('/outlet-unmount-before-ready')); + await ionPageVisible(page, 'outlet-unmount-before-ready'); + + for (let attempt = 1; attempt <= 3; attempt++) { + await page.locator('#mount-transient-outlet').click(); + await expect(page.locator('#attempts')).toHaveText(String(attempt)); + } + + // Give the async ready callback time to fire. + await page.waitForTimeout(500); + + expect(errors).toEqual([]); + await ionPageVisible(page, 'outlet-unmount-before-ready'); + }); +}); diff --git a/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts index f9539df0e94..8817177ce4d 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts @@ -1,13 +1,12 @@ import { test, expect } from '@playwright/test'; -import { ionPageVisible, ionPageHidden, ionPageDoesNotExist, ionNav } from './utils/test-utils'; +import { ionPageVisible, ionPageHidden, ionPageDoesNotExist, ionNav, withTestingMode } from './utils/test-utils'; import { ionSwipeToGoBack } from './utils/drag-utils'; const IOS_MODE = 'ionic:mode=ios'; test.describe('Swipe To Go Back', () => { /* - This spec tests that swipe to go back works. - Animations must be enabled (no ionic:_testing=true) for gesture tests. + This spec tests that swipe to go back works with animations enabled (no ionic:_testing=true). */ test('should swipe and abort', async ({ page }) => { @@ -218,3 +217,46 @@ test.describe('Swipe To Go Back', () => { await ionPageHidden(page, 'disabled-main'); }); }); + +test.describe('Swipe To Go Back without animations', () => { + /* + Testing mode disables animations, so these cover swipe to go back without them. + */ + + test('should swipe and abort', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await page.goto(withTestingMode(`/swipe-to-go-back?${IOS_MODE}`)); + await ionPageVisible(page, 'main'); + + await ionNav(page, 'ion-item', 'Details'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + + await ionSwipeToGoBack(page, false, 'ion-router-outlet#swipe-to-go-back'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + await expect(page).toHaveURL(/\/swipe-to-go-back\/details/); + }); + + test('should swipe and go back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await page.goto(withTestingMode(`/swipe-to-go-back?${IOS_MODE}`)); + await ionPageVisible(page, 'main'); + + await ionNav(page, 'ion-item', 'Details'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + + await ionSwipeToGoBack(page, true, 'ion-router-outlet#swipe-to-go-back'); + await ionPageVisible(page, 'main'); + await expect(page).toHaveURL(/\/swipe-to-go-back(\?|$)/); + }); +}); 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 7efd5018827..b5fe583d139 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 @@ -52,6 +52,31 @@ export async function settledLifecycleEvents(page: Page): Promise { return current; } +/** + * Read `window.renderCounts` once two consecutive reads match, so a render that + * commits late is still counted. + */ +export async function settledRenderCounts(page: Page): Promise> { + let previous: string | undefined; + let current: Record = {}; + + try { + await expect + .poll(async () => { + current = await page.evaluate(() => ({ ...((window as any).renderCounts ?? {}) })); + const serialized = JSON.stringify(current); + const settled = previous !== undefined && serialized === previous; + previous = serialized; + return settled; + }) + .toBe(true); + } catch (error) { + throw new Error(`Render counts never settled. Last read: ${JSON.stringify(current)}`, { cause: error }); + } + + return current; +} + let peakCounterId = 0; /** diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 86049812550..55fe936d73b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +**Note:** Version bump only for package @ionic/react + + + + + +## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) + +### Bug Fixes + +* **react-router:** stop hidden views from re-rendering on every navigation ([#31541](https://github.com/ionic-team/ionic-framework/issues/31541)) ([4ae0196](https://github.com/ionic-team/ionic-framework/commit/4ae01961e8ed65e26369e09d478fabeffba5ed6d)), closes [#31528](https://github.com/ionic-team/ionic-framework/issues/31528) + + +## [9.0.7](https://github.com/ionic-team/ionic-framework/compare/v9.0.6...v9.0.7) (2026-10-07) + +### Bug Fixes + +* **react:** prevent error when an outlet unmounts before it's ready ([#31517](https://github.com/ionic-team/ionic-framework/issues/31517)) ([d5bab2e](https://github.com/ionic-team/ionic-framework/commit/d5bab2e89548311b2e3f62b5fe2be477685cb06d)), closes [#31513](https://github.com/ionic-team/ionic-framework/issues/31513) + + ## [9.0.6](https://github.com/ionic-team/ionic-framework/compare/v9.0.5...v9.0.6) (2026-09-30) **Note:** Version bump only for package @ionic/react diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index c1f51ebfe4a..da52bb1b7a7 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/react", - "version": "9.0.6", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react", - "version": "9.0.6", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.6", + "@ionic/core": "^9.0.9", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" @@ -891,9 +891,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.6.tgz", - "integrity": "sha512-WWxjOjwKPU75BuEhc42t3n2Tn89YEDe5Pce9kA61w/TlWVM3tqiAYC+qfR9MXmER4pQcST01oM2gftheMzcV6g==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -4620,13 +4620,13 @@ "dev": true }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" diff --git a/packages/react/package.json b/packages/react/package.json index 6daf86bc3cd..c0452ad4ac5 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react", - "version": "9.0.6", + "version": "9.0.9", "description": "React specific wrapper for @ionic/core", "keywords": [ "ionic", @@ -41,7 +41,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.6", + "@ionic/core": "^9.0.9", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" diff --git a/packages/react/src/routing/NavManager.tsx b/packages/react/src/routing/NavManager.tsx index 9144992b5fe..1f2692c0e24 100644 --- a/packages/react/src/routing/NavManager.tsx +++ b/packages/react/src/routing/NavManager.tsx @@ -120,12 +120,29 @@ export class NavManager extends React.PureComponent