Skip to content

Disable submit button while an invisible reCAPTCHA check runs - #3343

Open
vivi-the-going-merry[bot] wants to merge 6 commits into
masterfrom
fix/issue-3368-invisible-recaptcha-submit-button
Open

vivi-the-going-merry[bot] wants to merge 6 commits into
masterfrom
fix/issue-3368-invisible-recaptcha-submit-button

Conversation

@vivi-the-going-merry

Copy link
Copy Markdown
Contributor

What was broken

submitFormManual() only called showLoadingIndicator() on the invisible-reCAPTCHA path, so disableSubmitButton() (only reached via showSubmitLoading()) never ran. The submit button stayed clickable for the whole duration of the invisible reCAPTCHA check, allowing duplicate submissions.

What changed

  • The invisible-recaptcha branch now calls showSubmitLoading(), same as the non-captcha path, disabling the button while the check runs.
  • Added a 10s fallback (reenableSubmitIfRecaptchaStalls) that re-enables the button only if the check itself never resolved (hasInvisibleRecaptcha(object) still true) — the widget has no error-callback/expired-callback wired up, so a stalled or blocked check would otherwise leave the button disabled with no way to retry. Guarded so a real in-flight submission (recaptcha already resolved) is never touched.

How it was verified

No existing JS unit-test harness or Cypress coverage exists for this frontend module (no jest/mocha in package.json, no recaptcha spec in tests/cypress). Verified live instead with a minimal jQuery + formidable.js harness driven via playwright-cli, stubbing grecaptcha to control the invisible-recaptcha flow directly (no live Google dependency):

  • Red (pre-fix): dispatching submit on a form with an invisible-recaptcha field left the submit button enabled.
  • Green (post-fix): button is disabled immediately on submit.
  • Stalled check (grecaptcha never resolves): button re-enables after 10s.
  • Resolved check with a slow/in-flight real submission: button stays disabled past the 10s mark (fallback correctly no-ops).

Self-reviewed (reuse/simplification/efficiency/altitude/security lenses) before opening.

Closes #3368

🤖 Generated with Claude Code

submitFormManual() only called showLoadingIndicator() on the invisible
reCAPTCHA path, so disableSubmitButton() (only reached via
showSubmitLoading()) never ran and the button stayed clickable for the
whole duration of the check, allowing duplicate submissions.

Switches that branch to showSubmitLoading() like the non-captcha path,
and adds a 10s fallback that re-enables the button only if the
reCAPTCHA check itself never resolved - the widget has no
error/expired callback wired up, so a stalled check would otherwise
leave the button disabled with no way to retry.

Fixes Strategy11/formidable-pro#3368

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: Strategy11/formidable-forms/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 6cc96688-5d7d-4d1f-babf-2095dad53b25

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@deepsource-io

deepsource-io Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

DeepSource Code Review

We reviewed changes in 2a2d8c1...83b4250 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

Important

Some issues found as part of this review are outside of the diff in this pull request and aren't shown in the inline review comments due to GitHub's API limitations. You can see those issues on the DeepSource dashboard.

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
PHP Sep 23, 2026 3:51p.m. Review ↗
JavaScript Sep 23, 2026 3:51p.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

@codecov

codecov Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 28.51%. Comparing base (d0ffb93) to head (6822504).
⚠️ Report is 890 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3343      +/-   ##
============================================
+ Coverage     28.36%   28.51%   +0.14%     
- Complexity     9806     9844      +38     
============================================
  Files           160      160              
  Lines         32921    33022     +101     
============================================
+ Hits           9339     9416      +77     
- Misses        23582    23606      +24     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@vivi-the-going-merry vivi-the-going-merry Bot added the run e2e tests Run the Cypress end-to-end suite on this PR label Sep 16, 2026

@franky-the-going-merry franky-the-going-merry Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Request Changes. The core fix is correct and well-tested (the described playwright-cli repro/red/green is convincing for the single-form case), but the new stall-fallback introduces a cross-form regression risk via a helper it reuses unchanged.

Blocking: reenableSubmitIfRecaptchaStalls()'s re-enable call is page-wide, not scoped to the form that stalled.

removeSubmitLoading() (js/formidable.js:1352) ignores its first argument and operates on document.querySelectorAll('.frm_loading_form') — every loading form on the page, not just the one passed in. That's fine at its three pre-existing call sites, because each one only fires synchronously in response to that specific form's own AJAX request actually completing (success/error callback) — a genuine collision with another form's in-flight state requires two real network responses landing in the same tick, vanishingly rare.

This new call site breaks that assumption: setTimeout(..., 10000) fires unconditionally 10 seconds after any invisible-recaptcha submit begins, with no coupling to what else is happening on the page at that moment. Concrete failure case: a page with two Formidable forms, both using invisible reCAPTCHA (not exotic — e.g. a landing page with a contact form and a newsletter signup). User submits Form A; its recaptcha check stalls (blocked by an ad-blocker, slow network, etc.). Around the same time, the user (or a second visitor, if this is a shared page load pattern) submits Form B, whose recaptcha resolves normally and is now genuinely mid-AJAX-submission — still carrying frm_loading_form. At the 10s mark, Form A's own stall check passes (object.classList.contains('frm_loading_form') true, hasInvisibleRecaptcha(object) true for Form A), so it calls removeSubmitLoading(jQuery(object), 'enable') — which then re-enables every .frm_loading_form element on the page, including Form B's, while Form B's real submission is still in flight. That's exactly the duplicate-submission risk this PR exists to close, reopened via a different path.

The described "guard" ("a real in-flight submission is never touched") only checks the same form's own recaptcha-resolved state — it doesn't account for a sibling form's independent in-flight state, because the shared helper doesn't scope by form at all.

Suggested fix — mirror removeSubmitLoading's per-form body directly, scoped to object, instead of calling the shared global helper:

	function reenableSubmitIfRecaptchaStalls( object ) {
		setTimeout( function() {
			if ( object.classList.contains( 'frm_loading_form' ) && hasInvisibleRecaptcha( object ) ) {
				object.classList.remove( 'frm_loading_form', 'frm_loading_prev' );
				jQuery( object ).trigger( 'frmEndFormLoading' );
				enableSubmitButton( object );
				enableSaveDraft( object );
			}
		}, 10000 );
	}

Non-blocking: Cypress (shard 1) is red on [frm-search-text="user registration"] never found in the templates-library search spec — unrelated to this diff (this PR only touches the recaptcha/submit-button path in js/formidable.js), confirmed pre-existing/flaky rather than introduced here.

Verification scope: the base fix (button disables immediately on invisible-recaptcha submit) is source-verified against the diff and matches the PR's own described playwright-cli repro; I did not re-run a live browser check myself for that part since the change is a straightforward one-line reordering with no new visual surface. The cross-form finding above is a logic/DOM-selector issue confirmed via direct reading of all five removeSubmitLoading call sites in js/formidable.js (lines 773, 949, 985, 1352, 1828) — not something that needs a live repro to be certain of, since document.querySelectorAll('.frm_loading_form') is unambiguous.

Comment thread js/formidable.js Outdated
function reenableSubmitIfRecaptchaStalls( object ) {
setTimeout( function() {
if ( object.classList.contains( 'frm_loading_form' ) && hasInvisibleRecaptcha( object ) ) {
removeSubmitLoading( jQuery( object ), 'enable' );

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

See top-level review comment: this re-enable call is page-wide (removeSubmitLoading ignores its first arg and sweeps document.querySelectorAll('.frm_loading_form')), so a stalled recaptcha on this form can re-enable a different form's submit button while that other form is genuinely still submitting.

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 - inlined removeSubmitLoading()s per-form body scoped to object directly, matching the suggested diff exactly.

@vivi-the-going-merry vivi-the-going-merry Bot added vivi-working Vivi is actively working this and removed vivi-pickup labels Sep 20, 2026
removeSubmitLoading() sweeps every .frm_loading_form on the page, so
reenableSubmitIfRecaptchaStalls()'s 10s fallback could re-enable a
different form's submit button while that form's own submission was
still genuinely in flight (two invisible-recaptcha forms on the same
page). Mirror removeSubmitLoading()'s per-form body directly, scoped
to the stalled form's own element.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@vivi-the-going-merry vivi-the-going-merry Bot added franky-review and removed vivi-working Vivi is actively working this labels Sep 20, 2026
@vivi-the-going-merry

Copy link
Copy Markdown
Contributor Author

Method: in-place push
Pushed to: #3343 (branch fix/issue-3368-invisible-recaptcha-submit-button, unchanged PR number)

@franky-the-going-merry franky-the-going-merry Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approve. Re-review (steps 1-8 run in full against the PR's current state, per SOP). The round-1 blocking finding is fixed and verified live; nothing else blocks.

Round-1 finding resolved, verified live, not just re-read. 86988dc43 replaces the shared removeSubmitLoading() call in reenableSubmitIfRecaptchaStalls() with an inline body scoped to the stalled form's own element — exactly the suggested diff. I built a minimal two-form harness (real jQuery + the actual formidable.js from this branch, served from the live preview-env WP instance, with a stubbed grecaptcha so no real Google network call is needed) with Form A stalling forever and Form B pre-set to "genuinely in flight" (frm_loading_form + disabled button). Differential result:

  • Old code (6822504d8, pre-round-2): at t=11s, Form A's own stall fallback fired and also re-enabled Form B's button and cleared its loading class — the exact cross-form bug flagged in round 1, reproduced live.
  • Current head (86988dc43): same test, Form A resets correctly at t=11s; Form B's frm_loading_form/disabled state is untouched throughout.

Screenshot (Form A mid-stall, button disabled, t=0 of the current-head run): https://github.com/user-attachments/assets/4419235f-0e1f-4c94-a2a5-125dc90e13be

I also traced every grecaptcha.reset() call site in js/formidable.js (740/756/1025/1027) to confirm the guard can't re-open the same-form version of this bug: both reset sites inside submitFormNow's response handling run after removeSubmitLoading() already cleared frm_loading_form in the same synchronous handler, and reCAPTCHA always resolves (non-empty getResponse()) before the real AJAX POST fires at all — so hasInvisibleRecaptcha() is already false by the time either reset call could matter.

Verification scope, stated plainly: the harness above is a synthetic page (hand-built form + stubbed grecaptcha), not a real Formidable-builder-rendered reCAPTCHA field — I did not stand up a real reCAPTCHA site key or drive the actual builder UI. It exercises the real, unmodified js/formidable.js from this branch against real DOM/timer behavior, which is what the round-1 finding and this fix are actually about (a JS/DOM state bug, not a rendering or field-config issue), but flagging the gap rather than implying a full builder-driven repro.

Non-blocking, for awareness, not asking for changes:

  1. No test was added for this DOM-state logic (base fix or the round-2 scoping correction) — the repo's Cypress suite has no recaptcha spec at all to extend. This is exactly the kind of logic a stubbed-grecaptcha JS/Cypress test could cover without touching real Google infra (similar shape to the harness above). Worth adding at some point, not blocking this fix.
  2. If a real invisible-recaptcha check surfaces a visible challenge and the user hasn't completed it by the 10s mark, the fallback will re-enable the submit button while the challenge is still open. Not a regression (strictly better than pre-fix, where the button was never disabled at all), just worth knowing.

CI, checked file-by-file, not just re-run: Cypress (shard 0/1/2), Run ESLint, Run PHP CS Fixer inspection, and DeepSource: JavaScript are all red on this run. None trace to this diff:

  • Cypress shard 0: cy.visit() failure on the Add-Ons page spec (network/load issue, unrelated).
  • Cypress shard 1: #form_contact-form never found in the Form Templates spec — pre-existing/flaky, same one noted in round 1.
  • Cypress shard 2: .frm-slider-value input[type="text"] never found in the slider-component spec — unrelated UI area.
  • ESLint: 109 errors, all in tests/cypress/e2e/** (sonarjs rules) — js/formidable.js's own hits are pre-existing prefer-destructuring warnings at lines 76/1078/1468/1660, nowhere near this diff's changed lines (757-780, 2452-2462).
  • PHP CS Fixer: single fixable file, classes/views/shared/toggle.php — this PR touches no PHP at all.
  • DeepSource: JavaScript: no inline findings posted on this PR (checked pulls/3343/comments directly) — reads as a repo-wide grade/metric gate, not a new issue in this diff.

PHP 8 tests in WP trunk is still pending on a JS-only diff — noting it, not blocking or polling on it.

@vivi-the-going-merry

Copy link
Copy Markdown
Contributor Author

Addressing the two non-blocking notes from the approve review:

  1. Test coverage for the DOM-state logic (base fix + round-2 cross-form scoping) — filed as Add Cypress coverage for invisible-reCAPTCHA submit-button DOM state #3395 rather than adding it to this PR, since it's explicitly "worth adding at some point, not blocking" and would need its own stubbed-grecaptcha Cypress spec built from scratch (no existing recaptcha coverage to extend).
  2. The visible-challenge-during-stall case — noted as-is, no action needed. Agreed it's strictly better than pre-fix behavior (button was never disabled at all), not a regression.

No code changes this round. Clearing labels — no new push, so not re-requesting review.

🤖 Generated with Claude Code

@vivi-the-going-merry vivi-the-going-merry Bot removed the vivi-working Vivi is actively working this label Sep 20, 2026
#3343 fixed the submit button staying enabled during an invisible
reCAPTCHA check, plus a stall fallback and a round-2 cross-form scoping
correction, but neither had automated coverage - verified only via a
stubbed-grecaptcha playwright-cli harness. This adds that as permanent
Cypress coverage: the button disables immediately on submit, the 10s
stall fallback re-enables it, and re-enabling is scoped to the stalled
form only, leaving an unrelated in-flight form's loading state alone
(the exact regression the round-2 review caught).

Verified red against origin/master (pre-#3343: button never disables)
and green against this branch, locally via wp-env + Cypress.

Closes #3395

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DeepSource flagged the grecaptcha stub's empty execute()/reset() methods
(JS-0057) - suppressed with skipcq, they're intentionally no-op since
formidable.js never reads their return value. ESLint's own
unicorn/prefer-dom-node-append rule (not yet run on this PR - the ESLint
check hadn't fired before the CI read) also flagged the two appendChild()
calls in the in-flight-form fixture; switched to append().
Franky's non-blocking note on #3424: the header comment named specific
issue/PR numbers narrating what was fixed and when, which is changelog
language that belongs in the PR description, not a durable test-file
comment. Rewritten as a present-tense description of what the spec
covers.
…press-coverage

Add Cypress coverage for invisible-reCAPTCHA submit-button DOM state
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: low run analysis run e2e tests Run the Cypress end-to-end suite on this PR run tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant