From 1d4c704dcbb80e9bb80ea3003486097078c05926 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 17 Jun 2026 09:14:51 -0500 Subject: [PATCH 01/14] fix(ion-button): sync disabled state in ion-button renderHiddenButton Closes #30968 --- core/src/components/button/button.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index 0e4741f4acd..b4aa82ec138 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -198,6 +198,7 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf * then do not append a new one again. */ if (formButtonEl !== null && formEl.contains(formButtonEl)) { + formButtonEl.disabled = this.disabled; return; } From 2964bc6a15552d19577bf0d92ff6f8558cd02272 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 17 Jun 2026 09:18:24 -0500 Subject: [PATCH 02/14] test(ion-button): add e2e test for async disabled state change (issue #30968) --- .../button/test/form-reference/button.e2e.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/core/src/components/button/test/form-reference/button.e2e.ts b/core/src/components/button/test/form-reference/button.e2e.ts index 5710f52da67..c8a0fe2039b 100644 --- a/core/src/components/button/test/form-reference/button.e2e.ts +++ b/core/src/components/button/test/form-reference/button.e2e.ts @@ -153,6 +153,42 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => expect(submitEvent).toHaveReceivedEvent(); }); + + test('should submit the form when disabled state changes asynchronously', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30968', + }); + + await page.setContent( + ` +
+ + Submit +
+ `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + const button = page.locator('ion-button'); + + // Simulate async disabled state change — e.g. async validator resolving + await button.evaluate((el: HTMLIonButtonElement) => { + el.disabled = true; + setTimeout(() => { + el.disabled = false; + }, 0); + }); + + // Wait for the async change to settle + await page.waitForTimeout(100); + + await page.click('ion-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + }); test.describe(title('should throw a warning if the form cannot be found'), () => { From 31aff3187b2951ab19514f35a454e4af876d641f Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 17 Jun 2026 12:52:21 -0500 Subject: [PATCH 03/14] npm run lint.fix --- core/src/components/button/test/form-reference/button.e2e.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/components/button/test/form-reference/button.e2e.ts b/core/src/components/button/test/form-reference/button.e2e.ts index c8a0fe2039b..c803dd2c226 100644 --- a/core/src/components/button/test/form-reference/button.e2e.ts +++ b/core/src/components/button/test/form-reference/button.e2e.ts @@ -188,7 +188,6 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => expect(submitEvent).toHaveReceivedEvent(); }); - }); test.describe(title('should throw a warning if the form cannot be found'), () => { From c235b7954e2775ae36629152cc84154c9ad30f56 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Thu, 18 Jun 2026 14:27:51 -0500 Subject: [PATCH 04/14] revert: fix(ion-button): sync disabled state in ion-button renderHiddenButton This reverts commit 1d4c704dcbb80e9bb80ea3003486097078c05926. Will retest changes without the fix and new test location --- core/src/components/button/button.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index b4aa82ec138..f8258af2239 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -195,10 +195,13 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf /** * If the form already has a rendered form button - * then do not append a new one again. + * then do not append a new one again. Sync the + * disabled state and type in it changes after button + * creation (e.g., runtime property updates). */ if (formButtonEl !== null && formEl.contains(formButtonEl)) { - formButtonEl.disabled = this.disabled; + // formButtonEl.disabled = this.disabled; + // formButtonEl.type = this.type; return; } From 5fb20dd4f9474ef979dd2fb4ebfa59bbcd52ed04 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Thu, 18 Jun 2026 14:43:25 -0500 Subject: [PATCH 05/14] revert: test(ion-button): add e2e test for async disabled state change (issue #30968) This reverts commit 2964bc6a15552d19577bf0d92ff6f8558cd02272. From 963ae43b5b679a1c7324f1e4443c70354c7abb93 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Sat, 20 Jun 2026 11:58:46 -0500 Subject: [PATCH 06/14] test(button): add e2e test for async disabled state change Imports AsyncValidatorFn in form.component.ts for new function Adds test to form.spec.ts to simulate disabled state --- .../test/base/e2e/src/lazy/form.spec.ts | 35 +++++++++++++++++++ .../base/src/app/lazy/form/form.component.ts | 16 +++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/angular/test/base/e2e/src/lazy/form.spec.ts b/packages/angular/test/base/e2e/src/lazy/form.spec.ts index a5cacabe5e2..1fa34ae58e5 100644 --- a/packages/angular/test/base/e2e/src/lazy/form.spec.ts +++ b/packages/angular/test/base/e2e/src/lazy/form.spec.ts @@ -284,6 +284,41 @@ test.describe('Form', () => { }); }); + test.describe('async disabled state during init', () => { + test('should submit form when button disabled state resolves asynchronously during init', async ({ page }) => { + // Wait for the component to initialize. The async validator resolves during ngOnInit + await page.waitForTimeout(200); + + // Get the submit button + const submitButton = page.locator('ion-button[type="submit"]'); + + // Set up a promise that resolves when the form submits + const submitPromise = page.evaluate(() => { + return new Promise((resolve) => { + const form = document.querySelector('form'); + if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); + resolve(); + }); + } + }); + }); + + // Click the submit button + await submitButton.click(); + + // Assert the form actually submitted + // This will timeout (fail) if the submit event never fires + await Promise.race([ + submitPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('Form did not submit within 2 seconds')), 2000) + ), + ]); + }); + }); + // Helper functions async function testStatus(page: any, status: string) { await expect(page.locator('#status')).toHaveText(status); diff --git a/packages/angular/test/base/src/app/lazy/form/form.component.ts b/packages/angular/test/base/src/app/lazy/form/form.component.ts index 5e670482b47..128ba1f47e5 100644 --- a/packages/angular/test/base/src/app/lazy/form/form.component.ts +++ b/packages/angular/test/base/src/app/lazy/form/form.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, UntypedFormControl, AbstractControl, ValidationErrors } from '@angular/forms'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, UntypedFormControl, AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms'; function otpRequiredLength(length: number) { return (control: AbstractControl): ValidationErrors | null => { @@ -10,6 +10,18 @@ function otpRequiredLength(length: number) { return null; }; } + +// async validation test +function asyncValidator() { + return (control: AbstractControl): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 100); + }); + }; +} + @Component({ selector: 'app-form', templateUrl: './form.component.html', @@ -26,7 +38,7 @@ export class FormComponent { datetime: ['2010-08-20', Validators.required], select: [undefined, Validators.required], toggle: [false], - input: ['', Validators.required], + input: ['', [Validators.required], [asyncValidator()]], input2: ['Default Value'], inputOtp: [null, [Validators.required, otpRequiredLength(4)]], inputOtpText: ['', [Validators.required, otpRequiredLength(4)]], From fb1d9f6c42d9940641dbe166ec21916a2cc00ebe Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Sat, 20 Jun 2026 12:01:23 -0500 Subject: [PATCH 07/14] fix(button): sync disabled state in renderHiddenButton --- core/src/components/button/button.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index f8258af2239..8a0a645177a 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -200,8 +200,8 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf * creation (e.g., runtime property updates). */ if (formButtonEl !== null && formEl.contains(formButtonEl)) { - // formButtonEl.disabled = this.disabled; - // formButtonEl.type = this.type; + formButtonEl.disabled = this.disabled; + formButtonEl.type = this.type; return; } From a96a6b5abbcf0df699a08794c8fae6083742f5f1 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Mon, 22 Jun 2026 10:16:19 -0500 Subject: [PATCH 08/14] revert: test(button): add e2e test for async disabled state change This reverts commit 963ae43b5b679a1c7324f1e4443c70354c7abb93 --- .../test/base/e2e/src/lazy/form.spec.ts | 35 ------------------- .../base/src/app/lazy/form/form.component.ts | 16 ++------- 2 files changed, 2 insertions(+), 49 deletions(-) diff --git a/packages/angular/test/base/e2e/src/lazy/form.spec.ts b/packages/angular/test/base/e2e/src/lazy/form.spec.ts index 1fa34ae58e5..a5cacabe5e2 100644 --- a/packages/angular/test/base/e2e/src/lazy/form.spec.ts +++ b/packages/angular/test/base/e2e/src/lazy/form.spec.ts @@ -284,41 +284,6 @@ test.describe('Form', () => { }); }); - test.describe('async disabled state during init', () => { - test('should submit form when button disabled state resolves asynchronously during init', async ({ page }) => { - // Wait for the component to initialize. The async validator resolves during ngOnInit - await page.waitForTimeout(200); - - // Get the submit button - const submitButton = page.locator('ion-button[type="submit"]'); - - // Set up a promise that resolves when the form submits - const submitPromise = page.evaluate(() => { - return new Promise((resolve) => { - const form = document.querySelector('form'); - if (form) { - form.addEventListener('submit', (e) => { - e.preventDefault(); - resolve(); - }); - } - }); - }); - - // Click the submit button - await submitButton.click(); - - // Assert the form actually submitted - // This will timeout (fail) if the submit event never fires - await Promise.race([ - submitPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error('Form did not submit within 2 seconds')), 2000) - ), - ]); - }); - }); - // Helper functions async function testStatus(page: any, status: string) { await expect(page.locator('#status')).toHaveText(status); diff --git a/packages/angular/test/base/src/app/lazy/form/form.component.ts b/packages/angular/test/base/src/app/lazy/form/form.component.ts index 128ba1f47e5..5e670482b47 100644 --- a/packages/angular/test/base/src/app/lazy/form/form.component.ts +++ b/packages/angular/test/base/src/app/lazy/form/form.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, UntypedFormControl, AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, UntypedFormControl, AbstractControl, ValidationErrors } from '@angular/forms'; function otpRequiredLength(length: number) { return (control: AbstractControl): ValidationErrors | null => { @@ -10,18 +10,6 @@ function otpRequiredLength(length: number) { return null; }; } - -// async validation test -function asyncValidator() { - return (control: AbstractControl): Promise => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(null); - }, 100); - }); - }; -} - @Component({ selector: 'app-form', templateUrl: './form.component.html', @@ -38,7 +26,7 @@ export class FormComponent { datetime: ['2010-08-20', Validators.required], select: [undefined, Validators.required], toggle: [false], - input: ['', [Validators.required], [asyncValidator()]], + input: ['', Validators.required], input2: ['Default Value'], inputOtp: [null, [Validators.required, otpRequiredLength(4)]], inputOtpText: ['', [Validators.required, otpRequiredLength(4)]], From 09a65cea555208dc35629c6961be7ad8af02f970 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Tue, 23 Jun 2026 10:59:50 -0500 Subject: [PATCH 09/14] style: code comment Removing extra space in code comment on 2 lines --- core/src/components/button/button.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index 8a0a645177a..575d188b580 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -195,8 +195,8 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf /** * If the form already has a rendered form button - * then do not append a new one again. Sync the - * disabled state and type in it changes after button + * then do not append a new one again. Sync the + * disabled state and type in it changes after button * creation (e.g., runtime property updates). */ if (formButtonEl !== null && formEl.contains(formButtonEl)) { From 914a42c906c19faaaef257ef969e4268b9337d51 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Tue, 23 Jun 2026 11:01:42 -0500 Subject: [PATCH 10/14] test(button): add e2e test for async disabled state change Test gets disabled state and tests if they match for both initial state and after set-values is called to simulate filling out the form. --- .../test/base/e2e/src/lazy/form.spec.ts | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/angular/test/base/e2e/src/lazy/form.spec.ts b/packages/angular/test/base/e2e/src/lazy/form.spec.ts index a5cacabe5e2..196a8cf26a4 100644 --- a/packages/angular/test/base/e2e/src/lazy/form.spec.ts +++ b/packages/angular/test/base/e2e/src/lazy/form.spec.ts @@ -282,7 +282,52 @@ test.describe('Form', () => { await expect(control).toHaveClass(/ng-invalid/); }); - }); + + test('should keep hidden submit button disabled state in sync', async ({ page }) => { + + // Get the disabled state of both visible and hidden button + const getDisabledState = () => + page.evaluate(() => { + const visible = document.querySelector( + '#submit-button' + ) as HTMLIonButtonElement; + + const hidden = document.querySelector( + 'form button[type="submit"][style*="display: none"]' + ) as HTMLButtonElement; + + return { + visible: visible?.disabled, + hidden: hidden?.disabled, + }; + }); + + // Ensure disabled state of both visible and hidden button + // Should match each other and expected + const expectDisabledStatesMatch = async (expected: boolean) => { + const state = await getDisabledState(); + expect(state.visible).toBe(expected); + expect(state.hidden).toBe(expected); + expect(state.visible).toBe(state.hidden); + return state; + }; + + // Initial state - should be disabled and both match + await expectDisabledStatesMatch(true); + + // Set form values - should be enabled + await page.locator('#set-values').click(); + + // After set values - should be enabled and both match + await expectDisabledStatesMatch(false); + + // User clicks submit button + await page.locator('#submit-button').click(); + + // Form should submit successfully + await expect(page.locator('#submit')).toHaveText('true'); + }); + }); // Helper functions async function testStatus(page: any, status: string) { From 869ccd61039fd86fed74767a7a98586203ad45a0 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Tue, 23 Jun 2026 11:39:53 -0500 Subject: [PATCH 11/14] style Fixing typo in code comment for button.tsx --- core/src/components/button/button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index 575d188b580..a1e7f72bf01 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -196,7 +196,7 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf /** * If the form already has a rendered form button * then do not append a new one again. Sync the - * disabled state and type in it changes after button + * disabled state and type if it changes after button * creation (e.g., runtime property updates). */ if (formButtonEl !== null && formEl.contains(formButtonEl)) { From 2c78cd07badc6744cd5622b38e08b2877e7e8844 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Tue, 23 Jun 2026 11:50:28 -0500 Subject: [PATCH 12/14] revert: test(ion-button): add e2e test for async disabled state change This reverts commit 2964bc6a15552d19577bf0d92ff6f8558cd02272. --- .../button/test/form-reference/button.e2e.ts | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/core/src/components/button/test/form-reference/button.e2e.ts b/core/src/components/button/test/form-reference/button.e2e.ts index c803dd2c226..02e8ff9bbd0 100644 --- a/core/src/components/button/test/form-reference/button.e2e.ts +++ b/core/src/components/button/test/form-reference/button.e2e.ts @@ -154,42 +154,6 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => expect(submitEvent).toHaveReceivedEvent(); }); - test('should submit the form when disabled state changes asynchronously', async ({ page }, testInfo) => { - testInfo.annotations.push({ - type: 'issue', - description: 'https://github.com/ionic-team/ionic-framework/issues/30968', - }); - - await page.setContent( - ` -
- - Submit -
- `, - config - ); - - const submitEvent = await page.spyOnEvent('submit'); - const button = page.locator('ion-button'); - - // Simulate async disabled state change — e.g. async validator resolving - await button.evaluate((el: HTMLIonButtonElement) => { - el.disabled = true; - setTimeout(() => { - el.disabled = false; - }, 0); - }); - - // Wait for the async change to settle - await page.waitForTimeout(100); - - await page.click('ion-button'); - - expect(submitEvent).toHaveReceivedEvent(); - }); - }); - test.describe(title('should throw a warning if the form cannot be found'), () => { test('form is a string selector', async ({ page }) => { const logs: string[] = []; From 7e25d02b25eb51aad7942d84790d32466e40cdd7 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Tue, 23 Jun 2026 11:50:28 -0500 Subject: [PATCH 13/14] revert: test(ion-button): add e2e test for async disabled state change This reverts commit 2964bc6a15552d19577bf0d92ff6f8558cd02272. --- .../button/test/form-reference/button.e2e.ts | 37 +------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/core/src/components/button/test/form-reference/button.e2e.ts b/core/src/components/button/test/form-reference/button.e2e.ts index c803dd2c226..8f4cfaaaa1e 100644 --- a/core/src/components/button/test/form-reference/button.e2e.ts +++ b/core/src/components/button/test/form-reference/button.e2e.ts @@ -153,43 +153,8 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => expect(submitEvent).toHaveReceivedEvent(); }); - - test('should submit the form when disabled state changes asynchronously', async ({ page }, testInfo) => { - testInfo.annotations.push({ - type: 'issue', - description: 'https://github.com/ionic-team/ionic-framework/issues/30968', - }); - - await page.setContent( - ` -
- - Submit -
- `, - config - ); - - const submitEvent = await page.spyOnEvent('submit'); - const button = page.locator('ion-button'); - - // Simulate async disabled state change — e.g. async validator resolving - await button.evaluate((el: HTMLIonButtonElement) => { - el.disabled = true; - setTimeout(() => { - el.disabled = false; - }, 0); - }); - - // Wait for the async change to settle - await page.waitForTimeout(100); - - await page.click('ion-button'); - - expect(submitEvent).toHaveReceivedEvent(); - }); }); - + test.describe(title('should throw a warning if the form cannot be found'), () => { test('form is a string selector', async ({ page }) => { const logs: string[] = []; From 26b8aaea07d92011761a869ee77d7c8e96cb0bf3 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Tue, 23 Jun 2026 12:32:28 -0500 Subject: [PATCH 14/14] style npm run lint --- core/src/components/button/test/form-reference/button.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/button/test/form-reference/button.e2e.ts b/core/src/components/button/test/form-reference/button.e2e.ts index 8f4cfaaaa1e..5710f52da67 100644 --- a/core/src/components/button/test/form-reference/button.e2e.ts +++ b/core/src/components/button/test/form-reference/button.e2e.ts @@ -154,7 +154,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => expect(submitEvent).toHaveReceivedEvent(); }); }); - + test.describe(title('should throw a warning if the form cannot be found'), () => { test('form is a string selector', async ({ page }) => { const logs: string[] = [];