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 e35474df669..4c80c5c61b9 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 dca2a5c5134..c8c4ee915cf 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 bb2cbb27eea..1ef7d916c85 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 f27c8b343bd..a1615d78b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,47 @@ 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 + +* **react-router:** dispatch view lifecycle events on non-animated transitions ([#31497](https://github.com/ionic-team/ionic-framework/issues/31497)) ([f01e9a7](https://github.com/ionic-team/ionic-framework/commit/f01e9a754d1267e2cdd60aae5d292ebbf2ef3deb)), closes [#31479](https://github.com/ionic-team/ionic-framework/issues/31479) +* **react-router:** keep splat route page mounted when a sibling route is pushed ([#31481](https://github.com/ionic-team/ionic-framework/issues/31481)) ([1130c2c](https://github.com/ionic-team/ionic-framework/commit/1130c2cc259c914a0a11fbe0976f4163de8d0e6e)), closes [#31477](https://github.com/ionic-team/ionic-framework/issues/31477) + + ## [9.0.5](https://github.com/ionic-team/ionic-framework/compare/v9.0.4...v9.0.5) (2026-09-23) ### Bug Fixes diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index d674ede539d..57a6c7960d3 100644 --- a/core/CHANGELOG.md +++ b/core/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 + +* **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 + + + + + ## [9.0.5](https://github.com/ionic-team/ionic-framework/compare/v9.0.4...v9.0.5) (2026-09-23) ### Bug Fixes 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 2d104b03ae2..87cddf4af80 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/core", - "version": "9.0.5", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "9.0.5", + "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.5.0", "@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", @@ -46,7 +46,7 @@ "jest": "^29.7.0", "jest-cli": "^29.7.0", "outsystems-design-tokens": "^2.5.0", - "playwright-core": "^1.63.0", + "playwright-core": "^1.64.0", "prettier": "^2.8.8", "rollup": "^2.26.4", "sass": "^1.33.0", @@ -2168,13 +2168,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" @@ -2370,9 +2370,9 @@ } }, "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" @@ -2434,13 +2434,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" }, @@ -10143,13 +10143,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" @@ -10159,9 +10159,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 4c5649af5d9..35900a172e0 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "9.0.5", + "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.5.0", "@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", @@ -103,7 +103,7 @@ "jest": "^29.7.0", "jest-cli": "^29.7.0", "outsystems-design-tokens": "^2.5.0", - "playwright-core": "^1.63.0", + "playwright-core": "^1.64.0", "prettier": "^2.8.8", "rollup": "^2.26.4", "sass": "^1.33.0", diff --git a/core/src/components.d.ts b/core/src/components.d.ts index ebfdb615049..4e57e18bf61 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -458,7 +458,7 @@ export namespace Components { */ "theme"?: "ios" | "md" | "ionic"; /** - * 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; @@ -1125,7 +1125,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; @@ -6365,7 +6365,7 @@ declare namespace LocalJSX { */ "theme"?: "ios" | "md" | "ionic"; /** - * 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-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Chrome-linux.png index 27357ca005a..34f90bb09ca 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Firefox-linux.png index 6b695595d3c..1bbb15e9d70 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Safari-linux.png index 1a3100821a8..14915a6cbe6 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Chrome-linux.png index 0cd63ab6026..60e6fac318a 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Firefox-linux.png index dbaf319d9f2..caca20d99f2 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Safari-linux.png index bec34fa1245..75302ff7b2d 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ionic-md-rtl-light-Mobile-Safari-linux.png differ 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 7cfd187a6f2..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 6cb2ae8ff34..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 2a1d883cbdb..b927bab7030 100644 --- a/core/src/components/backdrop/backdrop.tsx +++ b/core/src/components/backdrop/backdrop.tsx @@ -19,6 +19,10 @@ import { getIonTheme } 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 7bc145d116d..4e086f0b294 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -131,6 +131,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; @@ -567,6 +575,8 @@ export class Datetime implements ComponentInterface { } } + this.hasUnconfirmedSelection = false; + if (closeOverlay) { this.closeParentOverlay(CONFIRM_ROLE); } @@ -585,9 +595,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`. @@ -596,8 +607,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); } } @@ -667,6 +682,8 @@ export class Datetime implements ComponentInterface { if (popoverOrModal) { popoverOrModal.dismiss(undefined, role); } + + return popoverOrModal !== null; }; private setWorkingParts = (parts: DatetimeParts) => { @@ -684,6 +701,8 @@ export class Datetime implements ComponentInterface { return; } + this.hasUnconfirmedSelection = true; + const { multiple, minParts, maxParts, activeParts } = this; /** @@ -1104,6 +1123,7 @@ export class Datetime implements ComponentInterface { this.loadTimeout = setTimeout(() => { this.ensureReadyIfVisible(); }, 100); + this.initializeOverlayPresentListener(); } disconnectedCallback() { @@ -1112,6 +1132,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); } /** @@ -1365,7 +1418,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; @@ -1437,13 +1492,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/datetime/test/show-adjacent-days/index.html b/core/src/components/datetime/test/show-adjacent-days/index.html index 55d6dc2bfc1..eeadcb44c22 100644 --- a/core/src/components/datetime/test/show-adjacent-days/index.html +++ b/core/src/components/datetime/test/show-adjacent-days/index.html @@ -28,7 +28,12 @@ @media screen and (max-width: 800px) { .grid { - grid-template-columns: 1fr; + /** + * minmax(0, ...) keeps the column at the viewport width. + * A bare 1fr can't shrink below the datetime's min-content + * width, which WebKit measures across all three months. + */ + grid-template-columns: minmax(0, 1fr); padding: 0; } } diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 5e1388f6343..2da86d11139 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -68,6 +68,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; @AttachInternals() internals!: ElementInternals; @@ -233,6 +239,12 @@ export class InputOTP implements ComponentInterface { this.updateElementInternals(); } + @Watch('length') + lengthChanged() { + this.initializeValues(); + this.updateTabIndexesAfterRender = true; + } + /** * Processes the separators prop into an array of numbers. * @@ -309,6 +321,13 @@ export class InputOTP implements ComponentInterface { this.updateElementInternals(); } + 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 @@ -939,7 +958,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.common.scss b/core/src/components/input/input.common.scss index 2c67fe8ef79..dd499c68f7f 100644 --- a/core/src/components/input/input.common.scss +++ b/core/src/components/input/input.common.scss @@ -382,7 +382,12 @@ */ 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 cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1), + max-width 150ms cubic-bezier(0.4, 0, 0.2, 1); /** * This ensures that double tapping this text @@ -397,8 +402,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.md.outline.scss b/core/src/components/input/input.md.outline.scss index c4aa2d1c4ca..dfd3715c1fc 100644 --- a/core/src/components/input/input.md.outline.scss +++ b/core/src/components/input/input.md.outline.scss @@ -9,13 +9,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; } @@ -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 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; } @@ -87,9 +97,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%; } @@ -114,11 +123,12 @@ @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} + (100% + var(--internal-start-end-container-width) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale} ); } diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 7f7d5ea0581..6cb0561458b 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -13,12 +13,12 @@ import { forceUpdate, h, } from '@stencil/core'; -import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartEndContainerController } from '@utils/forms'; import { createClearButtonPressController, createClickController, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; @@ -64,8 +64,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; @@ -441,7 +442,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); @@ -454,15 +455,16 @@ export class Input implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonTheme(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); this.clickController = createClickController(el, () => this.nativeInput); @@ -511,7 +513,7 @@ export class Input implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } disconnectedCallback() { @@ -535,9 +537,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 @@ -1030,7 +1032,7 @@ export class Input implements ComponentInterface { private renderEndContainer() { return ( -
+
(this.endContainerEl = el)}> {this.renderClearButton()}
diff --git a/core/src/components/input/test/slot/input.e2e.ts b/core/src/components/input/test/slot/input.e2e.ts index 2bba6206d1b..6fa5583d38e 100644 --- a/core/src/components/input/test/slot/input.e2e.ts +++ b/core/src/components/input/test/slot/input.e2e.ts @@ -364,6 +364,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 input + * partway through the transition. + */ + test('should keep the label inside the input while it floats above a wide end slot', async ({ page }) => { + /** + * The label is longer than the input, 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 input = page.locator('ion-input'); + + /** + * 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(input).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(input).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 input.evaluate(async (el: HTMLIonInputElement) => { + const label = el.querySelector('.label-text-wrapper')!; + const wrapper = el.querySelector('.input-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/menu/menu.tsx b/core/src/components/menu/menu.tsx index a1d8c9bc56f..ab91f726e27 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -3,14 +3,13 @@ import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Wa import { getTimeGivenProgression } from '@utils/animation/cubic-bezier'; import { focusFirstDescendant, focusLastDescendant } from '@utils/focus-trap'; import { GESTURE_CONTROLLER } from '@utils/gesture'; -import { shouldUseCloseWatcher } from '@utils/hardware-back-button'; +import { shouldUseCloseWatcher, updateCloseWatcher } from '@utils/hardware-back-button'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers'; import { printIonError } from '@utils/logging'; import { menuController } from '@utils/menu-controller'; import { BACKDROP, GESTURE, getPresentedOverlay } from '@utils/overlays'; import { isPlatform } from '@utils/platform'; -import { hostContext } from '@utils/theme'; import { config } from '../../global/config'; import { getIonMode, getIonTheme } from '../../global/ionic-global'; @@ -273,7 +272,7 @@ export class Menu implements ComponentInterface, MenuI { * of the split pane. If the split pane is * hidden then the menu should be too. */ - const splitPane = this.el.closest('ion-split-pane'); + const splitPane = this.getSplitPane(); if (splitPane !== null) { this.isPaneVisible = await splitPane.isVisible(); } @@ -319,9 +318,9 @@ export class Menu implements ComponentInterface, MenuI { @Listen('ionSplitPaneVisible', { target: 'body' }) onSplitPaneChanged(ev: CustomEvent<{ visible: boolean }>) { - const closestSplitPane = this.el.closest('ion-split-pane'); + const splitPane = this.getSplitPane(); - if (closestSplitPane !== null && closestSplitPane === ev.target) { + if (splitPane !== null && splitPane === ev.target) { this.isPaneVisible = ev.detail.visible; this.updateState(); @@ -554,6 +553,27 @@ export class Menu implements ComponentInterface, MenuI { return !this.disabled && !this.isPaneVisible; } + /** + * Returns the split pane this menu is a side pane of. + * A menu inside the split pane's main content, such as one + * in a page, is a regular menu. The menu doesn't need to be + * a direct child, since it can be wrapped in a component. + */ + private getSplitPane(): HTMLIonSplitPaneElement | null { + const splitPane = this.el.closest('ion-split-pane'); + const main = this.el.closest('.split-pane-main'); + + /** + * A split pane can be the main content of an outer split + * pane, so only this split pane's main content counts. + */ + if (splitPane === null || (main !== null && main !== splitPane && splitPane.contains(main))) { + return null; + } + + return splitPane; + } + private canSwipe(): boolean { return this.swipeGesture && !this.isAnimating && this._isActive(); } @@ -730,6 +750,7 @@ export class Menu implements ComponentInterface, MenuI { // emit opened/closed events this._isOpen = isOpen; this.isAnimating = false; + updateCloseWatcher(); if (!this._isOpen) { this.blocker.unblock(); } @@ -833,7 +854,7 @@ export class Menu implements ComponentInterface, MenuI { } render() { - const { type, disabled, el, isPaneVisible, inheritedAttributes, side } = this; + const { type, disabled, isPaneVisible, inheritedAttributes, side } = this; const theme = getIonTheme(this); /** @@ -852,7 +873,7 @@ export class Menu implements ComponentInterface, MenuI { 'menu-enabled': !disabled, [`menu-side-${side}`]: true, 'menu-pane-visible': isPaneVisible, - 'split-pane-side': hostContext('ion-split-pane', el), + 'split-pane-side': this.getSplitPane() !== null, }} >
, -
+
(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..70a872f2bf7 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.shadowRoot!.querySelector('.label-text-wrapper')!; + const wrapper = el.shadowRoot!.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.common.scss b/core/src/components/textarea/textarea.common.scss index 0a5c3ca98b0..1b0b02e3d7e 100644 --- a/core/src/components/textarea/textarea.common.scss +++ b/core/src/components/textarea/textarea.common.scss @@ -434,8 +434,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.md.outline.scss b/core/src/components/textarea/textarea.md.outline.scss index eaf6a3f4f48..5624dd2fb66 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%; } @@ -114,11 +123,12 @@ @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} + (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.native.scss b/core/src/components/textarea/textarea.native.scss index 07cd70bb128..985eb95ddea 100644 --- a/core/src/components/textarea/textarea.native.scss +++ b/core/src/components/textarea/textarea.native.scss @@ -203,7 +203,12 @@ */ 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 cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1), + max-width 150ms cubic-bezier(0.4, 0, 0.2, 1); } .textarea-wrapper textarea { diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index b4abc853fdc..cfe618fa503 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 { NotchController, StartContainerController } from '@utils/forms'; +import type { NotchController, StartEndContainerController } from '@utils/forms'; import { getSlottedClickContent, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, reportValidityToElementInternals, } from '@utils/forms'; @@ -94,8 +94,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; /** * The value of the textarea when the textarea is focused. @@ -396,7 +397,7 @@ export class Textarea implements ComponentInterface { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); forceUpdate(this); }); @@ -407,15 +408,16 @@ export class Textarea implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonTheme(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); // Watch for class changes to update validation state if (Build.isBrowser && typeof MutationObserver !== 'undefined') { @@ -469,9 +471,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 @@ -503,7 +505,7 @@ export class Textarea implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } /** @@ -948,7 +950,7 @@ export class Textarea implements ComponentInterface { private renderEndContainer() { return ( -
+
(this.endContainerEl = el)}>
); diff --git a/core/src/css/float-elements.scss b/core/src/css/float-elements.scss index da4d6cbb4fe..a2aea965151 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/native/native.globals"; @import "../themes/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/mixins.scss b/core/src/themes/mixins.scss index d21c89d2e9f..16432a2b2d0 100644 --- a/core/src/themes/mixins.scss +++ b/core/src/themes/mixins.scss @@ -238,6 +238,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: #{&}; @@ -266,7 +271,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 8506a1093a6..77b8c08f8ac 100644 --- a/core/src/utils/config.ts +++ b/core/src/utils/config.ts @@ -356,7 +356,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 9d5a4bb7832..6120e5d0303 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 16f0baaf487..704dd0f30c2 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; @@ -120,16 +140,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!(); /** @@ -137,17 +173,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 2525d9ddb0f..7b890693862 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'; @@ -36,6 +36,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 & { @@ -697,6 +703,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); @@ -839,7 +851,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. @@ -849,6 +862,8 @@ export const cleanupRootFocusTrapAccessibility = () => { return; } + updateCloseWatcher(); + const remainingOverlays = getPresentedOverlays(document); const hasRemainingLocking = remainingOverlays.some((o) => locksAppRoot(o as OverlayWithFocusTrapProps)); @@ -873,7 +888,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 @@ -884,6 +901,8 @@ export const restoreRootFocusTrapAccessibility = (overlayEl: HTMLIonOverlayEleme return; } + updateCloseWatcher(); + const el = overlayEl as OverlayWithFocusTrapProps; if (!locksAppRoot(el)) { return; @@ -1149,6 +1168,12 @@ export const dismiss = async ( overlay.el.remove(); + /** + * Release the back button if nothing + * else is left to close. + */ + updateCloseWatcher(); + return true; }; @@ -1166,6 +1191,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 bd0885a068d..5287988471f 100644 --- a/core/src/utils/test/hardware-back-button.spec.ts +++ b/core/src/utils/test/hardware-back-button.spec.ts @@ -1,3 +1,5 @@ +import { newSpecPage } from '@stencil/core/testing'; + import type { BackButtonEvent } from '../../../src/interface'; import { startHardwareBackButton } from '../hardware-back-button'; import { config } from '../../global/config'; @@ -99,52 +101,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; - return mockCloseWatcher; + constructor() { + watchers.push(this); + } + + destroy() { + this.destroyed = true; + } + }; + + const active = () => watchers.filter((w) => !w.destroyed).slice(-1)[0]; + + 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 42b3c003f70..b55d0f68f59 100644 --- a/lerna.json +++ b/lerna.json @@ -10,5 +10,5 @@ "packages/vue", "packages/vue-router" ], - "version": "9.0.5" + "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 831afe9cf1d..28845030c40 100644 --- a/packages/angular-server/CHANGELOG.md +++ b/packages/angular-server/CHANGELOG.md @@ -3,6 +3,38 @@ 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 + + + + + ## [9.0.5](https://github.com/ionic-team/ionic-framework/compare/v9.0.4...v9.0.5) (2026-09-23) **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 9a8cb16ca3a..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.5", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular-server", - "version": "9.0.5", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.5" + "@ionic/core": "^9.0.9" }, "devDependencies": { "@angular/animations": "^22.0.0", @@ -1097,9 +1097,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.5.tgz", - "integrity": "sha512-Fo3hC0f7YI3XP0eAUUlm49q00u0OG0z55+EZ+s2YZHCUyNL1xq20JKtiPf79pn2tJ8CAYw4u52x6QcWUkL4P3A==", + "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 a2a988b82f1..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.5", + "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.5" + "@ionic/core": "^9.0.9" } } diff --git a/packages/angular/CHANGELOG.md b/packages/angular/CHANGELOG.md index 3b416608446..1d3e8740831 100644 --- a/packages/angular/CHANGELOG.md +++ b/packages/angular/CHANGELOG.md @@ -3,6 +3,38 @@ 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 + + + + + ## [9.0.5](https://github.com/ionic-team/ionic-framework/compare/v9.0.4...v9.0.5) (2026-09-23) **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 1d711964b88..61484bc67cb 100644 --- a/packages/angular/package-lock.json +++ b/packages/angular/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular", - "version": "9.0.5", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular", - "version": "9.0.5", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.5", + "@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.5", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.5.tgz", - "integrity": "sha512-Fo3hC0f7YI3XP0eAUUlm49q00u0OG0z55+EZ+s2YZHCUyNL1xq20JKtiPf79pn2tJ8CAYw4u52x6QcWUkL4P3A==", + "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 e6068202271..be120afbb94 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular", - "version": "9.0.5", + "version": "9.0.9", "description": "Angular specific wrappers for @ionic/core", "keywords": [ "ionic", @@ -171,7 +171,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.5", + "@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 79ec674b78c..2d6b28ca73e 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'; @@ -34,6 +40,26 @@ import { nullableBooleanAttribute } from '../../utils/boolean-attribute'; 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({ @@ -63,6 +89,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; @@ -135,6 +162,7 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit { } ngOnDestroy(): void { + this.unbindParentLifecycle?.(); this.stackCtrl.destroy(); this.inputBinder?.unsubscribeFromRouteData(this); } @@ -145,6 +173,7 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit { ngOnInit(): void { this.initializeOutletWithName(); + this.bindParentLifecycle(); } // Note: Ionic deviates from the Angular Router implementation here @@ -165,6 +194,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; } @@ -395,7 +523,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; @@ -439,7 +567,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; @@ -447,6 +575,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/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 67833761a51..fd259332472 100644 --- a/packages/docs/CHANGELOG.md +++ b/packages/docs/CHANGELOG.md @@ -3,6 +3,38 @@ 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 + + + + + ## [9.0.5](https://github.com/ionic-team/ionic-framework/compare/v9.0.4...v9.0.5) (2026-09-23) **Note:** Version bump only for package @ionic/docs diff --git a/packages/docs/package-lock.json b/packages/docs/package-lock.json index 659e6a2f786..86294ffba1a 100644 --- a/packages/docs/package-lock.json +++ b/packages/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/docs", - "version": "9.0.5", + "version": "9.0.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/docs", - "version": "9.0.5", + "version": "9.0.9", "license": "MIT" } } diff --git a/packages/docs/package.json b/packages/docs/package.json index a1990377554..ca1de91c4d7 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/docs", - "version": "9.0.5", + "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 7eb5270a76b..c9243f09c62 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -3,6 +3,35 @@ 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 + +* **react-router:** dispatch view lifecycle events on non-animated transitions ([#31497](https://github.com/ionic-team/ionic-framework/issues/31497)) ([f01e9a7](https://github.com/ionic-team/ionic-framework/commit/f01e9a754d1267e2cdd60aae5d292ebbf2ef3deb)), closes [#31479](https://github.com/ionic-team/ionic-framework/issues/31479) +* **react-router:** keep splat route page mounted when a sibling route is pushed ([#31481](https://github.com/ionic-team/ionic-framework/issues/31481)) ([1130c2c](https://github.com/ionic-team/ionic-framework/commit/1130c2cc259c914a0a11fbe0976f4163de8d0e6e)), closes [#31477](https://github.com/ionic-team/ionic-framework/issues/31477) + + ## [9.0.5](https://github.com/ionic-team/ionic-framework/compare/v9.0.4...v9.0.5) (2026-09-23) **Note:** Version bump only for package @ionic/react-router diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json index a3d57cb94be..13246f43bfb 100644 --- a/packages/react-router/package-lock.json +++ b/packages/react-router/package-lock.json @@ -1,15 +1,16 @@ { "name": "@ionic/react-router", - "version": "9.0.5", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react-router", - "version": "9.0.5", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/react": "^9.0.5", + "@ionic/core": "^9.0.9", + "@ionic/react": "^9.0.9", "tslib": "*" }, "devDependencies": { @@ -256,9 +257,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.5.tgz", - "integrity": "sha512-Fo3hC0f7YI3XP0eAUUlm49q00u0OG0z55+EZ+s2YZHCUyNL1xq20JKtiPf79pn2tJ8CAYw4u52x6QcWUkL4P3A==", + "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", @@ -296,12 +297,12 @@ } }, "node_modules/@ionic/react": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.5.tgz", - "integrity": "sha512-a77jWFtVxWZ5nprZqu8DMOpPjwIoCX3UxUbgQcPQ2ARu4a+Lqr9s045ddFoo8NHDCfBTcpMFbIby0Sns6AOMrQ==", + "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.5", + "@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 b7809e30215..6865c5be649 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react-router", - "version": "9.0.5", + "version": "9.0.9", "description": "React Router wrapper for @ionic/react", "keywords": [ "ionic", @@ -37,7 +37,8 @@ "dist/" ], "dependencies": { - "@ionic/react": "^9.0.5", + "@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 c937f20e4a4..28980ad5dce 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'; // Use Location directly - state is typed as `unknown` in history v5 type HistoryLocation = Location; @@ -36,7 +38,6 @@ interface IonRouterProps { registerHistoryListener: (cb: (location: HistoryLocation, action: HistoryAction) => void) => void; } -type RouteParams = Record; type SafeRouteParams = Record; const filterUndefinedParams = (params: RouteParams): SafeRouteParams => { @@ -73,29 +74,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 fb78cce7857..c9c72558087 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'; 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 ( @@ -581,7 +639,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 7fa9173eb29..456f14f2b8c 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -4,6 +4,12 @@ * particularly with animations and swipe gestures. */ +import { + LIFECYCLE_DID_ENTER, + LIFECYCLE_DID_LEAVE, + LIFECYCLE_WILL_ENTER, + LIFECYCLE_WILL_LEAVE, +} from '@ionic/core/components'; import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react'; import { IonRoute, RouteManagerContext, StackContext, createDebugLogger, generateId, getConfig } from '@ionic/react'; import React from 'react'; @@ -89,6 +95,19 @@ const revealIonPageForSwipeBack = (element: HTMLElement | undefined): void => { } }; +type ViewLifecycleEvent = + | typeof LIFECYCLE_WILL_ENTER + | typeof LIFECYCLE_DID_ENTER + | typeof LIFECYCLE_WILL_LEAVE + | typeof LIFECYCLE_DID_LEAVE; + +/** Dispatches a view lifecycle event the way core's `lifecycle()` does. */ +const dispatchLifecycleEvent = (element: HTMLElement | undefined, eventName: ViewLifecycleEvent): void => { + if (element) { + element.dispatchEvent(new CustomEvent(eventName, { bubbles: false, cancelable: false })); + } +}; + /** * A leaf view is "preservable" on browser-back (pop) when its React state * should survive a forward-pop round-trip. Non-parameterized leaf paths @@ -367,12 +386,8 @@ export class StackManager extends React.PureComponent { const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id); allViewsInOutlet.forEach((viewItem) => { if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) { - viewItem.ionPageElement.dispatchEvent( - new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false }) - ); - viewItem.ionPageElement.dispatchEvent( - new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false }) - ); + dispatchLifecycleEvent(viewItem.ionPageElement, LIFECYCLE_WILL_LEAVE); + dispatchLifecycleEvent(viewItem.ionPageElement, LIFECYCLE_DID_LEAVE); } }); @@ -409,12 +424,8 @@ export class StackManager extends React.PureComponent { return; } if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) { - viewItem.ionPageElement.dispatchEvent( - new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false }) - ); - viewItem.ionPageElement.dispatchEvent( - new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false }) - ); + dispatchLifecycleEvent(viewItem.ionPageElement, LIFECYCLE_WILL_LEAVE); + dispatchLifecycleEvent(viewItem.ionPageElement, LIFECYCLE_DID_LEAVE); } this.context.unMountViewItem(viewItem); }); @@ -1449,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(); @@ -1483,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; @@ -1782,10 +1793,36 @@ export class StackManager extends React.PureComponent { // Bail out if the component unmounted during waitForComponentsReady if (!this._isMounted) return; + const isCurrent = myGeneration === this.transitionGeneration; + // A page the newest transition is entering is not leaving after all. + const isLeaving = isCurrent || leavingEl !== this.transitionEnteringElement; + // Already hidden means the leave events have fired. This checks the class rather + // than `isViewVisible` because a nested outlet marks its leaving page + // `visibility: hidden` before we get here and still needs `ionViewDidLeave`. + const announceLeaving = isLeaving && !leavingEl.classList.contains('ion-page-hidden'); + + /** + * Dispatch the lifecycle events, since we skipped `commit()`. Only the + * newest transition fires the entering events, and the class swap follows + * all four so the ordering matches core's `transition()`. + * + * These run after `waitForComponentsReady` because on a first mount the + * page has not attached its listeners yet. + */ + if (announceLeaving) { + dispatchLifecycleEvent(leavingEl, LIFECYCLE_WILL_LEAVE); + } + if (isCurrent) { + dispatchLifecycleEvent(enteringEl, LIFECYCLE_WILL_ENTER); + dispatchLifecycleEvent(enteringEl, LIFECYCLE_DID_ENTER); + } + if (announceLeaving) { + dispatchLifecycleEvent(leavingEl, LIFECYCLE_DID_LEAVE); + } + // Swap visibility synchronously - show entering, hide leaving - // Skip hiding if a newer transition already made leavingEl the entering view enteringEl.classList.remove('ion-page-invisible'); - if (myGeneration === this.transitionGeneration || leavingEl !== this.transitionEnteringElement) { + if (isLeaving) { leavingEl.classList.add('ion-page-hidden'); leavingEl.setAttribute('aria-hidden', 'true'); } 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 96cb242cedc..f130c119338 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -48,6 +48,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 { @@ -56,6 +57,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'; @@ -120,12 +122,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 3dc6709f531..f6c14236275 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/direction-none-back/DirectionNoneBack.tsx b/packages/react-router/test/base/src/pages/direction-none-back/DirectionNoneBack.tsx index 078ef707d19..4d29266cc27 100644 --- a/packages/react-router/test/base/src/pages/direction-none-back/DirectionNoneBack.tsx +++ b/packages/react-router/test/base/src/pages/direction-none-back/DirectionNoneBack.tsx @@ -8,11 +8,16 @@ import { IonRouterOutlet, IonBackButton, IonButtons, + useIonViewDidEnter, + useIonViewDidLeave, + useIonViewWillEnter, + useIonViewWillLeave, } from '@ionic/react'; import React from 'react'; import { Route, Navigate } from 'react-router-dom'; import TestDescription from '../../components/TestDescription'; +import { pushLifecycleEvent } from '../../utils'; /** * Tests that IonBackButton works correctly after navigating with @@ -20,6 +25,11 @@ import TestDescription from '../../components/TestDescription'; * determine the previous page, not fall back to defaultHref. */ const PageA: React.FC = () => { + useIonViewWillEnter(() => pushLifecycleEvent('a:ionViewWillEnter')); + useIonViewDidEnter(() => pushLifecycleEvent('a:ionViewDidEnter')); + useIonViewWillLeave(() => pushLifecycleEvent('a:ionViewWillLeave')); + useIonViewDidLeave(() => pushLifecycleEvent('a:ionViewDidLeave')); + return ( @@ -41,6 +51,11 @@ const PageA: React.FC = () => { }; const PageB: React.FC = () => { + useIonViewWillEnter(() => pushLifecycleEvent('b:ionViewWillEnter')); + useIonViewDidEnter(() => pushLifecycleEvent('b:ionViewDidEnter')); + useIonViewWillLeave(() => pushLifecycleEvent('b:ionViewWillLeave')); + useIonViewDidLeave(() => pushLifecycleEvent('b:ionViewDidLeave')); + return ( 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/pages/tab-lifecycle/TabLifecycle.tsx b/packages/react-router/test/base/src/pages/tab-lifecycle/TabLifecycle.tsx index 00f176838d5..e64543783b4 100644 --- a/packages/react-router/test/base/src/pages/tab-lifecycle/TabLifecycle.tsx +++ b/packages/react-router/test/base/src/pages/tab-lifecycle/TabLifecycle.tsx @@ -21,11 +21,7 @@ import React from 'react'; import { Route, Navigate } from 'react-router'; import TestDescription from '../../components/TestDescription'; - -const pushEvent = (event: string) => { - (window as any).lifecycleEvents = (window as any).lifecycleEvents || []; - (window as any).lifecycleEvents.push(event); -}; +import { pushLifecycleEvent } from '../../utils'; const TabLifecycle: React.FC = () => { return ( @@ -50,10 +46,10 @@ const TabLifecycle: React.FC = () => { }; const HomeTab: React.FC = () => { - useIonViewWillEnter(() => pushEvent('home:ionViewWillEnter')); - useIonViewDidEnter(() => pushEvent('home:ionViewDidEnter')); - useIonViewWillLeave(() => pushEvent('home:ionViewWillLeave')); - useIonViewDidLeave(() => pushEvent('home:ionViewDidLeave')); + useIonViewWillEnter(() => pushLifecycleEvent('home:ionViewWillEnter')); + useIonViewDidEnter(() => pushLifecycleEvent('home:ionViewDidEnter')); + useIonViewWillLeave(() => pushLifecycleEvent('home:ionViewWillLeave')); + useIonViewDidLeave(() => pushLifecycleEvent('home:ionViewDidLeave')); return ( @@ -73,10 +69,10 @@ const HomeTab: React.FC = () => { }; const SettingsTab: React.FC = () => { - useIonViewWillEnter(() => pushEvent('settings:ionViewWillEnter')); - useIonViewDidEnter(() => pushEvent('settings:ionViewDidEnter')); - useIonViewWillLeave(() => pushEvent('settings:ionViewWillLeave')); - useIonViewDidLeave(() => pushEvent('settings:ionViewDidLeave')); + useIonViewWillEnter(() => pushLifecycleEvent('settings:ionViewWillEnter')); + useIonViewDidEnter(() => pushLifecycleEvent('settings:ionViewDidEnter')); + useIonViewWillLeave(() => pushLifecycleEvent('settings:ionViewWillLeave')); + useIonViewDidLeave(() => pushLifecycleEvent('settings:ionViewDidLeave')); return ( diff --git a/packages/react-router/test/base/src/utils/index.ts b/packages/react-router/test/base/src/utils/index.ts index 5c9612f4969..94b5a23cc04 100644 --- a/packages/react-router/test/base/src/utils/index.ts +++ b/packages/react-router/test/base/src/utils/index.ts @@ -1,2 +1,4 @@ export * from './generateId'; +export * from './lifecycleEvents'; export * from './dev'; +export * from './renderCounts'; diff --git a/packages/react-router/test/base/src/utils/lifecycleEvents.ts b/packages/react-router/test/base/src/utils/lifecycleEvents.ts new file mode 100644 index 00000000000..416d3691989 --- /dev/null +++ b/packages/react-router/test/base/src/utils/lifecycleEvents.ts @@ -0,0 +1,5 @@ +/** Records a view lifecycle event on `window.lifecycleEvents` for a spec to assert on. */ +export const pushLifecycleEvent = (event: string) => { + (window as any).lifecycleEvents = (window as any).lifecycleEvents || []; + (window as any).lifecycleEvents.push(event); +}; 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/direction-none-lifecycle.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/direction-none-lifecycle.spec.ts new file mode 100644 index 00000000000..7beacdcaf8b --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/direction-none-lifecycle.spec.ts @@ -0,0 +1,44 @@ +import { test, expect, type Page } from '@playwright/test'; +import { ionPageVisible, resetLifecycleEvents, settledLifecycleEvents, withTestingMode } from './utils/test-utils'; + +/** + * A navigation with routerDirection="none" is not animated, but it must still + * fire the four view lifecycle events, in the same order an animated one does. + */ +test.describe('routerDirection="none" lifecycle events', () => { + const expectedEvents = ['a:ionViewWillLeave', 'b:ionViewWillEnter', 'b:ionViewDidEnter', 'a:ionViewDidLeave']; + + const goToPageA = async (page: Page) => { + await page.goto(withTestingMode('/direction-none-back/a')); + await ionPageVisible(page, 'direction-none-page-a'); + await resetLifecycleEvents(page); + }; + + test('should fire enter and leave events on a routerDirection="none" navigation', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31479', + }); + + await goToPageA(page); + + await page.locator('#go-none').click(); + await ionPageVisible(page, 'direction-none-page-b'); + + expect(await settledLifecycleEvents(page)).toEqual(expectedEvents); + }); + + /** + * The control. A forward navigation keeps its direction, so it takes the + * regular transition path, and its event order is the one the test above + * has to match. + */ + test('should fire the same events for a forward navigation', async ({ page }) => { + await goToPageA(page); + + await page.locator('#go-forward').click(); + await ionPageVisible(page, 'direction-none-page-b'); + + expect(await settledLifecycleEvents(page)).toEqual(expectedEvents); + }); +}); 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/tab-lifecycle.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts index 7287d4c75df..3e479c9cb05 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts @@ -1,5 +1,12 @@ import { test, expect } from '@playwright/test'; -import { ionPageVisible, ionTabClick, trackPeakMatchCount, withTestingMode } from './utils/test-utils'; +import { + ionPageVisible, + ionTabClick, + resetLifecycleEvents, + settledLifecycleEvents, + trackPeakMatchCount, + withTestingMode, +} from './utils/test-utils'; test.describe('Tab Lifecycle Events', () => { test.beforeEach(async ({ page }) => { @@ -17,12 +24,12 @@ test.describe('Tab Lifecycle Events', () => { await page.goto(withTestingMode('/tab-lifecycle/home')); await ionPageVisible(page, 'tab-lifecycle-home'); - await page.evaluate(() => { (window as any).lifecycleEvents = []; }); + await resetLifecycleEvents(page); await page.locator('#go-outside').click(); await ionPageVisible(page, 'tab-lifecycle-outside'); - const events = await page.evaluate(() => (window as any).lifecycleEvents as string[]); + const events = await settledLifecycleEvents(page); expect(events).toContain('home:ionViewWillLeave'); expect(events).toContain('home:ionViewDidLeave'); }); @@ -39,12 +46,12 @@ test.describe('Tab Lifecycle Events', () => { await ionTabClick(page, 'Settings'); await ionPageVisible(page, 'tab-lifecycle-settings'); - await page.evaluate(() => { (window as any).lifecycleEvents = []; }); + await resetLifecycleEvents(page); await page.locator('#go-outside-settings').click(); await ionPageVisible(page, 'tab-lifecycle-outside'); - const events = await page.evaluate(() => (window as any).lifecycleEvents as string[]); + const events = await settledLifecycleEvents(page); expect(events).toContain('settings:ionViewWillLeave'); expect(events).toContain('settings:ionViewDidLeave'); }); @@ -61,16 +68,63 @@ test.describe('Tab Lifecycle Events', () => { await page.locator('#go-outside').click(); await ionPageVisible(page, 'tab-lifecycle-outside'); - await page.evaluate(() => { (window as any).lifecycleEvents = []; }); + await resetLifecycleEvents(page); await page.locator('#go-back-to-tabs').click(); await ionPageVisible(page, 'tab-lifecycle-home'); - const events = await page.evaluate(() => (window as any).lifecycleEvents as string[]); + const events = await settledLifecycleEvents(page); expect(events).toContain('home:ionViewWillEnter'); expect(events).toContain('home:ionViewDidEnter'); }); + test('should fire enter and leave events when switching tabs', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31479', + }); + + await page.goto(withTestingMode('/tab-lifecycle/home')); + await ionPageVisible(page, 'tab-lifecycle-home'); + + await resetLifecycleEvents(page); + + await ionTabClick(page, 'Settings'); + await ionPageVisible(page, 'tab-lifecycle-settings'); + + expect(await settledLifecycleEvents(page)).toEqual([ + 'home:ionViewWillLeave', + 'settings:ionViewWillEnter', + 'settings:ionViewDidEnter', + 'home:ionViewDidLeave', + ]); + }); + + test('should fire enter and leave events when switching back to a visited tab', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31479', + }); + + await page.goto(withTestingMode('/tab-lifecycle/home')); + await ionPageVisible(page, 'tab-lifecycle-home'); + + await ionTabClick(page, 'Settings'); + await ionPageVisible(page, 'tab-lifecycle-settings'); + + await resetLifecycleEvents(page); + + await ionTabClick(page, 'Home'); + await ionPageVisible(page, 'tab-lifecycle-home'); + + expect(await settledLifecycleEvents(page)).toEqual([ + 'settings:ionViewWillLeave', + 'home:ionViewWillEnter', + 'home:ionViewDidEnter', + 'settings:ionViewDidLeave', + ]); + }); + // A duplicate tab page, even briefly, fails this spec's page assertions on a // strict mode violation. test('should not duplicate the tab page in the DOM while returning to the tabs', async ({ page }) => { 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 e651cab081c..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 @@ -17,6 +17,66 @@ export function withTestingMode(path: string): string { return `${path}${separator}ionic:_testing=true`; } +/** Clear the recorded events so an assertion only sees the navigation under test. */ +export async function resetLifecycleEvents(page: Page): Promise { + await page.evaluate(() => { + (window as any).lifecycleEvents = []; + }); +} + +/** + * Read `window.lifecycleEvents` once two consecutive reads match, so an event + * arriving just behind the expected ones is included rather than missed. + * + * An empty array never settles, so only use this where events are expected. + */ +export async function settledLifecycleEvents(page: Page): Promise { + let previous: string[] | undefined; + let current: string[] = []; + + try { + await expect + .poll(async () => { + current = await page.evaluate(() => ((window as any).lifecycleEvents ?? []) as string[]); + const settled = current.length > 0 && previous !== undefined && current.join('|') === previous.join('|'); + previous = current; + return settled; + }) + .toBe(true); + } catch (error) { + // The poll only yields a boolean, so report what was actually seen. A failing + // `page.evaluate` ends up here too, so keep the original as the cause. + throw new Error(`Lifecycle events never settled. Last read: ${JSON.stringify(current)}`, { cause: error }); + } + + 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 2bf934dde36..55fe936d73b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -3,6 +3,36 @@ 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 + + + + + ## [9.0.5](https://github.com/ionic-team/ionic-framework/compare/v9.0.4...v9.0.5) (2026-09-23) **Note:** Version bump only for package @ionic/react diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index c7baf497222..da52bb1b7a7 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/react", - "version": "9.0.5", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react", - "version": "9.0.5", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.5", + "@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.5", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.5.tgz", - "integrity": "sha512-Fo3hC0f7YI3XP0eAUUlm49q00u0OG0z55+EZ+s2YZHCUyNL1xq20JKtiPf79pn2tJ8CAYw4u52x6QcWUkL4P3A==", + "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 3036654e2ba..c0452ad4ac5 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react", - "version": "9.0.5", + "version": "9.0.9", "description": "React specific wrapper for @ionic/core", "keywords": [ "ionic", @@ -41,7 +41,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.5", + "@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