1( function( window, document, undefined ) { 2 3 'use strict'; 4 5 /** 6 * Gravity Forms reCAPTCHA release controller. 7 * 8 * gravity-forms.php has held the add-on's frontend bundle inert, because that bundle 9 * captures window.grecaptcha by value the moment it runs. Our job is to run it at the 10 * one point where that capture is useful: after Google's api.js has actually defined 11 * grecaptcha, which only happens once the visitor's consent covers it. 12 * 13 * Until then the form cannot be submitted â a Google captcha cannot be satisfied 14 * without contacting Google â so we say so plainly instead of letting the button 15 * spin forever, which is what it did before this module existed. 16 */ 17 18 var HELD_SELECTOR = 'script[type="text/plain"][data-cn-gf-recaptcha-src]'; 19 var NOTICE_CLASS = 'cn-gf-recaptcha-notice'; 20 var POLL_INTERVAL = 200; 21 var POLL_TIMEOUT = 20000; 22 23 /** Gravity Forms' own data-submission-type value for a real submit. See isSubmitControl(). */ 24 var SUBMISSION_TYPE_SUBMIT = 'submit'; 25 26 /** Gravity Forms controls that move around a form rather than submitting it. */ 27 var NAV_SELECTOR = '.gform_next_button, .gform_previous_button, .gform_save_link, .gform_send_resume_link_button'; 28 29 var strings = window.cn_gf_recaptcha || {}; 30 var timedOut = false; 31 32 /** 33 * Does this visitor's recorded consent COVER Google reCAPTCHA? 34 * 35 * This is what tells the two silences apart. grecaptcha being absent means "we are 36 * holding it until your consent covers it", and "Google genuinely did not load" once 37 * it does â same observable, opposite advice to the visitor. timedOut alone cannot 38 * distinguish them, because the poll below is armed on every pageview that has 39 * something held, whatever the visitor has or has not chosen. 40 * 41 * â ï¸ NOT "did they answer the banner". A visitor who answered by REJECTING 42 * non-essential cookies has a consent record, yet reCAPTCHA stays blocked for them 43 * exactly as it does before any answer â so the honest advice is still "accept 44 * cookies", because accepting is what fixes it. Testing merely that a record EXISTS 45 * sent them to "reCAPTCHA did not load, reload the page": false, and unactionable, 46 * since reloading changes nothing and the hold is ours. 47 * 48 * â ï¸ NOT the `cookies-unblocked.hu` event either. That fires only when the widget 49 * actually unblocked something (its emit is gated on `unblocked > 0`), so for a 50 * returning visitor whose recorded consent already disabled blocking outright it 51 * never fires at all â reporting "has not consented" for someone who had, and pinning 52 * them there once the poll expired. 53 * 54 * So ask the question the widget itself asks per script â `categories[category] === 55 * true` â against the category THIS SITE has reCAPTCHA in, which the customer can 56 * change and which is localized in alongside the strings. 57 * 58 * Read synchronously at call time rather than latched, so it is correct on a pageview 59 * where no widget event ever fires. `readData` and `prefix` are both on the widget's 60 * documented global (`window.__hu` / `window.hu`). `prefix` is a build-time constant 61 * there and is always 'hu' today; the fallback is not for per-site renaming (there is 62 * none) but so a change to that constant cannot silently strand this lookup. 63 * 64 * Returns false whenever we cannot tell â widget absent because an optimizer dropped 65 * it or it bailed on bot detection, unreadable cookie, no categories. That is the safe 66 * direction: fall back to the pre-consent wording rather than blaming Google for a 67 * hold that is ours. 68 * 69 * @return {boolean} 70 */ 71 function consentCoversRecaptcha() { 72 var hu = window.__hu || window.hu; 73 74 if ( ! hu || typeof hu.readData !== 'function' ) { 75 return false; 76 } 77 78 try { 79 var data = hu.readData( 'cookie', ( hu.prefix || 'hu' ) + '-consent' ); 80 var categories = data && data.categories; 81 82 return !! categories && categories[ recaptchaCategory() ] === true; 83 } catch ( e ) { 84 return false; 85 } 86 } 87 88 /** 89 * The consent category Google reCAPTCHA sits in ON THIS SITE. 90 * 91 * Localized from the same cached portal config the PHP side reads, so a customer who 92 * moved reCAPTCHA to Analytics or Marketing gets the right question asked rather than 93 * a hardcoded 2. Falls back to the shipped default when the value is absent â an older 94 * cached page â which is DEFAULT_CATEGORY in gravity-forms.php. 95 * 96 * @return {number} 97 */ 98 function recaptchaCategory() { 99 return parseInt( strings.category, 10 ) || 2; 100 } 101 102 /** 103 * Anything still held on the page right now. 104 * 105 * State is read from the DOM rather than latched in a variable, so a form injected 106 * after an earlier release â an Elementor popup, an AJAX embed â is still handled 107 * instead of being left held while interception is switched off. 108 * 109 * @return {NodeList} 110 */ 111 function heldScripts() { 112 return document.querySelectorAll( HELD_SELECTOR ); 113 } 114 115 /** 116 * Is a usable grecaptcha present? The add-on supports both the standard and 117 * enterprise entry points, so accept either. 118 * 119 * @return {boolean} 120 */ 121 function recaptchaReady() { 122 var g = window.grecaptcha; 123 124 if ( ! g ) { 125 return false;
126 } 127 128 return typeof g.execute === 'function' || 129 typeof g.render === 'function' || 130 !! ( g.enterprise && ( typeof g.enterprise.execute === 'function' || typeof g.enterprise.render === 'function' ) ); 131 } 132 133 /** 134 * Re-insert the add-on bundle so it executes and captures the real grecaptcha. 135 * 136 * @return {void} 137 */ 138 function release() { 139 var held = heldScripts(); 140 141 if ( ! held.length ) { 142 return; 143 } 144 145 Array.prototype.forEach.call( held, function( placeholder ) { 146 var script = document.createElement( 'script' ); 147 var src = placeholder.getAttribute( 'data-cn-gf-recaptcha-src' ); 148 149 script.src = src; 150 151 // The v2 checkbox flow renders on GF's post_render event, which has already 152 // fired by now, so ask the add-on to sweep the page once it is in charge. 153 // v3 needs nothing here â it fetches its token at submit time. 154 script.onload = function() { 155 if ( typeof window.gravityformsrecaptchaRenderCheckboxes === 'function' ) { 156 window.gravityformsrecaptchaRenderCheckboxes(); 157 } 158 159 clearNotices(); 160 }; 161 162 // If the add-on bundle itself cannot be fetched, put the placeholder back so 163 // the submit stays intercepted with an explanation, rather than letting the 164 // form post a token it has no way of producing. 165 script.onerror = function() { 166 timedOut = true; 167 placeholder.setAttribute( 'data-cn-gf-recaptcha-src', src ); 168 placeholder.removeAttribute( 'data-cn-gf-recaptcha-released' ); 169 clearNotices(); 170 }; 171 172 // Mark it done rather than removing the placeholder, so a later pass can tell 173 // the difference between "not yet released" and "already released". 174 placeholder.removeAttribute( 'data-cn-gf-recaptcha-src' ); 175 placeholder.setAttribute( 'data-cn-gf-recaptcha-released', 'true' ); 176 177 ( document.head || document.documentElement ).appendChild( script ); 178 } ); 179 } 180 181 /** 182 * Wait for grecaptcha, then release. api.js loads asynchronously once unblocked, so 183 * the unblock event alone is too early â that race is the whole reason this exists. 184 * 185 * @return {void} 186 */ 187 function releaseWhenReady() { 188 if ( ! heldScripts().length ) { 189 return; 190 } 191 192 if ( recaptchaReady() ) { 193 timedOut = false; 194 release(); 195 196 return; 197 } 198 199 var waited = 0; 200 var timer = window.setInterval( function() { 201 waited += POLL_INTERVAL; 202 203 if ( recaptchaReady() ) { 204 window.clearInterval( timer ); 205 timedOut = false; 206 release(); 207 } else if ( waited >= POLL_TIMEOUT ) { 208 // Give up waiting, but stay held. Releasing anyway would run the bundle 209 // against a still-absent grecaptcha and reproduce the original hang. 210 // Remember it, so the visitor is told reCAPTCHA failed to load rather 211 // than being asked to accept cookies they may already have accepted. 212 window.clearInterval( timer ); 213 timedOut = true; 214 clearNotices(); 215 } 216 }, POLL_INTERVAL ); 217 } 218 219 /** 220 * Forms on this page that Gravity Forms has protected with reCAPTCHA. 221 * 222 * @param {Element} form 223 * @return {boolean} 224 */ 225 function isProtected( form ) { 226 return !! form.querySelector( '.ginput_recaptchav3, .ginput_container_recaptcha_checkbox, .gfield_recaptcha_response' ); 227 } 228 229 /** 230 * Is this control the one that actually submits the form? 231 * 232 * Only the submit needs a reCAPTCHA token, so only the submit may be held back. A 233 * multi-page form's Next and Previous, and its save-and-continue link, ask nothing of 234 * Google â stopping those leaves the visitor stuck in a form they cannot page through, 235 * and protects nothing (#47928). 236 * 237 * Telling them apart cannot be done on the button's `type`. Gravity Forms sets the 238 * Previous control's type itself â `o.type = t < r.length ? "submit" : "button"` in 239 * assets/js/dist/scripts-theme.min.js (3.0.3, read 2026-08-25) â so Previous genuinely 240 * IS an `input[type=submit]`, and narrowing the click selector alone would not exclude 241 * it. Three signals, in order of how much we can trust them: 242 * 243 * 1. `data-submission-type`, which the same bundle stamps and reads, with the values 244 * `S="submit",k="next",x="previous"`. When present it is Gravity Forms' own 245 * statement of intent, so it decides outright. 246 * 2. Failing that â older markup carries no such attribute â the class. That bundle 247 * matches navigation as `.gform_next_button` / `.gform_previous_button` and the 248 * submit as `.gform_button`, so rule the navigation classes out first, because a 249 * control can carry both. 250 * 3. Failing that too, position â but only as far as the evidence reaches. A theme can 251 * replace the submit's markup entirely through Gravity Forms' own 252 * `gform_submit_button` filter, and a replacement keeping neither the attribute nor 253 * the class would otherwise stop being held, which is the promise 3.1.5 made to 254 * those sites. On a SINGLE-PAGE form the submit sits in 255 * `<div class="gform-footer gform_footer ...">` â read off a live customer page on 256 * 2026-08-25. That is the only shape verified, and the only one this catches. 257 * 258 * Beyond that, biased toward letting an unrecognised control through. Over-blocking is 259 * the failure being fixed here, and under-blocking is not a hole: Gravity Forms 260 * validates the token server-side, so a submit that slips through fails its captcha 261 * check rather than bypassing it. Note it cannot hang either â a held bundle never ran, 262 * so it never registered a submission step to stall on. 263 * 264 * THREE THINGS NOT ESTABLISHED, because Gravity Forms is not vendored here and only its 265 * theme bundle and one live page could be read: 266 * 267 * a. What it stamps on a save-and-continue control. The bundle exports a 268 * SUBMISSION_TYPE_SAVE_AND_CONTINUE constant but does not carry its value, so if 269 * that value is "submit" this treats save as a submit and holds it. NAV_SELECTOR 270 * lists the class, which covers only the attribute-absent case. 271 * b. Where a MULTI-PAGE form puts its submit. If the last page's footer is not 272 * `.gform_footer`, signal 3 does nothing there and a themed submit on a multi-page 273 * form is let through â no worse than before this function existed, but not fixed 274 * either. Deliberately not guessed at: an earlier attempt to exclude a 275 * `.gform_page_footer` container was removed once it turned out to veto the very 276 * shape signal 3 is meant to catch. 277 * c. Whether a themed save-and-continue link, or a themed Next/Previous, keeps any GF 278 * class â GF exposes `gform_savecontinue_link` and `gform_next_button` filters just 279 * as it does `gform_submit_button`. If such a control keeps none and sits in the 280 * footer, signal 3 holds it â a known over-block, and the accepted cost of closing 281 * the themed-submit gap, since position cannot tell those two apart. A nav control 282 * that KEEPS its class is safe: the NAV_SELECTOR veto runs first. 283 * 284 * @param {Element} button 285 * @return {boolean} 286 */ 287 function isSubmitControl( button ) { 288 var declared = button.getAttribute( 'data-submission-type' ); 289 290 if ( declared !== null ) { 291 return declared === SUBMISSION_TYPE_SUBMIT; 292 } 293 294 if ( button.matches( NAV_SELECTOR ) ) { 295 return false;
296 } 297 298 return button.classList.contains( 'gform_button' ) || !! button.closest( '.gform_footer' ); 299 } 300 301 /** 302 * Remove any notice we added. 303 * 304 * @return {void} 305 */ 306 function clearNotices() { 307 var notices = document.getElementsByClassName( NOTICE_CLASS ); 308 309 while ( notices.length ) { 310 notices[0].parentNode.removeChild( notices[0] ); 311 } 312 } 313 314 /** 315 * Show, once per form, why the submission cannot proceed yet. 316 * 317 * THREE states, not two. Whether consent COVERS reCAPTCHA and whether we gave up 318 * waiting for Google are independent, and the pair decides the advice: 319 * 320 * covered? timed out? what the visitor is told 321 * -------- ---------- ----------------------------------------------------------- 322 * no no accept cookies â true, and accepting is what fixes it 323 * no yes accept cookies â still true; that expiry was OUR hold, not 324 * Google failing, so "did not load" would be a lie 325 * yes no still loading â transient, resolves when api.js defines 326 * grecaptcha. Telling them to accept is wrong (their consent 327 * already covers it) and blaming Google is premature 328 * yes yes reCAPTCHA did not load â the only honest failure report 329 * 330 * The `no` rows deliberately collapse two different visitors: one who has not answered 331 * the banner, and one who answered by declining. Both are told to accept, and for both 332 * that is true â reCAPTCHA is held either way, and accepting is the only thing that 333 * releases it. Splitting on "has a consent record" instead of "is reCAPTCHA covered" 334 * put the decliner in row 4 and told them Google had failed. 335 * 336 * Row 3 is what a covered visitor hits while api.js loads, and it had no message of 337 * its own: it fell through to "please accept cookies", to someone who already had. 338 * 339 * @param {Element} form 340 * @return {void} 341 */ 342 function showNotice( form ) { 343 if ( form.getElementsByClassName( NOTICE_CLASS ).length ) { 344 return; 345 } 346 347 var notice = document.createElement( 'p' ); 348 var text; 349 350 notice.className = NOTICE_CLASS; 351 notice.setAttribute( 'role', 'alert' ); 352 353 if ( ! consentCoversRecaptcha() ) { 354 text = strings.blockedMessage || 'Please accept cookies to submit this form.'; 355 } else if ( timedOut ) { 356 text = strings.unavailableMessage || 'This form could not be submitted because Google reCAPTCHA did not load.'; 357 } else { 358 text = strings.loadingMessage || 'This form is still loading. Please try again in a moment.'; 359 } 360 361 notice.textContent = text; 362 363 form.appendChild( notice ); 364 } 365 366 /** 367 * While the bundle is still held, stop the submit and explain. Capture phase, so we 368 * run before Gravity Forms' own button handler. 369 */ 370 document.addEventListener( 'click', function( e ) { 371 if ( ! e.target || typeof e.target.closest !== 'function' ) { 372 return; 373 } 374 375 // Match broadly, then let isSubmitControl() decide â a multi-page form's Next and 376 // Previous are themselves input[type=submit], so the selector cannot tell them apart. 377 var button = e.target.closest( '[data-submission-type], input[type="submit"], button[type="submit"]' ); 378 379 if ( ! button || ! isSubmitControl( button ) ) { 380 return; 381 } 382 383 var form = button.closest( 'form' ); 384 385 if ( ! form || ! isProtected( form ) ) { 386 return; 387 } 388 389 // Nothing held any more (released, or never blocked) â leave the form alone. 390 if ( ! heldScripts().length ) { 391 return; 392 } 393 394 e.preventDefault(); 395 e.stopPropagation(); 396 397 showNotice( form ); 398 }, true ); 399 400 /** 401 * Handle the widget's unblock event. 402 * 403 * This is a RELEASE TRIGGER and nothing more. It used to also latch "consent was 404 * given", which it cannot witness â see consentCoversRecaptcha() for why the event is 405 * silent on the covered visitor, and blind to the decliner. 406 * 407 * timedOut is still cleared here: if scripts were just unblocked, any earlier expiry 408 * was the pre-consent wait, and the one starting now is the only one that could 409 * honestly report a load failure. Clearing it on a signal we DO observe is sound; 410 * inferring consent from it was not. 411 */ 412 document.addEventListener( 'cookies-unblocked.hu', function() { 413 timedOut = false;
414 415 releaseWhenReady(); 416 }, false ); 417 418 /** 419 * Gravity Forms fires this after it renders a form, including forms brought in later 420 * by AJAX or a popup. Such a form arrives with its own held placeholder, so re-check 421 * rather than assuming the page was fully handled on first load. 422 */ 423 document.addEventListener( 'gform/post_render', releaseWhenReady, false ); 424 425 /** 426 * Safety net: if reCAPTCHA turns out not to be blocked on this pageview after all â 427 * an excluded handle, a returning visitor whose consent was already recorded, the 428 * widget failing to load â release on our own so we never leave a form worse off 429 * than it was before this module existed. 430 */ 431 if ( document.readyState === 'loading' ) { 432 document.addEventListener( 'DOMContentLoaded', releaseWhenReady, false ); 433 } else { 434 releaseWhenReady(); 435 } 436 437} )( window, document );
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.