Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions paypal/controllers/FrmPayPalLiteActionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -669,9 +669,9 @@

$captures = $payments->captures;

foreach ( $captures as $capture ) {
return $capture->id;
}

Check warning on line 674 in paypal/controllers/FrmPayPalLiteActionsController.php

View workflow job for this annotation

GitHub Actions / Mago

loop-does-not-iterate

Loop is unconditionally terminated and will not iterate. >This loop will only execute once at most >The loop is exited by this unconditional `return` statement Loops that do not iterate are often misleading and can indicate a logic error or redundant code. Help: Consider refactoring the code. If the loop is intended to run only once, an `if` statement might be clearer.
}

return '';
Expand Down Expand Up @@ -1409,13 +1409,14 @@
}

$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 );
Expand Down
307 changes: 210 additions & 97 deletions paypal/js/frontend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } );
Comment on lines +134 to +150

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Guard paypalInit() against stale async reruns.

These new deferred awaits create a real interleaving window where an older paypalInit() can resume after a later run has already cleared and rebuilt the shared module state. Because paymentMethods, thisForm, cardFieldsValid, and the SDK caches are all module-scoped, the stale run can register methods into the new registry, append UI against the wrong form state, or re-enable card submit using validity from the previous render. Add a monotonically increasing init token and reset the rest of the per-run globals before the first await.

Suggested direction
+	let initRunId = 0;
+
 	async function paypalInit() {
+		const runId = ++initRunId;
 		const cardElement = document.querySelector( '.frm-card-element' );
 		if ( ! cardElement ) {
 			return;
 		}
@@
 		// Reset state so each run rediscovers cleanly (the DOM above was cleared).
 		paymentMethods.clear();
 		selectedMethod = null;
+		cardFieldsValid = false;
+		submitEvent = null;
+		googlePayConfig = null;
+		applePayConfig = null;
+		applePayInstance = null;
 
 		// 1. Discover synchronous methods (Card, PayPal, alternative funding).
 		await discoverPaymentMethods( {
 			cardFieldsAreSupported,
 			buttonsAreEnabled,
 			isRecurring
 		} );
+		if ( runId !== initRunId ) {
+			return;
+		}
@@
 		await discoverDeferredPaymentMethods( { buttonsAreEnabled, isRecurring } );
+		if ( runId !== initRunId ) {
+			return;
+		}
+
+		// Apply the same stale-run guard after later awaited init steps that mutate DOM/state.

Also applies to: 294-308

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@paypal/js/frontend.js` around lines 134 - 150, The async interleaving in
paypalInit() can let stale runs mutate module-scoped state (paymentMethods,
thisForm, cardFieldsValid, SDK caches) after a newer run has already reset
things; add a monotonically increasing init token (e.g., module-scoped
initCounter and local runToken) and increment/init it before any awaits, reset
per-run globals (paymentMethods.clear(), selectedMethod=null, thisForm=null,
cardFieldsValid=false, clear relevant SDK caches) immediately before the first
await, then in any async continuations (after await discoverPaymentMethods and
await discoverDeferredPaymentMethods and the other affected block at 294-308)
check that the current module initCounter still equals the runToken and bail out
early if not, ensuring only the latest paypalInit run mutates shared state.


if ( paymentMethods.size === 0 ) {
displayPaymentFailure( 'No payment methods available.' );
return;
Expand Down Expand Up @@ -263,27 +274,117 @@
}
}

// --- 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<void>}
*/
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
} );
}
// 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
// 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<boolean>} 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<boolean>} 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<boolean>} Whether the predicate became true before the timeout.
*/
function waitFor( predicate, timeout = 3000, 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 );
} );
}

/**
Expand Down Expand Up @@ -319,89 +420,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,
Expand Down
Loading