diff --git a/BREAKING.md b/BREAKING.md
index 1061abec921..bad7d8407d8 100644
--- a/BREAKING.md
+++ b/BREAKING.md
@@ -18,6 +18,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- [Components](#version-9x-components)
- [Legacy Picker](#version-9x-legacy-picker)
- [Router Outlet](#version-9x-router-outlet)
+ - [Select](#version-9x-select)
- [Framework Specific](#version-9x-framework-specific)
- [React](#version-9x-react)
@@ -71,6 +72,12 @@ To disable the gesture on a specific outlet, set `swipeGesture` to `false`:
The `swipeBackEnabled` config option is still respected as the initial default and does not need to change for apps that set it once at startup.
+
Select
+
+The `ionChange` event on `ion-select` now only fires when the selected value actually changes. Previously, the `alert` and `action-sheet` interfaces emitted `ionChange` every time the overlay was confirmed, even when the user chose the option that was already selected. This aligns the `alert` and `action-sheet` interfaces with the existing behavior of the `popover` and `modal` interfaces, and with the documented contract of `ionChange`.
+
+Apps that relied on `ionChange` firing on every confirmation (for example, to detect overlay dismissal without a value change) should listen for `ionDismiss` instead, or use the `didDismiss` event on the underlying alert or action sheet.
+
Framework Specific
React
diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx
index e39856c2482..e82de53b1ae 100644
--- a/core/src/components/select/select.tsx
+++ b/core/src/components/select/select.tsx
@@ -282,10 +282,43 @@ export class Select implements ComponentInterface {
}
private setValue(value?: any | null) {
+ if (this.isValueEqual(this.value, value)) {
+ return;
+ }
this.value = value;
this.ionChange.emit({ value });
}
+ private isValueEqual(currentValue: any, newValue: any): boolean {
+ if (this.multiple) {
+ const currentArr = Array.isArray(currentValue) ? currentValue : [];
+ const newArr = Array.isArray(newValue) ? newValue : [];
+ if (currentArr.length !== newArr.length) {
+ return false;
+ }
+ // Multiset compare: each new value must match a distinct current value.
+ // A plain `every(isOptionSelected)` would accept ['a','a'] as equal to
+ // ['a','b'] when both 'a' and 'b' map to options whose values overlap.
+ const remaining = currentArr.slice();
+ return newArr.every((val: any) => {
+ const idx = remaining.findIndex((c: any) => compareOptions(c, val, this.compareWith));
+ if (idx === -1) {
+ return false;
+ }
+ remaining.splice(idx, 1);
+ return true;
+ });
+ }
+
+ if (currentValue == null && newValue == null) {
+ return true;
+ }
+ if (currentValue == null || newValue == null) {
+ return false;
+ }
+ return compareOptions(currentValue, newValue, this.compareWith);
+ }
+
async connectedCallback() {
const { el } = this;
diff --git a/core/src/components/select/test/basic/select.e2e.ts b/core/src/components/select/test/basic/select.e2e.ts
index 6b797740b96..d1fdcb8583f 100644
--- a/core/src/components/select/test/basic/select.e2e.ts
+++ b/core/src/components/select/test/basic/select.e2e.ts
@@ -981,6 +981,113 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
expect(ionChange).toHaveReceivedEventTimes(1);
});
+ test('should not fire ionChange when confirming the already-selected alert option', async ({ page }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/26789',
+ });
+
+ await page.setContent(
+ `
+
+ Apple
+ Banana
+
+ `,
+ config
+ );
+
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+ const ionAlertDidDismiss = await page.spyOnEvent('ionAlertDidDismiss');
+ const select = page.locator('ion-select') as E2ELocator;
+ const ionChange = await select.spyOnEvent('ionChange');
+
+ await select.click();
+ await ionAlertDidPresent.next();
+
+ const alert = page.locator('ion-alert');
+ const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)');
+
+ await confirmButton.click();
+ await ionAlertDidDismiss.next();
+
+ expect(ionChange).toHaveReceivedEventTimes(0);
+ await expect(select).toHaveJSProperty('value', 'apple');
+ });
+
+ test('should not fire ionChange when confirming the already-selected alert options (multiple)', async ({
+ page,
+ }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/26789',
+ });
+
+ await page.setContent(
+ `
+
+ Apple
+ Banana
+
+ `,
+ config
+ );
+
+ const select = page.locator('ion-select') as E2ELocator;
+ await select.evaluate((el: HTMLIonSelectElement) => (el.value = ['apple', 'banana']));
+
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+ const ionAlertDidDismiss = await page.spyOnEvent('ionAlertDidDismiss');
+ const ionChange = await select.spyOnEvent('ionChange');
+
+ await select.click();
+ await ionAlertDidPresent.next();
+
+ const alert = page.locator('ion-alert');
+ const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)');
+
+ await confirmButton.click();
+ await ionAlertDidDismiss.next();
+
+ expect(ionChange).toHaveReceivedEventTimes(0);
+ });
+
+ test('should not fire ionChange when tapping the already-selected action-sheet option', async ({
+ page,
+ }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/26789',
+ });
+
+ await page.setContent(
+ `
+
+ Apple
+ Banana
+
+ `,
+ config
+ );
+
+ const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
+ const ionActionSheetDidDismiss = await page.spyOnEvent('ionActionSheetDidDismiss');
+ const select = page.locator('ion-select') as E2ELocator;
+ const ionChange = await select.spyOnEvent('ionChange');
+
+ await select.click();
+ await ionActionSheetDidPresent.next();
+
+ const actionSheet = page.locator('ion-action-sheet');
+ const selectedButton = actionSheet.locator('.action-sheet-button[aria-checked="true"]');
+
+ await selectedButton.click();
+ await ionActionSheetDidDismiss.next();
+
+ expect(ionChange).toHaveReceivedEventTimes(0);
+ await expect(select).toHaveJSProperty('value', 'apple');
+ });
+
test('should not fire when programmatically setting a valid value', async ({ page }) => {
await page.setContent(
`