Commit a5ddaafdd05 for woocommerce

commit a5ddaafdd05d14cce15e5578d91d5da20f225490
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Oct 23 12:08:11 2026 +0100

    Stop one broken express payment button from hiding the others (#70136)

    * Fix one broken express payment button hiding all the others

    Give each express payment method its own error boundary. A broken
    button now shows an inline error in the editor and is hidden on the
    storefront, so the remaining buttons keep working.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Add changelog entry for express payment error boundary fix

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/42256-express-payment-error-boundary b/plugins/woocommerce/changelog/42256-express-payment-error-boundary
new file mode 100644
index 00000000000..d1859925f8c
--- /dev/null
+++ b/plugins/woocommerce/changelog/42256-express-payment-error-boundary
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep other express payment buttons working when one fails to load in the Cart and Checkout blocks.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.tsx
index bb8318ca610..3e6a5a61400 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.tsx
@@ -1,7 +1,8 @@
 /**
  * External dependencies
  */
-import { __ } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
+import NoticeBanner from '@woocommerce/base-components/notice-banner';
 import {
 	useExpressPaymentMethods,
 	usePaymentMethodInterface,
@@ -180,26 +181,54 @@ const ExpressPaymentMethods = () => {
 				const expressPaymentMethod = isEditor
 					? paymentMethod.edit
 					: paymentMethod.content;
-				return isValidElement( expressPaymentMethod ) ? (
+				const itemId = `express-payment-method-${ id }`;
+				// Each method gets its own error boundary so one broken button doesn't take down the others. A
+				// broken button shows an inline error in the editor and is hidden on the storefront.
+				const errorFallback = isEditor ? (
 					<ExpressPayItem
-						key={ id }
-						id={ `express-payment-method-${ id }` }
-						className={ clsx( {
-							'wc-block-components-express-payment__event-button--focused':
-								focusedExpressPaymentMethod === id,
-						} ) }
+						id={ itemId }
+						style={
+							buttonAttributes
+								? {
+										minHeight: `${ buttonAttributes.height }px`,
+									}
+								: undefined
+						}
 					>
-						{ cloneElement( expressPaymentMethod, {
-							...paymentMethodInterface,
-							onClick: onExpressPaymentClick( id ),
-							onClose: onExpressPaymentClose,
-							onError: onExpressPaymentError,
-							setExpressPaymentError:
-								deprecatedSetExpressPaymentError,
-							buttonAttributes,
-						} ) }
+						<NoticeBanner status="error" isDismissible={ false }>
+							{ sprintf(
+								/* translators: %s: express payment method title, e.g. "Apple Pay". */
+								__( "%s couldn't load.", 'woocommerce' ),
+								paymentMethod.title
+							) }
+						</NoticeBanner>
 					</ExpressPayItem>
 				) : null;
+				return isValidElement( expressPaymentMethod ) ? (
+					<PaymentMethodErrorBoundary
+						key={ id }
+						isEditor={ isEditor }
+						fallback={ errorFallback }
+					>
+						<ExpressPayItem
+							id={ itemId }
+							className={ clsx( {
+								'wc-block-components-express-payment__event-button--focused':
+									focusedExpressPaymentMethod === id,
+							} ) }
+						>
+							{ cloneElement( expressPaymentMethod, {
+								...paymentMethodInterface,
+								onClick: onExpressPaymentClick( id ),
+								onClose: onExpressPaymentClose,
+								onError: onExpressPaymentError,
+								setExpressPaymentError:
+									deprecatedSetExpressPaymentError,
+								buttonAttributes,
+							} ) }
+						</ExpressPayItem>
+					</PaymentMethodErrorBoundary>
+				) : null;
 			} )
 		) : (
 			<div key="noneRegistered">
@@ -208,14 +237,12 @@ const ExpressPaymentMethods = () => {
 		);

 	return (
-		<PaymentMethodErrorBoundary isEditor={ isEditor }>
-			<ExpressPayWrapper
-				className="wc-block-components-express-payment__event-buttons"
-				ref={ expressPaymentWrapperRef }
-			>
-				{ content }
-			</ExpressPayWrapper>
-		</PaymentMethodErrorBoundary>
+		<ExpressPayWrapper
+			className="wc-block-components-express-payment__event-buttons"
+			ref={ expressPaymentWrapperRef }
+		>
+			{ content }
+		</ExpressPayWrapper>
 	);
 };

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment/style.scss
index b0cdb3ce7a8..216aa23c145 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment/style.scss
@@ -41,6 +41,21 @@ $border-width: 1px;
 				width: 100%;
 				height: 48px;
 			}
+
+			// Inline error shown in the editor in place of a broken button. It inherits the item's min-height, which is
+			// set to the button height, and uses a fixed font size because the cart sets the item's font size to 0.
+			> .wc-block-components-notice-banner {
+				@include font-small-locked;
+				align-items: center;
+				box-sizing: border-box;
+				margin: 0;
+				min-height: inherit;
+				padding-block: 0 !important;
+
+				> svg {
+					height: 24px;
+				}
+			}
 		}
 	}
 }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-error-boundary.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-error-boundary.tsx
index 08bf40d6753..cd195a562d3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-error-boundary.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-error-boundary.tsx
@@ -3,6 +3,7 @@
  */
 import { __ } from '@wordpress/i18n';
 import { Component } from '@wordpress/element';
+import type { ReactNode } from 'react';
 import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
 import { StoreNoticesContainer } from '@woocommerce/blocks-components';
 import { noticeContexts } from '@woocommerce/base-context';
@@ -14,7 +15,11 @@ import {

 interface PaymentMethodErrorBoundaryProps {
 	isEditor: boolean;
-	children: React.ReactNode;
+	children: ReactNode;
+	/**
+	 * Rendered instead of the default error notice when a child throws. Pass `null` to render nothing.
+	 */
+	fallback?: ReactNode;
 }

 class PaymentMethodErrorBoundary extends Component< PaymentMethodErrorBoundaryProps > {
@@ -28,9 +33,12 @@ class PaymentMethodErrorBoundary extends Component< PaymentMethodErrorBoundaryPr

 	render() {
 		const { hasError, errorMessage } = this.state;
-		const { isEditor } = this.props;
+		const { isEditor, fallback } = this.props;

 		if ( hasError ) {
+			if ( fallback !== undefined ) {
+				return fallback;
+			}
 			let errorText = __(
 				'We are experiencing difficulties with this payment method. Please contact us for assistance.',
 				'woocommerce'
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/express-payment-methods.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/express-payment-methods.tsx
index d84d22f93b3..2e2eadd804b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/express-payment-methods.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/express-payment-methods.tsx
@@ -22,6 +22,7 @@ import mockEditorContext from './__mocks__/editor-context';
 import { getExpectedExpressPaymentProps } from './__mocks__/express-payment-props';
 import ExpressPaymentMethods from '../express-payment-methods';
 jest.mock( '@woocommerce/base-context', () => ( {
+	...jest.requireActual( '@woocommerce/base-context' ),
 	useEditorContext: mockEditorContext,
 } ) );

@@ -384,4 +385,82 @@ describe( 'Express payment methods', () => {
 			} );
 		} );
 	} );
+
+	describe( 'One payment method throws an error', () => {
+		const BrokenExpressButton = () => {
+			throw new Error( 'Broken express payment method' );
+		};
+
+		beforeAll( async () => {
+			registerExpressPaymentMethod( {
+				name: 'paypal',
+				title: 'PayPal',
+				gatewayId: 'test-express-payment-method',
+				content: <MockExpressButton name="paypal" />,
+				edit: <MockEditorExpressButton name="paypal" />,
+				canMakePayment: () => true,
+				supports: { features: [ 'products' ] },
+			} );
+			registerExpressPaymentMethod( {
+				name: 'apple-pay',
+				title: 'Apple Pay',
+				gatewayId: 'test-express-payment-method',
+				content: <BrokenExpressButton />,
+				edit: <BrokenExpressButton />,
+				canMakePayment: () => true,
+				supports: { features: [ 'products' ] },
+			} );
+			await dispatch(
+				paymentStore
+			).__internalUpdateAvailablePaymentMethods();
+		} );
+		afterAll( () => {
+			deregisterMockExpressPaymentMethods();
+		} );
+
+		it( 'should hide the broken method on the storefront and keep the others', () => {
+			mockEditorContext.mockImplementation( () => ( {
+				isEditor: false,
+			} ) );
+			render( <ExpressPaymentMethods /> );
+
+			expect( screen.getByText( 'paypal button' ) ).toBeVisible();
+			expect(
+				document.querySelector( '#express-payment-method-apple-pay' )
+			).toBeNull();
+			expect(
+				document.querySelector(
+					'.wc-block-components-express-payment__event-buttons'
+				)?.children
+			).toHaveLength( 1 );
+			expect(
+				document.querySelector( '.wc-block-components-notice-banner' )
+			).toBeNull();
+			expect( screen.queryByText( /couldn't load/ ) ).toBeNull();
+			// React logs errors caught by the error boundary.
+			expect( console ).toHaveErrored();
+		} );
+
+		it( 'should show an inline error for the broken method in the editor and keep the others', () => {
+			mockEditorContext.mockImplementation( () => ( {
+				isEditor: true,
+			} ) );
+			render( <ExpressPaymentMethods /> );
+
+			expect( screen.getByText( 'paypal preview' ) ).toBeVisible();
+			const brokenItem = document.querySelector(
+				'#express-payment-method-apple-pay'
+			);
+			expect( brokenItem ).toHaveProperty( 'tagName', 'LI' );
+			expect( brokenItem ).toHaveTextContent(
+				"Apple Pay couldn't load."
+			);
+			expect(
+				brokenItem?.querySelector(
+					'.wc-block-components-notice-banner.is-error'
+				)
+			).toBeInTheDocument();
+			expect( console ).toHaveErrored();
+		} );
+	} );
 } );