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:
Here's a video of the issue for clarity:
full-issue-demonstration.webm
To Reproduce
Steps to reproduce the behavior:
- 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' );
- Navigate to the site's checkout page and open the WordPress customizer
- Update a customizer setting with
'transport' => 'refresh'
(i.e. check the "Enable Simple Checkbox" setting if using the above "Simple Customizer Checkbox" plugin - 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 - If the issue doesn't occur immediately, try spamming changes to the setting and/or throttling the CPU on your browser.
- 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:
- The
client/blocks/upe/index.js
script calls thegetDeferredIntentCreationUPEFields
function while registering a checkout method to WooCommerce blocks (here are docs for the registerPaymentMethod):woocommerce-gateway-stripe/client/blocks/upe/index.js
Lines 42 to 87 in f89dee1
- The
getDeferredIntentCreationUPEFields
returns thePaymentElements
component to the WooCommerce block registration: - The
PaymentElements
React component calls theinitializeUPEAppearance
function: - The
initializeUPEAppearance
function calls thegetStripeServerData
function:woocommerce-gateway-stripe/client/stripe-utils/utils.js
Lines 461 to 485 in f89dee1
- The
getStripeServerData
function assumes that thewc_stripe_upe_params
variable will be available in the global scope and fails when it is not defined:woocommerce-gateway-stripe/client/stripe-utils/utils.js
Lines 18 to 31 in f89dee1
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>
);
};