From b7edf0e388718acd8b63fb96a037e74a6ddf453e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 16 Jul 2026 23:09:13 +0900 Subject: [PATCH] fix(kit): disable Ionic form submitters --- projects/kit/src/lib/utils/dom.spec.ts | 89 ++++++++++++++++++++++++++ projects/kit/src/lib/utils/dom.ts | 76 +++++++++++++++++----- 2 files changed, 150 insertions(+), 15 deletions(-) diff --git a/projects/kit/src/lib/utils/dom.spec.ts b/projects/kit/src/lib/utils/dom.spec.ts index 468f437..de7958d 100644 --- a/projects/kit/src/lib/utils/dom.spec.ts +++ b/projects/kit/src/lib/utils/dom.spec.ts @@ -22,4 +22,93 @@ describe('disableHandler', () => { await disableHandler(event, Promise.reject(new Error('boom'))); expect(button.disabled).toBe(false); }); + + it('prevents form navigation and disables a native submitter', async () => { + const form = document.createElement('form'); + const button = document.createElement('button'); + button.type = 'submit'; + form.appendChild(button); + const preventDefault = vi.fn(); + const event = { + type: 'submit', + target: form, + currentTarget: form, + submitter: button, + preventDefault, + } as unknown as SubmitEvent; + let disabledDuringWork = false; + + await disableHandler( + event, + Promise.resolve().then(() => (disabledDuringWork = button.disabled)), + ); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(disabledDuringWork).toBe(true); + expect(button.disabled).toBe(false); + }); + + it('disables an external ion-button associated with the submitted form', async () => { + const form = document.createElement('form'); + const proxy = document.createElement('button'); + proxy.type = 'submit'; + proxy.style.display = 'none'; + form.appendChild(proxy); + const ionButton = document.createElement('ion-button') as HTMLElement & { + disabled: boolean; + form: HTMLFormElement; + }; + ionButton.setAttribute('type', 'submit'); + ionButton.disabled = false; + ionButton.form = form; + document.body.append(form, ionButton); + const event = { + type: 'submit', + target: form, + currentTarget: form, + submitter: proxy, + preventDefault: vi.fn(), + } as unknown as SubmitEvent; + let disabledDuringWork = false; + + await disableHandler( + event, + Promise.resolve().then(() => (disabledDuringWork = ionButton.disabled)), + ); + + expect(disabledDuringWork).toBe(true); + expect(ionButton.disabled).toBe(false); + form.remove(); + ionButton.remove(); + }); + + it('restores the original disabled state of every submitter for the form', async () => { + const form = document.createElement('form'); + const first = document.createElement('ion-button') as HTMLElement & { + disabled: boolean; + form: HTMLFormElement; + }; + const second = document.createElement('ion-button') as typeof first; + [first, second].forEach((button) => { + button.setAttribute('type', 'submit'); + button.form = form; + }); + first.disabled = false; + second.disabled = true; + document.body.append(form, first, second); + const event = { + type: 'submit', + target: form, + submitter: document.createElement('button'), + preventDefault: vi.fn(), + } as unknown as SubmitEvent; + + await disableHandler(event, Promise.resolve()); + + expect(first.disabled).toBe(false); + expect(second.disabled).toBe(true); + form.remove(); + first.remove(); + second.remove(); + }); }); diff --git a/projects/kit/src/lib/utils/dom.ts b/projects/kit/src/lib/utils/dom.ts index f2a3876..035f28e 100644 --- a/projects/kit/src/lib/utils/dom.ts +++ b/projects/kit/src/lib/utils/dom.ts @@ -1,24 +1,70 @@ +type DisableableElement = HTMLElement & { disabled: boolean }; + +const isDisableable = (element: EventTarget | null): element is DisableableElement => + element instanceof HTMLElement && 'disabled' in element; + +const getIonicSubmitButtons = (form: HTMLFormElement): DisableableElement[] => + Array.from(document.querySelectorAll('ion-button[type="submit"]')).filter((button): button is DisableableElement => { + if (!isDisableable(button)) return false; + + const associatedForm = (button as HTMLElement & { form?: HTMLFormElement | string }).form; + return ( + associatedForm === form || + (typeof associatedForm === 'string' && associatedForm === form.id) || + (form.id !== '' && button.getAttribute('form') === form.id) || + button.closest('form') === form + ); + }); + +const getDisableTargets = (event: Event): DisableableElement[] => { + const submitter = 'submitter' in event ? (event as SubmitEvent).submitter : null; + const form = event.target instanceof HTMLFormElement ? event.target : null; + const targets: DisableableElement[] = []; + + if (event.type === 'submit' && form) { + targets.push(...getIonicSubmitButtons(form)); + } + + if (isDisableable(submitter)) { + const root = submitter.getRootNode(); + const target = root instanceof ShadowRoot ? root.host : submitter; + if (isDisableable(target)) targets.push(target); + } + + if (targets.length === 0 && isDisableable(event.currentTarget)) targets.push(event.currentTarget); + if (targets.length === 0 && isDisableable(event.target)) targets.push(event.target); + return [...new Set(targets)]; +}; + /** - * Disable the button that triggered an event while an async operation runs, re-enabling it after. + * Disable the controls that triggered an event while an async operation runs. * * @remarks - * Prevents the common double-submit / double-tap bug: the `event.target` button is disabled, the - * work is awaited, and the button is re-enabled — even if the work rejects (the rejection is - * swallowed here so the button always recovers; handle errors inside `work` if you need to react). + * For a click event, the clicked control is disabled. For a submit event, the submitter is + * disabled and the browser's default form navigation is prevented. Ionic's external + * `ion-button[form]` uses a hidden native submitter, so buttons associated with the submitted form + * are resolved through their `form` property and disabled instead. Rejections are swallowed so + * controls always recover; handle errors inside `work` when the caller needs to react. * - * @param event - The DOM event whose `target` is the button to disable (e.g. a click event). - * @param work - The async operation to run while the button is disabled. - * @returns A Promise that resolves once the work has settled and the button has been re-enabled. + * @param event - The click or submit event that triggered the operation. + * @param work - The async operation to run while the controls are disabled. + * @returns A Promise that resolves once the work has settled and the controls have been restored. * @example - * ```ts - * async submit(event: Event): Promise { - * await disableHandler(event, this.save()); - * } + * ```html + *
+ * Save * ``` */ export const disableHandler = async (event: Event, work: Promise): Promise => { - const target = event.target as HTMLButtonElement; - target.disabled = true; - await work.catch((): undefined => undefined); - target.disabled = false; + if (event.type === 'submit') event.preventDefault(); + + const targets = getDisableTargets(event); + const disabledStates = targets.map((target) => target.disabled); + targets.forEach((target) => (target.disabled = true)); + + try { + await work.catch((): undefined => undefined); + } finally { + targets.forEach((target, index) => (target.disabled = disabledStates[index])); + } };