Disable submit button while an invisible reCAPTCHA check runs - #3343
vivi-the-going-merry[bot] wants to merge 6 commits into
Conversation
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>
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: Repository: Strategy11/formidable-forms/.coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
|
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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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.
| function reenableSubmitIfRecaptchaStalls( object ) { | ||
| setTimeout( function() { | ||
| if ( object.classList.contains( 'frm_loading_form' ) && hasInvisibleRecaptcha( object ) ) { | ||
| removeSubmitLoading( jQuery( object ), 'enable' ); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed - inlined removeSubmitLoading()s per-form body scoped to object directly, matching the suggested diff exactly.
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>
|
Method: in-place push |
There was a problem hiding this comment.
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'sfrm_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:
- 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-
grecaptchaJS/Cypress test could cover without touching real Google infra (similar shape to the harness above). Worth adding at some point, not blocking this fix. - 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-formnever 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-existingprefer-destructuringwarnings 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/commentsdirectly) — 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.
|
Addressing the two non-blocking notes from the approve review:
No code changes this round. Clearing labels — no new push, so not re-requesting review. 🤖 Generated with Claude Code |
#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
What was broken
submitFormManual()only calledshowLoadingIndicator()on the invisible-reCAPTCHA path, sodisableSubmitButton()(only reached viashowSubmitLoading()) never ran. The submit button stayed clickable for the whole duration of the invisible reCAPTCHA check, allowing duplicate submissions.What changed
showSubmitLoading(), same as the non-captcha path, disabling the button while the check runs.reenableSubmitIfRecaptchaStalls) that re-enables the button only if the check itself never resolved (hasInvisibleRecaptcha(object)still true) — the widget has noerror-callback/expired-callbackwired 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, norecaptchaspec intests/cypress). Verified live instead with a minimal jQuery +formidable.jsharness driven viaplaywright-cli, stubbinggrecaptchato control the invisible-recaptcha flow directly (no live Google dependency):submiton a form with an invisible-recaptcha field left the submit button enabled.Self-reviewed (reuse/simplification/efficiency/altitude/security lenses) before opening.
Closes #3368
🤖 Generated with Claude Code