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();
+ } );
} );