woocommerce / woocommerce-gateway-stripe

The official Stripe Payment Gateway for WooCommerce
https://wordpress.org/plugins/woocommerce-gateway-stripe/
235 stars 206 forks source link

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

Closed danielshawellis closed 1 month ago

danielshawellis commented 4 months ago

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' );



2. Navigate to the site's checkout page and open the WordPress customizer
3. Update a customizer setting with `'transport' => 'refresh'` (i.e. check the "Enable Simple Checkbox" setting if using the above "Simple Customizer Checkbox" plugin  
4. 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
5. If the issue doesn't occur immediately, try spamming changes to the setting and/or [throttling the CPU on your browser](https://umaar.com/dev-tips/88-cpu-throttling/).
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.
danielshawellis commented 2 months ago

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): https://github.com/woocommerce/woocommerce-gateway-stripe/blob/f89dee1338f2c647664fa51fd7180d688fccc745/client/blocks/upe/index.js#L42-L87
  2. The getDeferredIntentCreationUPEFields returns the PaymentElements component to the WooCommerce block registration: https://github.com/woocommerce/woocommerce-gateway-stripe/blob/f89dee1338f2c647664fa51fd7180d688fccc745/client/blocks/upe/upe-deferred-intent-creation/payment-elements.js#L51-L81
  3. The PaymentElements React component calls the initializeUPEAppearance function: https://github.com/woocommerce/woocommerce-gateway-stripe/blob/f89dee1338f2c647664fa51fd7180d688fccc745/client/blocks/upe/upe-deferred-intent-creation/payment-elements.js#L13-L49
  4. The initializeUPEAppearance function calls the getStripeServerData function: https://github.com/woocommerce/woocommerce-gateway-stripe/blob/f89dee1338f2c647664fa51fd7180d688fccc745/client/stripe-utils/utils.js#L461-L485
  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: https://github.com/woocommerce/woocommerce-gateway-stripe/blob/f89dee1338f2c647664fa51fd7180d688fccc745/client/stripe-utils/utils.js#L18-L31
danielshawellis commented 2 months ago

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: https://github.com/danielshawellis/woocommerce-gateway-stripe/commit/6233201a8df1ba6bd92ea89126103216ffa0582c

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();
}
danielshawellis commented 2 months ago

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: https://github.com/danielshawellis/woocommerce-gateway-stripe/commit/9da0f95d8b42000d1558d430e616994dc9ba0606

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>
    );
};
danielshawellis commented 1 month ago

Thank you @Mayisha for solving this in #3387!