diff --git a/.github/actions/publish-npm/action.yml b/.github/actions/publish-npm/action.yml index 6913b493ef1..cce9c2c235a 100644 --- a/.github/actions/publish-npm/action.yml +++ b/.github/actions/publish-npm/action.yml @@ -22,7 +22,7 @@ runs: using: 'composite' steps: - name: 🟒 Configure Node for Publish - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ inputs.node-version }} registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/actions/build-angular-server/action.yml b/.github/workflows/actions/build-angular-server/action.yml index 70c65316883..283a9db0244 100644 --- a/.github/workflows/actions/build-angular-server/action.yml +++ b/.github/workflows/actions/build-angular-server/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic Angular Server' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-angular/action.yml b/.github/workflows/actions/build-angular/action.yml index 121fa6f7da9..7771cb0e879 100644 --- a/.github/workflows/actions/build-angular/action.yml +++ b/.github/workflows/actions/build-angular/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic Angular' runs: using: 'composite' steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-core-stencil-prerelease/action.yml b/.github/workflows/actions/build-core-stencil-prerelease/action.yml index f1e33c8b55e..0bd78e4adfe 100644 --- a/.github/workflows/actions/build-core-stencil-prerelease/action.yml +++ b/.github/workflows/actions/build-core-stencil-prerelease/action.yml @@ -13,7 +13,7 @@ runs: using: 'composite' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x diff --git a/.github/workflows/actions/build-core/action.yml b/.github/workflows/actions/build-core/action.yml index 068eab5c471..d155d64d4f1 100644 --- a/.github/workflows/actions/build-core/action.yml +++ b/.github/workflows/actions/build-core/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - name: πŸ•ΈοΈ Install Dependencies diff --git a/.github/workflows/actions/build-react-router/action.yml b/.github/workflows/actions/build-react-router/action.yml index 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/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index bdb408b3eb9..1fddfeb7fa0 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@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: javascript - - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 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 cf6be27ea7a..c50200accd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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 diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 531652cf2e3..84405878558 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.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 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 97ed7e472fe..14b7aad5b32 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/core", - "version": "9.0.7", + "version": "9.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "9.0.7", + "version": "9.0.8", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -23,7 +23,7 @@ "@eslint/js": "^9.0.0", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", "@stencil/angular-output-target": "^1.4.1", @@ -45,7 +45,7 @@ "fs-extra": "^9.0.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", - "playwright-core": "^1.63.0", + "playwright-core": "^1.64.0", "prettier": "^2.6.1", "rollup": "^2.26.4", "sass": "^1.33.0", @@ -633,9 +633,9 @@ "license": "MIT" }, "node_modules/@capacitor/core": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz", - "integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.3.tgz", + "integrity": "sha512-oBE1oGuYE4ZzYYrulC75OuHdSsD6zLUy7d5o1tFwDLG4govGoubY15cxpiI5l0+q87Lm5p0bhbehMdzJg4aFag==", "dev": true, "license": "MIT", "dependencies": { @@ -1637,13 +1637,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -9221,13 +9221,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -9237,9 +9237,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/core/package.json b/core/package.json index 6c255150c0f..6554c74ccd2 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "9.0.7", + "version": "9.0.8", "description": "Base components for Ionic", "engines": { "node": ">= 16" @@ -80,7 +80,7 @@ "@eslint/js": "^9.0.0", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", "@stencil/angular-output-target": "^1.4.1", @@ -102,7 +102,7 @@ "fs-extra": "^9.0.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", - "playwright-core": "^1.63.0", + "playwright-core": "^1.64.0", "prettier": "^2.6.1", "rollup": "^2.26.4", "sass": "^1.33.0", diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Chrome-linux.png index d9c803dfa7c..4e77dd36c94 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png index b8c706fb344..b2b830ef953 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Safari-linux.png index 6d11e95bba2..31a3feced83 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Chrome-linux.png index ade964c7b27..b7881eb34b4 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png index 59c03881daf..e98c0ede885 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Safari-linux.png index 4db55bcfcdc..2a3a4d09722 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Chrome-linux.png index e79d6e3c871..f19384016b4 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Firefox-linux.png index fab77e40ddc..65a04b6f10a 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Safari-linux.png index 1076d64e884..44fa87eb3d5 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Chrome-linux.png index 76e841e42fc..e3357e6a193 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Firefox-linux.png index b58450b4c15..8fcbef1cab1 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Safari-linux.png index 2eb01e783c9..c5da390317f 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/index.html b/core/src/components/alert/test/basic/index.html index e9ec5413a04..8c163252e46 100644 --- a/core/src/components/alert/test/basic/index.html +++ b/core/src/components/alert/test/basic/index.html @@ -190,11 +190,17 @@ type: 'date', min: '2017-03-01', max: '2018-01-12', + // WebKit renders today's date in empty date inputs, so set + // a value to keep the screenshot from changing every day. + value: '2017-06-15', }, // input date without min nor max { name: 'name5', type: 'date', + // WebKit renders today's date in empty date inputs, so set + // a value to keep the screenshot from changing every day. + value: '2020-09-24', }, { name: 'name6', diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index 5040271018a..deb08cf4046 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -9,6 +9,9 @@ import { getBackdropValueForSheet } from '../utils'; import { calculateSpringStep, canSwipeOnContent, handleCanDismiss } from './utils'; +/** How far ahead, in milliseconds, a drag's velocity is projected when snapping. */ +const FLICK_PROJECTION_MS = 350; + export interface MoveSheetToBreakpointOptions { /** * The breakpoint value to move the sheet to. @@ -84,6 +87,11 @@ export const createSheetGesture = ( const contentEl = baseEl.querySelector('ion-content'); // Cache the initial value so the gesture restores it instead of forcing scrolling on. const initialContentScrollY = contentEl?.scrollY ?? true; + /** + * The height of the sheet the user actually sees. This comes from the wrapper + * rather than the host element, which is stretched to the full viewport no + * matter how tall the sheet itself is. + */ const height = wrapperEl.clientHeight; let currentBreakpoint = initialBreakpoint; let offset = 0; @@ -419,13 +427,13 @@ export const createSheetGesture = ( offset = clamp(0.0001, processedStep, maxStep); animation.progressStep(offset); - const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY); + const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY, detail.velocityY); const eventDetail: ModalDragEventDetail = { currentY: detail.currentY, deltaY: detail.deltaY, velocityY: detail.velocityY, - progress: calculateProgress(detail.currentY), + progress: calculateEventProgress(detail.deltaY), snapBreakpoint: snapBreakpoint, }; @@ -433,7 +441,7 @@ export const createSheetGesture = ( }; const onEnd = (detail: GestureDetail) => { - const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY); + const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY, detail.velocityY); /** * `snapBreakpoint === 0` is not enough on its own. `canDismiss: false` @@ -449,7 +457,7 @@ export const createSheetGesture = ( currentY: detail.currentY, deltaY: detail.deltaY, velocityY: detail.velocityY, - progress: calculateProgress(detail.currentY), + progress: calculateEventProgress(detail.deltaY), snapBreakpoint, isDismissing, }; @@ -650,24 +658,15 @@ export const createSheetGesture = ( }; /** - * Calculates the breakpoint based on the current deltaY. - * This determines where the sheet should snap to when the user releases the - * gesture. + * Decides which breakpoint the sheet should settle on for the current drag. * - * @param deltaY The change in Y position since the gesture started. - * @returns The snap breakpoint value. + * @param deltaY The change in Y position since the gesture started + * @param velocityY The velocity in pixels per millisecond + * @returns The snap breakpoint value */ - const calculateSnapBreakpoint = (deltaY: number): number => { - /** - * Calculates the real-time vertical position of the modal. - * We combine the wrapper's current bounding box position with the - * gesture's deltaY to account for the physical movement during the drag. - */ - const currentY = wrapperEl.getBoundingClientRect().top + deltaY; - /** - * Convert that pixel position back into a 0 to 1 progress value. - */ - const currentProgress = calculateProgress(currentY); + const calculateSnapBreakpoint = (deltaY: number, velocityY: number): number => { + // Project the drag forward so a quick flick counts. + const currentProgress = calculateProgress(deltaY + velocityY * FLICK_PROJECTION_MS); /** * Find and return the defined breakpoint that is closest to the @@ -681,78 +680,29 @@ export const createSheetGesture = ( }; /** - * Calculates the progress of the swipe gesture. - * - * The progress is a value between 0 and 1 that represents how far - * the swipe has progressed towards closing the modal. - * - * A value closer to 1 means the modal is closer to being opened, - * while a value closer to 0 means the modal is closer to being closed. + * Calculates how open the sheet is part way through a gesture, on the same + * 0 to 1 scale as the breakpoints: 1 is fully open and 0 is fully closed. * - * @param currentY The current Y position of the gesture + * @param deltaY The change in Y position since the gesture started * @returns The progress of the sheet gesture */ - const calculateProgress = (currentY: number): number => { - const minBreakpoint = breakpoints[0]; - const maxBreakpoint = breakpoints[breakpoints.length - 1]; - - /** - * The lowest point the sheet can be dragged to aka the point at which - * the sheet is fully closed. - */ - const maxY = convertBreakpointToY(minBreakpoint); - /** - * The highest point the sheet can be dragged to aka the point at which - * the sheet is fully open. - */ - const minY = convertBreakpointToY(maxBreakpoint); - // The total distance between the fully open and fully closed positions. - const totalDistance = maxY - minY; - // The distance from the current position to the fully closed position. - const distanceFromBottom = maxY - currentY; - /** - * The progress represents how far the sheet is from the bottom relative - * to the total distance. When the user starts swiping up, the progress - * should be close to 1, and when the user has swiped all the way down, - * the progress should be close to 0. - */ - const progress = distanceFromBottom / totalDistance; + const calculateProgress = (deltaY: number): number => { + // Mirrors the animation step in onMove, so the snap matches where the sheet is drawn. + const progress = currentBreakpoint - deltaY / height; // Round to the nearest thousandth to avoid returning very small decimal const roundedProgress = Math.round(progress * 1000) / 1000; - return Math.max(0, Math.min(1, roundedProgress)); + return clamp(0, roundedProgress, 1); }; /** - * Converts a breakpoint value (0 to 1) into a pixel Y coordinate - * on the screen. - * - * @param breakpoint The breakpoint value (e.g., 0.5 for half-open) - * @returns The pixel Y coordinate on the screen + * Scales the progress from the lowest to the highest breakpoint, so the + * drag events report 0 at the lowest breakpoint and 1 at the highest. */ - const convertBreakpointToY = (breakpoint: number): number => { - const rect = baseEl.getBoundingClientRect(); - const modalHeight = rect.height; - // The bottom of the screen. - const viewportBottom = window.innerHeight; - /** - * The active height is how much of the modal is actually showing - * on the screen for this specific breakpoint. - */ - const activeHeight = modalHeight * breakpoint; + const calculateEventProgress = (deltaY: number): number => { + const progress = (calculateProgress(deltaY) - minBreakpoint) / (maxBreakpoint - minBreakpoint); - /** - * To find the Y coordinate, start at the bottom of the screen - * and move up by the active height of the modal. - * - * A breakpoint of 1.0 means the active height is the full modal height - * (fully open). A breakpoint of 0.0 means the active height is 0 - * (fully closed). - * - * Since screen Y coordinates get smaller as you go up, we subtract the - * active height from the viewport bottom. - */ - return viewportBottom - activeHeight; + return clamp(0, progress, 1); }; const gesture = createGesture({ diff --git a/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts b/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts index 865b5b5a8de..2be21e2c03b 100644 --- a/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts +++ b/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts @@ -112,11 +112,11 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await ionHandlerDone.next(); - const modal = page.locator('ion-modal'); - expect(modal).not.toBe(null); + await expect(page.locator('ion-modal')).toBeVisible(); }); test('should not dismiss on swipe when not attempting to close', async ({ page }) => { const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionDragEnd = await page.spyOnEvent('ionDragEnd'); await page.click('#sheet-can-dismiss-promise-true'); @@ -125,8 +125,14 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => const modalHeader = page.locator('#modal-header'); await dragElementBy(modalHeader, page, 0, 30); - const modal = page.locator('ion-modal'); - expect(modal).not.toBe(null); + /** + * A drag this small snaps the sheet back open, so canDismiss is never + * consulted and the sheet stays where it was. + */ + const dragEndEvent = await ionDragEnd.next(); + + expect(dragEndEvent.detail.isDismissing).toBe(false); + await expect(page.locator('ion-modal')).toBeVisible(); }); test('should hit the dismiss threshold when swiping', async ({ page }) => { const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); diff --git a/core/src/components/modal/test/sheet/index.html b/core/src/components/modal/test/sheet/index.html index 91b735edad7..cbf9b3e6cbf 100644 --- a/core/src/components/modal/test/sheet/index.html +++ b/core/src/components/modal/test/sheet/index.html @@ -21,6 +21,11 @@ --height: 50%; } + /* Kept below half the viewport so the drag tests catch sheets dismissing on upward drags. */ + .short-sheet { + --height: 40%; + } + .custom-handle::part(handle) { top: -16px; background: rgba(255, 255, 255, 0.53); @@ -127,6 +132,12 @@ + +

{{ 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/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 3cfe8d8f92a..153c73a6acd 100644 --- a/packages/docs/CHANGELOG.md +++ b/packages/docs/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.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 diff --git a/packages/docs/package-lock.json b/packages/docs/package-lock.json index 8b305da3546..8016c922474 100644 --- a/packages/docs/package-lock.json +++ b/packages/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/docs", - "version": "9.0.7", + "version": "9.0.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/docs", - "version": "9.0.7", + "version": "9.0.8", "license": "MIT" } } diff --git a/packages/docs/package.json b/packages/docs/package.json index c135f08f56d..43724875634 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/docs", - "version": "9.0.7", + "version": "9.0.8", "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 6abc6ef26e5..b2ec542f166 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.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", - "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "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.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", - "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.3", + "@vitest/spy": "5.0.4", "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.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", - "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", "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,23 +1509,23 @@ } }, "node_modules/vitest": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", - "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.3", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.4", + "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" }, @@ -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.3", - "@vitest/browser-preview": "5.0.3", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.3", - "@vitest/coverage-v8": "5.0.3", - "@vitest/ui": "5.0.3", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -1591,7 +1591,7 @@ } } }, - "node_modules/vitest/node_modules/why-is-node-running": { + "node_modules/why-is-node-running": { "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==", diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 2051b697ae5..2cd31e9b24c 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.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 diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json index bef9c768ae7..bf202eb5d01 100644 --- a/packages/react-router/package-lock.json +++ b/packages/react-router/package-lock.json @@ -1,16 +1,16 @@ { "name": "@ionic/react-router", - "version": "9.0.7", + "version": "9.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react-router", - "version": "9.0.7", + "version": "9.0.8", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.7", - "@ionic/react": "^9.0.7", + "@ionic/core": "^9.0.8", + "@ionic/react": "^9.0.8", "tslib": "*" }, "devDependencies": { @@ -257,9 +257,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.7.tgz", - "integrity": "sha512-ypaMX9RujNJa9V12ex614dkvadggc1QGPaF+qq0ZO7WyLilseMfsEv7FiY1J+o2LnXyr++gOPwpvZnE2hvL0Lg==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", + "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -297,12 +297,12 @@ } }, "node_modules/@ionic/react": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.7.tgz", - "integrity": "sha512-KavLmQ2UBGHMbCX5qTjSRIGqgVcHJbZjEJFCjVxueVRflYy2SbYow63BL4xHSPALRB25jnu5bvaW9DqXExrg0w==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.8.tgz", + "integrity": "sha512-8N9l+SA2VdcpX6lT9C6bH7brdHfekdbbRmjvXOU4kl86KHdVeqirkkt63iyLWm6VoEKLc2OuAfy4tjUSnlv0rw==", "license": "MIT", "dependencies": { - "@ionic/core": "9.0.7", + "@ionic/core": "9.0.8", "@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 710cdb4afcc..744ea00df1c 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react-router", - "version": "9.0.7", + "version": "9.0.8", "description": "React Router wrapper for @ionic/react", "keywords": [ "ionic", @@ -37,8 +37,8 @@ "dist/" ], "dependencies": { - "@ionic/core": "^9.0.7", - "@ionic/react": "^9.0.7", + "@ionic/core": "^9.0.8", + "@ionic/react": "^9.0.8", "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/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 64dfdffb265..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 { @@ -121,6 +122,7 @@ 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 f23ceb89a02..f6c14236275 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -94,6 +94,9 @@ const Main: React.FC = () => { Tab Lifecycle + + Hidden View Renders + diff --git a/packages/react-router/test/base/src/pages/hidden-view-renders/HiddenViewRenders.tsx b/packages/react-router/test/base/src/pages/hidden-view-renders/HiddenViewRenders.tsx new file mode 100644 index 00000000000..494969512f2 --- /dev/null +++ b/packages/react-router/test/base/src/pages/hidden-view-renders/HiddenViewRenders.tsx @@ -0,0 +1,114 @@ +import { + IonTabs, + IonRouterOutlet, + IonTabBar, + IonTabButton, + IonLabel, + IonPage, + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonButton, + useIonRouter, +} from '@ionic/react'; +import React, { useEffect } from 'react'; +import { Route, Navigate, useParams } from 'react-router-dom'; + +import TestDescription from '../../components/TestDescription'; +import { countRender } from '../../utils'; + +/** + * Counts committed renders. An effect with no dependency list runs once per + * commit, so a render that bails out isn't counted. + */ +const useCommitCounter = (id: string) => { + useEffect(() => countRender(id)); +}; + +const ParamsReader: React.FC<{ tab: string }> = ({ tab }) => { + useParams(); + useCommitCounter(`${tab}:params`); + return null; +}; + +const IonRouterReader: React.FC<{ tab: string }> = ({ tab }) => { + useIonRouter(); + useCommitCounter(`${tab}:ionRouter`); + return null; +}; + +const TabPage: React.FC<{ tab: string }> = ({ tab }) => { + return ( + + + + Tab {tab.toUpperCase()} + + + + + + + Switch between Tabs A, B and C a few times. Hidden pages that read useParams should not re-render, and ones + that read useIonRouter should re-render once per switch. Counts are in window.renderCounts. + + + + ); +}; + +const SplatPage: React.FC = () => { + const params = useParams(); + useCommitCounter('d:params'); + + return ( + + + + Tab D + + + +
{params['*']}
+ + Go to D Two + + + Tap Go to D Two. The splat param above should change to "two", and window.renderCounts['d:params'] should go + up by 1. + +
+
+ ); +}; + +const HiddenViewRenders: React.FC = () => { + return ( + + + } /> + } /> + } /> + } /> + } /> + + + + Tab A + + + Tab B + + + Tab C + + + Tab D + + + + ); +}; + +export default HiddenViewRenders; diff --git a/packages/react-router/test/base/src/utils/index.ts b/packages/react-router/test/base/src/utils/index.ts index 45db8939265..94b5a23cc04 100644 --- a/packages/react-router/test/base/src/utils/index.ts +++ b/packages/react-router/test/base/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './generateId'; export * from './lifecycleEvents'; export * from './dev'; +export * from './renderCounts'; diff --git a/packages/react-router/test/base/src/utils/renderCounts.ts b/packages/react-router/test/base/src/utils/renderCounts.ts new file mode 100644 index 00000000000..fc932c673ad --- /dev/null +++ b/packages/react-router/test/base/src/utils/renderCounts.ts @@ -0,0 +1,5 @@ +/** Records a committed render under `id` on `window.renderCounts` for a spec to assert on. */ +export const countRender = (id: string) => { + (window as any).renderCounts = (window as any).renderCounts || {}; + (window as any).renderCounts[id] = ((window as any).renderCounts[id] ?? 0) + 1; +}; diff --git a/packages/react-router/test/base/tests/e2e/playwright/hidden-view-renders.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/hidden-view-renders.spec.ts new file mode 100644 index 00000000000..7a4aea9061d --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/hidden-view-renders.spec.ts @@ -0,0 +1,76 @@ +import { test, expect, type Page } from '@playwright/test'; +import { ionPageVisible, ionTabClick, settledRenderCounts, withTestingMode } from './utils/test-utils'; + +const showTab = async (page: Page, tab: 'b' | 'c') => { + await ionTabClick(page, `Tab ${tab.toUpperCase()}`); + await ionPageVisible(page, `hidden-view-renders-${tab}`); +}; + +/** + * Visits Tabs A, B and C, then switches to B and C again. Returns the render + * counts from before and after that second round. + */ +const switchTabsAfterVisitingAll = async (page: Page) => { + await page.goto(withTestingMode('/hidden-view-renders/a')); + await ionPageVisible(page, 'hidden-view-renders-a'); + await showTab(page, 'b'); + await showTab(page, 'c'); + + const before = await settledRenderCounts(page); + + await showTab(page, 'b'); + await showTab(page, 'c'); + + const after = await settledRenderCounts(page); + + return { before, after }; +}; + +test.describe('Hidden View Renders', () => { + test('should not re-render pages that read useParams when switching tabs', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31528', + }); + + const { before, after } = await switchTabsAfterVisitingAll(page); + + // Tab A stayed hidden for both switches. + expect(after['a:params']).toBe(before['a:params']); + // B and C were shown again at the same pathname. + expect(after['b:params']).toBe(before['b:params']); + expect(after['c:params']).toBe(before['c:params']); + }); + + test('should re-render a hidden page that reads useIonRouter once per navigation', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31528', + }); + + const { before, after } = await switchTabsAfterVisitingAll(page); + + // Since `useIonRouter` exposes the live `routeInfo`, it re-renders once per switch. + expect(after['a:ionRouter'] - before['a:ionRouter']).toBe(2); + }); + + test('should render the shown page once when its route match changes', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31528', + }); + + await page.goto(withTestingMode('/hidden-view-renders/d/one')); + await ionPageVisible(page, 'hidden-view-renders-d'); + await expect(page.locator('#splat-param')).toHaveText('one'); + + const before = await settledRenderCounts(page); + + await page.locator('#go-to-d-two').click(); + await expect(page.locator('#splat-param')).toHaveText('two'); + + const after = await settledRenderCounts(page); + + expect(after['d:params'] - before['d:params']).toBe(1); + }); +}); diff --git a/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts b/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts index 7efd5018827..b5fe583d139 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts @@ -52,6 +52,31 @@ export async function settledLifecycleEvents(page: Page): Promise { return current; } +/** + * Read `window.renderCounts` once two consecutive reads match, so a render that + * commits late is still counted. + */ +export async function settledRenderCounts(page: Page): Promise> { + let previous: string | undefined; + let current: Record = {}; + + try { + await expect + .poll(async () => { + current = await page.evaluate(() => ({ ...((window as any).renderCounts ?? {}) })); + const serialized = JSON.stringify(current); + const settled = previous !== undefined && serialized === previous; + previous = serialized; + return settled; + }) + .toBe(true); + } catch (error) { + throw new Error(`Render counts never settled. Last read: ${JSON.stringify(current)}`, { cause: error }); + } + + return current; +} + let peakCounterId = 0; /** diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 8dba032817f..64cb657b909 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.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 diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index d5747ea6984..492e5364495 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/react", - "version": "9.0.7", + "version": "9.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react", - "version": "9.0.7", + "version": "9.0.8", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.7", + "@ionic/core": "^9.0.8", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" @@ -891,9 +891,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.7.tgz", - "integrity": "sha512-ypaMX9RujNJa9V12ex614dkvadggc1QGPaF+qq0ZO7WyLilseMfsEv7FiY1J+o2LnXyr++gOPwpvZnE2hvL0Lg==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", + "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", "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 4163da2e4f5..06287958443 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react", - "version": "9.0.7", + "version": "9.0.8", "description": "React specific wrapper for @ionic/core", "keywords": [ "ionic", @@ -41,7 +41,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.7", + "@ionic/core": "^9.0.8", "@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