From 63c3a2847be4e91ea7c6069ee36430d53b5080e7 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Sun, 16 Aug 2026 21:15:00 +0800 Subject: [PATCH 1/5] fix(content): keep the Enter trio and submit natively only when the page did not handle Enter Removing the synthetic Enter broke bare-form pages, value-transforming inputs (tag chips, invite-by-email), and Firefox contenteditable composers that commit only through their own keydown listener. Dispatch the Enter trio always; if dispatchEvent returns false the page already handled Enter, so skip requestSubmit to avoid a double send. requestSubmit now checks form validity first so an invalid form surfaces a clear failure with submitted:false instead of silently aborting, and the success result reports submitted:true when a native submit ran. --- src/chrome/src/content/content.js | 44 ++++++++++++++---- src/firefox/src/content/content.js | 40 ++++++++++++++-- test/run.js | 73 ++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 13 deletions(-) diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 65726d841..3fa470b57 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -3814,6 +3814,17 @@ return typeof el.innerText === 'string' ? el.innerText : (el.textContent || ''); } + // Pick the single commit path for set_field({submit:true}). Synthetic + // Synthetic (isTrusted:false) Enter events never trigger native submission — + // they only reach the page's own keydown listeners. A non-combobox field + // inside a form that has requestSubmit therefore needs a native submit as + // its only reliable commit path; comboboxes are committed by page JS + // listeners instead, because submitting the enclosing form while a picker + // popup is open is usually wrong. + function _setFieldUsesNativeSubmit(isCombobox, form) { + return !isCombobox && !!form && typeof form.requestSubmit === 'function'; + } + // The rich-text toolbar heuristic lives in one file shared by both builds // and by the CDP main-world probe — see // src/content/rich-text-toolbar-heuristic.js. Delegating keeps the scoring @@ -5711,6 +5722,7 @@ const actual = el.isContentEditable ? _editableTextValue(el) : (el.value || ''); const verified = _setFieldValueMatches(actual, prevValue, text, clear, el.isContentEditable); const fallbackAttempted = false; + let nativeSubmitAttempted = false; if (submit && verified) { try { // Detect combobox/searchbox pattern: if the element is a searchbox, @@ -5738,7 +5750,10 @@ } catch {} } const dispatchKey = (type, key, keyCode) => { - el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); + // Return the dispatch result: `false` means the page cancelled + // the event (preventDefault), which is how we detect that a + // keydown listener already handled Enter. + return el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); }; if (isCombobox) { // Give the listbox a tick to filter, then highlight the first @@ -5748,6 +5763,7 @@ dispatchKey('keyup', 'ArrowDown', 40); await new Promise(r => setTimeout(r, 30)); } + const form = el.form || (el.closest && el.closest('form')); if (msg.params?.messageRecipientGuardRequired === true) { const recipientValidation = _consumeMessageRecipientDispatchBinding(msg.params, el); if (recipientValidation.success !== true) { @@ -5760,15 +5776,26 @@ }); } } - dispatchKey('keydown', 'Enter', 13); + // Always dispatch the Enter trio: bare forms, tag-chip / email + // inputs that transform the value on Enter, and contenteditable + // composers only commit through their own keydown listener. If + // the page cancelled the keydown it already handled Enter, so a + // second submit would double-send. + const enterHandled = !dispatchKey('keydown', 'Enter', 13); dispatchKey('keypress', 'Enter', 13); dispatchKey('keyup', 'Enter', 13); - // Form submission: only fall back to requestSubmit for non-combobox - // inputs. Submitting a form while a combobox popup is open is - // usually wrong and can prematurely post the enclosing form. - if (!isCombobox) { - const form = el.form || (el.closest && el.closest('form')); - if (form && typeof form.requestSubmit === 'function') form.requestSubmit(); + if (!enterHandled && _setFieldUsesNativeSubmit(isCombobox, form)) { + // requestSubmit performs interactive constraint validation and + // silently aborts on an invalid form; surface that instead of + // reporting a successful submission. + if (typeof form.checkValidity === 'function' && !form.checkValidity()) { + return failure( + 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', + { verified: true, submitted: false, invalid: true, ref_id, rect }, + ); + } + form.requestSubmit(); + nativeSubmitAttempted = true; } } catch {} } @@ -5798,6 +5825,7 @@ verified: true, fieldMeta, fallbackAttempted, + submitted: nativeSubmitAttempted || undefined, }; } catch (e) { return failure(e && e.message || String(e)); diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index e6803278e..c30575e50 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -3080,6 +3080,16 @@ return typeof el.innerText === 'string' ? el.innerText : (el.textContent || ''); } + // Synthetic (isTrusted:false) Enter events never trigger native submission — + // they only reach the page's own keydown listeners. A non-combobox field + // inside a form that has requestSubmit therefore needs a native submit as + // its only reliable commit path; comboboxes are committed by page JS + // listeners instead, because submitting the enclosing form while a picker + // popup is open is usually wrong. + function _setFieldUsesNativeSubmit(isCombobox, form) { + return !isCombobox && !!form && typeof form.requestSubmit === 'function'; + } + // Above this length the per-candidate rescan below stops being worth its // cost. The edit still succeeds; it is reported unproven, which the callers // treat as "no positive proof", not as a failure. @@ -4840,6 +4850,7 @@ let actual = el.isContentEditable ? _editableTextValue(el) : (el.value || ''); let verified = _setFieldValueMatches(actual, prevValue, text, clear, el.isContentEditable); let fallbackAttempted = false; + let nativeSubmitAttempted = false; if (!verified) { fallbackAttempted = true; await _retryFieldWithExecCommand(el, (clear ? '' : prevValue) + text); @@ -4867,7 +4878,10 @@ } catch {} } const dispatchKey = (type, key, keyCode) => { - el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); + // Return the dispatch result: `false` means the page cancelled + // the event (preventDefault), which is how we detect that a + // keydown listener already handled Enter. + return el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); }; if (isCombobox) { await new Promise(r => setTimeout(r, 80)); @@ -4875,6 +4889,7 @@ dispatchKey('keyup', 'ArrowDown', 40); await new Promise(r => setTimeout(r, 30)); } + const form = el.form || (el.closest && el.closest('form')); if (msg.params?.messageRecipientGuardRequired === true) { const recipientValidation = _consumeMessageRecipientDispatchBinding(msg.params, el); if (recipientValidation.success !== true) { @@ -4887,12 +4902,26 @@ }); } } - dispatchKey('keydown', 'Enter', 13); + // Always dispatch the Enter trio: bare forms, tag-chip / email + // inputs that transform the value on Enter, and contenteditable + // composers only commit through their own keydown listener. If + // the page cancelled the keydown it already handled Enter, so a + // second submit would double-send. + const enterHandled = !dispatchKey('keydown', 'Enter', 13); dispatchKey('keypress', 'Enter', 13); dispatchKey('keyup', 'Enter', 13); - if (!isCombobox) { - const form = el.form || (el.closest && el.closest('form')); - if (form && typeof form.requestSubmit === 'function') form.requestSubmit(); + if (!enterHandled && _setFieldUsesNativeSubmit(isCombobox, form)) { + // requestSubmit performs interactive constraint validation and + // silently aborts on an invalid form; surface that instead of + // reporting a successful submission. + if (typeof form.checkValidity === 'function' && !form.checkValidity()) { + return failure( + 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', + { verified: true, submitted: false, invalid: true, ref_id, rect }, + ); + } + form.requestSubmit(); + nativeSubmitAttempted = true; } } catch {} } @@ -4921,6 +4950,7 @@ verified: true, fieldMeta, fallbackAttempted, + submitted: nativeSubmitAttempted || undefined, }; } catch (e) { return failure(e && e.message || String(e)); diff --git a/test/run.js b/test/run.js index cbab07c8a..b0f055ae7 100644 --- a/test/run.js +++ b/test/run.js @@ -55033,6 +55033,79 @@ test('set_field waits for reconciliation and verifies the complete value', () => } }); +test('set_field submit dispatches Enter once and submits natively only when unhandled', () => { + for (const [label, rel] of [ + ['chrome', 'src/chrome/src/content/content.js'], + ['firefox', 'src/firefox/src/content/content.js'], + ]) { + const source = fs.readFileSync(path.join(ROOT, rel), 'utf8'); + + // Pure boolean helper. + const helperStart = source.indexOf('function _setFieldUsesNativeSubmit('); + const helperEnd = source.indexOf('\n }\n', helperStart) + 4; + assert.ok(helperStart >= 0 && helperEnd > helperStart, `${label}: submit helper should remain independently testable`); + const usesNativeSubmit = vm.runInNewContext(`(${source.slice(helperStart, helperEnd)})`); + const formWithSubmit = { requestSubmit() {} }; + assert.equal(usesNativeSubmit(true, formWithSubmit), false, `${label}: combobox never uses native submit`); + assert.equal(usesNativeSubmit(false, formWithSubmit), true, `${label}: plain field in a form uses native submit`); + assert.equal(usesNativeSubmit(false, null), false, `${label}: form-less field does not use native submit`); + assert.equal(usesNativeSubmit(false, {}), false, `${label}: form without requestSubmit does not use native submit`); + + // Behavioral slice of the submit block, run against stubs. + const blockStart = source.indexOf("const form = el.form || (el.closest && el.closest('form'));"); + const blockEnd = source.indexOf('} catch {}', blockStart); + assert.ok(blockStart >= 0 && blockEnd > blockStart, `${label}: submit block not found`); + const block = source.slice(blockStart, blockEnd); + const runner = vm.runInNewContext(`(stubs) => { + const { dispatchKey, el, msg, failure, isCombobox, _setFieldUsesNativeSubmit, _consumeMessageRecipientDispatchBinding, ref_id, rect } = stubs; + let nativeSubmitAttempted = false; + ${block} + return nativeSubmitAttempted; + }`); + + const exercise = ({ keydownCancelled, checkValidity = () => true, isCombobox = false }) => { + const calls = []; + const form = { requestSubmit: () => calls.push('requestSubmit'), checkValidity }; + const el = { dispatchEvent: (ev) => (keydownCancelled && ev.type === 'keydown' ? false : true), form, closest: () => null }; + const dispatchKey = (type) => { calls.push(type); return el.dispatchEvent({ type }); }; + let failureResult = null; + const failure = (msg, data) => { failureResult = data; return { success: false, ...data }; }; + const submitted = runner({ + dispatchKey, el, + msg: { params: {} }, + failure, + isCombobox, + _setFieldUsesNativeSubmit: usesNativeSubmit, + _consumeMessageRecipientDispatchBinding: () => ({ success: true }), + ref_id: 'ref_1', + rect: { x: 0, y: 0, w: 1, h: 1 }, + }); + return { calls, submitted, failureResult }; + }; + + // Plain field + valid form + unhandled keydown: Enter trio + one submit. + const plain = exercise({}); + assert.deepEqual(plain.calls, ['keydown', 'keypress', 'keyup', 'requestSubmit'], `${label}: plain field must dispatch Enter then submit natively once`); + assert.equal(plain.submitted, true, `${label}: native submit must be reported`); + + // Page already handled Enter (keydown cancelled): no second submit. + const handled = exercise({ keydownCancelled: true }); + assert.deepEqual(handled.calls, ['keydown', 'keypress', 'keyup'], `${label}: handled Enter must not double-submit`); + assert.equal(handled.submitted, false, `${label}: handled Enter reports no native submit`); + + // Combobox: never native-submits (the Enter trio reaches page JS). + const combobox = exercise({ isCombobox: true }); + assert.deepEqual(combobox.calls, ['keydown', 'keypress', 'keyup'], `${label}: combobox must commit via synthetic keys only`); + assert.equal(combobox.submitted, false, `${label}: combobox reports no native submit`); + + // Invalid form: surface the silent requestSubmit abort. + const invalid = exercise({ checkValidity: () => false }); + assert.deepEqual(invalid.calls, ['keydown', 'keypress', 'keyup'], `${label}: invalid form must not call requestSubmit`); + assert.equal(invalid.failureResult?.submitted, false, `${label}: invalid form must fail with submitted:false`); + assert.equal(invalid.failureResult?.invalid, true, `${label}: invalid form must flag invalid:true`); + } +}); + test('type_ax shares settled exact verification and explicit recovery contract', () => { for (const [label, rel] of [ ['chrome', 'src/chrome/src/content/content.js'], From de9295366d9f50e567417f12f7df3fec29eb8957 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Mon, 17 Aug 2026 08:19:28 +0800 Subject: [PATCH 2/5] fix(content): require observed submits for set_field --- src/chrome/src/content/content.js | 64 +++++++++++++++++++++--------- src/firefox/src/content/content.js | 64 +++++++++++++++++++++--------- test/run.js | 45 ++++++++++++++++++--- 3 files changed, 130 insertions(+), 43 deletions(-) diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 3fa470b57..378c04bcc 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -5764,6 +5764,18 @@ await new Promise(r => setTimeout(r, 30)); } const form = el.form || (el.closest && el.closest('form')); + let submissionObserved = false; + let submissionCancelled = false; + let submissionOutcomeUnknown = false; + let removeSubmitObserver = () => {}; + if (form && typeof form.addEventListener === 'function') { + const onSubmit = event => { + submissionObserved = true; + submissionCancelled = event.defaultPrevented === true; + }; + form.addEventListener('submit', onSubmit, true); + removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); + } if (msg.params?.messageRecipientGuardRequired === true) { const recipientValidation = _consumeMessageRecipientDispatchBinding(msg.params, el); if (recipientValidation.success !== true) { @@ -5776,26 +5788,39 @@ }); } } - // Always dispatch the Enter trio: bare forms, tag-chip / email - // inputs that transform the value on Enter, and contenteditable - // composers only commit through their own keydown listener. If - // the page cancelled the keydown it already handled Enter, so a - // second submit would double-send. - const enterHandled = !dispatchKey('keydown', 'Enter', 13); - dispatchKey('keypress', 'Enter', 13); - dispatchKey('keyup', 'Enter', 13); - if (!enterHandled && _setFieldUsesNativeSubmit(isCombobox, form)) { - // requestSubmit performs interactive constraint validation and - // silently aborts on an invalid form; surface that instead of - // reporting a successful submission. - if (typeof form.checkValidity === 'function' && !form.checkValidity()) { - return failure( - 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', - { verified: true, submitted: false, invalid: true, ref_id, rect }, - ); + try { + // Always dispatch the Enter trio: bare forms, tag-chip / email + // inputs that transform the value on Enter, and contenteditable + // composers only commit through their own keydown listener. + const enterCancelled = !dispatchKey('keydown', 'Enter', 13); + dispatchKey('keypress', 'Enter', 13); + dispatchKey('keyup', 'Enter', 13); + if (submissionObserved) { + submissionOutcomeUnknown = submissionCancelled; + } else if (!enterCancelled && _setFieldUsesNativeSubmit(isCombobox, form)) { + // requestSubmit performs interactive constraint validation and + // silently aborts on an invalid form; surface that instead of + // reporting a successful submission. + if (typeof form.checkValidity === 'function' && !form.checkValidity()) { + return failure( + 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', + { verified: true, submitted: false, invalid: true, ref_id, rect }, + ); + } + try { + form.requestSubmit(); + } catch { + submissionOutcomeUnknown = true; + } + if (!submissionObserved) submissionOutcomeUnknown = true; + } else { + // preventDefault, combobox handling, contenteditable custom + // handlers, and form-less widgets do not prove submission. + submissionOutcomeUnknown = true; } - form.requestSubmit(); - nativeSubmitAttempted = true; + nativeSubmitAttempted = submissionObserved && !submissionCancelled; + } finally { + removeSubmitObserver(); } } catch {} } @@ -5826,6 +5851,7 @@ fieldMeta, fallbackAttempted, submitted: nativeSubmitAttempted || undefined, + outcomeUnknown: submissionOutcomeUnknown || undefined, }; } catch (e) { return failure(e && e.message || String(e)); diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index c30575e50..a238df242 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -4890,6 +4890,18 @@ await new Promise(r => setTimeout(r, 30)); } const form = el.form || (el.closest && el.closest('form')); + let submissionObserved = false; + let submissionCancelled = false; + let submissionOutcomeUnknown = false; + let removeSubmitObserver = () => {}; + if (form && typeof form.addEventListener === 'function') { + const onSubmit = event => { + submissionObserved = true; + submissionCancelled = event.defaultPrevented === true; + }; + form.addEventListener('submit', onSubmit, true); + removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); + } if (msg.params?.messageRecipientGuardRequired === true) { const recipientValidation = _consumeMessageRecipientDispatchBinding(msg.params, el); if (recipientValidation.success !== true) { @@ -4902,26 +4914,39 @@ }); } } - // Always dispatch the Enter trio: bare forms, tag-chip / email - // inputs that transform the value on Enter, and contenteditable - // composers only commit through their own keydown listener. If - // the page cancelled the keydown it already handled Enter, so a - // second submit would double-send. - const enterHandled = !dispatchKey('keydown', 'Enter', 13); - dispatchKey('keypress', 'Enter', 13); - dispatchKey('keyup', 'Enter', 13); - if (!enterHandled && _setFieldUsesNativeSubmit(isCombobox, form)) { - // requestSubmit performs interactive constraint validation and - // silently aborts on an invalid form; surface that instead of - // reporting a successful submission. - if (typeof form.checkValidity === 'function' && !form.checkValidity()) { - return failure( - 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', - { verified: true, submitted: false, invalid: true, ref_id, rect }, - ); + try { + // Always dispatch the Enter trio: bare forms, tag-chip / email + // inputs that transform the value on Enter, and contenteditable + // composers only commit through their own keydown listener. + const enterCancelled = !dispatchKey('keydown', 'Enter', 13); + dispatchKey('keypress', 'Enter', 13); + dispatchKey('keyup', 'Enter', 13); + if (submissionObserved) { + submissionOutcomeUnknown = submissionCancelled; + } else if (!enterCancelled && _setFieldUsesNativeSubmit(isCombobox, form)) { + // requestSubmit performs interactive constraint validation and + // silently aborts on an invalid form; surface that instead of + // reporting a successful submission. + if (typeof form.checkValidity === 'function' && !form.checkValidity()) { + return failure( + 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', + { verified: true, submitted: false, invalid: true, ref_id, rect }, + ); + } + try { + form.requestSubmit(); + } catch { + submissionOutcomeUnknown = true; + } + if (!submissionObserved) submissionOutcomeUnknown = true; + } else { + // preventDefault, combobox handling, contenteditable custom + // handlers, and form-less widgets do not prove submission. + submissionOutcomeUnknown = true; } - form.requestSubmit(); - nativeSubmitAttempted = true; + nativeSubmitAttempted = submissionObserved && !submissionCancelled; + } finally { + removeSubmitObserver(); } } catch {} } @@ -4951,6 +4976,7 @@ fieldMeta, fallbackAttempted, submitted: nativeSubmitAttempted || undefined, + outcomeUnknown: submissionOutcomeUnknown || undefined, }; } catch (e) { return failure(e && e.message || String(e)); diff --git a/test/run.js b/test/run.js index b0f055ae7..ceff9fc62 100644 --- a/test/run.js +++ b/test/run.js @@ -55026,7 +55026,9 @@ test('set_field waits for reconciliation and verifies the complete value', () => assert.match(branch, /(?:const|let) actual = el\.isContentEditable \? _editableTextValue\(el\)/, `${label}: rich-editor verification must use rendered text`); assert.match(branch, /_setFieldValueMatches\(actual, prevValue, text, clear, el\.isContentEditable\)/, `${label}: newline normalization must remain contenteditable-only`); assert.match(branch, /!el\.isConnected \|\| !rect \|\| rect\.w < 1 \|\| rect\.h < 1/, `${label}: stale or zero-sized targets must fail before typing`); - assert.match(branch, /if \(submit && verified\)/, `${label}: mismatched field values must not be submitted`); + assert.match(branch, /if \(submit && verified\)/, `${label}: mismatched field values must not be submitted`); + assert.match(branch, /addEventListener\('submit'/, `${label}: submit handling must observe actual submit events`); + assert.match(branch, /outcomeUnknown: submissionOutcomeUnknown/, `${label}: unproven submissions must be surfaced as unknown`); assert.match(branch, /if \(!verified\) \{[\s\S]*return failure\(/, `${label}: mismatched field values must be explicit failed actions`); assert.match(branch, /dispatched\s*\?\s*\{ dispatched: true \}/, `${label}: post-dispatch verification failures must preserve action evidence`); assert.doesNotMatch(branch, /actual\.includes\(text\)/, `${label}: substring matches must not count as verified field values`); @@ -55063,11 +55065,30 @@ test('set_field submit dispatches Enter once and submits natively only when unha return nativeSubmitAttempted; }`); - const exercise = ({ keydownCancelled, checkValidity = () => true, isCombobox = false }) => { + const exercise = ({ keydownCancelled, checkValidity = () => true, isCombobox = false, pageSubmitsOnKeydown = false, submitCancelled = false }) => { const calls = []; - const form = { requestSubmit: () => calls.push('requestSubmit'), checkValidity }; + const submitListeners = []; + const emitSubmit = () => { + const event = { defaultPrevented: submitCancelled }; + for (const listener of submitListeners) listener(event); + }; + const form = { + requestSubmit: () => { calls.push('requestSubmit'); emitSubmit(); }, + checkValidity, + addEventListener: (type, listener) => { if (type === 'submit') submitListeners.push(listener); }, + removeEventListener: (type, listener) => { + if (type === 'submit') { + const index = submitListeners.indexOf(listener); + if (index >= 0) submitListeners.splice(index, 1); + } + }, + }; const el = { dispatchEvent: (ev) => (keydownCancelled && ev.type === 'keydown' ? false : true), form, closest: () => null }; - const dispatchKey = (type) => { calls.push(type); return el.dispatchEvent({ type }); }; + const dispatchKey = (type) => { + calls.push(type); + if (type === 'keydown' && pageSubmitsOnKeydown) emitSubmit(); + return el.dispatchEvent({ type }); + }; let failureResult = null; const failure = (msg, data) => { failureResult = data; return { success: false, ...data }; }; const submitted = runner({ @@ -55086,18 +55107,32 @@ test('set_field submit dispatches Enter once and submits natively only when unha // Plain field + valid form + unhandled keydown: Enter trio + one submit. const plain = exercise({}); assert.deepEqual(plain.calls, ['keydown', 'keypress', 'keyup', 'requestSubmit'], `${label}: plain field must dispatch Enter then submit natively once`); - assert.equal(plain.submitted, true, `${label}: native submit must be reported`); + assert.equal(plain.submitted, true, `${label}: observed native submit must be reported`); + assert.equal(plain.failureResult, null, `${label}: observed native submit must not fail`); + + // A page listener may submit without cancelling Enter; the fallback must + // see the submit event and avoid a second requestSubmit call. + const pageSubmitted = exercise({ pageSubmitsOnKeydown: true }); + assert.deepEqual(pageSubmitted.calls, ['keydown', 'keypress', 'keyup'], `${label}: page-handled submit must not be submitted twice`); + assert.equal(pageSubmitted.submitted, true, `${label}: page-handled submit should be reported from the observed event`); // Page already handled Enter (keydown cancelled): no second submit. const handled = exercise({ keydownCancelled: true }); assert.deepEqual(handled.calls, ['keydown', 'keypress', 'keyup'], `${label}: handled Enter must not double-submit`); assert.equal(handled.submitted, false, `${label}: handled Enter reports no native submit`); + assert.equal(handled.failureResult, null, `${label}: handled Enter should preserve a non-failing unknown result`); // Combobox: never native-submits (the Enter trio reaches page JS). const combobox = exercise({ isCombobox: true }); assert.deepEqual(combobox.calls, ['keydown', 'keypress', 'keyup'], `${label}: combobox must commit via synthetic keys only`); assert.equal(combobox.submitted, false, `${label}: combobox reports no native submit`); + // A page can cancel a submit event after observing it; that is not proof + // that the consequential action reached the server. + const cancelledSubmit = exercise({ submitCancelled: true }); + assert.deepEqual(cancelledSubmit.calls, ['keydown', 'keypress', 'keyup', 'requestSubmit'], `${label}: cancelled submit still has one native attempt`); + assert.equal(cancelledSubmit.submitted, false, `${label}: cancelled submit must not be reported as submitted`); + // Invalid form: surface the silent requestSubmit abort. const invalid = exercise({ checkValidity: () => false }); assert.deepEqual(invalid.calls, ['keydown', 'keypress', 'keyup'], `${label}: invalid form must not call requestSubmit`); From 7976786f92bf60dcefa4f48c78b7d076a560b979 Mon Sep 17 00:00:00 2001 From: Barack Sokullu Date: Mon, 17 Aug 2026 03:47:31 +0300 Subject: [PATCH 3/5] fix(content): use one set_field submit path --- src/chrome/src/content/content.js | 78 ++++++++++----------- src/firefox/src/content/content.js | 74 ++++++++++---------- test/run.js | 106 +++++++++++++++++------------ 3 files changed, 136 insertions(+), 122 deletions(-) diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 378c04bcc..919727ce5 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -3815,14 +3815,14 @@ } // Pick the single commit path for set_field({submit:true}). Synthetic - // Synthetic (isTrusted:false) Enter events never trigger native submission — + // (isTrusted:false) Enter events never trigger native submission — // they only reach the page's own keydown listeners. A non-combobox field // inside a form that has requestSubmit therefore needs a native submit as // its only reliable commit path; comboboxes are committed by page JS // listeners instead, because submitting the enclosing form while a picker // popup is open is usually wrong. - function _setFieldUsesNativeSubmit(isCombobox, form) { - return !isCombobox && !!form && typeof form.requestSubmit === 'function'; + function _setFieldUsesNativeSubmit(isCombobox, isContentEditable, form) { + return !isCombobox && !isContentEditable && !!form && typeof form.requestSubmit === 'function'; } // The rich-text toolbar heuristic lives in one file shared by both builds @@ -5723,7 +5723,9 @@ const verified = _setFieldValueMatches(actual, prevValue, text, clear, el.isContentEditable); const fallbackAttempted = false; let nativeSubmitAttempted = false; + let submissionOutcomeUnknown = false; if (submit && verified) { + submissionOutcomeUnknown = true; try { // Detect combobox/searchbox pattern: if the element is a searchbox, // has role=combobox, has aria-controls pointing to a listbox, or a @@ -5750,32 +5752,12 @@ } catch {} } const dispatchKey = (type, key, keyCode) => { - // Return the dispatch result: `false` means the page cancelled - // the event (preventDefault), which is how we detect that a - // keydown listener already handled Enter. return el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); }; - if (isCombobox) { - // Give the listbox a tick to filter, then highlight the first - // option with ArrowDown, then commit with Enter. - await new Promise(r => setTimeout(r, 80)); - dispatchKey('keydown', 'ArrowDown', 40); - dispatchKey('keyup', 'ArrowDown', 40); - await new Promise(r => setTimeout(r, 30)); - } const form = el.form || (el.closest && el.closest('form')); + const usesNativeSubmit = _setFieldUsesNativeSubmit(isCombobox, el.isContentEditable, form); let submissionObserved = false; let submissionCancelled = false; - let submissionOutcomeUnknown = false; - let removeSubmitObserver = () => {}; - if (form && typeof form.addEventListener === 'function') { - const onSubmit = event => { - submissionObserved = true; - submissionCancelled = event.defaultPrevented === true; - }; - form.addEventListener('submit', onSubmit, true); - removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); - } if (msg.params?.messageRecipientGuardRequired === true) { const recipientValidation = _consumeMessageRecipientDispatchBinding(msg.params, el); if (recipientValidation.success !== true) { @@ -5788,16 +5770,20 @@ }); } } + let removeSubmitObserver = () => {}; + if (form && typeof form.addEventListener === 'function') { + const onSubmit = event => { + submissionObserved = true; + submissionCancelled = event.defaultPrevented === true; + }; + form.addEventListener('submit', onSubmit, true); + removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); + } try { - // Always dispatch the Enter trio: bare forms, tag-chip / email - // inputs that transform the value on Enter, and contenteditable - // composers only commit through their own keydown listener. - const enterCancelled = !dispatchKey('keydown', 'Enter', 13); - dispatchKey('keypress', 'Enter', 13); - dispatchKey('keyup', 'Enter', 13); - if (submissionObserved) { - submissionOutcomeUnknown = submissionCancelled; - } else if (!enterCancelled && _setFieldUsesNativeSubmit(isCombobox, form)) { + if (usesNativeSubmit) { + // Ordinary form controls use one native path. Dispatching a + // synthetic Enter first could make page code act and then + // make this fallback repeat the consequential action. // requestSubmit performs interactive constraint validation and // silently aborts on an invalid form; surface that instead of // reporting a successful submission. @@ -5809,20 +5795,30 @@ } try { form.requestSubmit(); - } catch { - submissionOutcomeUnknown = true; - } - if (!submissionObserved) submissionOutcomeUnknown = true; + } catch {} } else { - // preventDefault, combobox handling, contenteditable custom - // handlers, and form-less widgets do not prove submission. - submissionOutcomeUnknown = true; + // Comboboxes, contenteditables, and form-less widgets are + // committed by page-owned keyboard handlers. Never follow + // this path with requestSubmit: cancellation is not proof of + // submission, and an unobserved handler may already have acted. + if (isCombobox) { + await new Promise(r => setTimeout(r, 80)); + dispatchKey('keydown', 'ArrowDown', 40); + dispatchKey('keyup', 'ArrowDown', 40); + await new Promise(r => setTimeout(r, 30)); + } + dispatchKey('keydown', 'Enter', 13); + dispatchKey('keypress', 'Enter', 13); + dispatchKey('keyup', 'Enter', 13); } nativeSubmitAttempted = submissionObserved && !submissionCancelled; + submissionOutcomeUnknown = !nativeSubmitAttempted; } finally { removeSubmitObserver(); } - } catch {} + } catch { + submissionOutcomeUnknown = true; + } } if (!verified) { return failure( diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index a238df242..d10236be9 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -3086,8 +3086,8 @@ // its only reliable commit path; comboboxes are committed by page JS // listeners instead, because submitting the enclosing form while a picker // popup is open is usually wrong. - function _setFieldUsesNativeSubmit(isCombobox, form) { - return !isCombobox && !!form && typeof form.requestSubmit === 'function'; + function _setFieldUsesNativeSubmit(isCombobox, isContentEditable, form) { + return !isCombobox && !isContentEditable && !!form && typeof form.requestSubmit === 'function'; } // Above this length the per-candidate rescan below stops being worth its @@ -4851,6 +4851,7 @@ let verified = _setFieldValueMatches(actual, prevValue, text, clear, el.isContentEditable); let fallbackAttempted = false; let nativeSubmitAttempted = false; + let submissionOutcomeUnknown = false; if (!verified) { fallbackAttempted = true; await _retryFieldWithExecCommand(el, (clear ? '' : prevValue) + text); @@ -4859,6 +4860,7 @@ } if (submit && verified) { + submissionOutcomeUnknown = true; try { const roleAttr = (el.getAttribute && el.getAttribute('role') || '').toLowerCase(); const controls = el.getAttribute && el.getAttribute('aria-controls'); @@ -4878,30 +4880,12 @@ } catch {} } const dispatchKey = (type, key, keyCode) => { - // Return the dispatch result: `false` means the page cancelled - // the event (preventDefault), which is how we detect that a - // keydown listener already handled Enter. return el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); }; - if (isCombobox) { - await new Promise(r => setTimeout(r, 80)); - dispatchKey('keydown', 'ArrowDown', 40); - dispatchKey('keyup', 'ArrowDown', 40); - await new Promise(r => setTimeout(r, 30)); - } const form = el.form || (el.closest && el.closest('form')); + const usesNativeSubmit = _setFieldUsesNativeSubmit(isCombobox, el.isContentEditable, form); let submissionObserved = false; let submissionCancelled = false; - let submissionOutcomeUnknown = false; - let removeSubmitObserver = () => {}; - if (form && typeof form.addEventListener === 'function') { - const onSubmit = event => { - submissionObserved = true; - submissionCancelled = event.defaultPrevented === true; - }; - form.addEventListener('submit', onSubmit, true); - removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); - } if (msg.params?.messageRecipientGuardRequired === true) { const recipientValidation = _consumeMessageRecipientDispatchBinding(msg.params, el); if (recipientValidation.success !== true) { @@ -4914,16 +4898,20 @@ }); } } + let removeSubmitObserver = () => {}; + if (form && typeof form.addEventListener === 'function') { + const onSubmit = event => { + submissionObserved = true; + submissionCancelled = event.defaultPrevented === true; + }; + form.addEventListener('submit', onSubmit, true); + removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); + } try { - // Always dispatch the Enter trio: bare forms, tag-chip / email - // inputs that transform the value on Enter, and contenteditable - // composers only commit through their own keydown listener. - const enterCancelled = !dispatchKey('keydown', 'Enter', 13); - dispatchKey('keypress', 'Enter', 13); - dispatchKey('keyup', 'Enter', 13); - if (submissionObserved) { - submissionOutcomeUnknown = submissionCancelled; - } else if (!enterCancelled && _setFieldUsesNativeSubmit(isCombobox, form)) { + if (usesNativeSubmit) { + // Ordinary form controls use one native path. Dispatching a + // synthetic Enter first could make page code act and then + // make this fallback repeat the consequential action. // requestSubmit performs interactive constraint validation and // silently aborts on an invalid form; surface that instead of // reporting a successful submission. @@ -4935,20 +4923,30 @@ } try { form.requestSubmit(); - } catch { - submissionOutcomeUnknown = true; - } - if (!submissionObserved) submissionOutcomeUnknown = true; + } catch {} } else { - // preventDefault, combobox handling, contenteditable custom - // handlers, and form-less widgets do not prove submission. - submissionOutcomeUnknown = true; + // Comboboxes, contenteditables, and form-less widgets are + // committed by page-owned keyboard handlers. Never follow + // this path with requestSubmit: cancellation is not proof of + // submission, and an unobserved handler may already have acted. + if (isCombobox) { + await new Promise(r => setTimeout(r, 80)); + dispatchKey('keydown', 'ArrowDown', 40); + dispatchKey('keyup', 'ArrowDown', 40); + await new Promise(r => setTimeout(r, 30)); + } + dispatchKey('keydown', 'Enter', 13); + dispatchKey('keypress', 'Enter', 13); + dispatchKey('keyup', 'Enter', 13); } nativeSubmitAttempted = submissionObserved && !submissionCancelled; + submissionOutcomeUnknown = !nativeSubmitAttempted; } finally { removeSubmitObserver(); } - } catch {} + } catch { + submissionOutcomeUnknown = true; + } } if (!verified) { return failure( diff --git a/test/run.js b/test/run.js index ceff9fc62..128e3f14d 100644 --- a/test/run.js +++ b/test/run.js @@ -55035,7 +55035,7 @@ test('set_field waits for reconciliation and verifies the complete value', () => } }); -test('set_field submit dispatches Enter once and submits natively only when unhandled', () => { +test('set_field submit chooses exactly one native or page-owned commit path', async () => { for (const [label, rel] of [ ['chrome', 'src/chrome/src/content/content.js'], ['firefox', 'src/firefox/src/content/content.js'], @@ -55048,24 +55048,35 @@ test('set_field submit dispatches Enter once and submits natively only when unha assert.ok(helperStart >= 0 && helperEnd > helperStart, `${label}: submit helper should remain independently testable`); const usesNativeSubmit = vm.runInNewContext(`(${source.slice(helperStart, helperEnd)})`); const formWithSubmit = { requestSubmit() {} }; - assert.equal(usesNativeSubmit(true, formWithSubmit), false, `${label}: combobox never uses native submit`); - assert.equal(usesNativeSubmit(false, formWithSubmit), true, `${label}: plain field in a form uses native submit`); - assert.equal(usesNativeSubmit(false, null), false, `${label}: form-less field does not use native submit`); - assert.equal(usesNativeSubmit(false, {}), false, `${label}: form without requestSubmit does not use native submit`); + assert.equal(usesNativeSubmit(true, false, formWithSubmit), false, `${label}: combobox never uses native submit`); + assert.equal(usesNativeSubmit(false, true, formWithSubmit), false, `${label}: contenteditable never uses native submit`); + assert.equal(usesNativeSubmit(false, false, formWithSubmit), true, `${label}: plain field in a form uses native submit`); + assert.equal(usesNativeSubmit(false, false, null), false, `${label}: form-less field does not use native submit`); + assert.equal(usesNativeSubmit(false, false, {}), false, `${label}: form without requestSubmit does not use native submit`); // Behavioral slice of the submit block, run against stubs. const blockStart = source.indexOf("const form = el.form || (el.closest && el.closest('form'));"); - const blockEnd = source.indexOf('} catch {}', blockStart); + const blockEnd = source.indexOf('\n } catch {\n submissionOutcomeUnknown = true;', blockStart); assert.ok(blockStart >= 0 && blockEnd > blockStart, `${label}: submit block not found`); const block = source.slice(blockStart, blockEnd); - const runner = vm.runInNewContext(`(stubs) => { + const runner = vm.runInNewContext(`async (stubs) => { const { dispatchKey, el, msg, failure, isCombobox, _setFieldUsesNativeSubmit, _consumeMessageRecipientDispatchBinding, ref_id, rect } = stubs; let nativeSubmitAttempted = false; + let submissionOutcomeUnknown = true; ${block} - return nativeSubmitAttempted; - }`); - - const exercise = ({ keydownCancelled, checkValidity = () => true, isCombobox = false, pageSubmitsOnKeydown = false, submitCancelled = false }) => { + return { nativeSubmitAttempted, submissionOutcomeUnknown }; + }`, { setTimeout: callback => callback() }); + + const exercise = async ({ + keydownCancelled = false, + checkValidity = () => true, + isCombobox = false, + isContentEditable = false, + hasForm = true, + pageSubmitsOnKeydown = false, + pageUsesDirectSubmitOnKeydown = false, + submitCancelled = false, + } = {}) => { const calls = []; const submitListeners = []; const emitSubmit = () => { @@ -55083,15 +55094,21 @@ test('set_field submit dispatches Enter once and submits natively only when unha } }, }; - const el = { dispatchEvent: (ev) => (keydownCancelled && ev.type === 'keydown' ? false : true), form, closest: () => null }; - const dispatchKey = (type) => { - calls.push(type); - if (type === 'keydown' && pageSubmitsOnKeydown) emitSubmit(); - return el.dispatchEvent({ type }); + const el = { + dispatchEvent: event => !(keydownCancelled && event.type === 'keydown' && event.key === 'Enter'), + form: hasForm ? form : null, + closest: () => null, + isContentEditable, + }; + const dispatchKey = (type, key) => { + calls.push(`${type}:${key}`); + if (type === 'keydown' && key === 'Enter' && pageSubmitsOnKeydown) emitSubmit(); + if (type === 'keydown' && key === 'Enter' && pageUsesDirectSubmitOnKeydown) calls.push('form.submit'); + return el.dispatchEvent({ type, key }); }; let failureResult = null; const failure = (msg, data) => { failureResult = data; return { success: false, ...data }; }; - const submitted = runner({ + const result = await runner({ dispatchKey, el, msg: { params: {} }, failure, @@ -55101,41 +55118,44 @@ test('set_field submit dispatches Enter once and submits natively only when unha ref_id: 'ref_1', rect: { x: 0, y: 0, w: 1, h: 1 }, }); - return { calls, submitted, failureResult }; + return { calls, result, failureResult }; }; - // Plain field + valid form + unhandled keydown: Enter trio + one submit. - const plain = exercise({}); - assert.deepEqual(plain.calls, ['keydown', 'keypress', 'keyup', 'requestSubmit'], `${label}: plain field must dispatch Enter then submit natively once`); - assert.equal(plain.submitted, true, `${label}: observed native submit must be reported`); + const plain = await exercise(); + assert.deepEqual(plain.calls, ['requestSubmit'], `${label}: plain form field must use only native submission`); + assert.deepEqual({ ...plain.result }, { nativeSubmitAttempted: true, submissionOutcomeUnknown: false }); assert.equal(plain.failureResult, null, `${label}: observed native submit must not fail`); - // A page listener may submit without cancelling Enter; the fallback must - // see the submit event and avoid a second requestSubmit call. - const pageSubmitted = exercise({ pageSubmitsOnKeydown: true }); - assert.deepEqual(pageSubmitted.calls, ['keydown', 'keypress', 'keyup'], `${label}: page-handled submit must not be submitted twice`); - assert.equal(pageSubmitted.submitted, true, `${label}: page-handled submit should be reported from the observed event`); - - // Page already handled Enter (keydown cancelled): no second submit. - const handled = exercise({ keydownCancelled: true }); - assert.deepEqual(handled.calls, ['keydown', 'keypress', 'keyup'], `${label}: handled Enter must not double-submit`); - assert.equal(handled.submitted, false, `${label}: handled Enter reports no native submit`); - assert.equal(handled.failureResult, null, `${label}: handled Enter should preserve a non-failing unknown result`); - - // Combobox: never native-submits (the Enter trio reaches page JS). - const combobox = exercise({ isCombobox: true }); - assert.deepEqual(combobox.calls, ['keydown', 'keypress', 'keyup'], `${label}: combobox must commit via synthetic keys only`); - assert.equal(combobox.submitted, false, `${label}: combobox reports no native submit`); + const combobox = await exercise({ isCombobox: true, pageSubmitsOnKeydown: true }); + assert.deepEqual(combobox.calls, [ + 'keydown:ArrowDown', + 'keyup:ArrowDown', + 'keydown:Enter', + 'keypress:Enter', + 'keyup:Enter', + ], `${label}: combobox must use only its page-owned keyboard path`); + assert.deepEqual({ ...combobox.result }, { nativeSubmitAttempted: true, submissionOutcomeUnknown: false }); + + // form.submit() deliberately emits no submit event. The result remains + // unknown, but the native fallback must still not repeat the action. + const directSubmit = await exercise({ isCombobox: true, pageUsesDirectSubmitOnKeydown: true }); + assert.equal(directSubmit.calls.includes('form.submit'), true, `${label}: direct page submit was not exercised`); + assert.equal(directSubmit.calls.includes('requestSubmit'), false, `${label}: unobserved page action must not trigger a second submit`); + assert.deepEqual({ ...directSubmit.result }, { nativeSubmitAttempted: false, submissionOutcomeUnknown: true }); + + const contenteditable = await exercise({ isContentEditable: true, keydownCancelled: true }); + assert.deepEqual(contenteditable.calls, ['keydown:Enter', 'keypress:Enter', 'keyup:Enter'], `${label}: contenteditable must stay on the keyboard path`); + assert.deepEqual({ ...contenteditable.result }, { nativeSubmitAttempted: false, submissionOutcomeUnknown: true }); // A page can cancel a submit event after observing it; that is not proof // that the consequential action reached the server. - const cancelledSubmit = exercise({ submitCancelled: true }); - assert.deepEqual(cancelledSubmit.calls, ['keydown', 'keypress', 'keyup', 'requestSubmit'], `${label}: cancelled submit still has one native attempt`); - assert.equal(cancelledSubmit.submitted, false, `${label}: cancelled submit must not be reported as submitted`); + const cancelledSubmit = await exercise({ submitCancelled: true }); + assert.deepEqual(cancelledSubmit.calls, ['requestSubmit'], `${label}: cancelled native submit must not dispatch Enter`); + assert.deepEqual({ ...cancelledSubmit.result }, { nativeSubmitAttempted: false, submissionOutcomeUnknown: true }); // Invalid form: surface the silent requestSubmit abort. - const invalid = exercise({ checkValidity: () => false }); - assert.deepEqual(invalid.calls, ['keydown', 'keypress', 'keyup'], `${label}: invalid form must not call requestSubmit`); + const invalid = await exercise({ checkValidity: () => false }); + assert.deepEqual(invalid.calls, [], `${label}: invalid form must dispatch neither Enter nor requestSubmit`); assert.equal(invalid.failureResult?.submitted, false, `${label}: invalid form must fail with submitted:false`); assert.equal(invalid.failureResult?.invalid, true, `${label}: invalid form must flag invalid:true`); } From ed850499ec4392156aa9b2aed02262d2d84be7a9 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Mon, 17 Aug 2026 11:36:40 +0800 Subject: [PATCH 4/5] fix(content): observe final submit cancellation state --- src/chrome/src/content/content.js | 9 ++++++--- src/firefox/src/content/content.js | 7 +++++-- test/run.js | 10 ++++++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 378c04bcc..ab66f1d62 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -3815,7 +3815,7 @@ } // Pick the single commit path for set_field({submit:true}). Synthetic - // Synthetic (isTrusted:false) Enter events never trigger native submission — + // (isTrusted:false) Enter events never trigger native submission — // they only reach the page's own keydown listeners. A non-combobox field // inside a form that has requestSubmit therefore needs a native submit as // its only reliable commit path; comboboxes are committed by page JS @@ -5766,12 +5766,13 @@ const form = el.form || (el.closest && el.closest('form')); let submissionObserved = false; let submissionCancelled = false; + let submitEvent = null; let submissionOutcomeUnknown = false; let removeSubmitObserver = () => {}; if (form && typeof form.addEventListener === 'function') { const onSubmit = event => { submissionObserved = true; - submissionCancelled = event.defaultPrevented === true; + submitEvent = event; }; form.addEventListener('submit', onSubmit, true); removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); @@ -5796,12 +5797,13 @@ dispatchKey('keypress', 'Enter', 13); dispatchKey('keyup', 'Enter', 13); if (submissionObserved) { + submissionCancelled = submitEvent?.defaultPrevented === true; submissionOutcomeUnknown = submissionCancelled; } else if (!enterCancelled && _setFieldUsesNativeSubmit(isCombobox, form)) { // requestSubmit performs interactive constraint validation and // silently aborts on an invalid form; surface that instead of // reporting a successful submission. - if (typeof form.checkValidity === 'function' && !form.checkValidity()) { + if (form.noValidate !== true && typeof form.checkValidity === 'function' && !form.checkValidity()) { return failure( 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', { verified: true, submitted: false, invalid: true, ref_id, rect }, @@ -5812,6 +5814,7 @@ } catch { submissionOutcomeUnknown = true; } + submissionCancelled = submitEvent?.defaultPrevented === true; if (!submissionObserved) submissionOutcomeUnknown = true; } else { // preventDefault, combobox handling, contenteditable custom diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index a238df242..bdbe96792 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -4892,12 +4892,13 @@ const form = el.form || (el.closest && el.closest('form')); let submissionObserved = false; let submissionCancelled = false; + let submitEvent = null; let submissionOutcomeUnknown = false; let removeSubmitObserver = () => {}; if (form && typeof form.addEventListener === 'function') { const onSubmit = event => { submissionObserved = true; - submissionCancelled = event.defaultPrevented === true; + submitEvent = event; }; form.addEventListener('submit', onSubmit, true); removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); @@ -4922,12 +4923,13 @@ dispatchKey('keypress', 'Enter', 13); dispatchKey('keyup', 'Enter', 13); if (submissionObserved) { + submissionCancelled = submitEvent?.defaultPrevented === true; submissionOutcomeUnknown = submissionCancelled; } else if (!enterCancelled && _setFieldUsesNativeSubmit(isCombobox, form)) { // requestSubmit performs interactive constraint validation and // silently aborts on an invalid form; surface that instead of // reporting a successful submission. - if (typeof form.checkValidity === 'function' && !form.checkValidity()) { + if (form.noValidate !== true && typeof form.checkValidity === 'function' && !form.checkValidity()) { return failure( 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', { verified: true, submitted: false, invalid: true, ref_id, rect }, @@ -4938,6 +4940,7 @@ } catch { submissionOutcomeUnknown = true; } + submissionCancelled = submitEvent?.defaultPrevented === true; if (!submissionObserved) submissionOutcomeUnknown = true; } else { // preventDefault, combobox handling, contenteditable custom diff --git a/test/run.js b/test/run.js index ceff9fc62..7833cb9ba 100644 --- a/test/run.js +++ b/test/run.js @@ -55065,16 +55065,18 @@ test('set_field submit dispatches Enter once and submits natively only when unha return nativeSubmitAttempted; }`); - const exercise = ({ keydownCancelled, checkValidity = () => true, isCombobox = false, pageSubmitsOnKeydown = false, submitCancelled = false }) => { + const exercise = ({ keydownCancelled, checkValidity = () => true, isCombobox = false, pageSubmitsOnKeydown = false, submitCancelled = false, noValidate = false }) => { const calls = []; const submitListeners = []; const emitSubmit = () => { - const event = { defaultPrevented: submitCancelled }; + const event = { defaultPrevented: false }; for (const listener of submitListeners) listener(event); + if (submitCancelled) event.defaultPrevented = true; }; const form = { requestSubmit: () => { calls.push('requestSubmit'); emitSubmit(); }, checkValidity, + noValidate, addEventListener: (type, listener) => { if (type === 'submit') submitListeners.push(listener); }, removeEventListener: (type, listener) => { if (type === 'submit') { @@ -55133,6 +55135,10 @@ test('set_field submit dispatches Enter once and submits natively only when unha assert.deepEqual(cancelledSubmit.calls, ['keydown', 'keypress', 'keyup', 'requestSubmit'], `${label}: cancelled submit still has one native attempt`); assert.equal(cancelledSubmit.submitted, false, `${label}: cancelled submit must not be reported as submitted`); + const noValidate = exercise({ noValidate: true, checkValidity: () => false }); + assert.deepEqual(noValidate.calls, ['keydown', 'keypress', 'keyup', 'requestSubmit'], `${label}: novalidate forms must still attempt native submit`); + assert.equal(noValidate.submitted, true, `${label}: novalidate submit should rely on the observed submit event`); + // Invalid form: surface the silent requestSubmit abort. const invalid = exercise({ checkValidity: () => false }); assert.deepEqual(invalid.calls, ['keydown', 'keypress', 'keyup'], `${label}: invalid form must not call requestSubmit`); From 3bb0fe139baf0ddcfa707bd50075f9c782348669 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Mon, 17 Aug 2026 11:41:23 +0800 Subject: [PATCH 5/5] fix(content): observe final submit cancellation state --- src/chrome/src/content/content.js | 6 ++++-- src/firefox/src/content/content.js | 6 ++++-- test/run.js | 9 ++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 919727ce5..8a2871705 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -5771,10 +5771,11 @@ } } let removeSubmitObserver = () => {}; + let submitEvent = null; if (form && typeof form.addEventListener === 'function') { const onSubmit = event => { submissionObserved = true; - submissionCancelled = event.defaultPrevented === true; + submitEvent = event; }; form.addEventListener('submit', onSubmit, true); removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); @@ -5787,7 +5788,7 @@ // requestSubmit performs interactive constraint validation and // silently aborts on an invalid form; surface that instead of // reporting a successful submission. - if (typeof form.checkValidity === 'function' && !form.checkValidity()) { + if (form.noValidate !== true && typeof form.checkValidity === 'function' && !form.checkValidity()) { return failure( 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', { verified: true, submitted: false, invalid: true, ref_id, rect }, @@ -5811,6 +5812,7 @@ dispatchKey('keypress', 'Enter', 13); dispatchKey('keyup', 'Enter', 13); } + submissionCancelled = submitEvent?.defaultPrevented === true; nativeSubmitAttempted = submissionObserved && !submissionCancelled; submissionOutcomeUnknown = !nativeSubmitAttempted; } finally { diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index d10236be9..385c55a2c 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -4899,10 +4899,11 @@ } } let removeSubmitObserver = () => {}; + let submitEvent = null; if (form && typeof form.addEventListener === 'function') { const onSubmit = event => { submissionObserved = true; - submissionCancelled = event.defaultPrevented === true; + submitEvent = event; }; form.addEventListener('submit', onSubmit, true); removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); @@ -4915,7 +4916,7 @@ // requestSubmit performs interactive constraint validation and // silently aborts on an invalid form; surface that instead of // reporting a successful submission. - if (typeof form.checkValidity === 'function' && !form.checkValidity()) { + if (form.noValidate !== true && typeof form.checkValidity === 'function' && !form.checkValidity()) { return failure( 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', { verified: true, submitted: false, invalid: true, ref_id, rect }, @@ -4939,6 +4940,7 @@ dispatchKey('keypress', 'Enter', 13); dispatchKey('keyup', 'Enter', 13); } + submissionCancelled = submitEvent?.defaultPrevented === true; nativeSubmitAttempted = submissionObserved && !submissionCancelled; submissionOutcomeUnknown = !nativeSubmitAttempted; } finally { diff --git a/test/run.js b/test/run.js index 128e3f14d..dadc12c9e 100644 --- a/test/run.js +++ b/test/run.js @@ -55076,16 +55076,19 @@ test('set_field submit chooses exactly one native or page-owned commit path', as pageSubmitsOnKeydown = false, pageUsesDirectSubmitOnKeydown = false, submitCancelled = false, + noValidate = false, } = {}) => { const calls = []; const submitListeners = []; const emitSubmit = () => { - const event = { defaultPrevented: submitCancelled }; + const event = { defaultPrevented: false }; for (const listener of submitListeners) listener(event); + if (submitCancelled) event.defaultPrevented = true; }; const form = { requestSubmit: () => { calls.push('requestSubmit'); emitSubmit(); }, checkValidity, + noValidate, addEventListener: (type, listener) => { if (type === 'submit') submitListeners.push(listener); }, removeEventListener: (type, listener) => { if (type === 'submit') { @@ -55153,6 +55156,10 @@ test('set_field submit chooses exactly one native or page-owned commit path', as assert.deepEqual(cancelledSubmit.calls, ['requestSubmit'], `${label}: cancelled native submit must not dispatch Enter`); assert.deepEqual({ ...cancelledSubmit.result }, { nativeSubmitAttempted: false, submissionOutcomeUnknown: true }); + const noValidate = await exercise({ noValidate: true, checkValidity: () => false }); + assert.deepEqual(noValidate.calls, ['requestSubmit'], `${label}: novalidate forms must still use native submission`); + assert.deepEqual({ ...noValidate.result }, { nativeSubmitAttempted: true, submissionOutcomeUnknown: false }); + // Invalid form: surface the silent requestSubmit abort. const invalid = await exercise({ checkValidity: () => false }); assert.deepEqual(invalid.calls, [], `${label}: invalid form must dispatch neither Enter nor requestSubmit`);