woocommerce/woocommerce-gateway-stripe

Checkout fields fail to render in the WordPress customizer preview after a refresh (when a customizer setting is updated)

Opened this issue · 3 comments

Describe the bug
Whenever a setting with 'transport' => 'refresh' is changed in the WordPress customizer, WordPress will reload the entire preview iframe. When this happens, the upe_blocks.js script throws the following error:

ReferenceError: wc_stripe_upe_params is not defined
    at A (upe_blocks.js?ver=9721965e3d4217790b0c028bc16f3960:1:105128)
    at b (upe_blocks.js?ver=9721965e3d4217790b0c028bc16f3960:1:110654)
    at v (upe_blocks.js?ver=9721965e3d4217790b0c028bc16f3960:1:236091)
    at wt (react-dom.min.js?ver=18.2.0:10:47637)
    at js (react-dom.min.js?ver=18.2.0:10:120584)
    at wl (react-dom.min.js?ver=18.2.0:10:88659)
    at bl (react-dom.min.js?ver=18.2.0:10:88587)
    at yl (react-dom.min.js?ver=18.2.0:10:88450)
    at fl (react-dom.min.js?ver=18.2.0:10:85607)
    at Nn (react-dom.min.js?ver=18.2.0:10:32474)

When this happens, the checkout fields will fail to render in the preview iframe:

Screenshot from 2024-06-25 15-34-23

Here's a video of the issue for clarity:

full-issue-demonstration.webm

To Reproduce
Steps to reproduce the behavior:

  1. Set up WooCommerce, the WooCommerce Stripe Gateway, and a very simple plugin to add a customizer setting with 'transport' => 'refresh'. Here's the plugin that I used for testing:
<?php
/*
Plugin Name: Simple Customizer Checkbox
*/

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

function simple_customizer_checkbox_register( $wp_customize ) {
    // Add a section to the Customizer
    $wp_customize->add_section( 'simple_checkbox_section', array(
        'title'    => __( 'Simple Checkbox Section', 'simple_customizer_checkbox' ),
        'priority' => 30,
    ) );

    // Add a setting for the checkbox
    $wp_customize->add_setting( 'simple_checkbox_setting', array(
        'default'   => false,
        'transport' => 'refresh', // Ensure that changes to the setting trigger a reload of the whole preview frame: https://developer.wordpress.org/reference/classes/WP_Customize_Setting/__construct/
    ) );

    // Add the checkbox control
    $wp_customize->add_control( 'simple_checkbox_control', array(
        'label'    => __( 'Enable Simple Checkbox', 'simple_customizer_checkbox' ),
        'section'  => 'simple_checkbox_section',
        'settings' => 'simple_checkbox_setting',
        'type'     => 'checkbox',
    ) );
}

add_action( 'customize_register', 'simple_customizer_checkbox_register' );
  1. Navigate to the site's checkout page and open the WordPress customizer
  2. Update a customizer setting with 'transport' => 'refresh' (i.e. check the "Enable Simple Checkbox" setting if using the above "Simple Customizer Checkbox" plugin
  3. Notice that when the customizer preview iframe refreshes, the checkout fields fail to render and the wc_stripe_upe_params is not defined error is visible in the JS console
  4. If the issue doesn't occur immediately, try spamming changes to the setting and/or throttling the CPU on your browser.
  5. Interestingly, notice that when the customizer iframe is targeted in the JS console, window.wc_stripe_upe_params is set even after the JS error is thrown. See the video for clarification. To me, this indicates a probable race condition.

Expected behavior
The checkout fields should render correctly in the customizer preview iframe after the iframe is refreshed.

Screenshots
See the screenshot and video in the bug description.

Environment (please complete the following information):

  • WordPress Version: 6.5.5
  • WooCommerce Version: 9.0.2
  • Stripe Plugin Version: 8.4.0
  • Browsers: Chromium Version 126.0.6478.114 (Official Build) (64-bit)
  • Other plugins installed: WooCommerce, WooCommerce Stripe Gateway, and the "Simple Customizer Checkbox" plugin (source code above in the description)

Additional context
This problem is sometimes difficult to produce and it doesn't occur on every refresh. I believe that there must be a race condition causing it. If you aren't immediately able to reproduce it, try spamming changes to the relevant customizer setting until you see issues. It may also be helpful to throttle the CPU in Chrome developer tools: https://umaar.com/dev-tips/88-cpu-throttling/ Per my tests, the issue seems to happen more reliably when the CPU is throttled.

Here's a breakdown of the source code function calls that lead to this error:

  1. The client/blocks/upe/index.js script calls the getDeferredIntentCreationUPEFields function while registering a checkout method to WooCommerce blocks (here are docs for the registerPaymentMethod):
    registerPaymentMethod( {
    name: upeMethods[ upeName ],
    content: getDeferredIntentCreationUPEFields(
    upeName,
    upeMethods,
    api,
    upeConfig.description,
    upeConfig.testingInstructions,
    upeConfig.showSaveOption ?? false
    ),
    edit: getDeferredIntentCreationUPEFields(
    upeName,
    upeMethods,
    api,
    upeConfig.description,
    upeConfig.testingInstructions,
    upeConfig.showSaveOption ?? false
    ),
    savedTokenComponent: <SavedTokenHandler api={ api } />,
    canMakePayment: ( cartData ) => {
    const billingCountry = cartData.billingAddress.country;
    const isRestrictedInAnyCountry = !! upeConfig.countries.length;
    const isAvailableInTheCountry =
    ! isRestrictedInAnyCountry ||
    upeConfig.countries.includes( billingCountry );
    return isAvailableInTheCountry && !! api.getStripe();
    },
    // see .wc-block-checkout__payment-method styles in blocks/style.scss
    label: (
    <>
    <span>
    { upeConfig.title }
    <Icon alt={ upeConfig.title } />
    </span>
    </>
    ),
    ariaLabel: 'Stripe',
    supports: {
    // Use `false` as fallback values in case server provided configuration is missing.
    showSavedCards:
    getBlocksConfiguration()?.showSavedCards ?? false,
    showSaveOption: upeConfig.showSaveOption ?? false,
    features: getBlocksConfiguration()?.supports ?? [],
    },
    } );
  2. The getDeferredIntentCreationUPEFields returns the PaymentElements component to the WooCommerce block registration:
    /**
    * Renders a Stripe Payment elements component.
    *
    * @param {string} paymentMethodId
    * @param {Array} upeMethods
    * @param {WCStripeAPI} api
    * @param {string} description
    * @param {string} testingInstructions
    * @param {boolean} showSaveOption
    *
    * @return {JSX.Element} Rendered Payment elements.
    */
    export const getDeferredIntentCreationUPEFields = (
    paymentMethodId,
    upeMethods,
    api,
    description,
    testingInstructions,
    showSaveOption
    ) => {
    return (
    <PaymentElements
    paymentMethodId={ paymentMethodId }
    upeMethods={ upeMethods }
    api={ api }
    description={ description }
    testingInstructions={ testingInstructions }
    showSaveOption={ showSaveOption }
    />
    );
    };
  3. The PaymentElements React component calls the initializeUPEAppearance function:
    /**
    * Renders a Stripe Payment elements component.
    *
    * @param {*} props Additional props for payment processing.
    * @param {WCStripeAPI} props.api Object containing methods for interacting with Stripe.
    * @param {string} props.paymentMethodId The ID of the payment method.
    *
    * @return {JSX.Element} Rendered Payment elements.
    */
    const PaymentElements = ( { api, ...props } ) => {
    const stripe = api.getStripe();
    const amount = Number( getBlocksConfiguration()?.cartTotal );
    const currency = getBlocksConfiguration()?.currency.toLowerCase();
    const appearance = initializeUPEAppearance( api, 'true' );
    const options = {
    mode: amount < 1 ? 'setup' : 'payment',
    amount,
    currency,
    paymentMethodCreation: 'manual',
    paymentMethodTypes: getPaymentMethodTypes( props.paymentMethodId ),
    appearance,
    };
    // If the cart contains a subscription or the payment method supports saving, we need to use off_session setup so Stripe can display appropriate terms and conditions.
    if (
    getBlocksConfiguration()?.cartContainsSubscription ||
    props.showSaveOption
    ) {
    options.setupFutureUsage = 'off_session';
    }
    return (
    <Elements stripe={ stripe } options={ options }>
    <PaymentProcessor api={ api } { ...props } />
    </Elements>
    );
    };
  4. The initializeUPEAppearance function calls the getStripeServerData function:
    /**
    * Initializes the appearance of the payment element by retrieving the UPE configuration
    * from the API and saving the appearance if it doesn't exist.
    *
    * If the appearance already exists, it is simply returned.
    *
    * @param {Object} api The API object used to save the appearance.
    * @param {string} isBlockCheckout Whether the checkout is being used in a block context.
    *
    * @return {Object} The appearance object for the UPE.
    */
    export const initializeUPEAppearance = ( api, isBlockCheckout = 'false' ) => {
    let appearance =
    isBlockCheckout === 'true'
    ? getStripeServerData()?.blocksAppearance
    : getStripeServerData()?.appearance;
    // If appearance is empty, get a fresh copy and save it in a transient.
    if ( ! appearance ) {
    appearance = getAppearance();
    api.saveAppearance( appearance, isBlockCheckout );
    }
    return appearance;
    };
  5. The getStripeServerData function assumes that the wc_stripe_upe_params variable will be available in the global scope and fails when it is not defined:
    /**
    * Stripe data comes form the server passed on a global object.
    *
    * @return {StripeServerData} Stripe server data.
    */
    const getStripeServerData = () => {
    // Classic checkout.
    // eslint-disable-next-line camelcase
    if ( ! wc_stripe_upe_params ) {
    throw new Error( 'Stripe initialization data is not available' );
    }
    // eslint-disable-next-line camelcase
    return wc_stripe_upe_params;
    };

This problem can be fixed by configuring the /client/blocks/upe/index.js script to wait for window.wc_stripe_upe_params to be defined before registering payment methods.

This is messy, but it may be a step towards a better approach.

Here's a commit demonstrating these edits: danielshawellis@6233201

And here's the code:

const registerPaymentMethods = () => {
	const upeMethods = getPaymentMethodsConstants();
	Object.entries( getBlocksConfiguration()?.paymentMethodsConfig )
		.filter( ( [ upeName ] ) => upeName !== 'link' )
		.filter( ( [ upeName ] ) => upeName !== 'giropay' ) // Skip giropay as it was deprecated by Jun, 30th 2024.
		.forEach( ( [ upeName, upeConfig ] ) => {
			let iconName = upeName;

			// Afterpay/Clearpay have different icons for UK merchants.
			if ( upeName === 'afterpay_clearpay' ) {
				iconName =
					getBlocksConfiguration()?.accountCountry === 'GB'
						? 'clearpay'
						: 'afterpay';
			}

			const Icon = Icons[ iconName ];

			registerPaymentMethod( {
				name: upeMethods[ upeName ],
				content: getDeferredIntentCreationUPEFields(
					upeName,
					upeMethods,
					api,
					upeConfig.description,
					upeConfig.testingInstructions,
					upeConfig.showSaveOption ?? false
				),
				edit: getDeferredIntentCreationUPEFields(
					upeName,
					upeMethods,
					api,
					upeConfig.description,
					upeConfig.testingInstructions,
					upeConfig.showSaveOption ?? false
				),
				savedTokenComponent: <SavedTokenHandler api={ api } />,
				canMakePayment: ( cartData ) => {
					const billingCountry = cartData.billingAddress.country;
					const isRestrictedInAnyCountry = !! upeConfig.countries.length;
					const isAvailableInTheCountry =
						! isRestrictedInAnyCountry ||
						upeConfig.countries.includes( billingCountry );

					return isAvailableInTheCountry && !! api.getStripe();
				},
				// see .wc-block-checkout__payment-method styles in blocks/style.scss
				label: (
					<>
						<span>
							{ upeConfig.title }
							<Icon alt={ upeConfig.title } />
						</span>
					</>
				),
				ariaLabel: 'Stripe',
				supports: {
					// Use `false` as fallback values in case server provided configuration is missing.
					showSavedCards:
						getBlocksConfiguration()?.showSavedCards ?? false,
					showSaveOption: upeConfig.showSaveOption ?? false,
					features: getBlocksConfiguration()?.supports ?? [],
				},
			} );
		} );
};

// If this script is running in the customizer preview pane, wait for wc_stripe_upe_params to be defined on the window before registering payment methods
if ( wp.customize ) {
	const upeParamsReady = new Promise( ( resolve ) => {
		const interval = setInterval( () => {
			if ( window.wc_stripe_upe_params !== undefined ) {
				clearInterval( interval );
				resolve();
			}
		}, 10 );
	} );
	upeParamsReady.then( registerPaymentMethods );
} else {
	registerPaymentMethods();
}

A cleaner approach is to wait for wc_stripe_upe_params to be defined on the window before rendering the payment elements in the /client/blocks/upe/upe-deferred-intent-creation/payment-elements.js file.

Here's a commit demonstrating these edits: danielshawellis@9da0f95

And here's the code:

/**
 * Renders a Stripe Payment elements component.
 *
 * @param {*}           props                 Additional props for payment processing.
 * @param {WCStripeAPI} props.api             Object containing methods for interacting with Stripe.
 * @param {string}      props.paymentMethodId The ID of the payment method.
 *
 * @return {JSX.Element} Rendered Payment elements.
 */
const PaymentElements = ( { api, ...props } ) => {
	// If this script is running in the customizer preview pane, wait for wc_stripe_upe_params to be defined on the window before rendering
	const customizerIsRunning = wp.customize !== undefined;
	const [ upeParamsReady, setUpeParamsReady ] = useState(
		! customizerIsRunning
	);
	useEffect( () => {
		if ( ! customizerIsRunning ) return;
		const ready = new Promise( ( resolve ) => {
			const interval = setInterval( () => {
				if ( window.wc_stripe_upe_params !== undefined ) {
					clearInterval( interval );
					resolve();
				}
			}, 10 );
		} );
		ready.then( () => setUpeParamsReady( true ) );
	}, [ customizerIsRunning ] );
	if ( ! upeParamsReady ) return <div />;

	const stripe = api.getStripe();
	const amount = Number( getBlocksConfiguration()?.cartTotal );
	const currency = getBlocksConfiguration()?.currency.toLowerCase();
	const appearance = initializeUPEAppearance( api, 'true' );
	const options = {
		mode: amount < 1 ? 'setup' : 'payment',
		amount,
		currency,
		paymentMethodCreation: 'manual',
		paymentMethodTypes: getPaymentMethodTypes( props.paymentMethodId ),
		appearance,
	};

	// If the cart contains a subscription or the payment method supports saving, we need to use off_session setup so Stripe can display appropriate terms and conditions.
	if (
		getBlocksConfiguration()?.cartContainsSubscription ||
		props.showSaveOption
	) {
		options.setupFutureUsage = 'off_session';
	}

	return (
		<Elements stripe={ stripe } options={ options }>
			<PaymentProcessor api={ api } { ...props } />
		</Elements>
	);
};