From b4174f96f5061ebc93119cc7e1363162ae666ae9 Mon Sep 17 00:00:00 2001 From: Shane Date: Wed, 19 Aug 2026 19:37:50 +0000 Subject: [PATCH 01/17] fix(core): restore prop defaults when consumers downlevel (#31375) Issue number: internal --------- ## What is the current behavior? Currently, core builds with `target: es2022` and no `useDefineForClassFields` setting, so TypeScript defaults it to `true` and emits every `@Prop` default as a class field instead of a constructor assignment. Stencil proxies props onto the component prototype as getter/setter pairs, and its setter returns early when the host ref isn't registered yet. An app that downlevels those class fields hoists the initializers above `registerInstance()`, so the setter drops every default. That happens in any Stencil app importing `@ionic/core` with a target below es2022, since the app re-transpiles core. ## What is the new behavior? Setting `useDefineForClassFields: false` restores the emit v8 shipped: `registerInstance()` runs first, then the defaults assign through Stencil's setter. There's nothing left for a downstream bundler to hoist, so the defaults survive whatever target the app uses. Every output target picks it up. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information Regression from #31280, which moved core to `target: es2022` and flipped the TypeScript default. Brandy hit it on [the docs-demo v9 update](https://github.com/ionic-team/docs-demo/pull/179), where the Input OTP page renders an empty group. I packed this build into that branch with no docs-demo changes and the boxes come back. I put the flag in `core/tsconfig.json` rather than the shared `tsconfig.base.json`, so the six packages extending the base emit the same bytes as before. I didn't add a test. It only breaks in a downstream build, so covering it means asserting on the shape of core's emitted output, which we don't do anywhere. Happy to add something if you'd rather have one. --- core/tsconfig.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/tsconfig.json b/core/tsconfig.json index 2d8fb861ac0..3b9d1fd5054 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -9,6 +9,10 @@ "dom.iterable", "es2022" ], + // Must stay false. At target es2022 TypeScript defaults it to true, emitting @Prop + // defaults as class fields. Consumers that downlevel those hoist the initializers + // above Stencil's registerInstance(), so its prop setter drops every default. + "useDefineForClassFields": false, "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment", From fe3d351c48cc541ffd62ff3aa03d72cac99e0776 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:25:19 +0000 Subject: [PATCH 02/17] chore(deps): update actions/setup-node action to v7 (#31384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/setup-node](https://redirect.github.com/actions/setup-node) | action | major | `v6.4.0` → `v7.0.0` | --- ### Release Notes
actions/setup-node (actions/setup-node) ### [`v7.0.0`](https://redirect.github.com/actions/setup-node/releases/tag/v7.0.0) [Compare Source](https://redirect.github.com/actions/setup-node/compare/v7.0.0...v7.0.0) ##### What's Changed ##### Enhancements: - Add cache-primary-key and cache-matched-key as outputs by [@​gowridurgad](https://redirect.github.com/gowridurgad) in [#​1577](https://redirect.github.com/actions/setup-node/pull/1577) - Migrate to ESM and upgrade dependencies by [@​gowridurgad](https://redirect.github.com/gowridurgad) in [#​1574](https://redirect.github.com/actions/setup-node/pull/1574) ##### Bug fixes: - Remove dummy NODE\_AUTH\_TOKEN export by [@​gowridurgad](https://redirect.github.com/gowridurgad) in [#​1558](https://redirect.github.com/actions/setup-node/pull/1558) - Only use `mirrorToken` in `getManifest` if it's provided by [@​deiga](https://redirect.github.com/deiga) in [#​1548](https://redirect.github.com/actions/setup-node/pull/1548) ##### Documentation updates: - Add documentation for publishing to npm with Trusted Publisher (OIDC) by [@​chiranjib-swain](https://redirect.github.com/chiranjib-swain) in [#​1536](https://redirect.github.com/actions/setup-node/pull/1536) - docs: Update restore-only cache documentation by [@​priya-kinthali](https://redirect.github.com/priya-kinthali) in [#​1550](https://redirect.github.com/actions/setup-node/pull/1550) - docs: Update caching recommendations to mitigate cache poisoning risks by [@​chiranjib-swain](https://redirect.github.com/chiranjib-swain) in [#​1567](https://redirect.github.com/actions/setup-node/pull/1567) ##### Dependency update: - Upgrade [@​actions/cache](https://redirect.github.com/actions/cache) to 5.1.0, log cache write denied by [@​jasongin](https://redirect.github.com/jasongin) in [#​1569](https://redirect.github.com/actions/setup-node/pull/1569) ##### New Contributors - [@​chiranjib-swain](https://redirect.github.com/chiranjib-swain) made their first contribution in [#​1536](https://redirect.github.com/actions/setup-node/pull/1536) - [@​deiga](https://redirect.github.com/deiga) made their first contribution in [#​1548](https://redirect.github.com/actions/setup-node/pull/1548) - [@​jasongin](https://redirect.github.com/jasongin) made their first contribution in [#​1569](https://redirect.github.com/actions/setup-node/pull/1569) **Full Changelog**: ### [`v7`](https://redirect.github.com/actions/setup-node/compare/v6.5.0...v7.0.0) [Compare Source](https://redirect.github.com/actions/setup-node/compare/v6.5.0...v7.0.0) ### [`v6.5.0`](https://redirect.github.com/actions/setup-node/releases/tag/v6.5.0) [Compare Source](https://redirect.github.com/actions/setup-node/compare/v6.4.0...v6.5.0) ##### What's Changed - Update [@​actions/cache](https://redirect.github.com/actions/cache) to 5.1.0 and add security overrides for undici and fast-xml-parser by [@​HarithaVattikuti](https://redirect.github.com/HarithaVattikuti) in [#​1579](https://redirect.github.com/actions/setup-node/pull/1579) **Full Changelog**:
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - "every weekday before 11am" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/migrate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 3d9b03ea5c4..436ca80f871 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -64,7 +64,7 @@ jobs: working-directory: ./packages/migrate steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x cache: 'npm' From fea11f48a9ad89b3d2d7015fb0ef5ff3ed6353c8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:35:35 +0000 Subject: [PATCH 03/17] chore(deps): update actions/checkout action to v7.0.1 (#31378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/checkout](https://redirect.github.com/actions/checkout) | action | patch | `v7.0.0` → `v7.0.1` | --- ### Release Notes
actions/checkout (actions/checkout) ### [`v7.0.1`](https://redirect.github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v701) [Compare Source](https://redirect.github.com/actions/checkout/compare/v7...v7.0.1) - Bump github/codeql-action from 3 to 4 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2475](https://redirect.github.com/actions/checkout/pull/2475) - Bump actions/setup-node from 4 to 6 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2477](https://redirect.github.com/actions/checkout/pull/2477) - Bump docker/build-push-action from 6.5.0 to 7.2.0 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2478](https://redirect.github.com/actions/checkout/pull/2478) - Bump docker/login-action from 3.3.0 to 4.2.0 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2479](https://redirect.github.com/actions/checkout/pull/2479) - Bump actions/checkout from 6 to 7 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2488](https://redirect.github.com/actions/checkout/pull/2488) - Bump actions/upload-artifact from 4 to 7 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2476](https://redirect.github.com/actions/checkout/pull/2476) - eslint 9 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2474](https://redirect.github.com/actions/checkout/pull/2474) - Bump the minor-actions-dependencies group with 2 updates by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2499](https://redirect.github.com/actions/checkout/pull/2499) - skip running unsafe pr check if input is default by [@​aiqiaoy](https://redirect.github.com/aiqiaoy) in [#​2518](https://redirect.github.com/actions/checkout/pull/2518) - trim only ascii whitespace for branch by [@​aiqiaoy](https://redirect.github.com/aiqiaoy) in [#​2521](https://redirect.github.com/actions/checkout/pull/2521) - escape values passed to --unset by [@​aiqiaoy](https://redirect.github.com/aiqiaoy) in [#​2530](https://redirect.github.com/actions/checkout/pull/2530)
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - "every weekday before 11am" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/migrate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 436ca80f871..54990664544 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -28,7 +28,7 @@ jobs: outputs: migrate: ${{ steps.filter.outputs.migrate }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history so the PR base commit is reachable for the diff below. fetch-depth: 0 @@ -63,7 +63,7 @@ jobs: run: working-directory: ./packages/migrate steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x From 461fa1b6e3f293143b5fe98acada44169478c512 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:02:54 +0000 Subject: [PATCH 04/17] chore(deps): update dependency ts-morph to v28 (#31385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [ts-morph](https://redirect.github.com/dsherret/ts-morph) | [`^25.0.0` → `^28.0.0`](https://renovatebot.com/diffs/npm/ts-morph/25.0.1/28.0.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/ts-morph/28.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/ts-morph/25.0.1/28.0.0?slim=true) | --- ### Release Notes
dsherret/ts-morph (ts-morph) ### [`v28.0.0`](https://redirect.github.com/dsherret/ts-morph/releases/tag/28.0.0) [Compare Source](https://redirect.github.com/dsherret/ts-morph/compare/27.0.2...28.0.0) #### What's Changed - feat(BREAKING): TypeScript 6.0 by [@​dsherret](https://redirect.github.com/dsherret) in [#​1669](https://redirect.github.com/dsherret/ts-morph/pull/1669) - feat: standalone `printStructure` function by [@​dsherret](https://redirect.github.com/dsherret) in [#​1670](https://redirect.github.com/dsherret/ts-morph/pull/1670) - fix: `InvalidOperationError` in `addTypeArgument` by [@​jfirebaugh](https://redirect.github.com/jfirebaugh) in [#​1663](https://redirect.github.com/dsherret/ts-morph/pull/1663) Note there are some breaking changes due to TypeScript 6.0. Read more here: As part of this release, structures can now be printed to a string using the `printStructure` function. ```ts import { printStructure, StructureKind } from "ts-morph"; const code = printStructure({ kind: StructureKind.Class, name: "MyClass", isExported: true, properties: [{ name: "myProp", type: "string" }], methods: [{ name: "myMethod", parameters: [{ name: "param", type: "number" }], returnType: "void", }], }); console.log(code); ``` Outputs: ```ts export class MyClass { myProp: string; myMethod(param: number): void { } } ``` #### New Contributors - [@​jfirebaugh](https://redirect.github.com/jfirebaugh) made their first contribution in [#​1663](https://redirect.github.com/dsherret/ts-morph/pull/1663) **Full Changelog**: ### [`v27.0.2`](https://redirect.github.com/dsherret/ts-morph/releases/tag/27.0.2) [Compare Source](https://redirect.github.com/dsherret/ts-morph/compare/27.0.0...27.0.2) #### What's Changed - Fixes the npm publish in order to get the fixes in from 27.0.1 - fix: browser support no longer working since [#​1628](https://redirect.github.com/dsherret/ts-morph/pull/1628) in 26.0.0 due to missing browser fields by [@​BossSloth](https://redirect.github.com/BossSloth) in [#​1654](https://redirect.github.com/dsherret/ts-morph/pull/1654) **Full Changelog**: ### [`v27.0.0`](https://redirect.github.com/dsherret/ts-morph/releases/tag/27.0.0) [Compare Source](https://redirect.github.com/dsherret/ts-morph/compare/26.0.0...27.0.0) #### What's Changed - perf: switch to tinyglobby by [@​benmccann](https://redirect.github.com/benmccann) in [#​1635](https://redirect.github.com/dsherret/ts-morph/pull/1635) - fix: re-export StandardizedFilePath from [@​ts-morph/common](https://redirect.github.com/ts-morph/common) by [@​kronodeus](https://redirect.github.com/kronodeus) in [#​1637](https://redirect.github.com/dsherret/ts-morph/pull/1637) - chore: fix ci by [@​dsherret](https://redirect.github.com/dsherret) in [#​1645](https://redirect.github.com/dsherret/ts-morph/pull/1645) - feat: `TypeChecker` - add `getAwaitedType` by [@​JHawk0224](https://redirect.github.com/JHawk0224) in [#​1643](https://redirect.github.com/dsherret/ts-morph/pull/1643) - feat: TypeScript 5.9 by [@​dsherret](https://redirect.github.com/dsherret) in [#​1646](https://redirect.github.com/dsherret/ts-morph/pull/1646) - feat: wrap OptionalTypeNode by [@​dsherret](https://redirect.github.com/dsherret) in [#​1647](https://redirect.github.com/dsherret/ts-morph/pull/1647) - feat: wrap `JSDocImportTag` by [@​dsherret](https://redirect.github.com/dsherret) in [#​1648](https://redirect.github.com/dsherret/ts-morph/pull/1648) - fix: remove invalid sourceMappingURL from build output by [@​dsherret](https://redirect.github.com/dsherret) in [#​1649](https://redirect.github.com/dsherret/ts-morph/pull/1649) - feat: ability to add and remove the defer keyword in imports by [@​dsherret](https://redirect.github.com/dsherret) in [#​1651](https://redirect.github.com/dsherret/ts-morph/pull/1651) #### New Contributors - [@​JHawk0224](https://redirect.github.com/JHawk0224) made their first contribution in [#​1643](https://redirect.github.com/dsherret/ts-morph/pull/1643) **Full Changelog**: ### [`v26.0.0`](https://redirect.github.com/dsherret/ts-morph/releases/tag/26.0.0) [Compare Source](https://redirect.github.com/dsherret/ts-morph/compare/25.0.1...26.0.0) #### What's Changed - feat: TypeScript 5.8 [#​1628](https://redirect.github.com/dsherret/ts-morph/pull/1628) - fix: Improve comma appending logic to handle nested template literals correctly by [@​kingston](https://redirect.github.com/kingston) in [#​1630](https://redirect.github.com/dsherret/ts-morph/pull/1630) - fix(bootstrap): ignore dist-deno folder for npm publish [#​1610](https://redirect.github.com/dsherret/ts-morph/pull/1610) - feat: `npm publish` with provenance [#​1634](https://redirect.github.com/dsherret/ts-morph/pull/1634) #### New Contributors - [@​kingston](https://redirect.github.com/kingston) made their first contribution in [#​1630](https://redirect.github.com/dsherret/ts-morph/pull/1630) **Full Changelog**:
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - "every weekday before 11am" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/migrate/package-lock.json | 278 +++-------------------------- packages/migrate/package.json | 2 +- 2 files changed, 29 insertions(+), 251 deletions(-) diff --git a/packages/migrate/package-lock.json b/packages/migrate/package-lock.json index afd370c9a50..cd30980b53e 100644 --- a/packages/migrate/package-lock.json +++ b/packages/migrate/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "ts-morph": "^25.0.0" + "ts-morph": "^28.0.0" }, "bin": { "ionic-migrate": "dist/cli.js" @@ -472,41 +472,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -897,14 +862,14 @@ ] }, "node_modules/@ts-morph/common": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.26.1.tgz", - "integrity": "sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", "license": "MIT", "dependencies": { - "fast-glob": "^3.3.2", - "minimatch": "^9.0.4", - "path-browserify": "^1.0.1" + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" } }, "node_modules/@types/chai": { @@ -1068,30 +1033,24 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "20 || >=22" } }, "node_modules/cac": { @@ -1234,43 +1193,6 @@ "node": ">=12.0.0" } }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1286,48 +1208,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1352,38 +1232,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1445,18 +1303,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -1486,36 +1332,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1561,29 +1377,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1646,7 +1439,6 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -1663,7 +1455,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1681,7 +1472,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1720,25 +1510,13 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/ts-morph": { - "version": "25.0.1", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-25.0.1.tgz", - "integrity": "sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ==", + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", "license": "MIT", "dependencies": { - "@ts-morph/common": "~0.26.0", + "@ts-morph/common": "~0.29.0", "code-block-writer": "^13.0.3" } }, diff --git a/packages/migrate/package.json b/packages/migrate/package.json index c70a7184664..69276ed9418 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -41,7 +41,7 @@ "lint": "tsc --noEmit" }, "dependencies": { - "ts-morph": "^25.0.0" + "ts-morph": "^28.0.0" }, "devDependencies": { "@types/node": "^24.13.3", From d41f25860dcb9a71a40226973cca9ca223b7f7ca Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 20 Aug 2026 16:02:11 +0000 Subject: [PATCH 05/17] fix(refresher): validate slot after frameworks assign it (#31377) Issue number: resolves #31376 --------- ## What is the current behavior? Currently, `ion-refresher` reads its `slot` attribute in `connectedCallback` and gives up if it isn't `fixed`. In v9 `@ionic/react` uses the custom elements build, so `connectedCallback` runs while the element is still being inserted, which is before React assigns the slot. The check fails on perfectly correct markup, logs the "Make sure you use" error, and the pull-to-refresh gesture never gets created. This affects React 18 and 19. ## What is the new behavior? The check now runs in `componentWillLoad`, which is late enough that frameworks have assigned the slot, but still before rendering puts `slot="fixed"` on the host. Gesture setup no longer bails, so the refresher works whether the slot is in the markup or arrives later, and correctly written React apps stop logging the error. Markup that genuinely omits the slot still gets it. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information Current dev build: ``` 9.0.1-dev.11787177893.1979eb13 ``` --- core/src/components/refresher/refresher.tsx | 12 ++- .../test/slot-validation/refresher.e2e.ts | 96 +++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 core/src/components/refresher/test/slot-validation/refresher.e2e.ts diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index ecd27fd0965..153f4cc6f76 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -512,11 +512,19 @@ export class Refresher implements ComponentInterface { this.checkNativeRefresher(); } - async connectedCallback() { + /** + * Validate the slot attribute before rendering, while the host still reflects the + * developer's original markup. `connectedCallback` is too early: in the custom + * elements build it runs during insertion, before frameworks such as React assign + * the slot. + */ + componentWillLoad() { if (this.el.getAttribute('slot') !== 'fixed') { printIonError('[ion-refresher] - Make sure you use: '); - return; } + } + + async connectedCallback() { const contentEl = this.el.closest(ION_CONTENT_ELEMENT_SELECTOR); if (!contentEl) { printIonContentErrorMsg(this.el); diff --git a/core/src/components/refresher/test/slot-validation/refresher.e2e.ts b/core/src/components/refresher/test/slot-validation/refresher.e2e.ts new file mode 100644 index 00000000000..952c2e4125a --- /dev/null +++ b/core/src/components/refresher/test/slot-validation/refresher.e2e.ts @@ -0,0 +1,96 @@ +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { configs, dragElementByYAxis, test } from '@utils/test/playwright'; + +const collectConsoleErrors = (page: Page) => { + const logs: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'error') { + logs.push(msg.text()); + } + }); + + return logs; +}; + +const SLOT_ERROR = '[Ionic Error]: [ion-refresher] - Make sure you use: '; + +/** + * Rendering puts `slot="fixed"` on the host, so a refresher whose markup omits the + * slot still ends up in the right place and has to work. Frameworks that assign the + * slot after inserting the element start out the same way. + * + * This behavior does not vary across directions. + */ +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('refresher: slot validation'), () => { + test('should still set up the pull-to-refresh gesture when the slot is missing', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31376', + }); + + await page.setContent( + ` + + + + + +
+
+ `, + config + ); + /** + * Gesture setup runs behind a dynamic import, so dragging straight after + * setContent can land before the refresher is listening. + */ + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + const ionRefresh = await page.spyOnEvent('ionRefresh'); + + await dragElementByYAxis(page.locator('body'), page, 320); + + await expect.poll(() => ionRefresh.events.length).toBe(1); + }); + + test('should report an error telling the developer to add the slot', async ({ page }) => { + const logs = collectConsoleErrors(page); + + await page.setContent( + ` + + + + + + `, + config + ); + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain(SLOT_ERROR); + }); + + test('should not report an error when the slot is set', async ({ page }) => { + const logs = collectConsoleErrors(page); + + await page.setContent( + ` + + + + + + `, + config + ); + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + expect(logs).toEqual([]); + }); + }); +}); From 898001c5c6a92a3269dd8e60c4e79da1c634af9d Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 20 Aug 2026 17:32:26 +0000 Subject: [PATCH 06/17] fix(react-router): skip page clone when nothing will animate (#31387) Issue number: internal --------- ## What is the current behavior? `StackManager.transitionPage()` clones the leaving page on same-view transitions (`/user/1` to `/user/2`) so `commit()` gets a leaving element distinct from the entering one. It clones even when nothing will animate, which duplicates the page in the DOM for the length of the commit. Playwright locators then resolve to two elements and fail on a strict mode violation, which randomly fails CI and appears as a flake. ## What is the new behavior? The clone is gated on whether `commit()` will actually animate, mirroring the check `ion-router-outlet` makes. When it won't, `commit()` gets `undefined` for the leaving element, which it already handles, and no duplicate reaches the DOM. Two regression tests record the peak number of matching pages across a navigation so a reappearing duplicate fails directly instead of surfacing as a flake. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information The duplicate was never visible on screen. With animations off the clone is attached for about 5ms without crossing a frame boundary, and with animations on it carries `ion-page-invisible` (opacity 0) until `afterTransition`, which runs just before it's removed. This is a DOM-level fix, not a rendering one. --- .../src/ReactRouter/StackManager.tsx | 47 ++++++++++++++----- .../e2e/playwright/nested-params.spec.ts | 21 ++++++++- .../e2e/playwright/tab-lifecycle.spec.ts | 19 +++++++- .../tests/e2e/playwright/utils/test-utils.ts | 46 ++++++++++++++++++ 4 files changed, 120 insertions(+), 13 deletions(-) diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index bea1506cb3a..69ca358dc84 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -5,7 +5,7 @@ */ import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react'; -import { IonRoute, RouteManagerContext, StackContext, createDebugLogger, generateId } from '@ionic/react'; +import { IonRoute, RouteManagerContext, StackContext, createDebugLogger, generateId, getConfig } from '@ionic/react'; import React from 'react'; import type { RouteObject } from 'react-router-dom'; import { Route, UNSAFE_RouteContext as RouteContext, matchRoutes } from 'react-router-dom'; @@ -1513,8 +1513,37 @@ export class StackManager extends React.PureComponent { ) { const myGeneration = ++this.transitionGeneration; + const routerOutlet = this.routerOutletElement!; + + const routeInfoFallbackDirection = + routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root' ? undefined : routeInfo.routeDirection; + const directionToUse = direction ?? routeInfoFallbackDirection; + + /** + * The cases where we pass `commit()` a duration of 0. It's a function so each + * caller reads `skipTransition` as of when it runs, since the swipe gesture can + * set it after we get here. + */ + const isInstantCommit = () => this.skipTransition || skipAnimation || directionToUse === undefined; + + /** + * Whether `commit()` will run an animation. Mirrors the check in core's + * `router-outlet.tsx`, so keep the two in sync: an instant commit never + * animates, and otherwise the outlet's `animated` prop and the global + * `animated` config both have to allow it (`ionic:_testing` turns it off). + */ + const willCommitAnimate = () => { + if (isInstantCommit()) { + return false; + } + + const config = getConfig(); + return !!routerOutlet.animated && (config ? config.getBoolean('animated', true) : true); + }; + const runCommit = async (enteringEl: HTMLElement, leavingEl?: HTMLElement) => { const skipTransition = this.skipTransition; + const commitDuration = isInstantCommit() ? 0 : undefined; /** * If the transition was handled @@ -1551,8 +1580,6 @@ export class StackManager extends React.PureComponent { } } - const commitDuration = skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined; - // Race commit against a timeout to recover from hangs const commitPromise = routerOutlet.commit(enteringEl, leavingEl, { duration: commitDuration, @@ -1589,19 +1616,13 @@ export class StackManager extends React.PureComponent { } }; - const routerOutlet = this.routerOutletElement!; - - const routeInfoFallbackDirection = - routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root' ? undefined : routeInfo.routeDirection; - const directionToUse = direction ?? routeInfoFallbackDirection; - if (enteringViewItem && enteringViewItem.ionPageElement && this.routerOutletElement) { this.transitionEnteringElement = enteringViewItem.ionPageElement; if (leavingViewItem && leavingViewItem.ionPageElement && enteringViewItem === leavingViewItem) { // Clone page for same-view transitions (e.g., /user/1 → /user/2) const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname, undefined, this.outletMountPath); - if (match) { + if (match && willCommitAnimate()) { const newLeavingElement = clonePageElement(leavingViewItem.ionPageElement.outerHTML); if (newLeavingElement) { this.routerOutletElement.appendChild(newLeavingElement); @@ -1609,7 +1630,11 @@ export class StackManager extends React.PureComponent { this.routerOutletElement.removeChild(newLeavingElement); } } else { - // Route no longer matches (e.g., /user/1 → /settings) + /** + * Either the route no longer matches (e.g., /user/1 → /settings), or + * nothing will animate, so the clone would duplicate the page in the + * DOM for no benefit. + */ await runCommit(enteringViewItem.ionPageElement, undefined); } } else { diff --git a/packages/react-router/test/base/tests/e2e/playwright/nested-params.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/nested-params.spec.ts index e7ab3ee0713..0071e38dd33 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/nested-params.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/nested-params.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { ionPageVisible, withTestingMode } from './utils/test-utils'; +import { ionPageVisible, trackPeakMatchCount, withTestingMode } from './utils/test-utils'; test.describe('Nested Params', () => { @@ -44,4 +44,23 @@ test.describe('Nested Params', () => { await expect(page.getByText('Layout sees user: 42')).toBeVisible(); }); + // A duplicate details page, even briefly, fails this spec's text assertions on + // a strict mode violation. + test('should not duplicate the details page while switching params', async ({ page }) => { + await page.goto(withTestingMode('/nested-params')); + await ionPageVisible(page, 'nested-params-landing'); + + await page.locator('#go-to-user-99').click(); + await expect(page.getByText('Details view user: 99')).toBeVisible(); + + await page.locator('[data-pageid="nested-params-user-99"]:not(.ion-page-hidden) #back-to-landing').click(); + await ionPageVisible(page, 'nested-params-landing'); + + const peakDetailsPages = await trackPeakMatchCount(page, '[data-testid="user-details-param"]', '42'); + + await page.locator('#go-to-user-42').click(); + await expect(page.getByText('Details view user: 42')).toBeVisible(); + + expect(await peakDetailsPages()).toBe(1); + }); }); diff --git a/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts index f421357cac3..7287d4c75df 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/tab-lifecycle.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { ionPageVisible, ionTabClick, withTestingMode } from './utils/test-utils'; +import { ionPageVisible, ionTabClick, trackPeakMatchCount, withTestingMode } from './utils/test-utils'; test.describe('Tab Lifecycle Events', () => { test.beforeEach(async ({ page }) => { @@ -70,4 +70,21 @@ test.describe('Tab Lifecycle Events', () => { expect(events).toContain('home:ionViewWillEnter'); expect(events).toContain('home:ionViewDidEnter'); }); + + // A duplicate tab page, even briefly, fails this spec's page assertions on a + // strict mode violation. + test('should not duplicate the tab page in the DOM while returning to the tabs', async ({ page }) => { + await page.goto(withTestingMode('/tab-lifecycle/home')); + await ionPageVisible(page, 'tab-lifecycle-home'); + + await page.locator('#go-outside').click(); + await ionPageVisible(page, 'tab-lifecycle-outside'); + + const peakHomePages = await trackPeakMatchCount(page, 'div.ion-page[data-pageid="tab-lifecycle-home"]'); + + await page.locator('#go-back-to-tabs').click(); + await ionPageVisible(page, 'tab-lifecycle-home'); + + expect(await peakHomePages()).toBe(1); + }); }); diff --git a/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts b/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts index e7543ef88a1..e651cab081c 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/utils/test-utils.ts @@ -17,6 +17,52 @@ export function withTestingMode(path: string): string { return `${path}${separator}ionic:_testing=true`; } +let peakCounterId = 0; + +/** + * Start recording the largest number of elements matching `selector` (optionally + * narrowed to those containing `containsText`) that ever coexist. The returned + * function stops recording and resolves with the peak. + * + * Narrowing by text matters because several parameterized layouts can be in the + * DOM at once, so it's what limits the count to the page under test. + * + * Start tracking after the last navigation. The counter lives on `window`, so a + * `page.goto()` in between wipes it and the returned function will throw. + */ +export async function trackPeakMatchCount( + page: Page, + selector: string, + containsText?: string +): Promise<() => Promise> { + const key = `__peakMatchCount${peakCounterId++}`; + + await page.evaluate( + ({ selector, containsText, key }) => { + const state = window as any; + const count = () => { + const matches = Array.from(document.querySelectorAll(selector)); + return containsText ? matches.filter((el) => el.textContent?.includes(containsText)).length : matches.length; + }; + + state[key] = count(); + const observer = new MutationObserver(() => { + state[key] = Math.max(state[key], count()); + }); + observer.observe(document.body, { subtree: true, childList: true, characterData: true }); + state[`${key}Stop`] = () => observer.disconnect(); + }, + { selector, containsText, key } + ); + + return () => + page.evaluate((k) => { + const state = window as any; + state[`${k}Stop`](); + return state[k] as number; + }, key); +} + /** * Assert that a page is visible and not hidden or invisible. * Equivalent to Cypress `cy.ionPageVisible(pageId)`. From 8ab64ad47cbbf5fe5d2a1e288b43d6bdc4462e39 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:36:50 +0000 Subject: [PATCH 07/17] chore(deps): update capacitor to v8.5.0 (#31383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [@capacitor/core](https://capacitorjs.com) ([source](https://redirect.github.com/ionic-team/capacitor)) | [`8.3.4` → `8.5.0`](https://renovatebot.com/diffs/npm/@capacitor%2fcore/8.3.4/8.5.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fcore/8.5.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fcore/8.3.4/8.5.0?slim=true) | | [@capacitor/status-bar](https://redirect.github.com/ionic-team/capacitor-plugins) | [`8.0.2` → `8.0.3`](https://renovatebot.com/diffs/npm/@capacitor%2fstatus-bar/8.0.2/8.0.3) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fstatus-bar/8.0.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fstatus-bar/8.0.2/8.0.3?slim=true) | --- ### Release Notes
ionic-team/capacitor (@​capacitor/core) ### [`v8.5.0`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#850-2026-07-31) [Compare Source](https://redirect.github.com/ionic-team/capacitor/compare/8.4.2...8.5.0) ##### Bug Fixes - **cli:** support TypeScript 7 when loading capacitor.config.ts ([#​8534](https://redirect.github.com/ionic-team/capacitor/issues/8534)) ([4c1c870](https://redirect.github.com/ionic-team/capacitor/commit/4c1c8709413b9c19b008c99122ca330cc3c90e6f)) ##### Features - **cli:** add migrator functionality for adopting UIScene ([#​8544](https://redirect.github.com/ionic-team/capacitor/issues/8544)) ([984fa85](https://redirect.github.com/ionic-team/capacitor/commit/984fa85ba0adab0aacf895aed6323bf4b503dccb)) - **ios:** UIScene Support ([#​8536](https://redirect.github.com/ionic-team/capacitor/issues/8536)) ([3fa04a3](https://redirect.github.com/ionic-team/capacitor/commit/3fa04a357c92af34cd6fccb8124791963804a9dc)) #### [8.4.2](https://redirect.github.com/ionic-team/capacitor/compare/8.4.1...8.4.2) (2026-07-14) ##### Bug Fixes - **android:** explicitly grant URI permissions for image capture intent ([#​8526](https://redirect.github.com/ionic-team/capacitor/issues/8526)) ([6f2d328](https://redirect.github.com/ionic-team/capacitor/commit/6f2d3283897a375d09ca9ec8784a9f0d65f0a530)) #### [8.4.1](https://redirect.github.com/ionic-team/capacitor/compare/8.4.0...8.4.1) (2026-06-19) ##### Bug Fixes - **cli:** make SPM dependency patch work on prereleases ([#​8508](https://redirect.github.com/ionic-team/capacitor/issues/8508)) ([6048e90](https://redirect.github.com/ionic-team/capacitor/commit/6048e90171afa0229a3c25b52a23c377c6bb804c)) - **cli:** patch Capacitor SPM dependency version in plugins ([#​8492](https://redirect.github.com/ionic-team/capacitor/issues/8492)) ([28bb2c6](https://redirect.github.com/ionic-team/capacitor/commit/28bb2c687069dfdd6aa7abc866004a1c6388d103)) ### [`v8.4.2`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#842-2026-07-14) [Compare Source](https://redirect.github.com/ionic-team/capacitor/compare/8.4.1...8.4.2) ##### Bug Fixes - **android:** explicitly grant URI permissions for image capture intent ([#​8526](https://redirect.github.com/ionic-team/capacitor/issues/8526)) ([6f2d328](https://redirect.github.com/ionic-team/capacitor/commit/6f2d3283897a375d09ca9ec8784a9f0d65f0a530)) ### [`v8.4.1`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#841-2026-06-19) [Compare Source](https://redirect.github.com/ionic-team/capacitor/compare/8.4.0...8.4.1) ##### Bug Fixes - **cli:** make SPM dependency patch work on prereleases ([#​8508](https://redirect.github.com/ionic-team/capacitor/issues/8508)) ([6048e90](https://redirect.github.com/ionic-team/capacitor/commit/6048e90171afa0229a3c25b52a23c377c6bb804c)) - **cli:** patch Capacitor SPM dependency version in plugins ([#​8492](https://redirect.github.com/ionic-team/capacitor/issues/8492)) ([28bb2c6](https://redirect.github.com/ionic-team/capacitor/commit/28bb2c687069dfdd6aa7abc866004a1c6388d103)) ### [`v8.4.0`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#840-2026-06-02) [Compare Source](https://redirect.github.com/ionic-team/capacitor/compare/8.3.4...8.4.0) ##### Bug Fixes - **android:** show only the requested system bar ([#​8480](https://redirect.github.com/ionic-team/capacitor/issues/8480)) ([4c6c321](https://redirect.github.com/ionic-team/capacitor/commit/4c6c3219afb5223211e857457e46283c37eb9424)) - **cli:** revert live reload config on failure ([#​8485](https://redirect.github.com/ionic-team/capacitor/issues/8485)) ([1d031a4](https://redirect.github.com/ionic-team/capacitor/commit/1d031a4abec2c793079ba8897ad2e40c4cc6c7f9)) - **SystemBars:** make `safe-area-inset-x` available on API <= 34 ([#​8424](https://redirect.github.com/ionic-team/capacitor/issues/8424)) ([e456de0](https://redirect.github.com/ionic-team/capacitor/commit/e456de083e19644f484bec5a5359cb67960ac8bc)) - **SystemBars:** respect `insetsHandling` disable ([#​8481](https://redirect.github.com/ionic-team/capacitor/issues/8481)) ([d4ad7ff](https://redirect.github.com/ionic-team/capacitor/commit/d4ad7ffe39daf66e0cfc63af9028d5c05543bde7)) ##### Features - add method getDouble to plugin config ([#​7638](https://redirect.github.com/ionic-team/capacitor/issues/7638)) ([93c72de](https://redirect.github.com/ionic-team/capacitor/commit/93c72de40a2ec4c78b33659250cb08340083088e)) - **cli:** add experimental packageOptions ([#​8471](https://redirect.github.com/ionic-team/capacitor/issues/8471)) ([258867b](https://redirect.github.com/ionic-team/capacitor/commit/258867b7bf37b1837b99b02ec9638e5a6df08d97)) - **cli:** capture ios\_package\_manager in telemetry ([#​8482](https://redirect.github.com/ionic-team/capacitor/issues/8482)) ([b4b297a](https://redirect.github.com/ionic-team/capacitor/commit/b4b297a52f8732659662d5e5aaeff81c0f7d9835)) #### [8.3.4](https://redirect.github.com/ionic-team/capacitor/compare/8.3.3...8.3.4) (2026-05-12) **Note:** Version bump only for package capacitor #### [8.3.3](https://redirect.github.com/ionic-team/capacitor/compare/8.3.2...8.3.3) (2026-05-08) ##### Bug Fixes - **cli:** copy plugin files in CocoaPods projects ([#​8467](https://redirect.github.com/ionic-team/capacitor/issues/8467)) ([b2d7719](https://redirect.github.com/ionic-team/capacitor/commit/b2d771926a180e60deea31992d7d4abcd5ca3bc7)) #### [8.3.2](https://redirect.github.com/ionic-team/capacitor/compare/8.3.1...8.3.2) (2026-05-07) ##### Bug Fixes - **cli:** add cSettings support for compiler flags in generated Package.swift ([#​8448](https://redirect.github.com/ionic-team/capacitor/issues/8448)) ([0bd0676](https://redirect.github.com/ionic-team/capacitor/commit/0bd0676315c5fd77e50312dd7b5bf4990dcbd7d0)) - **cli:** add system framework and weak framework support in SPM Package.swift ([#​8447](https://redirect.github.com/ionic-team/capacitor/issues/8447)) ([3232f0f](https://redirect.github.com/ionic-team/capacitor/commit/3232f0fe1d9811b0b5c500e3dc05cb8a250177f8)) - **cli:** correct Capacitor plugin SPM compat check ([#​8440](https://redirect.github.com/ionic-team/capacitor/issues/8440)) ([e5ccc45](https://redirect.github.com/ionic-team/capacitor/commit/e5ccc451dda27d56bca824ed644bd20fe4d988cb)) - **cli:** generate binaryTarget entries for custom xcframeworks in Package.swift ([#​8445](https://redirect.github.com/ionic-team/capacitor/issues/8445)) ([1f7e33f](https://redirect.github.com/ionic-team/capacitor/commit/1f7e33fca43d183332ec19d22b0d75ef81d8cc6d)) - **cli:** generate resource entries in Package.swift ([#​8455](https://redirect.github.com/ionic-team/capacitor/issues/8455)) ([790bd27](https://redirect.github.com/ionic-team/capacitor/commit/790bd27123497111984227010c3162cec94a108e)) - **cli:** handle Cordova plugins without iOS source files ([#​8443](https://redirect.github.com/ionic-team/capacitor/issues/8443)) ([0da130e](https://redirect.github.com/ionic-team/capacitor/commit/0da130eb7a861bee4e2c35bc0aac53ba9c983fc3)) - **cli:** link plugin dependencies in Package.swift ([#​8457](https://redirect.github.com/ionic-team/capacitor/issues/8457)) ([b3c769e](https://redirect.github.com/ionic-team/capacitor/commit/b3c769e856c826b1174518877cf86ac7ce73bf09)) - **ios:** support Cordova plugins with Package.swift ([#​8438](https://redirect.github.com/ionic-team/capacitor/issues/8438)) ([139943b](https://redirect.github.com/ionic-team/capacitor/commit/139943b0c05fddb2d1ce2d6f468800fddf17b4cf)) - **SystemBars:** avoid extra view padding on API <= 34 ([#​8439](https://redirect.github.com/ionic-team/capacitor/issues/8439)) ([5b135a7](https://redirect.github.com/ionic-team/capacitor/commit/5b135a70217be560e7176c8d5b514cc92ed3e4e4)) #### [8.3.1](https://redirect.github.com/ionic-team/capacitor/compare/8.3.0...8.3.1) (2026-04-16) ##### Bug Fixes - **android:** handle null versionName in isNewBinary() ([#​8397](https://redirect.github.com/ionic-team/capacitor/issues/8397)) ([aa1a660](https://redirect.github.com/ionic-team/capacitor/commit/aa1a660f364f9b5f5a1e350e279c8864b04dd13b)) - boundary value extraction for form-data requests ([#​7518](https://redirect.github.com/ionic-team/capacitor/issues/7518)) ([64ab854](https://redirect.github.com/ionic-team/capacitor/commit/64ab854c12330804c24275d88d3a9f7c8e52a73d)) - **cli:** check CAPACITOR\_COCOAPODS\_PATH in determinePackageManager ([#​8407](https://redirect.github.com/ionic-team/capacitor/issues/8407)) ([acb64ab](https://redirect.github.com/ionic-team/capacitor/commit/acb64ab92a37ff53701cde453558e272e2e11eb6)) - **system-bars:** use separate current styles ([#​8409](https://redirect.github.com/ionic-team/capacitor/issues/8409)) ([3d1f8d1](https://redirect.github.com/ionic-team/capacitor/commit/3d1f8d1b61480187375f5cd4de7ba999db007542))
ionic-team/capacitor-plugins (@​capacitor/status-bar) ### [`v8.0.3`](https://redirect.github.com/ionic-team/capacitor-plugins/releases/tag/%40capacitor/status-bar%408.0.3) [Compare Source](https://redirect.github.com/ionic-team/capacitor-plugins/compare/@capacitor/status-bar@8.0.2...@capacitor/status-bar@8.0.3) **Note:** Version bump only for package [@​capacitor/status-bar](https://redirect.github.com/capacitor/status-bar)
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - "every weekday before 11am" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- core/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/core/package-lock.json b/core/package-lock.json index 93b7f706583..dbd8438611d 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -633,9 +633,9 @@ "license": "MIT" }, "node_modules/@capacitor/core": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz", - "integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.0.tgz", + "integrity": "sha512-Ca4krtqH1hothjtBIwf2J2TW7IhYq1ujp8QeItTiJohNsqij8ja2DYYH3DU0l8RmxCWaBAFTGA2TgOgOMCSNsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -663,9 +663,9 @@ } }, "node_modules/@capacitor/status-bar": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz", - "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz", + "integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==", "dev": true, "license": "MIT", "peerDependencies": { From 7956b973b8bb55e5f0cae844ee992fe9921432cb Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 20 Aug 2026 23:41:30 +0000 Subject: [PATCH 08/17] fix(item-sliding, segment-button): read props after frameworks assign them (#31391) Issue number: resolves #31388 --------- ## What is the current behavior? Currently, `ion-item-sliding` reads `side` off each `ion-item-options` in `connectedCallback`. It only waits for the option first if the element has a `componentOnReady` method, and the custom elements build doesn't add one, so it reads straight away and every option comes back as the default `end`. In v9 React that means `side="start"` options can't be swiped open. There's the same problem in `ion-segment-button`, where the check that stops a button tied to an `ion-segment-content` from being disabled also runs in `connectedCallback`, before `contentId` or `disabled` exist. Both come from `@ionic/react` v9 going through `@lit/react`, which assigns element props in a `useLayoutEffect` after React commits the DOM. ## What is the new behavior? Now `updateOptions` waits for every `ion-item-options` through the `componentOnReady` helper before reading `side`. The helper waits a frame in the custom elements build, which is after the props land, and is unchanged in the lazy build. That also drops the repo's only `eslint-disable custom-rules/no-component-on-ready-method`, since bypassing the helper was the bug. The `ion-segment-button` check moved to `componentWillLoad`, and it now also requires the parent segment to be enabled. A disabled `ion-segment` pushes `disabled` onto its buttons through `updateState`, which is why the check used to run first, and without the new condition the fix force-enables every button inside ``. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information I swept every core component with a `connectedCallback` for this pattern and only these two break, the rest self-heal through a `@Watch` or an existing `await`. Only `ion-radio-group` drifts, cosmetically, and I left it alone. The real fix isn't in core. If `@stencil/react-output-target` passed serializable props through `React.createElement` the way the v8 wrapper did, they'd be set before insertion and none of this would reach `connectedCallback` late. Could be worth trying to fix there for later. ## Current Dev Build ``` 9.0.1-dev.11787248963.10a00c73 ``` --- .../components/item-sliding/item-sliding.tsx | 23 ++-- .../test/basic/item-sliding.e2e.ts | 74 ++++++++++++ .../segment-button/segment-button.tsx | 20 ++-- .../test/disabled/segment-view.e2e.ts | 110 ++++++++++++++++++ core/src/utils/test/late-props/index.html | 48 ++++++++ core/src/utils/test/late-props/late-props.js | 69 +++++++++++ 6 files changed, 323 insertions(+), 21 deletions(-) create mode 100644 core/src/utils/test/late-props/index.html create mode 100644 core/src/utils/test/late-props/late-props.js diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index ba979164c47..d2bce0e0dfd 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -1,7 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core'; import { findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content'; -import { isEndSide } from '@utils/helpers'; +import { componentOnReady, isEndSide } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import { watchForOptions } from '@utils/watch-options'; @@ -245,24 +245,21 @@ export class ItemSliding implements ComponentInterface { } private async updateOptions() { - const options = this.el.querySelectorAll('ion-item-options'); + const options = Array.from(this.el.querySelectorAll('ion-item-options')); + + /** + * Frameworks that assign element props after inserting the element haven't set + * `side` while `connectedCallback` runs, so reading it any earlier reports every + * option as `end`. + */ + await Promise.all(options.map((option) => new Promise((resolve) => componentOnReady(option, resolve)))); let sides = 0; // Reset left and right options in case they were removed this.leftOptions = this.rightOptions = undefined; - for (let i = 0; i < options.length; i++) { - const item = options.item(i); - - /** - * We cannot use the componentOnReady helper - * util here since we need to wait for all of these items - * to be ready before we set `this.sides` and `this.optsDirty`. - */ - // eslint-disable-next-line custom-rules/no-component-on-ready-method - const option = (item as any).componentOnReady !== undefined ? await item.componentOnReady() : item; - + for (const option of options) { const side = isEndSide(option.side ?? option.getAttribute('side')) ? 'end' : 'start'; if (side === 'start') { diff --git a/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts b/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts index 908155f0ea5..66797b3ad4e 100644 --- a/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts +++ b/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; import { configs, dragElementBy, test } from '@utils/test/playwright'; /** @@ -190,3 +191,76 @@ configs().forEach(({ title, screenshot, config }) => { }); }); }); + +/** + * ion-item-sliding reads `side` off each ion-item-options to decide which way the item + * can open. Frameworks that assign element props after inserting the element haven't set + * it while `connectedCallback` runs. + * + * The shared harness page is used because it loads the custom elements build, which is + * where that ordering applies. + * + * This behavior does not vary across modes or directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('item-sliding: basic'), () => { + const openStartOptions = async (page: E2EPage, lateProps: boolean) => { + await page.goto('/src/utils/test/late-props', config); + await page.waitForFunction(() => (window as any).harnessReady === true); + + await page.evaluate( + (late: boolean) => + (window as any).mountLateProps( + ['ion-content', 'ion-list', 'ion-item', 'ion-item-sliding', 'ion-item-options', 'ion-item-option'], + { + tag: 'ion-content', + children: [ + { + tag: 'ion-list', + children: [ + { + tag: 'ion-item-sliding', + children: [ + { tag: 'ion-item', children: [{ tag: 'p', children: ['No label'] }] }, + { + // Passing `side` as a prop lets `lateProps` control when it can be read. + tag: 'ion-item-options', + props: { side: 'start' }, + children: [{ tag: 'ion-item-option', children: ['Favorite'] }], + }, + ], + }, + ], + }, + ], + }, + late + ), + lateProps + ); + await page.waitForChanges(); + + const slidingItem = page.locator('ion-item-sliding'); + + // A positive drag pulls the item to the right, revealing the start options. + await dragElementBy(slidingItem, page, 150); + await page.waitForChanges(); + + await expect(slidingItem).toHaveClass(/item-sliding-active-options-start/); + await expect(page.locator('ion-item-options')).toBeVisible(); + }; + + test('should open the start options when side is assigned before connecting', async ({ page }) => { + await openStartOptions(page, false); + }); + + test('should open the start options when side is assigned after connecting', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31388', + }); + + await openStartOptions(page, true); + }); + }); +}); diff --git a/core/src/components/segment-button/segment-button.tsx b/core/src/components/segment-button/segment-button.tsx index 115040298d3..6601efd5b40 100644 --- a/core/src/components/segment-button/segment-button.tsx +++ b/core/src/components/segment-button/segment-button.tsx @@ -73,14 +73,6 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { addEventListener(segmentEl, 'ionSelect', this.updateState); addEventListener(segmentEl, 'ionStyle', this.updateStyle); } - - // Prevent buttons from being disabled when associated with segment content - if (this.contentId && this.disabled) { - printIonWarning( - `[ion-segment-button] - Segment buttons cannot be disabled when associated with an .` - ); - this.disabled = false; - } } disconnectedCallback() { @@ -100,6 +92,18 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { // Return if there is no contentId defined if (!this.contentId) return; + /** + * Checked here rather than in `connectedCallback` so frameworks that assign element + * props after inserting the element have set `disabled` by now. A disabled ion-segment + * pushes that onto its buttons too, which this guard should not undo. + */ + if (this.disabled && this.segmentEl?.disabled !== true) { + printIonWarning( + `[ion-segment-button] - Segment buttons cannot be disabled when associated with an .` + ); + this.disabled = false; + } + // Attempt to find the Segment Content by its contentId const segmentContent = document.getElementById(this.contentId) as HTMLIonSegmentContentElement | null; diff --git a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts index c7dead8943f..8429f63fbbe 100644 --- a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts +++ b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts @@ -47,3 +47,113 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); }); + +/** + * Frameworks that assign element props after inserting the element have set neither + * `contentId` nor `disabled` while `connectedCallback` runs, so the check that keeps a + * button enabled has to happen later. + * + * The shared harness page is used because it loads the custom elements build, which is + * where that ordering applies. + * + * This behavior does not vary across modes or directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('segment-view: disabled'), () => { + [false, true].forEach((lateProps) => { + const when = lateProps ? 'after connecting' : 'before connecting'; + + test(`should only re-enable the developer-disabled button when props are assigned ${when}`, async ({ page }) => { + const warnings: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'warning') { + warnings.push(msg.text()); + } + }); + + await page.goto('/src/utils/test/late-props', config); + await page.waitForFunction(() => (window as any).harnessReady === true); + + await page.evaluate( + (late: boolean) => + (window as any).mountLateProps( + ['ion-segment', 'ion-segment-button', 'ion-segment-view', 'ion-segment-content', 'ion-label'], + { + tag: 'div', + children: [ + { + // The developer disabled the second button, which has to be forced back on. + tag: 'ion-segment', + props: { value: 'first' }, + children: [ + { + tag: 'ion-segment-button', + props: { value: 'first', contentId: 'first-content' }, + children: [{ tag: 'ion-label', children: ['First'] }], + }, + { + tag: 'ion-segment-button', + props: { value: 'second', contentId: 'second-content', disabled: true }, + children: [{ tag: 'ion-label', children: ['Second'] }], + }, + ], + }, + { + tag: 'ion-segment-view', + children: [ + { tag: 'ion-segment-content', attrs: { id: 'first-content' }, children: ['First'] }, + { tag: 'ion-segment-content', attrs: { id: 'second-content' }, children: ['Second'] }, + ], + }, + { + // This whole segment is disabled, so its buttons stay off. + tag: 'ion-segment', + props: { value: 'third', disabled: true }, + children: [ + { + tag: 'ion-segment-button', + props: { value: 'third', contentId: 'third-content' }, + children: [{ tag: 'ion-label', children: ['Third'] }], + }, + { + tag: 'ion-segment-button', + props: { value: 'fourth', contentId: 'fourth-content' }, + children: [{ tag: 'ion-label', children: ['Fourth'] }], + }, + ], + }, + { + tag: 'ion-segment-view', + children: [ + { tag: 'ion-segment-content', attrs: { id: 'third-content' }, children: ['Third'] }, + { tag: 'ion-segment-content', attrs: { id: 'fourth-content' }, children: ['Fourth'] }, + ], + }, + ], + }, + late + ), + lateProps + ); + await page.waitForChanges(); + + const disabled = await page + .locator('ion-segment') + .evaluateAll((segments: HTMLIonSegmentElement[]) => + segments.map((segment) => + Array.from(segment.querySelectorAll('ion-segment-button')).map((button) => button.disabled) + ) + ); + + expect(disabled).toEqual([ + [false, false], + [true, true], + ]); + expect(warnings.join('\n')).toContain( + '[ion-segment-button] - Segment buttons cannot be disabled when associated with an .' + ); + }); + }); + }); +}); diff --git a/core/src/utils/test/late-props/index.html b/core/src/utils/test/late-props/index.html new file mode 100644 index 00000000000..395328d21f7 --- /dev/null +++ b/core/src/utils/test/late-props/index.html @@ -0,0 +1,48 @@ + + + + + Late Props + + + + + + + + + + + +
+
+ + diff --git a/core/src/utils/test/late-props/late-props.js b/core/src/utils/test/late-props/late-props.js new file mode 100644 index 00000000000..7d29334b4cf --- /dev/null +++ b/core/src/utils/test/late-props/late-props.js @@ -0,0 +1,69 @@ +/** + * Test helpers for the custom elements build, where `connectedCallback` runs + * synchronously as the element is inserted. Frameworks that assign element props after + * inserting the element leave a window where a component can't read its own props or + * its children's, and it still has to work. + */ + +import { initialize } from '/components/index.js'; + +/** + * Initializes Ionic in the mode the test asked for. + */ +export const initializeIonic = () => { + initialize({ mode: new URLSearchParams(location.search).get('ionic:mode') ?? 'ios' }); +}; + +/** + * Defines the given tags. Safe to call again for tags that are already defined, so each + * test can ask for whatever it needs. + */ +export const defineTags = async (tags) => { + await Promise.all( + tags.map(async (tag) => { + const mod = await import(`/components/${tag}.js`); + mod.defineCustomElement(); + }) + ); +}; + +/** + * Builds the tree described by `spec` and appends it to `root`. A spec node is + * `{ tag, props, attrs, children }`, where `children` holds specs or strings. The + * `attrs` are always set before the element connects, and the `props` are set before + * connecting when `lateProps` is false, or after the whole tree connects when it is true. + */ +export const mount = (root, spec, lateProps) => { + const pending = []; + + const build = (node) => { + if (typeof node === 'string') { + return document.createTextNode(node); + } + + const el = document.createElement(node.tag); + + if (node.attrs) { + Object.entries(node.attrs).forEach(([key, value]) => el.setAttribute(key, String(value))); + } + + if (node.props) { + if (lateProps) { + pending.push([el, node.props]); + } else { + Object.assign(el, node.props); + } + } + + (node.children || []).forEach((child) => el.appendChild(build(child))); + + return el; + }; + + const tree = build(spec); + + root.appendChild(tree); + + // Descendants before ancestors, matching the order framework effects run in. + pending.reverse().forEach(([el, props]) => Object.assign(el, props)); +}; From 51a2694474683dabc18761a8ecdadbbef8a891fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:48:22 +0000 Subject: [PATCH 09/17] chore(deps): update dependency vitest to v4 (#31395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ℹ️ **Note** > > This PR body was truncated due to platform limits. This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [vitest](https://vitest.dev) ([source](https://redirect.github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`^3.0.0` → `^4.0.0`](https://renovatebot.com/diffs/npm/vitest/3.2.7/4.1.11) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.11?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/3.2.7/4.1.11?slim=true) | --- ### Release Notes
vitest-dev/vitest (vitest) ### [`v4.1.11`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.11) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.10...v4.1.11) #####    🐞 Bug Fixes - Revive global concurrency limit for test lifecycle \[backport to v4]  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) and [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10992](https://redirect.github.com/vitest-dev/vitest/issues/10992) [(5146d)](https://redirect.github.com/vitest-dev/vitest/commit/5146df80b) - **browser**: - Encode iframeId in tester iframe URL \[backport to v4]  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va), **Pduhard** and **Claude Opus 4.8** in [#​10955](https://redirect.github.com/vitest-dev/vitest/issues/10955) [(10b2c)](https://redirect.github.com/vitest-dev/vitest/commit/10b2cd201) - Trigger playwright/chromium gc on lower disk availability \[backport to v4]  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa), **Hiroshi Ogawa** and **OpenCode** in [#​10951](https://redirect.github.com/vitest-dev/vitest/issues/10951) [(9851d)](https://redirect.github.com/vitest-dev/vitest/commit/9851dbc41) - **mocker**: - Restrict redirect mocks to the fs allowlist \[backport to v4]  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10974](https://redirect.github.com/vitest-dev/vitest/issues/10974) [(fe5a1)](https://redirect.github.com/vitest-dev/vitest/commit/fe5a11d3c) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.10...v4.1.11) ### [`v4.1.10`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.10) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.9...v4.1.10) #####    🐞 Bug Fixes - **browser**: Check fs access in builtin commands \[backport to v4]  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa), **Hiroshi Ogawa** and **OpenCode (claude-opus-4-8)** in [#​10680](https://redirect.github.com/vitest-dev/vitest/issues/10680) [(5c18d)](https://redirect.github.com/vitest-dev/vitest/commit/5c18dd267) - **vm**: Fix external module resolve error with deps optimizer query for encoded URI \[backport to v4]  -  by [@​SveLil](https://redirect.github.com/SveLil) and [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10661](https://redirect.github.com/vitest-dev/vitest/issues/10661) [(bae52)](https://redirect.github.com/vitest-dev/vitest/commit/bae52b511) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.9...v4.1.10) ### [`v4.1.9`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.9) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.8...v4.1.9) ##### 🐞 Bug Fixes - Fix `importOriginal` with optimizer and query import \[backport to v4] - by **Hiroshi Ogawa**, **David Harris**, **Codex**and **Vladimir** in [#​10546](https://redirect.github.com/vitest-dev/vitest/issues/10546) [(a5180)](https://redirect.github.com/vitest-dev/vitest/commit/a5180190c) - **browser**: - Wait for orchestrator readiness before resolving browser sessions \[backport to v4] - by **Vladimir** and **Séamus O'Connor** in [#​10555](https://redirect.github.com/vitest-dev/vitest/issues/10555) [(7fb29)](https://redirect.github.com/vitest-dev/vitest/commit/7fb29651a) - Wait for iframe tester readiness before preparing \[backport to v4] - by **Vladimir** and **Séamus O'Connor** in [#​10497](https://redirect.github.com/vitest-dev/vitest/issues/10497) and [#​10556](https://redirect.github.com/vitest-dev/vitest/issues/10556) [(fbc62)](https://redirect.github.com/vitest-dev/vitest/commit/fbc626c40) - **mocker**: - Hoist vi.mock() for vite-plus/test imports \[backport to v4] - by **Hiroshi Ogawa**, **LongYinan**, **Claude Opus 4.8** and **Vladimir** in [#​10548](https://redirect.github.com/vitest-dev/vitest/issues/10548) [(2c955)](https://redirect.github.com/vitest-dev/vitest/commit/2c9559c02) - **pool**: - Prevent test run hang on worker crash \[backport to v4] - by **Ari Perkkiö** and **Jattioui Ismail** in [#​10543](https://redirect.github.com/vitest-dev/vitest/issues/10543) and [#​10564](https://redirect.github.com/vitest-dev/vitest/issues/10564) [(934b0)](https://redirect.github.com/vitest-dev/vitest/commit/934b0f587) ##### [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.8...v4.1.9) ### [`v4.1.8`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.8) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.7...v4.1.8) #####    🐞 Bug Fixes - **browser**: - Disable client `cdp` API when `allowWrite/allowExec: false` \[backport to v4]  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Codex** in [#​10450](https://redirect.github.com/vitest-dev/vitest/issues/10450) [(e4067)](https://redirect.github.com/vitest-dev/vitest/commit/e4067b3b1) - Remove orphaned Playwright route when same module is mocked via multiple ids \[backport to v4]  -  by [@​toxik](https://redirect.github.com/toxik) and [@​Zelys-DFKH](https://redirect.github.com/Zelys-DFKH) in [#​10474](https://redirect.github.com/vitest-dev/vitest/issues/10474) [(675b4)](https://redirect.github.com/vitest-dev/vitest/commit/675b4343f) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.7...v4.1.8) ### [`v4.1.7`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.7) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.6...v4.1.7) #####    🐞 Bug Fixes - **runner**: Limit concurrency per task branch in addition to per leaf callbacks (backport)  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10384](https://redirect.github.com/vitest-dev/vitest/issues/10384) [(4f0f2)](https://redirect.github.com/vitest-dev/vitest/commit/4f0f2a1ee) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.6...v4.1.7) ### [`v4.1.6`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.6) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.5...v4.1.6) #####    🐞 Bug Fixes - **browser**: Provide project reference in `ToMatchScreenshotResolvePath`  -  by [@​macarie](https://redirect.github.com/macarie) and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10138](https://redirect.github.com/vitest-dev/vitest/issues/10138) [(31882)](https://redirect.github.com/vitest-dev/vitest/commit/31882607c) - Global `sequence.concurrent: true` with top-level `test(..., { concurrent: false })` + depreacte `sequential` test API and options  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa), **Codex** and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10196](https://redirect.github.com/vitest-dev/vitest/issues/10196) [(2847d)](https://redirect.github.com/vitest-dev/vitest/commit/2847dfa2a) - **browser**: Simplify orchestrator otel carrier  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10285](https://redirect.github.com/vitest-dev/vitest/issues/10285) [(18af9)](https://redirect.github.com/vitest-dev/vitest/commit/18af98cee) #####    🏎 Performance - Stringify diff objects only once  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10276](https://redirect.github.com/vitest-dev/vitest/issues/10276) [(9f7b1)](https://redirect.github.com/vitest-dev/vitest/commit/9f7b1528c) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.5...v4.1.6) ### [`v4.1.5`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.5) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5) #####    🚀 Experimental Features - **coverage**: Istanbul to support `instrumenter` option  -  by [@​BartWaardenburg](https://redirect.github.com/BartWaardenburg) and [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​10119](https://redirect.github.com/vitest-dev/vitest/issues/10119) [(0e0ff)](https://redirect.github.com/vitest-dev/vitest/commit/0e0ff41c7) #####    🐞 Bug Fixes - \--project negation excludes browser instances  -  by [@​felamaslen](https://redirect.github.com/felamaslen) in [#​10131](https://redirect.github.com/vitest-dev/vitest/issues/10131) [(9423d)](https://redirect.github.com/vitest-dev/vitest/commit/9423dc084) - Project color label on html reporter  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10142](https://redirect.github.com/vitest-dev/vitest/issues/10142) [(596f7)](https://redirect.github.com/vitest-dev/vitest/commit/596f73986) - Fix `vi.defineHelper` called as object method  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10163](https://redirect.github.com/vitest-dev/vitest/issues/10163) [(122c2)](https://redirect.github.com/vitest-dev/vitest/commit/122c25b5b) - Alias `agent` reporter to `minimal`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10157](https://redirect.github.com/vitest-dev/vitest/issues/10157) [(663b9)](https://redirect.github.com/vitest-dev/vitest/commit/663b99fe3) - Respect diff config options in soft assertions  -  by [@​Copilot](https://redirect.github.com/Copilot), **sheremet-va** and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​8696](https://redirect.github.com/vitest-dev/vitest/issues/8696) [(9787d)](https://redirect.github.com/vitest-dev/vitest/commit/9787dedad) - Respect diff config options in soft assertions "  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​8696](https://redirect.github.com/vitest-dev/vitest/issues/8696) [(7dc6d)](https://redirect.github.com/vitest-dev/vitest/commit/7dc6d54fd) - **ast-collect**: Recognize \_*vi\_import* prefix in static test discovery  -  by [@​Yejneshwar](https://redirect.github.com/Yejneshwar) in [#​10129](https://redirect.github.com/vitest-dev/vitest/issues/10129) [(32546)](https://redirect.github.com/vitest-dev/vitest/commit/325463ab2) - **coverage**: Descriptive error message when reports directory is removed during test run  -  by [@​DaveT1991](https://redirect.github.com/DaveT1991) and [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​10117](https://redirect.github.com/vitest-dev/vitest/issues/10117) [(14133)](https://redirect.github.com/vitest-dev/vitest/commit/1413382e1) - **snapshot**: Increase default snapshot max output length  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Codex** in [#​10150](https://redirect.github.com/vitest-dev/vitest/issues/10150) [(21e66)](https://redirect.github.com/vitest-dev/vitest/commit/21e66ff63) - **ui**: Fix jsx/tsx syntax highlight  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10152](https://redirect.github.com/vitest-dev/vitest/issues/10152) [(f1b1f)](https://redirect.github.com/vitest-dev/vitest/commit/f1b1f6c7b) - **web-worker**: Support MessagePort objects referenced inside postMessage data  -  by [@​whitphx](https://redirect.github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#​9927](https://redirect.github.com/vitest-dev/vitest/issues/9927) and [#​10124](https://redirect.github.com/vitest-dev/vitest/issues/10124) [(7ad7d)](https://redirect.github.com/vitest-dev/vitest/commit/7ad7d39af) - **api**: Make test-specification options writable  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10154](https://redirect.github.com/vitest-dev/vitest/issues/10154) [(6abd5)](https://redirect.github.com/vitest-dev/vitest/commit/6abd557b7) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5) ### [`v4.1.4`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.4) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.3...v4.1.4) #####    🚀 Features - **coverage**: - Default to text reporter `skipFull` if agent detected  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10018](https://redirect.github.com/vitest-dev/vitest/issues/10018) [(53757)](https://redirect.github.com/vitest-dev/vitest/commit/53757804c) - **experimental**: - Expose `assertion` as a public field  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10095](https://redirect.github.com/vitest-dev/vitest/issues/10095) [(a120e)](https://redirect.github.com/vitest-dev/vitest/commit/a120e3ab8) - Support aria snapshot  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa), **Claude Opus 4.6 (1M context)**, [@​AriPerkkio](https://redirect.github.com/AriPerkkio), **Codex** and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9668](https://redirect.github.com/vitest-dev/vitest/issues/9668) [(d4fbb)](https://redirect.github.com/vitest-dev/vitest/commit/d4fbb5cc9) - **reporter**: - Add filterMeta option to json reporter  -  by [@​nami8824](https://redirect.github.com/nami8824) and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10078](https://redirect.github.com/vitest-dev/vitest/issues/10078) [(b77de)](https://redirect.github.com/vitest-dev/vitest/commit/b77de968e) #####    🐞 Bug Fixes - Use "black" foreground for labeled terminal message to ensure contrast  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10076](https://redirect.github.com/vitest-dev/vitest/issues/10076) [(203f0)](https://redirect.github.com/vitest-dev/vitest/commit/203f07af7) - Make `expect(..., message)` consistent as error message prefix  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Codex** in [#​10068](https://redirect.github.com/vitest-dev/vitest/issues/10068) [(a1b5f)](https://redirect.github.com/vitest-dev/vitest/commit/a1b5f0f4f) - Do not hoist imports whose names match class properties .  -  by [@​SunsetFi](https://redirect.github.com/SunsetFi) in [#​10093](https://redirect.github.com/vitest-dev/vitest/issues/10093) and [#​10094](https://redirect.github.com/vitest-dev/vitest/issues/10094) [(0fc4b)](https://redirect.github.com/vitest-dev/vitest/commit/0fc4b47e0) - **browser**: Spread user server options into browser Vite server in project  -  by [@​GoldStrikeArch](https://redirect.github.com/GoldStrikeArch) in [#​10049](https://redirect.github.com/vitest-dev/vitest/issues/10049) [(65c9d)](https://redirect.github.com/vitest-dev/vitest/commit/65c9d55eb) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.3...v4.1.4) ### [`v4.1.3`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.3) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.2...v4.1.3) #####    🚀 Experimental Features - Add `experimental.preParse` flag  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10070](https://redirect.github.com/vitest-dev/vitest/issues/10070) [(78273)](https://redirect.github.com/vitest-dev/vitest/commit/7827363bd) - Support `browser.locators.exact` option  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10013](https://redirect.github.com/vitest-dev/vitest/issues/10013) [(48799)](https://redirect.github.com/vitest-dev/vitest/commit/487990a19) - Add `TestAttachment.bodyEncoding`  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9969](https://redirect.github.com/vitest-dev/vitest/issues/9969) [(89ca0)](https://redirect.github.com/vitest-dev/vitest/commit/89ca0e254) - Support custom snapshot matcher  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa), **Claude Sonnet 4.6** and **Codex** in [#​9973](https://redirect.github.com/vitest-dev/vitest/issues/9973) [(59b0e)](https://redirect.github.com/vitest-dev/vitest/commit/59b0e6411) #####    🐞 Bug Fixes - Advance fake timers with `expect.poll` interval  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#​10022](https://redirect.github.com/vitest-dev/vitest/issues/10022) [(3f5bf)](https://redirect.github.com/vitest-dev/vitest/commit/3f5bfa365) - Add `@vitest/coverage-v8` and `@vitest/coverage-istanbul` as optional dependency  -  by [@​alan-agius4](https://redirect.github.com/alan-agius4) in [#​10025](https://redirect.github.com/vitest-dev/vitest/issues/10025) [(146d4)](https://redirect.github.com/vitest-dev/vitest/commit/146d4f0a0) - Fix `defineHelper` for webkit async stack trace + update playwright 1.59.0  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​10036](https://redirect.github.com/vitest-dev/vitest/issues/10036) [(5a5fa)](https://redirect.github.com/vitest-dev/vitest/commit/5a5fa49fe) - Fix suite hook throwing errors for unused auto test-scoped fixture  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#​10035](https://redirect.github.com/vitest-dev/vitest/issues/10035) [(39865)](https://redirect.github.com/vitest-dev/vitest/commit/398657e8d) - **expect**: - Remove `JestExtendError.context` from verbose error reporting  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9983](https://redirect.github.com/vitest-dev/vitest/issues/9983) [(66751)](https://redirect.github.com/vitest-dev/vitest/commit/66751c9e8) - Don't leak "runner" types  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10004](https://redirect.github.com/vitest-dev/vitest/issues/10004) [(ec204)](https://redirect.github.com/vitest-dev/vitest/commit/ec2045543) - **snapshot**: - Fix flagging obsolete snapshots for snapshot properties mismatch  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#​9986](https://redirect.github.com/vitest-dev/vitest/issues/9986) [(6b869)](https://redirect.github.com/vitest-dev/vitest/commit/6b869156b) - Export custom snapshot matcher helper from `vitest`  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Codex** in [#​10042](https://redirect.github.com/vitest-dev/vitest/issues/10042) [(691d3)](https://redirect.github.com/vitest-dev/vitest/commit/691d341fd) - **ui**: - Don't leak vite types  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​10005](https://redirect.github.com/vitest-dev/vitest/issues/10005) [(fdff1)](https://redirect.github.com/vitest-dev/vitest/commit/fdff1bf9a) - **vm**: - Fix external module resolve error with deps optimizer query  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#​10024](https://redirect.github.com/vitest-dev/vitest/issues/10024) [(9dbf4)](https://redirect.github.com/vitest-dev/vitest/commit/9dbf47786) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.2...v4.1.3) ### [`v4.1.2`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.2) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.1...v4.1.2) This release bumps Vitest's `flatted` version and removes version pinning to resolve `flatted`'s CVE related issues ([#​9975](https://redirect.github.com/vitest-dev/vitest/issues/9975)). #####    🐞 Bug Fixes - Don't resolve `setupFiles` from parent directory  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9960](https://redirect.github.com/vitest-dev/vitest/issues/9960) [(7aa93)](https://redirect.github.com/vitest-dev/vitest/commit/7aa937776) - Ensure sequential mock/unmock resolution  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Opus 4.6** in [#​9830](https://redirect.github.com/vitest-dev/vitest/issues/9830) [(7c065)](https://redirect.github.com/vitest-dev/vitest/commit/7c06598db) - **browser**: Take failure screenshot if `toMatchScreenshot` can't capture a stable screenshot  -  by [@​macarie](https://redirect.github.com/macarie) in [#​9847](https://redirect.github.com/vitest-dev/vitest/issues/9847) [(faace)](https://redirect.github.com/vitest-dev/vitest/commit/faace1fbe) - **coverage**: Correct `coverageConfigDefaults` values and types  -  by [@​Arthie](https://redirect.github.com/Arthie) in [#​9940](https://redirect.github.com/vitest-dev/vitest/issues/9940) [(b3c99)](https://redirect.github.com/vitest-dev/vitest/commit/b3c992cb2) - **pretty-format**: Fix output limit over counting  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9965](https://redirect.github.com/vitest-dev/vitest/issues/9965) [(d3b7a)](https://redirect.github.com/vitest-dev/vitest/commit/d3b7a40fa) - Disable colors if agent is detected  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) and [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9851](https://redirect.github.com/vitest-dev/vitest/issues/9851) [(6f97b)](https://redirect.github.com/vitest-dev/vitest/commit/6f97b55dd) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.1...v4.1.2) ### [`v4.1.1`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.1) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.0...v4.1.1) #####    🚀 Features - **experimental**: - Expose `matchesTagsFilter` to test if the current filter matches tags  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9913](https://redirect.github.com/vitest-dev/vitest/issues/9913) [(eec53)](https://redirect.github.com/vitest-dev/vitest/commit/eec53d9f5) - Introduce `experimental.vcsProvider`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9928](https://redirect.github.com/vitest-dev/vitest/issues/9928) [(56115)](https://redirect.github.com/vitest-dev/vitest/commit/561150036) #####    🐞 Bug Fixes - Mark `TestProject.testFilesList` internal properly  -  by [@​sapphi-red](https://redirect.github.com/sapphi-red) in [#​9867](https://redirect.github.com/vitest-dev/vitest/issues/9867) [(54f26)](https://redirect.github.com/vitest-dev/vitest/commit/54f2660f5) - Detect fixture that returns without calling `use`  -  by [@​oilater](https://redirect.github.com/oilater) in [#​9831](https://redirect.github.com/vitest-dev/vitest/issues/9831) and [#​9861](https://redirect.github.com/vitest-dev/vitest/issues/9861) [(633ae)](https://redirect.github.com/vitest-dev/vitest/commit/633ae2303) - Drop vite 8.beta support  -  by [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9862](https://redirect.github.com/vitest-dev/vitest/issues/9862) [(b78f5)](https://redirect.github.com/vitest-dev/vitest/commit/b78f5389d) - Type regression in vi.mocked() static class methods  -  by [@​purepear](https://redirect.github.com/purepear) and [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9857](https://redirect.github.com/vitest-dev/vitest/issues/9857) [(90926)](https://redirect.github.com/vitest-dev/vitest/commit/90926641b) - Properly re-evaluate actual modules of mocked external  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9898](https://redirect.github.com/vitest-dev/vitest/issues/9898) [(ae5ec)](https://redirect.github.com/vitest-dev/vitest/commit/ae5ec03ef) - Preserve coverage report when html reporter overlaps  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9889](https://redirect.github.com/vitest-dev/vitest/issues/9889) [(2d81a)](https://redirect.github.com/vitest-dev/vitest/commit/2d81ad897) - Provide `vi.advanceTimers` to the preview provider  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9891](https://redirect.github.com/vitest-dev/vitest/issues/9891) [(1bc3e)](https://redirect.github.com/vitest-dev/vitest/commit/1bc3e63be) - Don't leak event listener in playwright provider  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9910](https://redirect.github.com/vitest-dev/vitest/issues/9910) [(d9355)](https://redirect.github.com/vitest-dev/vitest/commit/d93550ff7) - Open browser in `--standalone` mode without running tests  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9911](https://redirect.github.com/vitest-dev/vitest/issues/9911) [(e78ad)](https://redirect.github.com/vitest-dev/vitest/commit/e78adcf97) - Guard disposable and optional `body`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9912](https://redirect.github.com/vitest-dev/vitest/issues/9912) [(6fdb2)](https://redirect.github.com/vitest-dev/vitest/commit/6fdb2ba61) - Resolve `retry.condition` RegExp serialization issue  -  by [@​nstepien](https://redirect.github.com/nstepien) and [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9942](https://redirect.github.com/vitest-dev/vitest/issues/9942) [(7b605)](https://redirect.github.com/vitest-dev/vitest/commit/7b6054328) - **collect**: - Don't treat extra props on `test` return as tests  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9871](https://redirect.github.com/vitest-dev/vitest/issues/9871) [(141e7)](https://redirect.github.com/vitest-dev/vitest/commit/141e72aa1) - **coverage**: - Simplify provider types  -  by [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9931](https://redirect.github.com/vitest-dev/vitest/issues/9931) [(aaf9f)](https://redirect.github.com/vitest-dev/vitest/commit/aaf9f18ae) - Load built-in provider without module runner  -  by [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9939](https://redirect.github.com/vitest-dev/vitest/issues/9939) [(bf892)](https://redirect.github.com/vitest-dev/vitest/commit/bf8920817) - **expect**: - Soft assertions continue after .resolves/.rejects promise errors  -  by [@​mixelburg](https://redirect.github.com/mixelburg), **Maks Pikov**, **Claude Opus 4.6 (1M context)** and [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9843](https://redirect.github.com/vitest-dev/vitest/issues/9843) [(6d74b)](https://redirect.github.com/vitest-dev/vitest/commit/6d74b4948) - Fix sinon-chai style API  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9943](https://redirect.github.com/vitest-dev/vitest/issues/9943) [(0f08d)](https://redirect.github.com/vitest-dev/vitest/commit/0f08dda2c) - **pretty-format**: - Limit output for large object  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Opus 4.6 (1M context)** in [#​9949](https://redirect.github.com/vitest-dev/vitest/issues/9949) [(0d5f9)](https://redirect.github.com/vitest-dev/vitest/commit/0d5f9d6ef) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v4.1.0...v4.1.1) ### [`v4.1.0`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v4.1.0) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v4.0.18...v4.1.0) Vitest 4.1 is out! This release page lists all changes made to the project during the 4.1 beta. To get a review of all the new features, read our [blog post](https://vitest.dev/blog/vitest-4). #####    🚀 Features - Return a disposable from doMock()  -  by [@​kirkwaiblinger](https://redirect.github.com/kirkwaiblinger) in [#​9332](https://redirect.github.com/vitest-dev/vitest/issues/9332) [(e3e65)](https://redirect.github.com/vitest-dev/vitest/commit/e3e659a96) - Added chai style assertions  -  by [@​ronnakamoto](https://redirect.github.com/ronnakamoto) and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​8842](https://redirect.github.com/vitest-dev/vitest/issues/8842) [(841df)](https://redirect.github.com/vitest-dev/vitest/commit/841df9ac5) - Update to sinon/fake-timers v15 and add `setTickMode` to timer controls  -  by [@​atscott](https://redirect.github.com/atscott) and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​8726](https://redirect.github.com/vitest-dev/vitest/issues/8726) [(4b480)](https://redirect.github.com/vitest-dev/vitest/commit/4b480aaed) - Expose matcher types  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9448](https://redirect.github.com/vitest-dev/vitest/issues/9448) [(3e4b9)](https://redirect.github.com/vitest-dev/vitest/commit/3e4b913b1) - Add `toTestSpecification` to reported tasks  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9464](https://redirect.github.com/vitest-dev/vitest/issues/9464) [(1a470)](https://redirect.github.com/vitest-dev/vitest/commit/1a4705da9) - Show a warning if `vi.mock` or `vi.hoisted` are declared outside of top level of the module  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9387](https://redirect.github.com/vitest-dev/vitest/issues/9387) [(5db54)](https://redirect.github.com/vitest-dev/vitest/commit/5db54a468) - Track and display expectedly failed tests (.fails) in UI and CLI  -  by [@​Copilot](https://redirect.github.com/Copilot), **sheremet-va** and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9476](https://redirect.github.com/vitest-dev/vitest/issues/9476) [(77d75)](https://redirect.github.com/vitest-dev/vitest/commit/77d75fd34) - Support tags  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9478](https://redirect.github.com/vitest-dev/vitest/issues/9478) [(de7c8)](https://redirect.github.com/vitest-dev/vitest/commit/de7c8a521) - Implement `aroundEach` and `aroundAll` hooks  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9450](https://redirect.github.com/vitest-dev/vitest/issues/9450) [(2a8cb)](https://redirect.github.com/vitest-dev/vitest/commit/2a8cb9dc2) - Stabilize experimental features  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9529](https://redirect.github.com/vitest-dev/vitest/issues/9529) [(b5fd2)](https://redirect.github.com/vitest-dev/vitest/commit/b5fd2a16a) - Accept `new` or `all` in `--update` flag  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9543](https://redirect.github.com/vitest-dev/vitest/issues/9543) [(a5acf)](https://redirect.github.com/vitest-dev/vitest/commit/a5acf28a5) - Support `meta` in test options  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9535](https://redirect.github.com/vitest-dev/vitest/issues/9535) [(7d622)](https://redirect.github.com/vitest-dev/vitest/commit/7d622e3d1) - Support type inference with a new `test.extend` syntax  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9550](https://redirect.github.com/vitest-dev/vitest/issues/9550) [(e5385)](https://redirect.github.com/vitest-dev/vitest/commit/e53854fcc) - Support vite 8 beta, fix type issues in the config with different vite versions  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9587](https://redirect.github.com/vitest-dev/vitest/issues/9587) [(99028)](https://redirect.github.com/vitest-dev/vitest/commit/990281dfd) - Add assertion helper to hide internal stack traces  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Opus 4.6** in [#​9594](https://redirect.github.com/vitest-dev/vitest/issues/9594) [(eeb0a)](https://redirect.github.com/vitest-dev/vitest/commit/eeb0ae2f8) - Store failure screenshots using artifacts API  -  by [@​macarie](https://redirect.github.com/macarie) in [#​9588](https://redirect.github.com/vitest-dev/vitest/issues/9588) [(24603)](https://redirect.github.com/vitest-dev/vitest/commit/24603e3c4) - Allow `vitest list` to statically collect tests instead of running files to collect them  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9630](https://redirect.github.com/vitest-dev/vitest/issues/9630) [(7a8e7)](https://redirect.github.com/vitest-dev/vitest/commit/7a8e7fc20) - Add `--detect-async-leaks`  -  by [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9528](https://redirect.github.com/vitest-dev/vitest/issues/9528) [(c594d)](https://redirect.github.com/vitest-dev/vitest/commit/c594d4af3) - Implement `mockThrow` and `mockThrowOnce`  -  by [@​thor-juhasz](https://redirect.github.com/thor-juhasz) and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9512](https://redirect.github.com/vitest-dev/vitest/issues/9512) [(61917)](https://redirect.github.com/vitest-dev/vitest/commit/619179fb7) - Support `update: "none"` and add docs about snapshots behavior on CI  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9700](https://redirect.github.com/vitest-dev/vitest/issues/9700) [(05f18)](https://redirect.github.com/vitest-dev/vitest/commit/05f1854e2) - Support playwright `launchOptions` with `connectOptions`  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9702](https://redirect.github.com/vitest-dev/vitest/issues/9702) [(f0ff1)](https://redirect.github.com/vitest-dev/vitest/commit/f0ff1b2a0) - Add `page/locator.mark` API to enhance playwright trace  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9652](https://redirect.github.com/vitest-dev/vitest/issues/9652) [(d0ee5)](https://redirect.github.com/vitest-dev/vitest/commit/d0ee546fe) - **api**: - Support tests starting or ending with `test` in `experimental_parseSpecification`  -  by [@​jgillick](https://redirect.github.com/jgillick) and **Jeremy Gillick** in [#​9235](https://redirect.github.com/vitest-dev/vitest/issues/9235) [(2f367)](https://redirect.github.com/vitest-dev/vitest/commit/2f367fad3) - Add filters to `createSpecification`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9336](https://redirect.github.com/vitest-dev/vitest/issues/9336) [(c8e6c)](https://redirect.github.com/vitest-dev/vitest/commit/c8e6c7fbf) - Expose `runTestFiles` as alternative to `runTestSpecifications`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9443](https://redirect.github.com/vitest-dev/vitest/issues/9443) [(43d76)](https://redirect.github.com/vitest-dev/vitest/commit/43d761821) - Add `allowWrite` and `allowExec` options to `api`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9350](https://redirect.github.com/vitest-dev/vitest/issues/9350) [(20e00)](https://redirect.github.com/vitest-dev/vitest/commit/20e00ef78) - Allow passing down test cases to `toTestSpecification`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9627](https://redirect.github.com/vitest-dev/vitest/issues/9627) [(6f17d)](https://redirect.github.com/vitest-dev/vitest/commit/6f17d5ddf) - **browser**: - Add `userEvent.wheel` API  -  by [@​macarie](https://redirect.github.com/macarie) in [#​9188](https://redirect.github.com/vitest-dev/vitest/issues/9188) [(66080)](https://redirect.github.com/vitest-dev/vitest/commit/660801979) - Add `filterNode` option to prettyDOM for filtering browser assertion error output  -  by [@​Copilot](https://redirect.github.com/Copilot), **sheremet-va** and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9475](https://redirect.github.com/vitest-dev/vitest/issues/9475) [(d3220)](https://redirect.github.com/vitest-dev/vitest/commit/d3220fcd8) - Support playwright persistent context  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa), **Claude Opus 4.6** and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9229](https://redirect.github.com/vitest-dev/vitest/issues/9229) [(f865d)](https://redirect.github.com/vitest-dev/vitest/commit/f865d2ba4) - Added `detailsPanelPosition` option and button  -  by [@​shairez](https://redirect.github.com/shairez) in [#​9525](https://redirect.github.com/vitest-dev/vitest/issues/9525) [(c8a31)](https://redirect.github.com/vitest-dev/vitest/commit/c8a31147c) - Use BlazeDiff instead of pixelmatch  -  by [@​macarie](https://redirect.github.com/macarie) in [#​9514](https://redirect.github.com/vitest-dev/vitest/issues/9514) [(30936)](https://redirect.github.com/vitest-dev/vitest/commit/309362089) - Add `findElement` and enable strict mode in webdriverio and preview  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9677](https://redirect.github.com/vitest-dev/vitest/issues/9677) [(c3f37)](https://redirect.github.com/vitest-dev/vitest/commit/c3f37721c) - **cli**: - Add [@​bomb](https://redirect.github.com/bomb).sh/tab completions  -  by [@​AmirSa12](https://redirect.github.com/AmirSa12) and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​8639](https://redirect.github.com/vitest-dev/vitest/issues/8639) [(200f3)](https://redirect.github.com/vitest-dev/vitest/commit/200f31704) - **coverage**: - Support `ignore start/stop` ignore hints  -  by [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9204](https://redirect.github.com/vitest-dev/vitest/issues/9204) [(e59c9)](https://redirect.github.com/vitest-dev/vitest/commit/e59c94ba6) - Add `coverage.changed` option to report only changed files  -  by [@​kykim00](https://redirect.github.com/kykim00) and [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9521](https://redirect.github.com/vitest-dev/vitest/issues/9521) [(1d939)](https://redirect.github.com/vitest-dev/vitest/commit/1d9392c67) - **experimental**: - Add `onModuleRunner` hook to `worker.init`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9286](https://redirect.github.com/vitest-dev/vitest/issues/9286) [(e977f)](https://redirect.github.com/vitest-dev/vitest/commit/e977f3deb) - Option to disable the module runner  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) and [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9210](https://redirect.github.com/vitest-dev/vitest/issues/9210) [(9be61)](https://redirect.github.com/vitest-dev/vitest/commit/9be6121ee) - Add `importDurations: { limit, print }` options  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa), **Claude Opus 4.6** and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9401](https://redirect.github.com/vitest-dev/vitest/issues/9401) [(7e10f)](https://redirect.github.com/vitest-dev/vitest/commit/7e10fb356) - Add print and fail thresholds for `importDurations`  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Opus 4.6** in [#​9533](https://redirect.github.com/vitest-dev/vitest/issues/9533) [(3f7a5)](https://redirect.github.com/vitest-dev/vitest/commit/3f7a5f8f8) - **fixtures**: - Pass down file context to `beforeAll`/`afterAll`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9572](https://redirect.github.com/vitest-dev/vitest/issues/9572) [(c8339)](https://redirect.github.com/vitest-dev/vitest/commit/c83395f2c) - **reporters**: - Add `agent` reporter to reduce ai agent token usage  -  by [@​cpojer](https://redirect.github.com/cpojer) in [#​9779](https://redirect.github.com/vitest-dev/vitest/issues/9779) [(3e9e0)](https://redirect.github.com/vitest-dev/vitest/commit/3e9e096a2) - **runner**: - Enhance `retry` options  -  by [@​MazenSamehR](https://redirect.github.com/MazenSamehR), **Matan Shavit**, [@​AriPerkkio](https://redirect.github.com/AriPerkkio) and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9370](https://redirect.github.com/vitest-dev/vitest/issues/9370) [(9e4cf)](https://redirect.github.com/vitest-dev/vitest/commit/9e4cfd295) - **ui**: - Allow run individual test/suites  -  by [@​userquin](https://redirect.github.com/userquin) in [#​9465](https://redirect.github.com/vitest-dev/vitest/issues/9465) [(73b10)](https://redirect.github.com/vitest-dev/vitest/commit/73b10f1b9) - Add project filter/sort support  -  by [@​userquin](https://redirect.github.com/userquin) in [#​8689](https://redirect.github.com/vitest-dev/vitest/issues/8689) [(0c7ea)](https://redirect.github.com/vitest-dev/vitest/commit/0c7eaac16) - Add duration sorting to explorer  -  by [@​julianhahn](https://redirect.github.com/julianhahn) and [@​cursoragent](https://redirect.github.com/cursoragent) in [#​9603](https://redirect.github.com/vitest-dev/vitest/issues/9603) [(209b1)](https://redirect.github.com/vitest-dev/vitest/commit/209b1b0e1) - Implement filter for slow tests  -  by [@​DerYeger](https://redirect.github.com/DerYeger) and [@​userquin](https://redirect.github.com/userquin) in [#​9705](https://redirect.github.com/vitest-dev/vitest/issues/9705) [(8880c)](https://redirect.github.com/vitest-dev/vitest/commit/8880c907a) - **vitest**: - Add run summary in GitHub Actions Reporter  -  by [@​macarie](https://redirect.github.com/macarie) and **jhnance** in [#​9579](https://redirect.github.com/vitest-dev/vitest/issues/9579) [(96bfc)](https://redirect.github.com/vitest-dev/vitest/commit/96bfc8345) #####    🐞 Bug Fixes - Deprecate several vitest/\* entry points  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9347](https://redirect.github.com/vitest-dev/vitest/issues/9347) [(fd459)](https://redirect.github.com/vitest-dev/vitest/commit/fd45928be) - Use `meta.url` in `createRequire`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9441](https://redirect.github.com/vitest-dev/vitest/issues/9441) [(e3422)](https://redirect.github.com/vitest-dev/vitest/commit/e34225563) - Preact browser mode init example of render function not async  -  by [@​WuMingDao](https://redirect.github.com/WuMingDao) in [#​9375](https://redirect.github.com/vitest-dev/vitest/issues/9375) [(2bea5)](https://redirect.github.com/vitest-dev/vitest/commit/2bea549c7) - Deprecate unused types in matcher context  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9449](https://redirect.github.com/vitest-dev/vitest/issues/9449) [(20f87)](https://redirect.github.com/vitest-dev/vitest/commit/20f8753a2) - Handle `external/noExternal` during `configEnvironment` hook  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Opus 4.6** in [#​9508](https://redirect.github.com/vitest-dev/vitest/issues/9508) [(59ea2)](https://redirect.github.com/vitest-dev/vitest/commit/59ea27c1c) - Replace default ssr environment runner with Vitest server module runner  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Opus 4.6** in [#​9506](https://redirect.github.com/vitest-dev/vitest/issues/9506) [(cd5db)](https://redirect.github.com/vitest-dev/vitest/commit/cd5db660c) - Propagate experimental CLI options to child projects  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Opus 4.6** in [#​9531](https://redirect.github.com/vitest-dev/vitest/issues/9531) [(b624f)](https://redirect.github.com/vitest-dev/vitest/commit/b624fae53) - Show a warning when `browser.isolate` is used  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9410](https://redirect.github.com/vitest-dev/vitest/issues/9410) [(3d48e)](https://redirect.github.com/vitest-dev/vitest/commit/3d48ebcb9) - Fix `vi.mock({ spy: true })` node v8 coverage  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa), **hi-ogawa** and **Claude Opus 4.6** in [#​9541](https://redirect.github.com/vitest-dev/vitest/issues/9541) [(687b6)](https://redirect.github.com/vitest-dev/vitest/commit/687b633c1) - Don't show internal ssr handler in errors  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9547](https://redirect.github.com/vitest-dev/vitest/issues/9547) [(76c43)](https://redirect.github.com/vitest-dev/vitest/commit/76c4397b5) - Close vitest if it failed to start  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9573](https://redirect.github.com/vitest-dev/vitest/issues/9573) [(728ba)](https://redirect.github.com/vitest-dev/vitest/commit/728ba617f) - Fix ssr environment runner in project  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9584](https://redirect.github.com/vitest-dev/vitest/issues/9584) [(09006)](https://redirect.github.com/vitest-dev/vitest/commit/090064f97) - Trim trailing white spaces in code block  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9591](https://redirect.github.com/vitest-dev/vitest/issues/9591) [(f78be)](https://redirect.github.com/vitest-dev/vitest/commit/f78bea992) - Support inline snapshot inside test.for/each  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9590](https://redirect.github.com/vitest-dev/vitest/issues/9590) [(615fd)](https://redirect.github.com/vitest-dev/vitest/commit/615fd521e) - Apply source maps for external module stack trace  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9152](https://redirect.github.com/vitest-dev/vitest/issues/9152) [(79e20)](https://redirect.github.com/vitest-dev/vitest/commit/79e20d5a3) - Remove the `.name` from statically collected test  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9596](https://redirect.github.com/vitest-dev/vitest/issues/9596) [(b66ff)](https://redirect.github.com/vitest-dev/vitest/commit/b66ff691a) - Don't suppress warnings on pnp  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9602](https://redirect.github.com/vitest-dev/vitest/issues/9602) [(89cbd)](https://redirect.github.com/vitest-dev/vitest/commit/89cbdaea3) - Support snapshot with `expect.soft`  -  by [@​iumehara](https://redirect.github.com/iumehara), [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Opus 4.6** in [#​9231](https://redirect.github.com/vitest-dev/vitest/issues/9231) [(3eb2c)](https://redirect.github.com/vitest-dev/vitest/commit/3eb2cd541) - Log seed when only `sequence.shuffle.tests` is enabled  -  by [@​kaigritun](https://redirect.github.com/kaigritun), **Kai Gritun** and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9576](https://redirect.github.com/vitest-dev/vitest/issues/9576) [(8182b)](https://redirect.github.com/vitest-dev/vitest/commit/8182b77ad) - Externalize `expect/src/utils` from `vitest`  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9616](https://redirect.github.com/vitest-dev/vitest/issues/9616) [(48739)](https://redirect.github.com/vitest-dev/vitest/commit/487398422) - Ignore test.override during static collection  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9620](https://redirect.github.com/vitest-dev/vitest/issues/9620) [(09174)](https://redirect.github.com/vitest-dev/vitest/commit/0917470ce) - Increase stacktrace limit for `--detect-async-leaks`  -  by [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9638](https://redirect.github.com/vitest-dev/vitest/issues/9638) [(9fd4c)](https://redirect.github.com/vitest-dev/vitest/commit/9fd4ce533) - Hanging-reporter link in cli  -  by [@​flx-sta](https://redirect.github.com/flx-sta) in [#​9649](https://redirect.github.com/vitest-dev/vitest/issues/9649) [(7c103)](https://redirect.github.com/vitest-dev/vitest/commit/7c103055c) - Fix teardown timeout of `aroundEach/All` when inner `aroundEach/All` throws  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9657](https://redirect.github.com/vitest-dev/vitest/issues/9657) [(4ec6c)](https://redirect.github.com/vitest-dev/vitest/commit/4ec6cb305) - Fix ui mode / html reporter and coverage integration  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and **Claude Opus 4.6** in [#​9626](https://redirect.github.com/vitest-dev/vitest/issues/9626) [(86fad)](https://redirect.github.com/vitest-dev/vitest/commit/86fad4b42) - Don't continue when `aroundEach/All` setup timed out  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9670](https://redirect.github.com/vitest-dev/vitest/issues/9670) [(bb013)](https://redirect.github.com/vitest-dev/vitest/commit/bb013d54b) - Align `VitestRunnerConfig` optional fields with `SerializedConfig`  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9661](https://redirect.github.com/vitest-dev/vitest/issues/9661) [(79520)](https://redirect.github.com/vitest-dev/vitest/commit/79520d82d) - Handle Symbol values in format utility  -  by [@​nami8824](https://redirect.github.com/nami8824) in [#​9658](https://redirect.github.com/vitest-dev/vitest/issues/9658) [(0583f)](https://redirect.github.com/vitest-dev/vitest/commit/0583f067e) - Deprecate `toBe*` spy assertions in favor of `toHaveBeen*` (and `toThrowError`)  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9665](https://redirect.github.com/vitest-dev/vitest/issues/9665) [(4d390)](https://redirect.github.com/vitest-dev/vitest/commit/4d390dfe9) - Don't propagate nested `aroundEach/All` errors but aggregate them on runner  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9673](https://redirect.github.com/vitest-dev/vitest/issues/9673) [(b6365)](https://redirect.github.com/vitest-dev/vitest/commit/b63653f5a) - Show a better error if there is a pending dynamic import  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9676](https://redirect.github.com/vitest-dev/vitest/issues/9676) [(7ef5c)](https://redirect.github.com/vitest-dev/vitest/commit/7ef5cf4b7) - Preserve stack trace of `resolves/rejects` chained assertion error  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9679](https://redirect.github.com/vitest-dev/vitest/issues/9679) [(c6151)](https://redirect.github.com/vitest-dev/vitest/commit/c61511d4a) - Handle module-sync condition in vmThreads/vmForks require  -  by [@​lesleh](https://redirect.github.com/lesleh) in [#​9650](https://redirect.github.com/vitest-dev/vitest/issues/9650) and [#​9651](https://redirect.github.com/vitest-dev/vitest/issues/9651) [(bb203)](https://redirect.github.com/vitest-dev/vitest/commit/bb20389f4) - Hooks should respect `maxConcurrency`  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9653](https://redirect.github.com/vitest-dev/vitest/issues/9653) [(16d13)](https://redirect.github.com/vitest-dev/vitest/commit/16d13d981) - Recursively autospy module object  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9687](https://redirect.github.com/vitest-dev/vitest/issues/9687) [(695a8)](https://redirect.github.com/vitest-dev/vitest/commit/695a86b41) - Remove trailing spaces from diff error log  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) and [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9680](https://redirect.github.com/vitest-dev/vitest/issues/9680) [(395d1)](https://redirect.github.com/vitest-dev/vitest/commit/395d1a29e) - Respect project `resolve.conditions` for externals  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9717](https://redirect.github.com/vitest-dev/vitest/issues/9717) [(1d498)](https://redirect.github.com/vitest-dev/vitest/commit/1d4987498) - Use object for WeakMap instead of a symbol to support webcontainers  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9731](https://redirect.github.com/vitest-dev/vitest/issues/9731) [(c5225)](https://redirect.github.com/vitest-dev/vitest/commit/c52259330) - Fix re-mocking virtual module  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9748](https://redirect.github.com/vitest-dev/vitest/issues/9748) [(3cbbb)](https://redirect.github.com/vitest-dev/vitest/commit/3cbbb17f1) - Cancelling should stop current test immediately  -  by [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​9729](https://redirect.github.com/vitest-dev/vitest/issues/9729) [(0cb2f)](https://redirect.github.com/vitest-dev/vitest/commit/0cb2f7239) - Make `mockObject` change backwards compatible  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​9744](https://redirect.github.com/vitest-dev/vitest/issues/9744) [(84c69)](https://redirect.github.com/vitest-dev/vitest/commit/84c69497f) - Fix `URL.name` on jsdom  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9767](https://redirect.github.com/vitest-dev/vitest/issues/9767) [(031f3)](https://redirect.github.com/vitest-dev/vitest/commit/031f3a374) - Save and restore module graph in blob reporter  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9740](https://redirect.github.com/vitest-dev/vitest/issues/9740) [(84355)](https://redirect.github.com/vitest-dev/vitest/commit/843554bf0) - Don't silence reporter errors from test runtime events handler in normal run and --merge-reports  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa) in [#​9727](https://redirect.github.com/vitest-dev/vitest/issues/9727) [(4072d)](https://redirect.github. > ✂ **Note** > > PR body was truncated to here.
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - "every weekday before 11am" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/migrate/package-lock.json | 412 ++++++++++------------------- packages/migrate/package.json | 2 +- 2 files changed, 146 insertions(+), 268 deletions(-) diff --git a/packages/migrate/package-lock.json b/packages/migrate/package-lock.json index cd30980b53e..53f1b317380 100644 --- a/packages/migrate/package-lock.json +++ b/packages/migrate/package-lock.json @@ -17,7 +17,7 @@ "devDependencies": { "@types/node": "^24.13.3", "typescript": "^5.7.3", - "vitest": "^3.0.0" + "vitest": "^4.0.0" }, "engines": { "node": ">=20.0.0" @@ -861,6 +861,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@ts-morph/common": { "version": "0.29.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", @@ -908,39 +915,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -952,42 +960,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -995,28 +1003,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1053,81 +1058,26 @@ "node": "20 || >=22" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, @@ -1208,20 +1158,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1247,13 +1183,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1273,6 +1202,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1286,16 +1229,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1303,6 +1236,18 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -1402,25 +1347,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1429,11 +1361,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -1468,42 +1403,10 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -1616,29 +1519,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1657,79 +1537,80 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -1740,21 +1621,18 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "license": "MIT" }, "node_modules/why-is-node-running": { "version": "2.3.0", diff --git a/packages/migrate/package.json b/packages/migrate/package.json index 69276ed9418..00855bfdda8 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -46,6 +46,6 @@ "devDependencies": { "@types/node": "^24.13.3", "typescript": "^5.7.3", - "vitest": "^3.0.0" + "vitest": "^4.0.0" } } From 7879ec5763be92b4ecf03c6c58975265166e33ba Mon Sep 17 00:00:00 2001 From: ptmkenny <1451472+ptmkenny@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:21:53 +0000 Subject: [PATCH 10/17] fix(select): derive option text from all child nodes (#31382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue number: resolves #31381 --------- ## What is the current behavior? `ion-select` derives the text it displays for the selected option, and that option's contribution to the button's `aria-label`, from the option's child nodes. In v9 that derivation is wrong whenever an option has more than one child node, or has its text wrapped in an element. | `ion-select-option` content | browser renders | v8 | v9 | | --- | --- | --- | --- | | `Star` | `Star` | `Star` | `Star` | | two sibling text nodes, `{'★'}{'Star'}` | `★Star` | `★Star` | `★ Star` | | `A Star` | `A Star` | `A Star` | `A` | | `Star` | `Star` | `Star` | *(empty)* | Two distinct defects: 1. **A space is inserted between adjacent text nodes.** Every framework renders `{icon}{label}` as two sibling text nodes with no whitespace between them, so any option with an emoji, flag, or icon prefix gains a space that is not in the DOM. 2. **Text wrapped in an element is dropped entirely.** An option whose content is `Star`, `Star`, or an i18n component's wrapper element renders as an **empty select** with an empty accessible name, silently. This is the more damaging of the two. Both affect the visible selected text and the `aria-label`, and both propagate to every overlay interface — `createAlertInputs`, `createActionSheetButtons`, and `createOverlaySelectOptions` read the option through the same helper. In the linked reproduction, opening the first select shows an alert radio labelled `★ Star`. There is no error or warning; the text is just wrong. ## What is the new behavior? The selected text and `aria-label` should match what the browser renders for the option's content, which is what v8 produced via `textContent`: `★Star` for two adjacent text nodes, and `A Star` for `A Star`. - - - ## Does this introduce a breaking change? - [ ] Yes - [X] No (It fixes an unreported breaking change made in https://github.com/ionic-team/ionic-framework/pull/31241) ## Other information --------- Co-authored-by: Shane --- core/src/components/select/select.tsx | 92 ++++--- .../test/rich-content-option/select.e2e.ts | 14 +- .../components/select/test/select.spec.tsx | 254 ++++++++++++++++++ 3 files changed, 318 insertions(+), 42 deletions(-) diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index c870291ec4c..7c3f7f7477f 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -15,7 +15,7 @@ import { printIonWarning } from '@utils/logging'; import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays'; import type { OverlaySelect } from '@utils/overlays-interface'; import { isRTL } from '@utils/rtl'; -import { reflectPropertiesToAttributes, sanitizeDOMTree } from '@utils/sanitization'; +import { blockedTags, reflectPropertiesToAttributes, sanitizeDOMTree } from '@utils/sanitization'; import { createSlotMutationController } from '@utils/slot-mutation-controller'; import type { SlotMutationController } from '@utils/slot-mutation-controller'; import { createColorClasses, hostContext } from '@utils/theme'; @@ -1481,33 +1481,14 @@ const textForValue = ( } /** - * When custom HTML is enabled, extract only the default slot content. - * This ensures aria-label and other text-only contexts read only - * the relevant option text. + * Every text-only context reads only the default slot, so the start + * and end slots stay out of the `aria-label` and the overlay labels. + * Both config paths derive that text through the same helper, so they + * cannot disagree about what an option's text is. `null` marks an + * option with no text, which is dropped from the joined text of a + * `multiple` select rather than joined in as an empty entry. */ - if (customHTMLEnabled) { - const content = getOptionContent(selectOpt); - - if (typeof content === 'string') { - return content; - } - - /** - * Elements were found in the default slot, extract and concatenate - * their text content while trimming whitespace. - */ - if (content) { - const texts = Array.from(content.childNodes) - .map((n) => n.textContent?.trim()) - .filter((t) => t); - return texts.join(' ') || null; - } - - // Empty option - return null; - } - - return getDefaultSlotPlainText(selectOpt); + return getDefaultSlotPlainText(selectOpt) || null; }; /** @@ -1570,9 +1551,13 @@ const getOptionContent = ( return null; } - // Return plain text if no elements are found + /** + * Return plain text if no elements are found. This reads the option the + * same way the non-custom-HTML path does, so the two do not disagree + * about what an option's text is. + */ if (!slotName && nodes.every((n) => n.nodeType === Node.TEXT_NODE)) { - return nodes.map((n) => n.textContent?.trim()).join(' ') || null; + return getDefaultSlotPlainText(option) || null; } /** @@ -1636,22 +1621,47 @@ const getOptionDefaultSlot = (option: HTMLIonSelectOptionElement): Node[] | null return defaultSlotNodes; }; +/** + * Concatenates the text a node renders, skipping the subtrees of tags + * whose contents the browser never paints (`script`, `style`, and the + * rest of `blockedTags`). `textContent` includes those, so reading it + * directly would put stylesheet or script source into the select text + * and the `aria-label`. + * + * @param node - The node to read text from. + * @returns The node's rendered text. + */ +const getRenderedTextContent = (node: Node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ''; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + + if (blockedTags.includes((node as Element).tagName.toLowerCase())) { + return ''; + } + + return Array.from(node.childNodes) + .map((child) => getRenderedTextContent(child)) + .join(''); +}; + /** * Extracts plain text from only the default slot of an option, - * excluding content assigned to named slots (start/end). + * excluding content assigned to named slots (start/end). Text is + * concatenated with no separator and collapsible whitespace is + * collapsed, approximating how the browser renders the option. + * NBSP is not collapsible, so it is preserved. + * + * @param option - The `ion-select-option` element to read text from. + * @returns The option's default slot text. */ const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => { - const texts = Array.from(option.childNodes) - .filter((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - return !(node as HTMLElement).hasAttribute('slot'); - } - return node.nodeType === Node.TEXT_NODE; - }) - .filter((node) => node.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter((t) => t); - return texts.join(' '); + const text = (getOptionDefaultSlot(option) ?? []).map((node) => getRenderedTextContent(node)).join(''); + return text.replace(/[ \t\n\r\f]+/g, ' ').replace(/^[ \t\n\r\f]+|[ \t\n\r\f]+$/g, ''); }; /** diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts b/core/src/components/select/test/rich-content-option/select.e2e.ts index 3acf2d806ac..fa29d7e49e5 100644 --- a/core/src/components/select/test/rich-content-option/select.e2e.ts +++ b/core/src/components/select/test/rich-content-option/select.e2e.ts @@ -430,7 +430,7 @@ configs({ modes: ['md'] }).forEach(({ title, config }) => { */ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('select: rich content options'), () => { - test('it should only render text nodes when `innerHTMLTemplatesEnabled` is disabled', async ({ page }) => { + test('should not render markup when `innerHTMLTemplatesEnabled` is disabled', async ({ page }) => { await page.setContent( ` @@ -466,6 +466,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(endContainer).toHaveCount(0); await expect(span).toHaveCount(0); + /** + * The span is not rendered, but the text it wrapped still reads as + * text, so the option is not silently emptied out. + */ + await expect(firstOption).toContainText('Full Content This is a span element'); + // Click on the first option await firstOption.click(); @@ -479,6 +485,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const selectTextSpan = selectText.locator('.span-style'); await expect(selectTextSpan).toHaveCount(0); + + /** + * Only the default slot is read, so the text of the `start` and `end` + * slots stays out of the selected text. + */ + await expect(selectText).toHaveText('Full Content This is a span element'); }); }); }); diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index ad7c0d3d050..f214e289501 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -1,5 +1,6 @@ import { h } from '@stencil/core'; import { newSpecPage } from '@stencil/core/testing'; +import { alertController } from '@utils/overlays'; import { config } from '../../../global/config'; import { SelectOption } from '../../select-option/select-option'; @@ -160,6 +161,259 @@ describe('ion-select: required', () => { }); }); +describe('ion-select: option plain text', () => { + it('should not insert a space between adjacent text nodes in an option', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + + appendAdjacentTextNodes(select.querySelector('ion-select-option')!); + + select.value = 'star'; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('★Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('★Star'); + }); + + it('should read option text that is wrapped in an element', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `A Star`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('A Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('A Star'); + }); + + it('should read option text when the whole option content is wrapped in an element', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `Star`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + /** + * An option with no text node of its own, such as one whose label comes + * from an i18n component, would otherwise render as an empty select with + * an empty accessible name. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); + }); + + it('should ignore content assigned to the start and end slots', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `LeadStarTrail`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); + }); + + it('should not read text the browser never paints', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `Star`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + /** + * `textContent` includes the source of tags the browser does not render, + * and those tags are the same ones the sanitizer strips from the + * custom HTML path, so both paths have to agree to ignore them. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); + }); + + it('should collapse whitespace from the source markup around option text', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ` + + + Star Option + + + `, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star Option'); + }); + + it('should preserve a non-breaking space that indents option text', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `  Star Option`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + /** + * NBSP is not collapsible, so an option indented with ` ` to fake a + * hierarchy keeps its indentation. Trimming has to leave it alone too, + * which rules out `String.prototype.trim`. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('\u00a0\u00a0Star Option'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('\u00a0\u00a0Star Option'); + }); +}); + +/** + * Frameworks render `{icon}{label}` as two sibling text nodes with no + * whitespace between them. The nodes have to be built here rather than in + * markup, because a parser collapses adjacent text into a single node. + */ +const appendAdjacentTextNodes = (option: Element) => { + option.append(document.createTextNode('★'), document.createTextNode('Star')); +}; + +/** + * The overlay interfaces build their labels from the same helper that produces + * the displayed text, so they need the same coverage. `ion-alert` is not + * defined in a spec page, so the created overlay is stubbed and the options + * passed to the controller are asserted instead. + */ +const stubAlertController = () => + jest.spyOn(alertController, 'create').mockImplementation(async () => { + const overlay = document.createElement('div') as any; + overlay.present = () => Promise.resolve(); + // Never resolves, so the select keeps treating the overlay as open. + overlay.onDidDismiss = () => new Promise(() => {}); + return overlay; + }); + +describe('ion-select: overlay option labels', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should label alert inputs with the text the option renders', async () => { + const createAlert = stubAlertController(); + + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ` + + + Star + + `, + }); + + const select = page.body.querySelector('ion-select')!; + + appendAdjacentTextNodes(select.querySelector('ion-select-option[value="adjacent"]')!); + + await page.waitForChanges(); + + await select.open(); + + expect(createAlert).toHaveBeenCalledTimes(1); + const { inputs } = createAlert.mock.calls[0][0]; + expect(inputs!.map((input) => input.label)).toEqual(['★Star', 'Star']); + }); +}); + +describe('ion-select: option plain text with custom HTML enabled', () => { + /** + * With `innerHTMLTemplatesEnabled` on, the option is read through + * `getOptionContent` instead. An option that holds only text still has to + * produce the same text as the default path. + */ + beforeEach(() => { + config.reset({ innerHTMLTemplatesEnabled: true }); + }); + + afterEach(() => { + config.reset({}); + jest.restoreAllMocks(); + }); + + it('should not insert a space between adjacent text nodes in an option', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + appendAdjacentTextNodes(select.querySelector('ion-select-option')!); + + select.value = 'star'; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.innerHTML).toBe('★Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('★Star'); + }); + + it('should not insert a space between adjacent text nodes in an option that also holds an element', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + const option = select.querySelector('ion-select-option')!; + appendAdjacentTextNodes(option); + + const badge = document.createElement('ion-badge'); + badge.textContent = 'NEW'; + option.append(badge); + + select.value = 'star'; + await page.waitForChanges(); + + /** + * An element in the default slot reads the option through a different + * branch than an option that holds only text. The text nodes render as + * one span, so the `aria-label` has to keep them together too. The + * visible separation from the badge comes from `--select-text-gap` + * rather than from a space in the text. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.innerHTML).toBe( + '★StarNEW' + ); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('★StarNEW'); + }); + + it('should label alert inputs with the text the option renders', async () => { + const createAlert = stubAlertController(); + + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + appendAdjacentTextNodes(select.querySelector('ion-select-option')!); + await page.waitForChanges(); + + await select.open(); + + const { inputs } = createAlert.mock.calls[0][0]; + expect(inputs!.map((input) => input.label)).toEqual(['★Star']); + }); +}); + describe('ion-select: option content property reflection', () => { beforeEach(() => { // Cloning rich option content into the select text only happens when From ab731a13e4d7ffe52a283ca660960fdac8f9401d Mon Sep 17 00:00:00 2001 From: OS-jacobbell <228905018+OS-jacobbell@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:53:09 +0000 Subject: [PATCH 11/17] chore(deps): update to stencil/core 4.44.1 (#31396) ## What is the current behavior? The latest version of `@stencil/core` changes a line in the generated `components.d.ts`, failing the nightly build. ## What is the new behavior? Bump `@stencil/core` on the main branch and commit the new `components.d.ts`. ## Does this introduce a breaking change? - [ ] Yes - [X] No --- core/package-lock.json | 8 ++++---- core/package.json | 2 +- core/src/components.d.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core/package-lock.json b/core/package-lock.json index dbd8438611d..975d713ee68 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -9,7 +9,7 @@ "version": "9.0.0", "license": "MIT", "dependencies": { - "@stencil/core": "^4.43.5", + "@stencil/core": "^4.44.1", "ionicons": "^8.1.0", "tslib": "^2.1.0" }, @@ -1839,9 +1839,9 @@ } }, "node_modules/@stencil/core": { - "version": "4.43.5", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.5.tgz", - "integrity": "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.1.tgz", + "integrity": "sha512-OaFtMODcDcKswPbJC6An6xAcNmeuBggxyUTVKSX1UY7bPtWgIR5mGy1jZ6JkyFGgIxZCj/9wp2r97KBQow9wOQ==", "license": "MIT", "bin": { "stencil": "bin/stencil" diff --git a/core/package.json b/core/package.json index a17456f8ef1..0a27877474b 100644 --- a/core/package.json +++ b/core/package.json @@ -66,7 +66,7 @@ "loader/" ], "dependencies": { - "@stencil/core": "^4.43.5", + "@stencil/core": "^4.44.1", "ionicons": "^8.1.0", "tslib": "^2.1.0" }, diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 2873ac12d68..a6c8a7032d3 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -5168,7 +5168,7 @@ declare global { } } declare namespace LocalJSX { - type OneOf = { [P in K]: PropT } & { [P in `attr:${K}` | `prop:${K}`]?: never } | { [P in `attr:${K}`]: AttrT } & { [P in K | `prop:${K}`]?: never } | { [P in `prop:${K}`]: PropT } & { [P in K | `attr:${K}`]?: never }; + type OneOf = { [P in K]: PropT } & { [P in `attr:${K}`]?: never } | { [P in `attr:${K}`]: AttrT } & { [P in K]?: never }; interface IonAccordion { /** From 95e222488d4e4bac9c6c8c0b42a2154082846767 Mon Sep 17 00:00:00 2001 From: Shane Date: Fri, 21 Aug 2026 19:55:24 +0000 Subject: [PATCH 12/17] fix(picker-column): commit value on outside press (#31397) Issue number: resolves #30449 --------- ## What is the current behavior? `ion-picker-column` commits the centered option on a 250ms idle timer that only starts once the wheel stops moving. Flick a wheel and tap a Save button while it's still coasting, and the click handler reads the previous value. On an `ion-datetime` with `preferWheel`, the wheel shows one date while `value` still reports another. ## What is the new behavior? During a scroll the user started, the column watches for `pointerdown` anywhere outside itself. On that press it halts the wheel on the option under the highlight and commits it synchronously, so an application's own click handler reads the value the user can see. A press on a sibling wheel in the same picker is exempt, so reaching for the next column leaves the first one coasting. The datetime wheel handlers now read the working and active parts at event time, because an outside press settles every coasting column in one synchronous dispatch and the previous render-time reads clobbered each other. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information The community PR for this issue, #30674, was a great attempt, but it takes the reporter's other suggestion and adds public `ionScrollStart` and `ionScrollEnd` events to `ion-picker-column` and `ion-datetime`. I didn't go that way for two reasons. It's permanent public API on two components plus the Angular and Vue proxies, carried by every consumer forever, to work around a bug. More importantly it doesn't fix the stale value: every app has to write the disable-Save logic itself, and its `ionScrollEnd` is debounced 300ms after the wheel settles, so a tap during the flick still reads the old value at the moment the click handler runs. Committing on `pointerdown` fixes existing apps with no code change. The scroll events are a fair feature request on their own, just a separate one from this bug fix. Preview: - Picker column scroll: [iOS](https://ionic-framework-git-fw-7706-ionic1.vercel.app/src/components/picker-column/test/scroll?ionic:mode=ios) / [MD](https://ionic-framework-git-fw-7706-ionic1.vercel.app/src/components/picker-column/test/scroll?ionic:mode=md) - Datetime prefer-wheel: [iOS](https://ionic-framework-git-fw-7706-ionic1.vercel.app/src/components/datetime/test/prefer-wheel?ionic:mode=ios) / [MD](https://ionic-framework-git-fw-7706-ionic1.vercel.app/src/components/datetime/test/prefer-wheel?ionic:mode=md) --- core/src/components/datetime/datetime.tsx | 45 +- .../test/prefer-wheel/datetime.e2e.ts | 101 ++++ .../picker-column/picker-column.tsx | 284 +++++++-- .../picker-column/test/scroll/index.html | 74 +++ .../test/scroll/picker-column.e2e.ts | 562 ++++++++++++++++++ 5 files changed, 1005 insertions(+), 61 deletions(-) create mode 100644 core/src/components/picker-column/test/scroll/index.html create mode 100644 core/src/components/picker-column/test/scroll/picker-column.e2e.ts diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 4613befccac..3de359a494a 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -1716,8 +1716,6 @@ export class Datetime implements ComponentInterface { private renderCombinedDatePickerColumn() { const { defaultParts, disabled, workingParts, locale, minParts, maxParts, todayParts, isDateEnabled } = this; - const activePart = this.getActivePartsWithFallback(); - /** * By default, generate a range of 3 months: * Previous month, current month, and next month @@ -1801,8 +1799,11 @@ export class Datetime implements ComponentInterface { const { value } = ev.detail; const findPart = parts.find(({ month, day, year }) => value === `${year}-${month}-${day}`); + // Read live so parts a sibling column just committed are included. + const activePart = this.getActivePartsWithFallback(); + this.setWorkingParts({ - ...workingParts, + ...this.workingParts, ...findPart, }); @@ -1908,7 +1909,6 @@ export class Datetime implements ComponentInterface { const { disabled, workingParts } = this; - const activePart = this.getActivePartsWithFallback(); const pickerColumnValue = (workingParts.day !== null ? workingParts.day : this.defaultParts.day) ?? undefined; return ( @@ -1920,8 +1920,11 @@ export class Datetime implements ComponentInterface { disabled={disabled} value={pickerColumnValue} onIonChange={(ev: CustomEvent) => { + // Read live so parts a sibling column just committed are included. + const activePart = this.getActivePartsWithFallback(); + this.setWorkingParts({ - ...workingParts, + ...this.workingParts, day: ev.detail.value, }); @@ -1955,8 +1958,6 @@ export class Datetime implements ComponentInterface { const { disabled, workingParts } = this; - const activePart = this.getActivePartsWithFallback(); - return ( { + // Read live so parts a sibling column just committed are included. + const activePart = this.getActivePartsWithFallback(); + this.setWorkingParts({ - ...workingParts, + ...this.workingParts, month: ev.detail.value, }); @@ -2003,8 +2007,6 @@ export class Datetime implements ComponentInterface { const { disabled, workingParts } = this; - const activePart = this.getActivePartsWithFallback(); - return ( { + // Read live so parts a sibling column just committed are included. + const activePart = this.getActivePartsWithFallback(); + this.setWorkingParts({ - ...workingParts, + ...this.workingParts, year: ev.detail.value, }); @@ -2079,7 +2084,7 @@ export class Datetime implements ComponentInterface { } private renderHourPickerColumn(hoursData: WheelColumnOption[]) { - const { disabled, workingParts } = this; + const { disabled } = this; if (hoursData.length === 0) return []; const activePart = this.getActivePartsWithFallback(); @@ -2093,8 +2098,9 @@ export class Datetime implements ComponentInterface { value={activePart.hour} numericInput onIonChange={(ev: CustomEvent) => { + // Read live so parts a sibling column just committed are included. this.setWorkingParts({ - ...workingParts, + ...this.workingParts, hour: ev.detail.value, }); @@ -2121,7 +2127,7 @@ export class Datetime implements ComponentInterface { ); } private renderMinutePickerColumn(minutesData: WheelColumnOption[]) { - const { disabled, workingParts } = this; + const { disabled } = this; if (minutesData.length === 0) return []; const activePart = this.getActivePartsWithFallback(); @@ -2135,8 +2141,9 @@ export class Datetime implements ComponentInterface { value={activePart.minute} numericInput onIonChange={(ev: CustomEvent) => { + // Read live so parts a sibling column just committed are included. this.setWorkingParts({ - ...workingParts, + ...this.workingParts, minute: ev.detail.value, }); @@ -2163,7 +2170,7 @@ export class Datetime implements ComponentInterface { ); } private renderDayPeriodPickerColumn(dayPeriodData: WheelColumnOption[]) { - const { disabled, workingParts } = this; + const { disabled } = this; if (dayPeriodData.length === 0) { return []; } @@ -2180,10 +2187,12 @@ export class Datetime implements ComponentInterface { disabled={disabled} value={activePart.ampm} onIonChange={(ev: CustomEvent) => { - const hour = calculateHourFromAMPM(workingParts, ev.detail.value); + // Read live so parts a sibling column just committed are included. + const currentParts = this.workingParts; + const hour = calculateHourFromAMPM(currentParts, ev.detail.value); this.setWorkingParts({ - ...workingParts, + ...currentParts, ampm: ev.detail.value, hour, }); diff --git a/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts b/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts index fb1b2e31ebf..edfdd29c1d3 100644 --- a/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts +++ b/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts @@ -688,3 +688,104 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); }); + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + /** + * The column sits in the datetime's Shadow DOM, so a document-level listener + * sees the press retargeted to the datetime host. + */ + test.describe(title('datetime: wheel value'), () => { + test('should give an outside click handler the date the wheel is showing', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30449', + }); + + await page.setContent( + ` + + + `, + config + ); + + await page.locator('.datetime-ready').waitFor(); + + await page.evaluate(() => { + const datetime = document.querySelector('ion-datetime')!; + const column = datetime.shadowRoot!.querySelector('ion-picker-column.year-column')!; + const scrollEl = column.shadowRoot!.querySelector('.picker-opts')!; + const w = window as any; + + w.lastScrollAt = 0; + scrollEl.addEventListener('scroll', () => { + w.lastScrollAt = performance.now(); + }); + + /** + * Stands in for an application's own Save button, which reads the + * datetime's value when it is clicked. + */ + document.querySelector('#save')!.addEventListener('click', () => { + w.onSave = { + datetimeValue: datetime.value, + visibleYear: String(column.querySelector('.option-active')?.value ?? ''), + }; + }); + + w.startScroll = () => scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + + /** + * Presses Save in the same frame the wheel is first seen showing a + * different year while still scrolling. Done in the page so no round trip + * can let the scroll finish first, which would leave nothing to race. + */ + w.pressSaveWhenMidScroll = () => + new Promise((resolve, reject) => { + const deadline = performance.now() + 5000; + + const poll = () => { + const highlighted = column.querySelector('.option-active'); + const isScrolling = performance.now() - w.lastScrollAt < 100; + + if (highlighted !== null && String(highlighted.value) !== '2022' && isScrolling) { + const save = document.querySelector('#save')!; + save.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + save.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + resolve(); + } else if (performance.now() > deadline) { + reject(new Error('the wheel never showed a year other than the one it was reporting')); + } else { + requestAnimationFrame(poll); + } + }; + + requestAnimationFrame(poll); + }); + }); + + /** + * Press the column the way a drag would, so the scroll that follows counts + * as the user's. Pressing dead center lands on the year already selected, + * so the press itself does not change the value. + */ + const column = (await page.locator('.year-column').boundingBox())!; + await page.mouse.move(column.x + column.width / 2, column.y + column.height / 2); + await page.mouse.down(); + await page.mouse.up(); + + await page.evaluate(() => (window as any).startScroll()); + await page.evaluate(() => (window as any).pressSaveWhenMidScroll()); + + const onSave = await page.evaluate(() => (window as any).onSave); + + expect(onSave.visibleYear).not.toBe('2022'); + + // The value the Save button saw is the year the user could see. + expect(onSave.datetimeValue.split('-')[0]).toBe(onSave.visibleYear); + }); + }); +}); diff --git a/core/src/components/picker-column/picker-column.tsx b/core/src/components/picker-column/picker-column.tsx index 80de2c2daee..ea56f0711b2 100644 --- a/core/src/components/picker-column/picker-column.tsx +++ b/core/src/components/picker-column/picker-column.tsx @@ -33,6 +33,19 @@ export class PickerColumn implements ComponentInterface { private canExitInputMode = true; private assistiveFocusable?: HTMLElement; private updateValueTextOnScroll = false; + private scrollEndTimeout?: ReturnType; + private centeredOption?: HTMLIonPickerColumnOptionElement; + private isUserScroll = false; + /** + * Where the column halted itself, so the halt's own scroll events are not + * read as movement. + */ + private haltedAtScrollTop?: number; + /** + * The haptics for the wheel picker are an iOS-only feature. As a result, + * they should be disabled on Android. + */ + private enableHaptics = false; @State() ariaLabel: string | null = null; @@ -135,6 +148,15 @@ export class PickerColumn implements ComponentInterface { } else { this.isColumnVisible = false; + /** + * The pending timer still runs so it resets the scroll state, but the + * option it would have committed is dropped. A column the user cannot + * see should not change its value. + */ + this.stopWatchingForOutsidePress(); + this.centeredOption = undefined; + this.haltedAtScrollTop = undefined; + if (this.destroyScrollListener) { this.destroyScrollListener(); this.destroyScrollListener = undefined; @@ -219,6 +241,14 @@ export class PickerColumn implements ComponentInterface { this.ariaLabel = this.el.getAttribute('aria-label') ?? 'Select a value'; } + disconnectedCallback() { + /** + * A pending settle would otherwise commit a value on a column that is no + * longer in the DOM. + */ + this.discardScroll(); + } + private centerPickerItemInView = (target: HTMLElement, smooth = true, canExitInputMode = true) => { const { isColumnVisible, scrollEl } = this; @@ -236,6 +266,14 @@ export class PickerColumn implements ComponentInterface { */ this.canExitInputMode = canExitInputMode; this.updateValueTextOnScroll = false; + + /** + * This scroll takes over from whatever the user was doing, so an outside + * press must not commit an option the column is only passing through. + */ + this.isUserScroll = false; + this.stopWatchingForOutsidePress(); + scrollEl.scroll({ top, left: 0, @@ -298,6 +336,136 @@ export class PickerColumn implements ComponentInterface { this.isActive = state; }; + private clearScrollEndTimeout = () => { + if (this.scrollEndTimeout) { + clearTimeout(this.scrollEndTimeout); + this.scrollEndTimeout = undefined; + } + }; + + /** + * Resets the idle timer that commits the centered option. + */ + private resetScrollEndTimeout = () => { + this.clearScrollEndTimeout(); + this.scrollEndTimeout = setTimeout(this.settle, SCROLL_END_DELAY); + }; + + /** + * Capture so the column still commits if an application's own pointerdown + * handler stops the event before it reaches the document. + */ + private watchForOutsidePress = () => { + doc?.addEventListener('pointerdown', this.onPointerDownOutside, { capture: true }); + }; + + private stopWatchingForOutsidePress = () => { + doc?.removeEventListener('pointerdown', this.onPointerDownOutside, { capture: true }); + }; + + /** + * Resets everything the current scroll interaction is holding, so nothing + * pending on it can commit later. + */ + private discardScroll = () => { + this.stopWatchingForOutsidePress(); + this.clearScrollEndTimeout(); + this.scrollEndCallback = undefined; + this.centeredOption = undefined; + this.haltedAtScrollTop = undefined; + this.isScrolling = false; + this.isUserScroll = false; + this.canExitInputMode = true; + }; + + /** + * Commits the option that is currently centered under the highlight and ends + * the scroll interaction. + */ + private settle = () => { + const { centeredOption } = this; + + this.clearScrollEndTimeout(); + this.stopWatchingForOutsidePress(); + + this.isUserScroll = false; + this.isScrolling = false; + this.updateValueTextOnScroll = true; + + this.enableHaptics && hapticSelectionEnd(); + + /** + * Certain tasks (such as those that + * cause re-renders) should only be done + * once scrolling has finished, otherwise + * flickering may occur. + */ + const { scrollEndCallback } = this; + if (scrollEndCallback) { + scrollEndCallback(); + this.scrollEndCallback = undefined; + } + + /** + * Reset this flag as the + * next scroll interaction could + * be a scroll from the user. In this + * case, we should exit input mode. + */ + this.canExitInputMode = true; + + if (centeredOption !== undefined) { + // Cleared here so a later scroll that centers nothing cannot fall back on it. + this.centeredOption = undefined; + + if (centeredOption.isConnected) { + this.setValue(centeredOption.value); + } + } + }; + + /** + * Commits the option the user can see when they press anything outside of the + * column during a scroll they started. This runs on pointerdown rather than + * click so the value is up to date by the time an application's own click + * handler (a Save button, for example) reads it. + */ + private onPointerDownOutside = (ev: Event) => { + const { centeredOption, parentEl, scrollEl } = this; + + if (!this.isScrolling) { + return; + } + + /** + * The column can live in another component's Shadow DOM, where a document + * listener sees the target retargeted to the outer host. The composed path + * is the only reliable way to tell a press on the column apart. + */ + const path = ev.composedPath(); + + /** + * A press anywhere in the parent picker counts as inside, so reaching for a + * sibling wheel leaves this column coasting. + */ + if (path.includes(this.el) || (parentEl != null && path.includes(parentEl))) { + return; + } + + /** + * Landing on the centered option halts the in-flight momentum scroll, so the + * committed value and the option the user is left looking at agree. The + * option list can be replaced mid-scroll, and a detached option has no + * offset to center on. + */ + if (centeredOption !== undefined && centeredOption.isConnected) { + this.centerPickerItemInView(centeredOption, false, false); + this.haltedAtScrollTop = scrollEl?.scrollTop; + } + + this.settle(); + }; + /** * When the column scrolls, the component * needs to determine which item is centered @@ -305,31 +473,49 @@ export class PickerColumn implements ComponentInterface { * the item object. */ private initializeScrollListener = () => { - /** - * The haptics for the wheel picker are - * an iOS-only feature. As a result, they should - * be disabled on Android. - */ - const enableHaptics = isPlatform('ios'); + this.enableHaptics = isPlatform('ios'); const { el, scrollEl } = this; - let timeout: ReturnType | undefined; let activeEl: HTMLIonPickerColumnOptionElement | undefined = this.activeItem; const scrollCallback = () => { raf(() => { - if (!scrollEl) return; + /** + * This frame cannot be cancelled, and moving the column disconnects it + * without the observer reporting a change, so this checks live state. + */ + if (!this.el.isConnected || !this.isColumnVisible || !scrollEl) return; + + const { haltedAtScrollTop } = this; + + if (haltedAtScrollTop !== undefined) { + /** + * Still at the halted position, so this frame is the halt landing + * rather than the wheel moving, and re-arming would start a second + * commit cycle. + */ + if (Math.abs(scrollEl.scrollTop - haltedAtScrollTop) <= HALT_TOLERANCE) return; - if (timeout) { - clearTimeout(timeout); - timeout = undefined; + this.haltedAtScrollTop = undefined; } + // Armed before the early returns below, so a scroll never ends uncommitted. + this.resetScrollEndTimeout(); + if (!this.isScrolling) { - enableHaptics && hapticSelectionStart(); + this.enableHaptics && hapticSelectionStart(); this.isScrolling = true; } + /** + * Only a user-driven scroll holds an uncommitted selection. Checked every + * frame because the user can take hold of the wheel mid-scroll, and + * re-adding the same listener is a no-op. + */ + if (this.isUserScroll) { + this.watchForOutsidePress(); + } + /** * Select item in the center of the column * which is the month/year that we want to select @@ -396,20 +582,25 @@ export class PickerColumn implements ComponentInterface { } } - if (activeEl !== undefined) { - this.setPickerItemActiveState(activeEl, false); - } - + /** + * A scroll can land with no selectable option centered, such as an + * overscroll bounce briefly centering the empty padding rows. Keep the + * current selection until an option is centered again. + */ if (newActiveElement === undefined || newActiveElement.disabled) { return; } + if (activeEl !== undefined) { + this.setPickerItemActiveState(activeEl, false); + } + /** * If we are selecting a new value, * we need to run haptics again. */ if (newActiveElement !== activeEl) { - enableHaptics && hapticSelectionChanged(); + this.enableHaptics && hapticSelectionChanged(); if (this.canExitInputMode) { /** @@ -445,34 +636,15 @@ export class PickerColumn implements ComponentInterface { this.assistiveFocusable?.setAttribute('aria-valuetext', this.getOptionValueText(newActiveElement)); } - timeout = setTimeout(() => { - this.isScrolling = false; - this.updateValueTextOnScroll = true; - enableHaptics && hapticSelectionEnd(); - - /** - * Certain tasks (such as those that - * cause re-renders) should only be done - * once scrolling has finished, otherwise - * flickering may occur. - */ - const { scrollEndCallback } = this; - if (scrollEndCallback) { - scrollEndCallback(); - this.scrollEndCallback = undefined; - } + this.centeredOption = newActiveElement; + }); + }; - /** - * Reset this flag as the - * next scroll interaction could - * be a scroll from the user. In this - * case, we should exit input mode. - */ - this.canExitInputMode = true; + const userScrollCallback = () => { + this.isUserScroll = true; - this.setValue(newActiveElement.value); - }, 250); - }); + // Taking hold of the wheel ends the halt even if it has not moved yet. + this.haltedAtScrollTop = undefined; }; /** @@ -483,9 +655,20 @@ export class PickerColumn implements ComponentInterface { if (!scrollEl) return; scrollEl.addEventListener('scroll', scrollCallback); + scrollEl.addEventListener('pointerdown', userScrollCallback); + scrollEl.addEventListener('wheel', userScrollCallback, { passive: true }); + /** + * A `pointerdown` fires once per touch, so a drag that pauses long enough + * to commit would look like a scroll the column started. A `touchmove` + * keeps arriving while the finger moves. + */ + scrollEl.addEventListener('touchmove', userScrollCallback, { passive: true }); this.destroyScrollListener = () => { scrollEl.removeEventListener('scroll', scrollCallback); + scrollEl.removeEventListener('pointerdown', userScrollCallback); + scrollEl.removeEventListener('wheel', userScrollCallback); + scrollEl.removeEventListener('touchmove', userScrollCallback); }; }); }; @@ -707,3 +890,18 @@ export class PickerColumn implements ComponentInterface { } const PICKER_ITEM_ACTIVE_CLASS = 'option-active'; + +/** + * How long the column must be idle before the centered option is treated as + * the user's selection. Replaceable by the `scrollend` event once that is + * supported everywhere (https://caniuse.com/?search=scrollend). + */ +const SCROLL_END_DELAY = 250; + +/** + * How far, in pixels, the column may report from where it was halted and still + * count as having stayed there. Centering targets a position between two snap + * points, so mandatory snapping corrects it by half the column height less + * three option heights: 2px at the default sizes. + */ +const HALT_TOLERANCE = 2; diff --git a/core/src/components/picker-column/test/scroll/index.html b/core/src/components/picker-column/test/scroll/index.html new file mode 100644 index 00000000000..d5727968f73 --- /dev/null +++ b/core/src/components/picker-column/test/scroll/index.html @@ -0,0 +1,74 @@ + + + + + Picker Column - Scroll + + + + + + + + + + + + + Picker Column - Scroll + + + + + + + + + + Save + +
Flick the wheel, then tap Save mid-scroll.
+
+
+ + + + diff --git a/core/src/components/picker-column/test/scroll/picker-column.e2e.ts b/core/src/components/picker-column/test/scroll/picker-column.e2e.ts new file mode 100644 index 00000000000..000b99a5fe6 --- /dev/null +++ b/core/src/components/picker-column/test/scroll/picker-column.e2e.ts @@ -0,0 +1,562 @@ +import { expect } from '@playwright/test'; +import type { E2EPage, E2ELocator } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +/** The idle period the column waits out before it commits the centered option. */ +const SCROLL_END_DELAY = 250; + +/** Long enough that a pending commit has either landed or is never coming. */ +const COMMIT_WINDOW = SCROLL_END_DELAY + 350; + +interface SaveRecord { + value: string; + highlighted: string; +} + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('picker-column: scroll'), () => { + test.beforeEach(async ({ page }) => { + await page.setContent( + ` + + + ${Array.from( + { length: 200 }, + (_, i) => `${i}` + ).join('')} + + + + `, + config + ); + + await page.locator('ion-picker-column-option.option-active').waitFor(); + + await page.evaluate(() => { + const column = document.querySelector('ion-picker-column')!; + const scrollEl = column.shadowRoot!.querySelector('.picker-opts')!; + const w = window as any; + + w.lastScrollAt = 0; + scrollEl.addEventListener('scroll', () => { + w.lastScrollAt = performance.now(); + }); + + /** + * Records the value at the moment the click handler ran, alongside the + * option the user could see under the highlight. + */ + document.querySelector('#save')!.addEventListener('click', () => { + w.onSave = { + value: String(column.value), + highlighted: String(column.querySelector('.option-active')?.value ?? ''), + }; + }); + }); + }); + + const startScroll = (page: E2EPage) => + page.evaluate(() => { + const scrollEl = document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + }); + + /** + * Presses the column the way a drag would, so the scroll that follows counts + * as the user's. Pressing dead center lands on the option already selected, + * so the press itself does not change the value. + */ + const pressColumn = async (page: E2EPage) => { + const box = (await page.locator('ion-picker-column').boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.up(); + }; + + /** + * The race needs the highlight off the committed value, so the column is + * showing one option while reporting another. + */ + const waitForMidScroll = (page: E2EPage) => + page.waitForFunction( + () => { + const highlighted = document.querySelector( + 'ion-picker-column .option-active' + ); + const column = document.querySelector('ion-picker-column')!; + const isScrolling = performance.now() - (window as any).lastScrollAt < 100; + return highlighted !== null && String(highlighted.value) !== String(column.value) && isScrolling; + }, + undefined, + { timeout: 5000 } + ); + + /** Presses an element with `pointerdown` before `click`, the order a tap uses. */ + const press = (page: E2EPage, selector: string) => + page.evaluate((selector) => { + const target = document.querySelector(selector)!; + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + target.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + }, selector); + + /** + * Presses the given element in the same frame the column is first seen + * mid-scroll. The polling and the press share one evaluate so no round trip + * can let the scroll finish first, which would leave nothing to race. + * + * Passing `past` waits for the highlight to get beyond a given option, for + * when the column already reports the option it is traveling towards. + */ + const pressWhenMidScroll = (page: E2EPage, selector: string, past?: number) => + page.evaluate( + ({ selector, past }) => + new Promise((resolve, reject) => { + const column = document.querySelector('ion-picker-column')!; + const deadline = performance.now() + 5000; + + const isMidScroll = () => { + const highlighted = column.querySelector('.option-active'); + + if (highlighted === null || performance.now() - (window as any).lastScrollAt >= 100) { + return false; + } + + return past === undefined + ? String(highlighted.value) !== String(column.value) + : Number(highlighted.value) > past; + }; + + const poll = () => { + if (isMidScroll()) { + const target = document.querySelector(selector)!; + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + target.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + resolve(); + } else if (performance.now() > deadline) { + reject(new Error('the column never reached the expected mid-scroll state')); + } else { + requestAnimationFrame(poll); + } + }; + + requestAnimationFrame(poll); + }), + { selector, past } + ); + + const highlightedValue = (page: E2EPage) => + page.evaluate(() => { + const highlighted = document.querySelector( + 'ion-picker-column .option-active' + ); + return highlighted === null ? null : Number(highlighted.value); + }); + + /** + * Presses Save once the column has visibly moved off the option it is + * reporting but is still scrolling towards its resting place. + */ + const pressSaveMidScroll = async (page: E2EPage): Promise => { + await pressColumn(page); + await startScroll(page); + await pressWhenMidScroll(page, '#save'); + + return await page.evaluate(() => (window as any).onSave); + }; + + /** + * Stands in for an overscroll bounce leaving the empty padding rows under + * the highlight. Scrolling onto the padding cannot produce this reliably, + * because snapping pulls the column straight back. + */ + const hideOptionsFromHitTesting = (page: E2EPage) => + page.evaluate(() => { + document.querySelectorAll('ion-picker-column-option').forEach((option) => { + option.style.pointerEvents = 'none'; + }); + }); + + const dispatchScroll = (page: E2EPage) => + page.evaluate(() => { + const scrollEl = document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.dispatchEvent(new Event('scroll')); + }); + + const waitForColumnIdle = (page: E2EPage) => + page.waitForFunction((delay) => performance.now() - (window as any).lastScrollAt > delay, COMMIT_WINDOW, { + timeout: 10000, + }); + + /** Drains the rAF the column schedules to react to a scroll. */ + const flushAnimationFrames = (page: E2EPage) => + page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + ); + + test('should commit the visible option before an outside click handler runs', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30449', + }); + + const onSave = await pressSaveMidScroll(page); + + expect(onSave.highlighted).not.toBe('5'); + + // The value the Save button saw is the option the user could see. + expect(onSave.value).toBe(onSave.highlighted); + }); + + /** + * Tapping an option part way through a flick replaces the selection, so an + * outside press during the scroll to it must not commit an option on the way. + */ + test('should keep a selection made mid-flick when Save is pressed', async ({ page }) => { + await pressColumn(page); + await startScroll(page); + await waitForMidScroll(page); + + // Stands in for tapping an option while the wheel is still coasting. + await page.locator('ion-picker-column').evaluate((column: HTMLIonPickerColumnElement) => column.setValue('150')); + await pressWhenMidScroll(page, '#save'); + + const onSave: SaveRecord = await page.evaluate(() => (window as any).onSave); + + expect(onSave.value).toBe('150'); + + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', '150'); + }); + + /** + * Taking hold of the wheel mid-scroll does not start a new scroll, so the + * column has to notice the user part way through one it is already running. + */ + test('should commit the visible option when the user takes over a scroll in progress', async ({ page }) => { + await page.locator('ion-picker-column').evaluate((column: HTMLIonPickerColumnElement) => { + column.value = '150'; + }); + + // Wait until the column is genuinely in transit, past 5 but not yet at 150. + await page.waitForFunction( + () => { + const highlighted = document.querySelector( + 'ion-picker-column .option-active' + ); + const value = highlighted === null ? null : Number(highlighted.value); + return value !== null && value > 5 && value < 150; + }, + undefined, + { timeout: 5000 } + ); + + // Stands in for grabbing the wheel while it is still traveling. + await page.evaluate(() => { + const scrollEl = document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + }); + + // Press once the user's own scroll has carried it past where it was headed. + await pressWhenMidScroll(page, '#save', 150); + + const onSave: SaveRecord = await page.evaluate(() => (window as any).onSave); + + expect(onSave.value).toBe(onSave.highlighted); + }); + + test('should not move on past the option it committed to an outside click', async ({ page }) => { + const onSave = await pressSaveMidScroll(page); + + /** + * Waits for the column to stop rather than a fixed window. If the press had + * not halted the momentum, the scroll would run on for about a second and + * commit a later option. + */ + await waitForColumnIdle(page); + + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', onSave.value); + }); + + /** + * The halt is the column scrolling itself, so the scroll events it produces + * must not be read as the wheel still moving and start a second commit. + */ + test('should emit one change for a scroll ended by an outside click', async ({ page }) => { + const ionChange = await page.spyOnEvent('ionChange'); + + const onSave = await pressSaveMidScroll(page); + + await waitForColumnIdle(page); + + expect(ionChange).toHaveReceivedEventTimes(1); + expect(ionChange).toHaveReceivedEventDetail({ value: onSave.value }); + }); + + /** + * A frame with no option centered must leave the highlight where it is. + */ + test('should keep the highlight when a scroll frame has no option centered', async ({ page }) => { + await hideOptionsFromHitTesting(page); + await dispatchScroll(page); + await page.waitForTimeout(COMMIT_WINDOW); + + await expect(page.locator('ion-picker-column-option.option-active')).toHaveCount(1); + await expect(page.locator('ion-picker-column-option.option-active')).toHaveJSProperty('value', '5'); + }); + + /** + * The same uncentered frame arriving part way through a scroll must keep the + * option that was already centered, and must still commit it. + */ + test('should commit the last centered option when a later frame has none', async ({ page }) => { + await pressColumn(page); + await startScroll(page); + await waitForMidScroll(page); + + /** + * From here no frame can center an option, so whatever was centered last is + * what the column falls back on. Read it after the scroll rAF has drained + * so an already queued frame cannot move it afterwards. + */ + await hideOptionsFromHitTesting(page); + await flushAnimationFrames(page); + + const centered = await page + .locator('ion-picker-column-option.option-active') + .evaluate((option: HTMLIonPickerColumnOptionElement) => String(option.value)); + expect(centered).not.toBe('5'); + + await dispatchScroll(page); + + /** + * The scroll is still coasting, and every frame it produces now finds no + * option, so the commit lands once it stops. + */ + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', centered); + + await expect(page.locator('ion-picker-column-option.option-active')).toHaveCount(1); + await expect(page.locator('ion-picker-column-option.option-active')).toHaveJSProperty('value', centered); + }); + + /** + * Once a commit has landed, a later scroll that never centers an option must + * not fall back on it. + */ + test('should not commit an option left over from a finished scroll', async ({ page }) => { + await pressColumn(page); + await startScroll(page); + await waitForColumnIdle(page); + + // The scroll ran to the end of the column, so this is where it settled. + const committed = await highlightedValue(page); + expect(committed).toBe(199); + + // From here the column can never find an option under the highlight again. + await hideOptionsFromHitTesting(page); + + await pressColumn(page); + await page.evaluate(() => { + const scrollEl = document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.scrollTo({ top: 0, behavior: 'smooth' }); + }); + await press(page, '#save'); + + /** + * Falling back on the leftover option would have halted this scroll and + * dragged the column back to the far end, so it would never arrive. + */ + await page.waitForFunction( + () => document.querySelector('ion-picker-column')!.shadowRoot!.querySelector('.picker-opts')!.scrollTop < 50, + undefined, + { timeout: 5000 } + ); + + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', String(committed)); + }); + + /** + * A keyed list reorder moves a column rather than replacing it, which + * disconnects and reconnects it without the visibility observer reporting a + * change. The column has to keep reacting to scrolls afterwards. + */ + test('should still track scrolling after the column is moved', async ({ page }) => { + await page.evaluate(() => { + const host = document.createElement('div'); + document.body.appendChild(host); + host.appendChild(document.querySelector('ion-picker')!); + }); + + await startScroll(page); + + // The last option, since the scroll runs to the end of the column. + await expect(page.locator('ion-picker-column-option.option-active')).toHaveJSProperty('value', '199'); + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', '199'); + }); + + /** + * The frame in which the column reacts to a scroll cannot be cancelled, so + * it can land after the column has already been torn down. + */ + test('should not commit a value after the column is removed mid-scroll', async ({ page }) => { + /** + * Spied on the column rather than the page because the picker is removed + * before the commit would fire, and an event on a detached element never + * reaches the page. + */ + const ionChange = await (page.locator('ion-picker-column') as E2ELocator).spyOnEvent('ionChange'); + + await page.evaluate(() => { + const column = document.querySelector('ion-picker-column')!; + const scrollEl = column.shadowRoot!.querySelector('.picker-opts')!; + + /** + * The column registered its own scroll listener first, so by the time + * this one runs the column has already queued the frame that reacts to + * this scroll. Removing the picker here leaves that frame pending. Wait + * a few scrolls first so the column has centered an option to commit. + */ + let scrolls = 0; + scrollEl.addEventListener('scroll', () => { + if (++scrolls === 5) { + document.querySelector('ion-picker')!.remove(); + } + }); + + scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + }); + + await page.waitForTimeout(COMMIT_WINDOW); + + expect(ionChange).not.toHaveReceivedEvent(); + }); + + /** + * A scroll the column starts itself is not a selection the user made, so an + * outside press during one must not freeze it. + */ + test('should not commit an option that a programmatic scroll is passing through', async ({ page }) => { + const ionChange = await page.spyOnEvent('ionChange'); + + await page.locator('ion-picker-column').evaluate((column: HTMLIonPickerColumnElement) => { + column.value = '150'; + }); + + await pressWhenMidScroll(page, '#save'); + + await page.waitForTimeout(COMMIT_WINDOW); + + // Setting the `value` property must not emit `ionChange`. + expect(ionChange).not.toHaveReceivedEvent(); + + // The column must still be headed for the option the application asked for. + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', '150'); + }); + + /** + * Selecting an option directly scrolls the column to it. That scroll belongs + * to the selection the user already made, so an outside press during it must + * not redirect the value. + */ + test('should not commit an option that a selected scroll is passing through', async ({ page }) => { + const ionChange = await page.spyOnEvent('ionChange'); + + // A press that selects the option already under the highlight, so nothing scrolls yet. + await pressColumn(page); + + // Stands in for tapping an option far down the column. + await page.locator('ion-picker-column').evaluate((column: HTMLIonPickerColumnElement) => column.setValue('150')); + + await pressWhenMidScroll(page, '#save'); + + await page.waitForTimeout(COMMIT_WINDOW); + + // Only the selection itself is committed, not an option on the way to it. + expect(ionChange).toHaveReceivedEventTimes(1); + expect(ionChange).toHaveReceivedEventDetail({ value: '150' }); + await expect(page.locator('ion-picker-column')).toHaveJSProperty('value', '150'); + }); + }); +}); + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('picker-column: sibling columns'), () => { + /** + * A press inside the picker does not read a coasting column's value, so it + * must not stop it on an option the user was only scrolling past. + */ + test('should keep coasting when another column in the picker is pressed', async ({ page }) => { + await page.setContent( + ` + + + ${Array.from( + { length: 200 }, + (_, i) => `${i}` + ).join('')} + + + a + b + + + `, + config + ); + + await page.locator('.first ion-picker-column-option.option-active').waitFor(); + + await page.evaluate(() => { + const scrollEl = document.querySelector('.first')!.shadowRoot!.querySelector('.picker-opts')!; + (window as any).lastScrollAt = 0; + scrollEl.addEventListener('scroll', () => { + (window as any).lastScrollAt = performance.now(); + }); + }); + + const first = page.locator('.first'); + const box = (await first.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.up(); + + // Press the sibling in the same frame the first column is seen mid-flick. + await page.evaluate(() => { + const scrollEl = document.querySelector('.first')!.shadowRoot!.querySelector('.picker-opts')!; + scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'smooth' }); + + return new Promise((resolve, reject) => { + const column = document.querySelector('.first')!; + const deadline = performance.now() + 5000; + + const poll = () => { + const highlighted = column.querySelector('.option-active'); + const isScrolling = performance.now() - (window as any).lastScrollAt < 100; + + if (highlighted !== null && String(highlighted.value) !== String(column.value) && isScrolling) { + const sibling = document.querySelector('.second')!; + sibling.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + resolve(); + } else if (performance.now() > deadline) { + reject(new Error('the first column never reached the expected mid-scroll state')); + } else { + requestAnimationFrame(poll); + } + }; + + requestAnimationFrame(poll); + }); + }); + + // The scroll was headed for the end of the column, so that is where it belongs. + await expect(first).toHaveJSProperty('value', '199'); + await expect(page.locator('.first ion-picker-column-option.option-active')).toHaveJSProperty('value', '199'); + }); + }); +}); From bfff8e1377a8faff3581e61026733ce29b671513 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 25 Aug 2026 20:52:29 +0000 Subject: [PATCH 13/17] fix(overlays): restore presented state after a DOM move (#31400) Issue number: resolves #31389 --------- ## What is the current behavior? Currently, `componentWillUnmount` in `createInlineOverlayComponent` removes a nested inline overlay host that `CoreDelegate` has teleported out of its `