From 842f51522e3da7e3e72e25460eff3069135c6661 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Mon, 8 Jun 2026 14:09:18 -0300 Subject: [PATCH 1/3] Avoid possible race condition issues with loading google and apple pay sdks --- paypal/js/frontend.js | 301 ++++++++++++++++++++++++++++-------------- 1 file changed, 204 insertions(+), 97 deletions(-) diff --git a/paypal/js/frontend.js b/paypal/js/frontend.js index 6b3a6e1048..c1ff757e27 100644 --- a/paypal/js/frontend.js +++ b/paypal/js/frontend.js @@ -131,13 +131,24 @@ // Clear the card element. We rebuild it entirely. cardElement.innerHTML = ''; - // 1. Discover eligible methods and register them. + // Reset state so each run rediscovers cleanly (the DOM above was cleared). + paymentMethods.clear(); + selectedMethod = null; + + // 1. Discover synchronous methods (Card, PayPal, alternative funding). await discoverPaymentMethods( { cardFieldsAreSupported, buttonsAreEnabled, isRecurring } ); + // 1b. Discover Google Pay / Apple Pay. Their SDKs (pay.js / PayPal applepay + // component) load asynchronously and are frequently not ready during the first + // run, which previously left these buttons missing on first load. We wait + // (bounded) for them here so every eligible method appears together in one + // render instead of popping in afterward. + await discoverDeferredPaymentMethods( { buttonsAreEnabled, isRecurring } ); + if ( paymentMethods.size === 0 ) { displayPaymentFailure( 'No payment methods available.' ); return; @@ -263,29 +274,113 @@ } } - // --- Google Pay --- - if ( buttonsAreEnabled && ! isRecurring ) { - const googlePayEligible = await checkGooglePayEligibility(); - if ( googlePayEligible ) { - registerMethod( 'google_pay', { - eligible: true, - render: renderGooglePayButton - } ); - } + // Google Pay and Apple Pay are discovered separately in + // discoverDeferredPaymentMethods() because their SDKs (pay.js / PayPal applepay + // component) may not be ready yet when this runs. + } + + /** + * Discover and register Google Pay and Apple Pay. + * + * Their SDKs load asynchronously and are frequently not ready during the first + * paypalInit() run, which previously caused these buttons to be missing on first + * load and only appear after a page change. We wait (bounded) for each SDK and + * register the eligible methods so they are included when the selector is built. + * + * @param {Object} opts Config flags. + * + * @return {Promise} + */ + async function discoverDeferredPaymentMethods( opts ) { + const { buttonsAreEnabled, isRecurring } = opts; + + if ( ! buttonsAreEnabled || isRecurring ) { + return; } - // --- Apple Pay --- - if ( buttonsAreEnabled && ! isRecurring ) { - const applePayEligibilityResult = await checkApplePayEligibility(); - if ( applePayEligibilityResult === '' ) { - registerMethod( 'apple_pay', { - eligible: true, - render: renderApplePayButton - } ); - } + // Resolve both eligibility checks in parallel so the combined wait is bounded by + // the slower of the two, then register them in a fixed order (Google Pay, then + // Apple Pay). Registration happens before the selector is built, so they render + // together with the other methods. + const [ googlePayEligible, applePayEligible ] = await Promise.all( [ + resolveGooglePayEligibility(), + resolveApplePayEligibility() + ] ); + + if ( googlePayEligible ) { + registerMethod( 'google_pay', { + eligible: true, + render: renderGooglePayButton + } ); + } + + if ( applePayEligible ) { + registerMethod( 'apple_pay', { + eligible: true, + render: renderApplePayButton + } ); } } + /** + * Wait for the Google Pay SDK and resolve whether Google Pay is eligible. + * + * @return {Promise} Whether Google Pay is eligible. + */ + async function resolveGooglePayEligibility() { + const sdkReady = await waitFor( + () => 'function' === typeof paypal.Googlepay && 'undefined' !== typeof google && undefined !== google.payments + ); + if ( ! sdkReady ) { + return false; + } + + return checkGooglePayEligibility(); + } + + /** + * Wait for the Apple Pay SDK and resolve whether Apple Pay is eligible. + * + * @return {Promise} Whether Apple Pay is eligible. + */ + async function resolveApplePayEligibility() { + const sdkReady = await waitFor( () => 'function' === typeof paypal.Applepay ); + if ( ! sdkReady ) { + return false; + } + + return '' === await checkApplePayEligibility(); + } + + /** + * Poll for a condition until it is true or a timeout elapses. + * + * @param {Function} predicate Returns true when the awaited dependency is ready. + * @param {number} [timeout] Maximum time to wait, in milliseconds. + * @param {number} [interval] Poll interval, in milliseconds. + * + * @return {Promise} Whether the predicate became true before the timeout. + */ + function waitFor( predicate, timeout = 2000, interval = 50 ) { + return new Promise( resolve => { + if ( predicate() ) { + resolve( true ); + return; + } + + const start = Date.now(); + const timer = setInterval( () => { + if ( predicate() ) { + clearInterval( timer ); + resolve( true ); + } else if ( Date.now() - start >= timeout ) { + clearInterval( timer ); + resolve( false ); + } + }, interval ); + } ); + } + /** * Register a payment method in the registry. * @@ -319,89 +414,101 @@ group.setAttribute( 'aria-label', 'Select payment method' ); for ( const [ key, method ] of paymentMethods ) { - const label = document.createElement( 'label' ); - label.classList.add( 'frm-payment-method-option' ); - label.setAttribute( 'for', `frm-payment-method-radio-${ key }` ); - - const radio = document.createElement( 'input' ); - radio.type = 'radio'; - radio.name = 'frm_payment_method'; - radio.id = `frm-payment-method-radio-${ key }`; - radio.value = key; - - radio.addEventListener( 'change', () => selectPaymentMethod( key ) ); - - // Text column: label + description. - const textWrap = document.createElement( 'div' ); - textWrap.classList.add( 'frm-payment-method-text' ); - - const labelText = document.createElement( 'span' ); - labelText.classList.add( 'frm-payment-method-label-text' ); - labelText.textContent = method.label; - textWrap.append( labelText ); - - // Mark column: will be populated by renderMarks() after the group is in the DOM. - const markWrap = document.createElement( 'div' ); - markWrap.classList.add( 'frm-payment-method-mark' ); - markWrap.id = `frm-payment-mark-${ key }`; - - const baseUrl = frmPayPalVars.imagesUrl || ''; - - if ( key === 'card' ) { - const cardBrands = [ - { file: 'visa.svg', alt: 'Visa' }, - { file: 'mastercard.svg', alt: 'Mastercard' }, - { file: 'amex.svg', alt: 'American Express' }, - { file: 'discover.svg', alt: 'Discover' }, - ]; - cardBrands.forEach( function( brand ) { - const img = document.createElement( 'img' ); - img.src = baseUrl + brand.file; - img.alt = brand.alt; - img.height = 24; - markWrap.append( img ); - } ); - } else if ( key === 'google_pay' ) { - markWrap.classList.add( 'frm-payment-method-google-pay-icon' ); - const img = document.createElement( 'img' ); - img.src = `${ baseUrl }gpay.svg`; - img.alt = 'Google Pay'; - img.height = 24; - markWrap.append( img ); - } else if ( key === 'apple_pay' ) { - markWrap.classList.add( 'frm-payment-method-apple-pay-icon' ); - const img = document.createElement( 'img' ); - img.src = `${ baseUrl }apple-pay.svg`; - img.alt = 'Apple Pay'; - img.height = 24; - img.style.width = 'auto'; - markWrap.append( img ); - } - - label.append( radio ); - label.append( textWrap ); - label.append( markWrap ); - - if ( key === 'paylater' ) { - // Wrap the label and a message container in a div. - const wrapper = document.createElement( 'div' ); - wrapper.classList.add( 'frm-payment-method-paylater-wrap' ); - wrapper.append( label ); - - const msgContainer = document.createElement( 'div' ); - msgContainer.id = 'frm-paylater-message'; - msgContainer.classList.add( 'frm-payment-method-paylater-msg' ); - wrapper.append( msgContainer ); - - group.append( wrapper ); - } else { - group.append( label ); - } + group.append( buildMethodOption( key, method ) ); } return group; } + /** + * Build a single payment method radio option row. + * + * @param {string} key The payment method key. + * @param {Object} method The registered method object. + * + * @return {HTMLElement} The option element (a label, or a wrapper for Pay Later). + */ + function buildMethodOption( key, method ) { + const label = document.createElement( 'label' ); + label.classList.add( 'frm-payment-method-option' ); + label.setAttribute( 'for', `frm-payment-method-radio-${ key }` ); + + const radio = document.createElement( 'input' ); + radio.type = 'radio'; + radio.name = 'frm_payment_method'; + radio.id = `frm-payment-method-radio-${ key }`; + radio.value = key; + + radio.addEventListener( 'change', () => selectPaymentMethod( key ) ); + + // Text column: label + description. + const textWrap = document.createElement( 'div' ); + textWrap.classList.add( 'frm-payment-method-text' ); + + const labelText = document.createElement( 'span' ); + labelText.classList.add( 'frm-payment-method-label-text' ); + labelText.textContent = method.label; + textWrap.append( labelText ); + + // Mark column: will be populated by renderMarks() after the group is in the DOM. + const markWrap = document.createElement( 'div' ); + markWrap.classList.add( 'frm-payment-method-mark' ); + markWrap.id = `frm-payment-mark-${ key }`; + + const baseUrl = frmPayPalVars.imagesUrl || ''; + + if ( key === 'card' ) { + const cardBrands = [ + { file: 'visa.svg', alt: 'Visa' }, + { file: 'mastercard.svg', alt: 'Mastercard' }, + { file: 'amex.svg', alt: 'American Express' }, + { file: 'discover.svg', alt: 'Discover' }, + ]; + cardBrands.forEach( function( brand ) { + const img = document.createElement( 'img' ); + img.src = baseUrl + brand.file; + img.alt = brand.alt; + img.height = 24; + markWrap.append( img ); + } ); + } else if ( key === 'google_pay' ) { + markWrap.classList.add( 'frm-payment-method-google-pay-icon' ); + const img = document.createElement( 'img' ); + img.src = `${ baseUrl }gpay.svg`; + img.alt = 'Google Pay'; + img.height = 24; + markWrap.append( img ); + } else if ( key === 'apple_pay' ) { + markWrap.classList.add( 'frm-payment-method-apple-pay-icon' ); + const img = document.createElement( 'img' ); + img.src = `${ baseUrl }apple-pay.svg`; + img.alt = 'Apple Pay'; + img.height = 24; + img.style.width = 'auto'; + markWrap.append( img ); + } + + label.append( radio ); + label.append( textWrap ); + label.append( markWrap ); + + if ( key === 'paylater' ) { + // Wrap the label and a message container in a div. + const wrapper = document.createElement( 'div' ); + wrapper.classList.add( 'frm-payment-method-paylater-wrap' ); + wrapper.append( label ); + + const msgContainer = document.createElement( 'div' ); + msgContainer.id = 'frm-paylater-message'; + msgContainer.classList.add( 'frm-payment-method-paylater-msg' ); + wrapper.append( msgContainer ); + + return wrapper; + } + + return label; + } + /** * Render PayPal Marks into the radio group containers. * Must be called AFTER the radio group is appended to the DOM, From 4befcb7685650e6bb4695770184008fb21c84167 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Mon, 8 Jun 2026 14:18:36 -0300 Subject: [PATCH 2/3] Do not wait if google pay / apple pay are disabled --- .../FrmPayPalLiteActionsController.php | 15 ++++++++------- paypal/js/frontend.js | 6 ++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/paypal/controllers/FrmPayPalLiteActionsController.php b/paypal/controllers/FrmPayPalLiteActionsController.php index 00c243fff9..2410b5a66a 100644 --- a/paypal/controllers/FrmPayPalLiteActionsController.php +++ b/paypal/controllers/FrmPayPalLiteActionsController.php @@ -1409,13 +1409,14 @@ function ( $tag, $handle ) use ( $has_break ) { } $paypal_vars = array( - 'formId' => $form_id, - 'nonce' => wp_create_nonce( 'frm_paypal_ajax' ), - 'ajax' => esc_url_raw( FrmAppHelper::get_ajax_url() ), - 'settings' => $action_settings, - 'style' => self::get_style_for_js( $form_id ), - 'buttonStyle' => self::get_button_style_for_js( $action ), - 'imagesUrl' => FrmPayPalLiteAppHelper::plugin_url() . 'images/', + 'formId' => $form_id, + 'nonce' => wp_create_nonce( 'frm_paypal_ajax' ), + 'ajax' => esc_url_raw( FrmAppHelper::get_ajax_url() ), + 'settings' => $action_settings, + 'style' => self::get_style_for_js( $form_id ), + 'buttonStyle' => self::get_button_style_for_js( $action ), + 'imagesUrl' => FrmPayPalLiteAppHelper::plugin_url() . 'images/', + 'includeGooglePayApplePay' => $include_google_apple_pay, ); wp_localize_script( 'formidable-paypal', 'frmPayPalVars', $paypal_vars ); diff --git a/paypal/js/frontend.js b/paypal/js/frontend.js index c1ff757e27..b41ee9c5aa 100644 --- a/paypal/js/frontend.js +++ b/paypal/js/frontend.js @@ -298,6 +298,12 @@ return; } + // Skip (and avoid the SDK wait) when Google Pay / Apple Pay were not enqueued + // server-side, e.g. via the frm_include_google_pay_apple_pay filter or non-SSL. + if ( ! frmPayPalVars.includeGooglePayApplePay ) { + return; + } + // Resolve both eligibility checks in parallel so the combined wait is bounded by // the slower of the two, then register them in a fixed order (Google Pay, then // Apple Pay). Registration happens before the selector is built, so they render From 9de81eb53c2725086a5c223342342204a88df81d Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Mon, 8 Jun 2026 14:19:49 -0300 Subject: [PATCH 3/3] Wait for 3 seconds --- paypal/js/frontend.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paypal/js/frontend.js b/paypal/js/frontend.js index b41ee9c5aa..46459ddef1 100644 --- a/paypal/js/frontend.js +++ b/paypal/js/frontend.js @@ -367,7 +367,7 @@ * * @return {Promise} Whether the predicate became true before the timeout. */ - function waitFor( predicate, timeout = 2000, interval = 50 ) { + function waitFor( predicate, timeout = 3000, interval = 50 ) { return new Promise( resolve => { if ( predicate() ) { resolve( true );