Summary
When a Form Builder (or Optin Form) add-on is configured with Cloudflare Turnstile as the captcha and a success redirect URL, the redirect never fires after a successful AJAX submission. The form also appears to "hang" — the submit button spinner stays visible and the button remains disabled. Other captcha types (reCAPTCHA, hCaptcha, POW Captcha) redirect correctly.
Steps to reproduce
- Add a Form Builder add-on to a page.
- In the add-on settings, enable Captcha and select Turnstile (requires the Joomla
plg_captcha_turnstileplugin to be installed, configured with site/secret keys, and enabled). - Enable Redirect and set a Redirect URL (e.g.
/thank-you). - Save and load the page on the front end.
- Fill in the form, complete the Turnstile widget, and submit.
Expected result
The form submits successfully, the success message is shown, and after ~2.5 seconds the browser redirects to the configured Redirect URL.
Actual result
-
The form submits and the server returns a success response, but:
- The submit button spinner never clears and the button stays disabled.
- The success message is not displayed.
- No redirect occurs — the user remains on the same page.
-
Opening the browser DevTools console reveals an uncaught exception thrown during the AJAX
successcallback:Uncaught TypeError: Cannot read properties of undefined (reading 'split') at ... (sppagebuilder.js, in the success handler)
Disabling Turnstile (or switching to any other captcha type) immediately restores correct redirect behavior.
System information
- Joomla: 6.1.3 Stable
- SP Page Builder: 6.8.0
- Captcha plugin: Cloudflare Turnstile (
plg_captcha_turnstile, e.g. SharkyKZ's plugin or any plugin that renders<div class="cf-turnstile" ...>and lets Cloudflare'sapi.jsimplicitly inject the hiddencf-turnstile-responseinput) - Browser: any (the error is in SP Page Builder's JS, not browser-specific)
Root cause
The Turnstile captcha plugin renders a widget container div with an id, for example:
<div class="cf-turnstile sppb-dynamic-recaptcha" id="custom_captcha_123" data-sitekey="..."></div>
Cloudflare's api.js then implicitly injects a hidden input inside that div to carry the token:
<input type="hidden" name="cf-turnstile-response" value="TOKEN">
Per Cloudflare's documentation, this implicitly-injected input has a name but no id attribute. (See https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/ — response-field-name controls the name; no id is set.)
The SP Page Builder front-end JS (components/com_sppagebuilder/assets/js/sppagebuilder.js) attempts to reset the Turnstile widget after a successful AJAX submission. The reset code (minified; present in 3 handlers — ajax_contact, form_builder, optin_form) does this:
var c = form.find("#custom_captcha_" + response.gcaptchaId.split("_")[2] + " input").attr("id").split("-")[3].split("_")[0];
c ? turnstile.reset(c) : turnstile.reset();
Step-by-step failure:
form.find("#custom_captcha_123 input")→ finds the hiddencf-turnstile-responseinput..attr("id")→ returnsundefined(the input has noid)..split("-")→ throwsTypeError: Cannot read properties of undefined (reading 'split').
Because this line is inside the AJAX success callback, the uncaught exception aborts the entire callback. The subsequent statements — which clear the spinner, re-enable the submit button, show the success message, and schedule the redirect — never execute:
form.find(".fa-spin").removeClass("fa-spinner fa-spin"),
form.find('button[type="submit"]').prop("disabled", false),
form.next(".sppb-ajax-contact-status").html(content).fadeIn().delay(4000).fadeOut(500),
response?.status && redirect === "yes" && setTimeout(function(){ window.location.href = redirectUrl }, 2500) // never reached
This is why the symptom is specific to Turnstile: the reCAPTCHA, hCaptcha, and POW Captcha branches use different reset logic that does not throw.
Proposed fix
The fix has two parts:
-
Read the widget id from the correct element.
turnstile.reset(id)expects the widget id — i.e. theidof the.cf-turnstilecontainer div, which the captcha plugin always sets. The original code tried to derive it from the id-less hidden input. The fix reads it from the.cf-turnstilecontainer directly. -
Wrap the
turnstile.reset()call in a try/catch. Even with the correct widget id,turnstile.reset()can throw in certain widget states (e.g. after the token has already been consumed by the server-side verification, or if the widget has been implicitly rendered and the id is not registered as an explicit widget id). Because this code runs inside the AJAXsuccesscallback, any throw — even fromreset()— aborts the entire callback and prevents the redirect. The try/catch ensures the rest of the success callback (spinner removal, button re-enable, success message, redirect) always executes, even if the reset fails.
Handler 1 — ajax_contact (latent: throws a console error on every successful Turnstile submission; no redirect feature, but the error is noise and the success UI is still broken)
Before:
else if("turnstile"==f){var p=a.find("#custom_captcha_"+n.gcaptchaId.split("_")[2]+" input").attr("id").split("-")[3].split("_")[0];p?turnstile.reset(p):turnstile.reset()}
After:
else if("turnstile"==f){var p=a.find(".cf-turnstile").attr("id");try{p?turnstile.reset(p):turnstile.reset()}catch(e){}}
Handler 2 — form_builder (the reported redirect bug)
Before:
else if("turnstile"==g){var c=i.find("#custom_captcha_"+s.gcaptchaId.split("_")[2]+" input").attr("id").split("-")[3].split("_")[0];c?turnstile.reset(c):turnstile.reset()}
After:
else if("turnstile"==g){var c=i.find(".cf-turnstile").attr("id");try{c?turnstile.reset(c):turnstile.reset()}catch(e){}}
Handler 3 — optin_form (same redirect bug for optin forms)
Before:
else if("turnstile"==v){var h=i.find("#custom_captcha_"+o.gcaptchaId.split("_")[2]+" input").attr("id").split("-")[3].split("_")[0];h?turnstile.reset(h):turnstile.reset()}
After:
else if("turnstile"==v){var h=i.find(".cf-turnstile").attr("id");try{h?turnstile.reset(h):turnstile.reset()}catch(e){}}
Why this is correct and safe
- The Turnstile plugin always renders
<div class="cf-turnstile" id="...">— thatidis the widget id Cloudflare'sturnstile.reset(id)expects. - It removes the brittle
.split("-")[3].split("_")[0]chain that assumed a specificidformat on the hidden input (which has noidat all). - The
try/catchensures that even ifturnstile.reset()throws for any reason (invalid widget id, widget already destroyed, token already consumed), the rest of the success callback still runs — the spinner is cleared, the submit button is re-enabled, the success message is shown, and the redirect fires. The captcha reset is a cosmetic convenience (so the user can submit again without reloading the page); it is not security-critical and must not be allowed to break the success flow. - The
elsefallback (turnstile.reset()with no argument → resets all widgets on the page) is preserved for the theoretical case where no.cf-turnstileelement is found. - No other captcha branches (reCAPTCHA / hCaptcha / POW Captcha / default) are touched.
Files modified
components/com_sppagebuilder/assets/js/sppagebuilder.js— 3 single-line edits (one per AJAX submit handler:ajax_contact,form_builder,optin_form).
Verification
After applying the fix:
- Clear Joomla cache (System → Clear Cache) — the JS is loaded with a versioned query string.
- Load a Form Builder page with Turnstile enabled + redirect configured; submit the form.
- Confirm: spinner stops, submit button re-enables, success message shows, and after ~2.5s the browser redirects to the configured URL.
- Open DevTools console during submission — confirm no
TypeErroris thrown. - Repeat with an Optin Form using Turnstile + redirect.
- Sanity-check a reCAPTCHA (non-Turnstile) form still resets and redirects normally.