Skip to content

fix(content): submit set_field forms exactly once via requestSubmit - #2814

Merged
esokullu merged 6 commits into
webbrain-one:mainfrom
alectimison-maker:fix/set-field-single-submit
Aug 17, 2026
Merged

fix(content): submit set_field forms exactly once via requestSubmit#2814
esokullu merged 6 commits into
webbrain-one:mainfrom
alectimison-maker:fix/set-field-single-submit

Conversation

@alectimison-maker

Copy link
Copy Markdown
Contributor

Summary

  • set_field({ submit: true }) on a regular (non-combobox) field now submits through one trusted path: form.requestSubmit().
  • The synthetic (untrusted) Enter dispatch is dropped for form-backed fields; it remains only as a fallback for form-less pages and for comboboxes (unchanged picker commit flow).

Motivation

The old path dispatched synthetic KeyboardEvents (never isTrusted, so never producing native default actions) and then called form.requestSubmit():

  • Double submit: on any page whose JS keydown listener submits on Enter (search boxes, composer/comment forms — extremely common), the page handler fires from the synthetic event and requestSubmit() fires a second native submit. On POST forms (login, checkout, send) this duplicates posts/orders/messages.
  • The file itself documents the trust distinction for press_keys (content.js: "native browser default actions are only guaranteed for trusted events"), but this path still leaned on an untrusted Enter for submission semantics.

Design

Introduced _setFieldSubmitMode(isCombobox, form) returning the single commit path:

  • comboboxsynthetic-enter (page JS commits the picker; submitting the enclosing form while a popup is open is usually wrong — preserved rationale)
  • form present with requestSubmitrequestSubmit only (trusted native submission fires the form's JS handlers and the browser's own submit exactly once)
  • form-less → synthetic-enter (the only mechanism that reaches JS-listener-only submit handlers)

Testing

  • node test/run.js — 1764 passed, 0 failed (1 new test, both builds)
  • npm run test:security — 60/60 passed
  • npm run test:toolbar-guard — 33 passed

New test covers the decision matrix (combobox / form / form-less) plus source-structure guards: the native branch contains no Enter dispatch, the fallback branch contains no requestSubmit(), and ArrowDown stays gated on isCombobox.

Compatibility and risks

  • Combobox behavior unchanged. Form-backed pages now submit exactly once instead of (possibly) twice.
  • Not verified: a live-browser double-submit scenario — unit tests cover the extracted decision and the dispatched paths, not a real page (no E2E for content-script submit behavior in this repo).

Scope

  • Combobox "no submit at all when the page relies on native submission" was assessed as a deliberate design tradeoff (popup-open submission is usually wrong) and left unchanged.

@vercel

vercel Bot commented Aug 16, 2026

Copy link
Copy Markdown

@alectimison-maker is attempting to deploy a commit to the esokullu's projects Team on Vercel.

A member of the Team first needs to authorize it.

@esokullu

Copy link
Copy Markdown
Collaborator

Reviewed this alongside #2808, #2809, #2813 and #2815.

The double-submit this fixes is real, but the fix removes the synthetic Enter entirely for non-combobox fields inside a form, and that Enter was load-bearing for a whole class of pages.

Before: the Enter trio always fired, and requestSubmit() ran as an extra for non-comboboxes. After (src/chrome/src/content/content.js:5776-5788): a non-combobox field in a form gets requestSubmit() and nothing else. Pages that commit in the field's own keydown listener never see the Enter.

Where that breaks:

Form with no submit handler. An SPA search or chat widget wraps its input in a bare <form> (no action, no onSubmit) and commits in input.onkeydown with preventDefault(). The keydown no longer fires, requestSubmit() finds nothing to call, and the browser falls through to a default GET navigation to the current URL with the field serialized into the query string. The SPA state is gone and the intended action never ran.

Inputs that transform the value on Enter. Tag-chips and invite-by-email fields turn the typed text into a chip on Enter, then submit. Now the form posts the raw uncommitted value, or posts with zero recipients.

Firefox contenteditable. Firefox handles contenteditable inline in set_field (src/firefox/src/content/content.js:4806-4820) rather than bailing to CDP like Chrome. A contenteditable composer nested in a <form> now takes the requestSubmit branch, but a contenteditable isn't a form control, so the page's Enter listener was the only send path. The adapters at src/chrome/src/agent/adapters.js:17158 and :17170 explicitly tell the model "set_field works; Enter sends by default" for exactly these composers.

Related, and independent of which branch runs: requestSubmit() performs interactive constraint validation and silently aborts when any control in the form is invalid. The handler still returns { success: true, verified: true } at :5809 with no submitted field, and nothing in agent.js reads one, so a signup form with an empty required field shows the native validation bubble, submits nothing, and the model carries on believing the account was created. The one place that ever sets submitted: true is the Chrome trusted-CDP path at agent.js:24172. Returning the result of the submit attempt would let the agent tell the two apart.

A narrower fix for the original bug: keep the Enter trio, and skip requestSubmit() when the keydown was cancelled (dispatchEvent returns false). That covers "the page handled Enter itself" without removing the path for pages that need it.

Two notes on the tests:

  • test/run.js:55040-55058 re-reads content.js and re-derives branchStart/branchEnd with lines identical to :55019-55022 sixteen lines above, where source and branch are already in scope. Two extra 283 KB reads and a second sentinel pair to keep in sync.
  • The assertions pin on source text, including const helperTail = "return 'synthetic-enter';\n }" at :55043, so the duplicated return 'synthetic-enter'; tail in _setFieldSubmitMode is now load-bearing for the test. test/run.js:7695-7710 shows the better idiom already in the repo: slice the handler and run it against stubs, counting submissions. That would catch a double submit introduced by any line in the handler; matching source text won't.

Minor: _setFieldSubmitMode returns one of two string constants for what is a boolean decision, which makes === 'requestSubmit' at :5776 stringly typed across three production sites and five assertions. _setFieldUsesNativeSubmit(isCombobox, form) returning !isCombobox && typeof form?.requestSubmit === 'function' says the same thing.

…age 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.
@alectimison-maker
alectimison-maker force-pushed the fix/set-field-single-submit branch from 1b542e9 to 63c3a28 Compare August 16, 2026 13:15
@alectimison-maker

Copy link
Copy Markdown
Contributor Author

Reworked per review. The Enter trio was load-bearing, so the fix now keeps it:

  • Enter trio always dispatches (bare forms, tag-chip/email transforms, and Firefox contenteditable composers commit only through their own keydown listener).
  • requestSubmit is skipped when the page cancelled the keydown (dispatchEvent returns false), which covers "the page handled Enter itself" without removing the native path for pages that need it. No double-send.
  • Invalid forms are surfaced instead of silently aborting: requestSubmit now checks form.checkValidity() first and returns failure({ submitted: false, invalid: true }) with recovery guidance, so the agent no longer believes an account/signup was created when a required field was empty.
  • submitted: true is reported on the success result when a native submit actually ran.
  • Renamed the decision to a boolean _setFieldUsesNativeSubmit(isCombobox, form), and replaced the source-text-pinning tests with a behavioral slice that runs the submit block against stubs and counts the requestSubmit call (matches the handler-slice idiom at test/run.js:7695).

Note the original dispatchKey helper also discarded dispatchEvent's return value; it now returns it so the cancelled-keydown detection works.

@webbrain-one webbrain-one left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking correctness issue remains in the single-submit contract. Please use one deliberate submission path, or observe an actual submit event before deciding whether a fallback is safe.

Comment thread src/chrome/src/content/content.js Outdated
// 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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not treat key cancellation as proof of submission

dispatchEvent() returning false only says that some listener called preventDefault(); it does not prove that the listener submitted. Conversely, a listener may submit on Enter without cancelling, in which case this code calls requestSubmit() and submits again. Cancellation therefore cannot guarantee exactly one action on checkout, message, or other consequential forms. Choose one submission path, or observe an actual submission event before falling back. The Firefox mirror has the same issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in de929536. The Enter trio remains for page-owned transforms and Firefox contenteditable, but cancellation is no longer treated as proof. The handler now temporarily observes the form submit event: a page-side submit prevents the native fallback, a native requestSubmit() is used only when no submit was observed, and submitted: true is emitted only when an uncancelled submit event was observed. Cancelled/unobserved paths return outcomeUnknown: true; invalid forms still return the explicit invalid failure. Chrome and Firefox are mirrored. Regression coverage now includes page-submit-without-cancel (no double submit), cancelled keydown, cancelled submit, native submit, combobox, and invalid form. node test/run.js: 1772 passed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated through 3bb0fe13, on top of the maintainer-directed one-path fix 7976786f. Ordinary form controls now use only requestSubmit(); comboboxes, contenteditable, and form-less widgets use only the page-owned Enter path, so an unobservable form.submit() cannot trigger a second native fallback. The submit observer now stores the event and evaluates defaultPrevented after dispatch/requestSubmit, so bubble-phase cancellation is not reported as success. The validation gate respects form.noValidate, and tests cover native/page-owned paths, direct form.submit(), cancelled submit, contenteditable, invalid, and novalidate forms. node test/run.js: 1772 passed.

@webbrain-one

Copy link
Copy Markdown
Owner

Current-head compatibility note after the exact-once rework:

The current implementation deliberately chooses one path: plain form-backed inputs use requestSubmit(), while combobox, contenteditable, and formless inputs retain synthetic Enter. That satisfies the conservative exactly-once requirement and avoids the earlier Enter-plus-fallback double-submit path.

The remaining tradeoff is compatibility. An otherwise ordinary form input whose page behavior lives only in its own Enter handler will no longer receive that key event. Custom SPA search/chat fields and tag/email-chip inputs wrapped in forms may therefore skip their page-owned transform or commit path. Restoring Enter followed by a conditional fallback would reintroduce the consequential double-submit ambiguity, so this is a rollout/canary caveat rather than a request for that design.

Before broad rollout, browser-level coverage for a plain native form, an input-level Enter-only form, a tag/chip transform, and a contenteditable composer would make the boundary explicit.

@alectimison-maker

Copy link
Copy Markdown
Contributor Author

Reviewed the current-head compatibility note. The one-path design remains intentional: plain form controls use native requestSubmit(), while combobox/contenteditable/form-less controls use page-owned Enter, with unknown outcomes surfaced rather than falling back and risking a duplicate consequential action. The branch now also handles bubble-phase submit cancellation and novalidate forms. node test/run.js passes (1772). Browser-level canary coverage for native forms, Enter-only SPA fields, chip transforms, and contenteditable remains a rollout follow-up; no additional production change is required for the reviewed contract.

@esokullu
esokullu merged commit 21a3643 into webbrain-one:main Aug 17, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants