1/** 2 * Lazy-load Google reCAPTCHA script on-demand 3 * 4 * Loads reCAPTCHA only when the form is visible in viewport OR when user interacts with it. 5 * Prevents render-blocking script from impacting LCP performance. 6 * Ensures script only loads once per page. 7 * 8 * @author ShipStation Dev Team 9 * @version 1.0.0 10 */ 11(function(window) { 12 'use strict'; 13 14 // Global state tracking - prevents duplicate loads 15 let recaptchaLoadState = { 16 loading: false, 17 loaded: false, 18 promise: null 19 }; 20 21 const RECAPTCHA_SITE_KEY = '6LeqeuAbAAAAAHNfIhSsRta-WkFzLz3bEcI7NGKt'; 22 const RECAPTCHA_SCRIPT_URL = `https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_SITE_KEY}`; 23 24 /** 25 * Load reCAPTCHA script and return Promise that resolves when ready 26 * @returns {Promise<void>} 27 */ 28 function loadRecaptcha() { 29 // If already loaded, resolve immediately 30 if (recaptchaLoadState.loaded && window.grecaptcha) { 31 return Promise.resolve(); 32 } 33 34 // If currently loading, return existing promise 35 if (recaptchaLoadState.loading && recaptchaLoadState.promise) { 36 return recaptchaLoadState.promise; 37 } 38 39 // Start loading 40 recaptchaLoadState.loading = true; 41 recaptchaLoadState.promise = new Promise((resolve, reject) => { 42 // Check if script already exists in DOM (edge case) 43 const existingScript = document.querySelector(`script[src*="recaptcha/api.js"]`); 44 if (existingScript) { 45 if (window.grecaptcha && window.grecaptcha.ready) { 46 window.grecaptcha.ready(() => { 47 recaptchaLoadState.loaded = true; 48 recaptchaLoadState.loading = false; 49 console.log('[reCAPTCHA] Already loaded and ready'); 50 resolve(); 51 }); 52 } else { 53 // Script tag exists but not yet loaded - wait for it 54 const handleLoad = () => { 55 if (window.grecaptcha && window.grecaptcha.ready) { 56 window.grecaptcha.ready(() => { 57 recaptchaLoadState.loaded = true; 58 recaptchaLoadState.loading = false; 59 console.log('[reCAPTCHA] Existing script loaded and ready'); 60 resolve(); 61 }); 62 } else { 63 recaptchaLoadState.loaded = true; 64 recaptchaLoadState.loading = false; 65 resolve(); 66 } 67 }; 68 69 const handleError = () => { 70 console.error('[reCAPTCHA] Existing script failed to load'); 71 recaptchaLoadState.loading = false; 72 reject(new Error('Existing reCAPTCHA script failed to load')); 73 }; 74 75 existingScript.addEventListener('load', handleLoad); 76 existingScript.addEventListener('error', handleError); 77 } 78 return; 79 } 80 81 // Create and inject script 82 const script = document.createElement('script'); 83 script.src = RECAPTCHA_SCRIPT_URL; 84 script.async = true; 85 script.defer = true; 86 87 script.onload = function() { 88 // Poll for grecaptcha.ready with fixed interval 89 // Max wait time: 8 attempts à 400ms = 3.2 seconds 90 let attempts = 0; 91 const maxAttempts = 8; 92 const checkInterval = 400; // milliseconds 93 94 const checkReady = () => { 95 attempts++; 96 97 if (window.grecaptcha && window.grecaptcha.ready) { 98 window.grecaptcha.ready(() => { 99 recaptchaLoadState.loaded = true; 100 recaptchaLoadState.loading = false; 101 console.log('[reCAPTCHA] Loaded and ready'); 102 resolve(); 103 }); 104 } else if (attempts >= maxAttempts) { 105 // Timeout after 3.2 seconds (8 à 400ms) 106 console.error('[reCAPTCHA] Script loaded but grecaptcha.ready never became available'); 107 recaptchaLoadState.loading = false; 108 reject(new Error('reCAPTCHA script loaded but grecaptcha.ready not available')); 109 } else { 110 // Retry at fixed 400ms intervals 111 setTimeout(checkReady, checkInterval); 112 } 113 }; 114 115 checkReady(); 116 }; 117 118 script.onerror = function() { 119 console.error('[reCAPTCHA] Failed to load script'); 120 recaptchaLoadState.loading = false; 121 reject(new Error('Failed to load reCAPTCHA')); 122 }; 123 124 document.head.appendChild(script); 125 }); 126 127 return recaptchaLoadState.promise; 128 } 129 130 /** 131 * Initialize lazy-loading for a form element 132 * @param {string} formSelector - CSS selector for the form 133 * @param {Object} options - Configuration options 134 * @param {string} [options.rootMargin='200px'] - Load when form is this far from viewport 135 * @param {boolean} [options.loadOnInteraction=true] - Also load on form interaction 136 * @param {string[]} [options.interactionEvents] - Events that trigger loading 137 */ 138 function initRecaptchaLazyLoad(formSelector, options = {}) { 139 const config = { 140 rootMargin: '200px', 141 loadOnInteraction: true, 142 interactionEvents: ['focus', 'click', 'touchstart'], 143 ...options 144 }; 145 146 const formElement = document.querySelector(formSelector); 147 if (!formElement) { 148 console.warn(`[reCAPTCHA] Form element not found: ${formSelector}`); 149 return; 150 } 151 152 let hasTriggeredLoad = false;
153 154 // Strategy 1: IntersectionObserver (load when form enters viewport) 155 if ('IntersectionObserver' in window) { 156 const observer = new IntersectionObserver((entries) => { 157 entries.forEach(entry => { 158 if (entry.isIntersecting && !hasTriggeredLoad) { 159 hasTriggeredLoad = true; 160 console.log('[reCAPTCHA] Form in viewport, loading...'); 161 loadRecaptcha(); 162 observer.disconnect(); // Stop observing once loaded 163 } 164 }); 165 }, { 166 rootMargin: config.rootMargin 167 }); 168 169 observer.observe(formElement); 170 } else { 171 // Fallback for browsers without IntersectionObserver (IE11, old Safari) 172 // Load immediately on page load 173 console.log('[reCAPTCHA] IntersectionObserver not supported, loading immediately'); 174 loadRecaptcha(); 175 hasTriggeredLoad = true; 176 } 177 178 // Strategy 2: Interaction-based loading (focus, click, touch) 179 // This catches the case where user jumps directly to form before scrolling 180 if (config.loadOnInteraction) { 181 const handleInteraction = () => { 182 if (!hasTriggeredLoad) { 183 hasTriggeredLoad = true; 184 console.log('[reCAPTCHA] Form interaction detected, loading...'); 185 loadRecaptcha(); 186 // Remove listeners after first trigger 187 config.interactionEvents.forEach(event => { 188 formElement.removeEventListener(event, handleInteraction, true); 189 }); 190 } 191 }; 192 193 // Use capture phase to catch events on form children (inputs, buttons) 194 config.interactionEvents.forEach(event => { 195 formElement.addEventListener(event, handleInteraction, true); 196 }); 197 } 198 } 199 200 /** 201 * Ensure reCAPTCHA is loaded, wait if necessary 202 * Call this before form submission to guarantee reCAPTCHA is ready 203 * @returns {Promise<void>} 204 */ 205 function ensureRecaptchaLoaded() { 206 if (recaptchaLoadState.loaded && window.grecaptcha) { 207 return Promise.resolve(); 208 } 209 210 if (recaptchaLoadState.loading && recaptchaLoadState.promise) { 211 return recaptchaLoadState.promise; 212 } 213 214 // Not yet triggered, load now 215 console.log('[reCAPTCHA] Force loading for form submission'); 216 return loadRecaptcha(); 217 } 218 219 /** 220 * Ensure reCAPTCHA is loaded, then mint a v3 token for `action`. 221 * 222 * This is the single place that turns "reCAPTCHA is available" into an actual 223 * token, so callers never need the site key. REJECTS (rather than resolving 224 * with an empty string) when verification is unavailable or token generation 225 * fails, so callers can fail CLOSED â submitting an empty token silently marks 226 * the registration as fraudulent upstream. 227 * 228 * @param {string} action - reCAPTCHA v3 action name (e.g. 'submit') 229 * @returns {Promise<string>} the token 230 */ 231 function executeRecaptcha(action) { 232 return ensureRecaptchaLoaded().then(function() { 233 if (!window.grecaptcha || !window.grecaptcha.execute) { 234 return Promise.reject( 235 new Error('grecaptcha unavailable after ensure()') 236 ); 237 } 238 return new Promise(function(resolve, reject) { 239 window.grecaptcha.ready(function() { 240 window.grecaptcha 241 .execute(RECAPTCHA_SITE_KEY, { action: action || 'submit' }) 242 .then(function(token) { 243 if (token) { 244 resolve(token); 245 } else { 246 reject(new Error('grecaptcha returned an empty token')); 247 } 248 }) 249 .catch(reject); 250 }); 251 }); 252 }); 253 } 254 255 // Expose public API 256 window.RecaptchaLazyLoader = { 257 /** 258 * Initialize lazy-loading for a form 259 * @param {string} formSelector - CSS selector 260 * @param {Object} options - Configuration 261 */ 262 init: initRecaptchaLazyLoad, 263 264 /** 265 * Manually trigger reCAPTCHA load 266 * @returns {Promise<void>} 267 */ 268 load: loadRecaptcha, 269 270 /** 271 * Ensure reCAPTCHA is loaded (wait if loading) 272 * @returns {Promise<void>} 273 */ 274 ensure: ensureRecaptchaLoaded, 275 276 /** 277 * Ensure loaded, then mint a v3 token. Rejects if unavailable. 278 * @param {string} action 279 * @returns {Promise<string>} 280 */ 281 execute: executeRecaptcha, 282 283 /** 284 * Check if reCAPTCHA is fully loaded 285 * @returns {boolean} 286 */ 287 isLoaded: () => recaptchaLoadState.loaded, 288 289 /** 290 * Check if reCAPTCHA is currently loading 291 * @returns {boolean} 292 */ 293 isLoading: () => recaptchaLoadState.loading, 294 295 /** 296 * Get the reCAPTCHA site key (single source of truth) 297 * @returns {string} 298 */ 299 siteKey: RECAPTCHA_SITE_KEY 300 }; 301 302})(window);
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.