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 {