Commit b04e6f2f0e3 for woocommerce
commit b04e6f2f0e31a2f02cd4ef3f56c8f0d506e0d845
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Wed Apr 8 03:22:08 2026 -0400
Allow extensionCartUpdate to selectively overwrite shipping or billing address (#64032)
* Allow extensionCartUpdate to selectively overwrite shipping or billing address
The overwriteDirtyCustomerData option on extensionCartUpdate previously only
supported a boolean, forcing both addresses to be overwritten or preserved
together. This adds support for an object form that allows per-address control,
enabling use cases like address autocomplete that only have data for one address.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add changelog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Harden overwriteDirtyCustomerData against null, arrays, and non-boolean fields
Treats null and arrays as false instead of crashing, and uses strict
=== true checks on address fields so truthy non-booleans don't slip through.
Adds edge case tests for null, arrays, non-boolean fields, and empty objects.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add regression test for explicit overwriteDirtyCustomerData: false
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Use isObject type guard and add explicit false flags regression test
Replace inline plain-object check with existing isObject utility.
Add test for overwriteDirtyCustomerData: { shipping_address: false, billing_address: false }.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/opr-cart-update-partial-addr b/plugins/woocommerce/changelog/opr-cart-update-partial-addr
new file mode 100644
index 00000000000..3ff909b2acc
--- /dev/null
+++ b/plugins/woocommerce/changelog/opr-cart-update-partial-addr
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Allow extensionCartUpdate's overwriteDirtyCustomerData option to accept an object with per-address control ({ shipping_address?: boolean, billing_address?: boolean }) in addition to a boolean.
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/cart/test/thunks.ts b/plugins/woocommerce/client/blocks/assets/js/data/cart/test/thunks.ts
index 93338d40a21..0eec805217f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/cart/test/thunks.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/test/thunks.ts
@@ -1,8 +1,18 @@
+/**
+ * External dependencies
+ */
+import { ExtensionCartUpdateArgs } from '@woocommerce/types';
+
/**
* Internal dependencies
*/
-import { changeCartItemQuantity, receiveCart } from '../thunks';
+import {
+ changeCartItemQuantity,
+ receiveCart,
+ applyExtensionCartUpdate,
+} from '../thunks';
import { apiFetchWithHeaders } from '../../shared-controls';
+import { getIsCustomerDataDirty } from '../utils';
jest.mock( '../../shared-controls', () => ( {
apiFetchWithHeaders: jest.fn(),
@@ -16,6 +26,17 @@ jest.mock( '../notify-errors', () => ( {
updateCartErrorNotices: jest.fn(),
} ) );
+jest.mock( '../utils', () => ( {
+ getIsCustomerDataDirty: jest.fn( () => false ),
+ setIsCustomerDataDirty: jest.fn(),
+ setTriggerStoreSyncEvent: jest.fn(),
+} ) );
+
+const mockGetIsCustomerDataDirty =
+ getIsCustomerDataDirty as jest.MockedFunction<
+ typeof getIsCustomerDataDirty
+ >;
+
const mockApiFetchWithHeaders = apiFetchWithHeaders as jest.MockedFunction<
typeof apiFetchWithHeaders
>;
@@ -363,3 +384,245 @@ describe( 'receiveCart', () => {
expect( dispatch.itemIsPendingDelete ).not.toHaveBeenCalled();
} );
} );
+
+describe( 'applyExtensionCartUpdate', () => {
+ const mockResponse = {
+ items: [],
+ shipping_address: { address_1: '123 Ship St' },
+ billing_address: { address_1: '456 Bill Ave' },
+ totals: { total_price: '1000' },
+ };
+
+ const createMockDispatch = () => ( {
+ receiveCart: jest.fn(),
+ receiveError: jest.fn(),
+ } );
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockGetIsCustomerDataDirty.mockReturnValue( false );
+ mockApiFetchWithHeaders.mockResolvedValue( {
+ response: mockResponse,
+ } );
+ } );
+
+ it( 'should include both addresses when customer data is not dirty', async () => {
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ } )( { dispatch } as never );
+
+ expect( dispatch.receiveCart ).toHaveBeenCalledWith( mockResponse );
+ } );
+
+ it( 'should strip both addresses when customer data is dirty and no overwrite specified', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received ).not.toHaveProperty( 'shipping_address' );
+ expect( received ).not.toHaveProperty( 'billing_address' );
+ expect( received ).toHaveProperty( 'totals' );
+ } );
+
+ it( 'should strip both addresses when customer data is dirty and overwriteDirtyCustomerData is false', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: false,
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received ).not.toHaveProperty( 'shipping_address' );
+ expect( received ).not.toHaveProperty( 'billing_address' );
+ expect( received ).toHaveProperty( 'totals' );
+ } );
+
+ it( 'should include both addresses when overwriteDirtyCustomerData is true', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: true,
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received ).toHaveProperty( 'shipping_address' );
+ expect( received ).toHaveProperty( 'billing_address' );
+ } );
+
+ it( 'should overwrite only shipping_address when specified as object', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: { shipping_address: true },
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received.shipping_address ).toEqual( {
+ address_1: '123 Ship St',
+ } );
+ expect( received ).not.toHaveProperty( 'billing_address' );
+ expect( received ).toHaveProperty( 'totals' );
+ } );
+
+ it( 'should overwrite only billing_address when specified as object', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: { billing_address: true },
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received ).not.toHaveProperty( 'shipping_address' );
+ expect( received.billing_address ).toEqual( {
+ address_1: '456 Bill Ave',
+ } );
+ } );
+
+ it( 'should overwrite both addresses when both specified in object', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: {
+ shipping_address: true,
+ billing_address: true,
+ },
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received.shipping_address ).toEqual( {
+ address_1: '123 Ship St',
+ } );
+ expect( received.billing_address ).toEqual( {
+ address_1: '456 Bill Ave',
+ } );
+ } );
+
+ it( 'should strip both addresses when object has explicit false flags', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: {
+ shipping_address: false,
+ billing_address: false,
+ },
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received ).not.toHaveProperty( 'shipping_address' );
+ expect( received ).not.toHaveProperty( 'billing_address' );
+ expect( received ).toHaveProperty( 'totals' );
+ } );
+
+ it( 'should overwrite specified address even when customer data is not dirty', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( false );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: { shipping_address: true },
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ // shipping_address should be included (overwrite requested)
+ expect( received.shipping_address ).toEqual( {
+ address_1: '123 Ship St',
+ } );
+ // billing_address should also be included (data is not dirty, no reason to strip)
+ expect( received.billing_address ).toEqual( {
+ address_1: '456 Bill Ave',
+ } );
+ } );
+
+ it( 'should treat null as false (no overwrite)', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData:
+ null as unknown as ExtensionCartUpdateArgs[ 'overwriteDirtyCustomerData' ],
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received ).not.toHaveProperty( 'shipping_address' );
+ expect( received ).not.toHaveProperty( 'billing_address' );
+ } );
+
+ it( 'should treat an array as false (no overwrite)', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: [
+ true,
+ ] as unknown as ExtensionCartUpdateArgs[ 'overwriteDirtyCustomerData' ],
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received ).not.toHaveProperty( 'shipping_address' );
+ expect( received ).not.toHaveProperty( 'billing_address' );
+ } );
+
+ it( 'should treat non-boolean address fields as false', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: {
+ shipping_address: 'yes',
+ billing_address: 1,
+ } as unknown as ExtensionCartUpdateArgs[ 'overwriteDirtyCustomerData' ],
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received ).not.toHaveProperty( 'shipping_address' );
+ expect( received ).not.toHaveProperty( 'billing_address' );
+ } );
+
+ it( 'should default missing address fields to false', async () => {
+ mockGetIsCustomerDataDirty.mockReturnValue( true );
+ const dispatch = createMockDispatch();
+
+ await applyExtensionCartUpdate( {
+ namespace: 'test',
+ data: {},
+ overwriteDirtyCustomerData: {},
+ } )( { dispatch } as never );
+
+ const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
+ expect( received ).not.toHaveProperty( 'shipping_address' );
+ expect( received ).not.toHaveProperty( 'billing_address' );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
index 73aa1301742..c6823bcda7b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
@@ -32,6 +32,7 @@ import {
} from './notify-quantity-changes';
import { updateCartErrorNotices } from './notify-errors';
import { apiFetchWithHeaders } from '../shared-controls';
+import { isObject } from '../../types/type-guards/object';
import {
getIsCustomerDataDirty,
setIsCustomerDataDirty,
@@ -139,19 +140,43 @@ export const applyExtensionCartUpdate =
data: { namespace: args.namespace, data: args.data },
cache: 'no-store',
} );
- if ( args.overwriteDirtyCustomerData === true ) {
- dispatch.receiveCart( response );
- return response;
- }
- if ( getIsCustomerDataDirty() ) {
- // If the customer data is dirty, we don't want to overwrite it with the response.
- // Remove shipping and billing address from the response and then receive the cart.
+ // Determine which addresses should be overwritten in the store.
+ const raw = args.overwriteDirtyCustomerData;
+ const overwrite = isObject( raw )
+ ? {
+ shipping_address: raw.shipping_address === true,
+ billing_address: raw.billing_address === true,
+ }
+ : {
+ shipping_address: raw === true,
+ billing_address: raw === true,
+ };
+
+ const isDirty = getIsCustomerDataDirty();
+
+ // Decide per-address: include it unless it's dirty and not being overwritten.
+ const includeShipping = overwrite.shipping_address || ! isDirty;
+ const includeBilling = overwrite.billing_address || ! isDirty;
+
+ if ( ! includeShipping || ! includeBilling ) {
const {
shipping_address: _,
billing_address: __,
- ...responseWithoutShippingOrBilling
+ ...responseWithoutAddresses
} = response;
- dispatch.receiveCart( responseWithoutShippingOrBilling );
+
+ const cartToReceive: Partial< CartResponse > = {
+ ...responseWithoutAddresses,
+ };
+
+ if ( includeShipping ) {
+ cartToReceive.shipping_address = response.shipping_address;
+ }
+ if ( includeBilling ) {
+ cartToReceive.billing_address = response.billing_address;
+ }
+
+ dispatch.receiveCart( cartToReceive );
return response;
}
dispatch.receiveCart( response );
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.ts
index dc2576d53d0..998f6e759ad 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.ts
@@ -219,7 +219,13 @@ export interface CartMeta {
export interface ExtensionCartUpdateArgs {
data: Record< string, unknown >;
namespace: string;
- overwriteDirtyCustomerData?: undefined | boolean;
+ overwriteDirtyCustomerData?:
+ | undefined
+ | boolean
+ | {
+ shipping_address?: boolean;
+ billing_address?: boolean;
+ };
}
export interface BillingAddressShippingAddress {