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( `