Commit a40a943d902 for woocommerce

commit a40a943d902b01f94bed17a8eb0eb9399ceed142
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Fri Oct 30 10:06:38 2026 +0000

    Fix add to cart buttons for cross-sells in Safari and Firefox (#70238)

    * Fix Cart cross-sell buttons losing click handlers

    * Add changelog entry for Cart cross-sell hydration fix

    * Update naming

    * Remove added Cart cross-sell E2E regression test

diff --git a/plugins/woocommerce/changelog/70147-fix-cart-cross-sells-hydration b/plugins/woocommerce/changelog/70147-fix-cart-cross-sells-hydration
new file mode 100644
index 00000000000..0f6f90940f4
--- /dev/null
+++ b/plugins/woocommerce/changelog/70147-fix-cart-cross-sells-hydration
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix cross-sell Add to cart buttons sometimes not responding in Safari and other browsers.
diff --git a/plugins/woocommerce/client/blocks/assets/js/utils/render-parent-block.tsx b/plugins/woocommerce/client/blocks/assets/js/utils/render-parent-block.tsx
index 93cf9f3da11..e354e4f070b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/utils/render-parent-block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/utils/render-parent-block.tsx
@@ -8,6 +8,7 @@ import {
 	Suspense,
 	isValidElement,
 	cloneElement,
+	useCallback,
 } from '@wordpress/element';
 import parse from 'html-react-parser';
 import {
@@ -17,6 +18,7 @@ import {
 import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
 import type { ReactRootWithContainer } from '@woocommerce/base-utils';
 import { RegisteredBlockComponent } from '@woocommerce/types';
+import type * as React from 'react';

 /**
  * This file contains logic used on the frontend to convert DOM elements (saved by the block editor) to React
@@ -89,7 +91,7 @@ const renderForcedBlocks = (
 				(
 					{ blockName, component },
 					index: number
-				): JSX.Element | null => {
+				): React.JSX.Element | null => {
 					const ForcedComponent = component
 						? component
 						: getBlockComponentFromMap( blockName, blockMap );
@@ -125,6 +127,20 @@ interface RenderInnerBlocksProps {
 	depth?: number;
 }

+/**
+ * Keep interactive regions outside React's DOM reconciliation so their hydration and event handlers remain.
+ */
+const InteractivityApiElement = ( { node }: { node: HTMLElement } ) => {
+	const ref = useCallback(
+		( container: HTMLDivElement | null ) => {
+			container?.appendChild( node );
+		},
+		[ node ]
+	);
+
+	return <div ref={ ref } style={ { display: 'contents' } } />;
+};
+
 /**
  * Recursively replace block markup in the DOM with React Components.
  */
@@ -139,7 +155,7 @@ const renderInnerBlocks = ( {
 	children,
 	// Current depth of the children. Used to ensure keys are unique.
 	depth = 1,
-}: RenderInnerBlocksProps ): ( string | JSX.Element | null )[] | null => {
+}: RenderInnerBlocksProps ): ( string | React.JSX.Element | null )[] | null => {
 	if ( ! children || children.length === 0 ) {
 		return null;
 	}
@@ -165,6 +181,18 @@ const renderInnerBlocks = ( {
 		 * Note we use childNodes rather than children so that text nodes are also rendered.
 		 */
 		if ( ! InnerBlockComponent ) {
+			if (
+				node instanceof HTMLElement &&
+				node.hasAttribute( 'data-wp-interactive' )
+			) {
+				return (
+					<InteractivityApiElement
+						key={ componentKey }
+						node={ node }
+					/>
+				);
+			}
+
 			const parsedElement = parse(
 				( node instanceof Element && node?.outerHTML ) ||
 					node?.textContent ||
diff --git a/plugins/woocommerce/client/blocks/assets/js/utils/test/render-parent-block.test.tsx b/plugins/woocommerce/client/blocks/assets/js/utils/test/render-parent-block.test.tsx
index ec6d95f6118..d63e06ce2ad 100644
--- a/plugins/woocommerce/client/blocks/assets/js/utils/test/render-parent-block.test.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/utils/test/render-parent-block.test.tsx
@@ -1,7 +1,9 @@
 /**
  * External dependencies
  */
-import { act } from '@testing-library/react';
+import { act, fireEvent, screen, within } from '@testing-library/react';
+import { useState } from '@wordpress/element';
+import type { ReactNode } from 'react';

 /**
  * Internal dependencies
@@ -9,24 +11,39 @@ import { act } from '@testing-library/react';
 import { renderParentBlock } from '../render-parent-block';

 describe( 'renderParentBlock', () => {
-	afterEach( () => {
-		document.body.innerHTML = '';
-	} );
-
-	it( 'keeps the markup inside template elements', async () => {
-		document.body.innerHTML =
-			'<div class="test-parent"><div class="inner"><template data-wp-each="state.items"><p class="item">Item</p></template></div></div>';
+	let roots: ReturnType< typeof renderParentBlock > = [];

+	const renderTestParent = async (
+		props: Partial< Parameters< typeof renderParentBlock >[ 0 ] > = {}
+	) => {
+		// renderParentBlock creates its own root, outside Testing Library's render.
+		// eslint-disable-next-line testing-library/no-unnecessary-act
 		await act( async () => {
-			renderParentBlock( {
+			roots = renderParentBlock( {
 				Block: ( { children } ) => <div>{ children }</div>,
 				selector: '.test-parent',
 				blockName: 'woocommerce/test-parent',
 				blockMap: {},
 				getProps: () => ( {} ),
 				options: { multiple: true },
+				...props,
 			} );
 		} );
+	};
+
+	afterEach( () => {
+		act( () => {
+			roots.forEach( ( { root } ) => root.unmount() );
+		} );
+		roots = [];
+		document.body.innerHTML = '';
+	} );
+
+	it( 'keeps the markup inside template elements', async () => {
+		document.body.innerHTML =
+			'<div class="test-parent"><div class="inner"><template data-wp-each="state.items"><p class="item">Item</p></template></div></div>';
+
+		await renderTestParent();

 		const template = document.querySelector( 'template' );

@@ -35,4 +52,92 @@ describe( 'renderParentBlock', () => {
 		);
 		expect( template?.innerHTML ).toBe( '<p class="item">Item</p>' );
 	} );
+
+	it( 'keeps interactive nodes and their existing event handlers', async () => {
+		document.body.innerHTML =
+			'<div class="test-parent"><div class="inner"><div data-wp-interactive="woocommerce/product-collection"><button data-wp-on--click="actions.addCartItem">Add to cart</button><template data-wp-each="state.notices"><p>Notice</p></template></div></div></div>';
+		const region = document.querySelector( '[data-wp-interactive]' );
+		const button = screen.getByRole( 'button' );
+		const template = document.querySelector( 'template' );
+		const onClick = jest.fn();
+		button.addEventListener( 'click', onClick );
+
+		await renderTestParent();
+
+		fireEvent.click( screen.getByRole( 'button' ) );
+		expect( onClick ).toHaveBeenCalledTimes( 1 );
+		expect( document.querySelector( '[data-wp-interactive]' ) ).toBe(
+			region
+		);
+		expect( document.querySelector( 'button' ) ).toBe( button );
+		expect( document.querySelector( 'template' ) ).toBe( template );
+		expect( template?.innerHTML ).toBe( '<p>Notice</p>' );
+	} );
+
+	it( 'keeps interactive regions available to hydration that started before React rendering', async () => {
+		document.body.innerHTML =
+			'<div class="test-parent"><div data-wp-interactive="woocommerce/product-collection"><button>Add to cart</button></div></div>';
+		// Hydration queries regions before yielding to other work on the main thread.
+		const regions = document.querySelectorAll< HTMLElement >(
+			'[data-wp-interactive]'
+		);
+		const onClick = jest.fn();
+
+		await renderTestParent();
+
+		within( regions[ 0 ] )
+			.getByRole( 'button' )
+			.addEventListener( 'click', onClick );
+		fireEvent.click( screen.getByRole( 'button' ) );
+
+		expect( regions[ 0 ].isConnected ).toBe( true );
+		expect( onClick ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'leaves interactive updates intact when the React parent rerenders', async () => {
+		document.body.innerHTML =
+			'<div class="test-parent"><div data-wp-interactive="woocommerce/product-collection"><button>Add to cart</button></div></div>';
+		const button = screen.getByRole( 'button' );
+		const Parent = ( { children }: { children: ReactNode } ) => {
+			const [ count, setCount ] = useState( 0 );
+			return (
+				<div>
+					<button onClick={ () => setCount( count + 1 ) }>
+						Update
+					</button>
+					<p>{ count }</p>
+					{ children }
+				</div>
+			);
+		};
+
+		await renderTestParent( { Block: Parent } );
+		button.textContent = '1 in cart';
+		fireEvent.click( screen.getByRole( 'button', { name: 'Update' } ) );
+
+		expect( document.querySelector( 'p' ) ).toHaveTextContent( '1' );
+		expect( document.querySelector( '[data-wp-interactive] button' ) ).toBe(
+			button
+		);
+		expect( button ).toHaveTextContent( '1 in cart' );
+	} );
+
+	it( 'still renders registered React blocks alongside interactive regions', async () => {
+		document.body.innerHTML =
+			'<div class="test-parent"><div class="inner"><div data-block-name="woocommerce/test-child">Server content</div><div data-wp-interactive="woocommerce/product-collection"><button>Add to cart</button></div></div></div>';
+
+		await renderTestParent( {
+			blockMap: {
+				'woocommerce/test-child': () => <p>React content</p>,
+			},
+		} );
+
+		expect( document.querySelector( 'p' ) ).toHaveTextContent(
+			'React content'
+		);
+		expect( document.body ).not.toHaveTextContent( 'Server content' );
+		expect(
+			document.querySelector( '[data-wp-interactive]' )
+		).toBeVisible();
+	} );
 } );