Skip to content

Commit 870159a

Browse files
committed
Merge trunk — 3.6.11 shipped; keep 3.7.0 as the unreleased section
Conflicts in changelog.txt and readme.txt, identical in both files. trunk released 3.6.11 on 2026-08-05 carrying the WOOTAX-74 fix ("incomplete tax response") that this branch still listed as unreleased, plus the React 19 store-notices fix. This branch had both that line and its own Tweak under an unreleased 3.7.0 heading. Resolution: 3.6.11 stays verbatim as shipped history, and the WOOTAX-74 line is dropped from the unreleased section rather than duplicated across two versions. 3.7.0 keeps only this PR's Tweak entry, dated 2026-xx-xx for woorelease to fill. 3.7.0 remains the right target — it is still greater than the newly released 3.6.11, and it matches the five `@since 3.7.0` stamps already on the branch. `Stable tag` and the plugin Version header auto-merged to 3.6.11, which is correct; woorelease bumps them at release time. Suite: 252 tests, 543 assertions, green.
2 parents 676e7d6 + 590070b commit 870159a

13 files changed

Lines changed: 471 additions & 62 deletions

.eslintrc.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,29 @@ Object.assign( calypsoLintConfig.globals, {
2323
global: true,
2424
});
2525

26+
// The store notices bundle is rendered by the React that WordPress provides, inside the
27+
// Cart/Checkout blocks. React 19 rejects elements created by an older React runtime, so
28+
// importing the plugin's bundled React here would break the block cart and checkout.
29+
// Everything in this tree must build its elements with window.wp.element instead.
30+
calypsoLintConfig.overrides = [
31+
...( calypsoLintConfig.overrides || [] ),
32+
{
33+
files: [ 'client/store-notices.js', 'client/components/store/notices/*.js' ],
34+
rules: {
35+
'no-restricted-imports': [
36+
'error',
37+
{
38+
paths: [
39+
{
40+
name: 'react',
41+
message:
42+
'This tree is rendered by the host React: build elements with window.wp.element instead. See client/store-notices.js.',
43+
},
44+
],
45+
},
46+
],
47+
},
48+
},
49+
];
50+
2651
module.exports = useE2EEsLintConfig( calypsoLintConfig );

changelog.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
*** WooCommerce Tax Changelog ***
22

33
= 3.7.0 - 2026-xx-xx =
4-
* Fix - Prevent a fatal error during cart and checkout tax calculation when TaxJar returns an incomplete tax response.
54
* Tweak - Reduce redundant TaxJar API calls by matching cached tax responses across the cart and order paths and ignoring irrelevant formatting differences.
65

6+
= 3.6.11 - 2026-08-05 =
7+
* Fix - Prevent a fatal error during cart and checkout tax calculation when TaxJar returns an incomplete tax response.
8+
* Fix - React 19 compatibility for store notices on the block cart and checkout.
9+
710
= 3.6.10 - 2026-07-27 =
811
* Tweak - WooCommerce 11.0 Compatibility.
912

client/components/store/notices/store-notices.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
/**
2-
* External dependencies
2+
* This component is rendered by the host React inside the Cart/Checkout blocks, so it must
3+
* not import the plugin's bundled React or return JSX built with it: React 19 rejects
4+
* elements created by an older React runtime. Hooks and elements come from window.wp.*.
5+
*
6+
* @see ../../../store-notices.js
37
*/
4-
import React from 'react';
58

69
const { useDispatch, useSelect } = window.wp.data;
710
const { useEffect } = window.wp.element;
@@ -19,7 +22,7 @@ const noticeIdPrefix = 'wcservices-store-notices-';
1922
* @param {Object} props.extensions - An object containing store API response data related for all extensions.
2023
* @param {Object} props.cart - An object containing details about the cart.
2124
*
22-
* @returns {JSX.Element} - An empty React fragment.
25+
* @returns {null} - Nothing is rendered; the component only manages notices.
2326
*/
2427
export const StoreNotices = ( {
2528
extensions, cart,
@@ -55,8 +58,14 @@ export const StoreNotices = ( {
5558
return;
5659
}
5760

58-
// Get new notices from the API response.
59-
const newNotices = extensions[ 'woocommerce-services' ].notices;
61+
// Get new notices from the API response. The extension data is
62+
// missing on WooCommerce versions where the Store API extension
63+
// is not registered, and anything else on the response is outside
64+
// our control, so treat everything but a list as "no notices":
65+
// throwing here would take down the whole checkout fill.
66+
const wcservicesData = extensions[ 'woocommerce-services' ];
67+
const rawNotices = wcservicesData && wcservicesData.notices;
68+
const newNotices = Array.isArray( rawNotices ) ? rawNotices : [];
6069

6170
if ( 0 === newNotices.length ) {
6271
return;
@@ -75,5 +84,5 @@ export const StoreNotices = ( {
7584
[ extensions ]
7685
);
7786

78-
return <></>;
87+
return null;
7988
};
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/** @format */
2+
3+
/**
4+
* External dependencies
5+
*/
6+
import React from 'react';
7+
import { mount } from 'enzyme';
8+
9+
describe( 'StoreNotices', () => {
10+
let createNotice;
11+
let removeNotices;
12+
let StoreNotices;
13+
let existingNotices;
14+
15+
const usCart = { shippingAddress: { country: 'US', state: 'CA', postcode: '94105' } };
16+
const caCart = { shippingAddress: { country: 'CA', state: 'ON', postcode: 'M5V 2T6' } };
17+
18+
const zipNotices = {
19+
'woocommerce-services': {
20+
notices: [ { type: 'error', message: 'ZIP could not be validated.' } ],
21+
},
22+
};
23+
24+
beforeEach( () => {
25+
jest.resetModules();
26+
27+
createNotice = jest.fn();
28+
removeNotices = jest.fn();
29+
existingNotices = [];
30+
31+
global.window.wp = {
32+
element: { useEffect: React.useEffect },
33+
data: {
34+
useDispatch: () => ( { createNotice, removeNotices } ),
35+
useSelect: ( selector ) => selector( () => ( { getNotices: () => existingNotices } ) ),
36+
},
37+
};
38+
39+
( { StoreNotices } = require( '../store-notices' ) );
40+
} );
41+
42+
afterEach( () => {
43+
delete global.window.wp;
44+
} );
45+
46+
it( 'creates notices from the extension data for US addresses', () => {
47+
mount( <StoreNotices extensions={ zipNotices } cart={ usCart } /> );
48+
49+
// The id prefix is the contract the removal path relies on to find our own
50+
// notices, so it is asserted rather than ignored.
51+
expect( createNotice ).toHaveBeenCalledWith(
52+
'error',
53+
'ZIP could not be validated.',
54+
expect.objectContaining( { id: 'wcservices-store-notices-0', context: 'wc/cart' } )
55+
);
56+
} );
57+
58+
it( 'does not create notices for non-US addresses', () => {
59+
mount( <StoreNotices extensions={ zipNotices } cart={ caCart } /> );
60+
61+
expect( createNotice ).not.toHaveBeenCalled();
62+
} );
63+
64+
it( 'removes only its own notices for US addresses', () => {
65+
existingNotices = [
66+
{ id: 'wcservices-store-notices-0' },
67+
{ id: 'some-other-plugin-notice' },
68+
];
69+
70+
mount( <StoreNotices extensions={ {} } cart={ usCart } /> );
71+
72+
expect( removeNotices ).toHaveBeenCalledWith( [ 'wcservices-store-notices-0' ], 'wc/cart' );
73+
} );
74+
75+
it( 'does not remove notices for non-US addresses', () => {
76+
existingNotices = [ { id: 'wcservices-store-notices-0' } ];
77+
78+
mount( <StoreNotices extensions={ {} } cart={ caCart } /> );
79+
80+
expect( removeNotices ).not.toHaveBeenCalled();
81+
} );
82+
83+
it( 'creates notices again when new extension data arrives', () => {
84+
const wrapper = mount( <StoreNotices extensions={ zipNotices } cart={ usCart } /> );
85+
86+
wrapper.setProps( {
87+
extensions: {
88+
'woocommerce-services': {
89+
notices: [ { type: 'error', message: 'Address could not be validated.' } ],
90+
},
91+
},
92+
} );
93+
94+
expect( createNotice ).toHaveBeenCalledTimes( 2 );
95+
expect( createNotice ).toHaveBeenLastCalledWith(
96+
'error',
97+
'Address could not be validated.',
98+
expect.objectContaining( { id: 'wcservices-store-notices-0', context: 'wc/cart' } )
99+
);
100+
} );
101+
102+
it( 'renders without crashing when the extension data is missing', () => {
103+
// Old WooCommerce versions register the blocks integration but not the
104+
// Store API extension, so extensions has no woocommerce-services key.
105+
expect( () => {
106+
mount( <StoreNotices extensions={ {} } cart={ usCart } /> );
107+
} ).not.toThrow();
108+
109+
expect( createNotice ).not.toHaveBeenCalled();
110+
} );
111+
112+
it( 'renders without crashing when the notices key is missing', () => {
113+
expect( () => {
114+
mount( <StoreNotices extensions={ { 'woocommerce-services': {} } } cart={ usCart } /> );
115+
} ).not.toThrow();
116+
117+
expect( createNotice ).not.toHaveBeenCalled();
118+
} );
119+
120+
it( 'renders without crashing when the notices data is not a list', () => {
121+
// Anything on the Store API response is outside our control; a non-list must not
122+
// reach forEach and take down the checkout fill.
123+
expect( () => {
124+
mount(
125+
<StoreNotices
126+
extensions={ { 'woocommerce-services': { notices: {} } } }
127+
cart={ usCart }
128+
/>
129+
);
130+
} ).not.toThrow();
131+
132+
expect( createNotice ).not.toHaveBeenCalled();
133+
} );
134+
} );

client/store-notices.js

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
1-
/**
2-
* External dependencies
3-
*/
4-
import React from 'react';
5-
61
/**
72
* Internal dependencies
83
*/
94
import { StoreNotices } from 'components/store/notices';
105

6+
// Every global read here is a script dependency: keep this list in sync with the
7+
// dependencies declared in WooCommerceBlocksIntegration::register_script(), which is
8+
// what guarantees these are loaded before this script runs.
119
const { registerPlugin } = window.wp.plugins;
10+
const { createElement } = window.wp.element;
1211
const { ExperimentalOrderMeta } = window.wc.blocksCheckout;
1312

1413
/**
1514
* Render function for the store notices.
1615
*
16+
* Elements here must be created with the WordPress-provided React
17+
* (window.wp.element), not the plugin's bundled React: this tree is rendered
18+
* by the host React inside the Cart/Checkout blocks, and React 19 rejects
19+
* elements created by an older React runtime. This is why the slot fill is
20+
* built with createElement rather than the JSX used in the doc below.
21+
*
1722
* @see https://github.com/woocommerce/woocommerce/blob/a7231863c014a95602f5932f702171465fa7bcf2/docs/cart-and-checkout-blocks/available-slot-fills.md?plain=1#L53
23+
* ExperimentalOrderMeta slot fill reference.
1824
*
19-
* @return {JSX.Element} The plugin content.
25+
* @return {Object} The plugin content, as a host React element.
2026
*/
2127
const render = () => {
22-
return (
23-
<ExperimentalOrderMeta>
24-
<StoreNotices />
25-
</ExperimentalOrderMeta>
26-
);
28+
return createElement( ExperimentalOrderMeta, null, createElement( StoreNotices ) );
2729
};
2830

2931
registerPlugin( 'woocommerce-services-store-notices', {

client/test/store-notices.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/** @format */
2+
3+
describe( 'store-notices entry', () => {
4+
let registerPlugin;
5+
let hostCreateElement;
6+
const ExperimentalOrderMeta = () => null;
7+
8+
beforeEach( () => {
9+
jest.resetModules();
10+
11+
registerPlugin = jest.fn();
12+
hostCreateElement = jest.fn( ( type, props, ...children ) => ( {
13+
type,
14+
props,
15+
children,
16+
createdByHost: true,
17+
} ) );
18+
19+
global.window.wp = {
20+
plugins: { registerPlugin },
21+
element: { createElement: hostCreateElement, useEffect: () => {} },
22+
data: { useDispatch: () => ( {} ), useSelect: () => [] },
23+
};
24+
global.window.wc = { blocksCheckout: { ExperimentalOrderMeta } };
25+
} );
26+
27+
afterEach( () => {
28+
delete global.window.wp;
29+
delete global.window.wc;
30+
} );
31+
32+
it( 'registers the store notices plugin for the checkout scope', () => {
33+
require( '../store-notices' );
34+
35+
expect( registerPlugin ).toHaveBeenCalledTimes( 1 );
36+
expect( registerPlugin ).toHaveBeenCalledWith(
37+
'woocommerce-services-store-notices',
38+
expect.objectContaining( { scope: 'woocommerce-checkout' } )
39+
);
40+
} );
41+
42+
it( 'creates elements with the host React (window.wp.element), not a bundled React', () => {
43+
// Share the entry's module registry so this is the same component instance
44+
// the entry renders.
45+
const { StoreNotices } = require( 'components/store/notices' );
46+
require( '../store-notices' );
47+
48+
const { render } = registerPlugin.mock.calls[ 0 ][ 1 ];
49+
const element = render();
50+
51+
// Every element handed to the host renderer must be created by the host
52+
// createElement. An element created by the plugin's bundled React is
53+
// rejected by React 19 ("A React Element from an older version of
54+
// React was rendered").
55+
expect( hostCreateElement ).toHaveBeenCalledTimes( 2 );
56+
expect( element.createdByHost ).toBe( true );
57+
expect( element.type ).toBe( ExperimentalOrderMeta );
58+
59+
// The child counts too: building only the outer element with the host React
60+
// leaves the same React 19 rejection one level down.
61+
const child = element.children[ 0 ];
62+
expect( child.createdByHost ).toBe( true );
63+
expect( child.type ).toBe( StoreNotices );
64+
} );
65+
} );

0 commit comments

Comments
 (0)