Commit 90ebe7b4337 for woocommerce
commit 90ebe7b433729807e250b0fe20f975e5bfcb20c2
Author: Mike Jolley <mike.jolley@me.com>
Date: Fri Apr 3 15:08:37 2026 +0100
Cart: Fix stale pending delete state for bundled products (#63972)
* Cart: Clear stale pending delete state for server-removed items
When a bundled product parent is removed from the cart, the server also
removes its child items. However, the cart store kept those children
marked as "pending delete" indefinitely, causing shipping and totals
to show skeleton placeholders forever.
After receiving a cart response, clear pending delete flags for any
items that are no longer present in the cart.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add changelog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add changefile(s) from automation for the following project(s): woocommerce
* Remove duplicate changelog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/63972-mikejolley-fix-stale-pending-delete b/plugins/woocommerce/changelog/63972-mikejolley-fix-stale-pending-delete
new file mode 100644
index 00000000000..053eb986542
--- /dev/null
+++ b/plugins/woocommerce/changelog/63972-mikejolley-fix-stale-pending-delete
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix cart shipping and totals stuck as loading when removing bundled products
\ No newline at end of file
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 f2196b0fb8b..93338d40a21 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,19 +1,27 @@
/**
* Internal dependencies
*/
-import { changeCartItemQuantity } from '../thunks';
+import { changeCartItemQuantity, receiveCart } from '../thunks';
import { apiFetchWithHeaders } from '../../shared-controls';
jest.mock( '../../shared-controls', () => ( {
apiFetchWithHeaders: jest.fn(),
} ) );
+jest.mock( '../notify-quantity-changes', () => ( {
+ notifyQuantityChanges: jest.fn(),
+} ) );
+
+jest.mock( '../notify-errors', () => ( {
+ updateCartErrorNotices: jest.fn(),
+} ) );
+
const mockApiFetchWithHeaders = apiFetchWithHeaders as jest.MockedFunction<
typeof apiFetchWithHeaders
>;
describe( 'changeCartItemQuantity', () => {
- const createMockDispatchAndSelect = (
+ const createChangeQuantityMocks = (
cartItems: Record< string, number >
) => {
const mockDispatch = {
@@ -39,7 +47,7 @@ describe( 'changeCartItemQuantity', () => {
} );
it( 'should not make API call if quantity is unchanged', async () => {
- const { dispatch, select } = createMockDispatchAndSelect( {
+ const { dispatch, select } = createChangeQuantityMocks( {
'item-1': 5,
} );
@@ -53,7 +61,7 @@ describe( 'changeCartItemQuantity', () => {
} );
it( 'should make API call when quantity changes', async () => {
- const { dispatch, select } = createMockDispatchAndSelect( {
+ const { dispatch, select } = createChangeQuantityMocks( {
'item-1': 1,
} );
@@ -225,7 +233,7 @@ describe( 'changeCartItemQuantity', () => {
} );
it( 'should handle API errors', async () => {
- const { dispatch, select } = createMockDispatchAndSelect( {
+ const { dispatch, select } = createChangeQuantityMocks( {
'item-1': 1,
} );
@@ -247,3 +255,111 @@ describe( 'changeCartItemQuantity', () => {
);
} );
} );
+
+describe( 'receiveCart', () => {
+ const createReceiveCartMocks = ( {
+ cartItems,
+ pendingDelete,
+ }: {
+ cartItems: Array< { key: string } >;
+ pendingDelete: string[];
+ } ) => {
+ let cartData = { items: cartItems, errors: [] as never[] };
+
+ const mockDispatch = {
+ setCartData: jest.fn( ( newCart ) => {
+ cartData = newCart;
+ } ),
+ itemIsPendingDelete: jest.fn(),
+ setErrorData: jest.fn(),
+ };
+
+ const mockSelect = {
+ getCartData: jest.fn( () => cartData ),
+ getCartErrors: jest.fn( () => [] ),
+ getItemsPendingDelete: jest.fn( () => pendingDelete ),
+ getItemsPendingQuantityUpdate: jest.fn( () => [] ),
+ getProductsPendingAdd: jest.fn( () => [] ),
+ };
+
+ return { dispatch: mockDispatch, select: mockSelect };
+ };
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'should clear pending delete for items removed server-side (e.g. bundle children)', () => {
+ // Simulate: parent bundle "bundle-parent" was deleted by the user.
+ // Its children "bundle-child-1" and "bundle-child-2" were marked
+ // as pending delete by the extension but removed server-side
+ // when the parent was deleted.
+ const { dispatch, select } = createReceiveCartMocks( {
+ cartItems: [
+ { key: 'bundle-parent' },
+ { key: 'bundle-child-1' },
+ { key: 'bundle-child-2' },
+ { key: 'simple-product' },
+ ],
+ pendingDelete: [
+ 'bundle-parent',
+ 'bundle-child-1',
+ 'bundle-child-2',
+ ],
+ } );
+
+ // The API response after removing the parent no longer contains
+ // the parent or its children — only the simple product remains.
+ receiveCart( {
+ items: [ { key: 'simple-product' } ],
+ errors: [],
+ } as never )( { dispatch, select } as never );
+
+ // All three pending-delete items are gone from the cart,
+ // so their pending status should be cleared.
+ expect( dispatch.itemIsPendingDelete ).toHaveBeenCalledWith(
+ 'bundle-parent',
+ false
+ );
+ expect( dispatch.itemIsPendingDelete ).toHaveBeenCalledWith(
+ 'bundle-child-1',
+ false
+ );
+ expect( dispatch.itemIsPendingDelete ).toHaveBeenCalledWith(
+ 'bundle-child-2',
+ false
+ );
+ expect( dispatch.itemIsPendingDelete ).toHaveBeenCalledTimes( 3 );
+ } );
+
+ it( 'should not clear pending delete for items still in the cart', () => {
+ // An item is pending delete but still present in the response
+ // (e.g. the API call hasn't finished processing yet).
+ const { dispatch, select } = createReceiveCartMocks( {
+ cartItems: [ { key: 'item-1' }, { key: 'item-2' } ],
+ pendingDelete: [ 'item-1' ],
+ } );
+
+ receiveCart( {
+ items: [ { key: 'item-1' }, { key: 'item-2' } ],
+ errors: [],
+ } as never )( { dispatch, select } as never );
+
+ // item-1 is still in the cart, so pending delete should NOT be cleared.
+ expect( dispatch.itemIsPendingDelete ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should handle empty pending delete list', () => {
+ const { dispatch, select } = createReceiveCartMocks( {
+ cartItems: [ { key: 'item-1' } ],
+ pendingDelete: [],
+ } );
+
+ receiveCart( {
+ items: [ { key: 'item-1' } ],
+ errors: [],
+ } as never )( { dispatch, select } as never );
+
+ expect( dispatch.itemIsPendingDelete ).not.toHaveBeenCalled();
+ } );
+} );
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 4ff460d51fb..73aa1301742 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
@@ -61,14 +61,31 @@ export const receiveCart =
// Get the new cart data before showing updates.
const newCart = select.getCartData();
+ const cartItemsPendingDelete = select.getItemsPendingDelete();
+
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: select.getItemsPendingQuantityUpdate(),
- cartItemsPendingDelete: select.getItemsPendingDelete(),
+ cartItemsPendingDelete,
productsPendingAdd: select.getProductsPendingAdd(),
} );
+ // Clear pending delete status for items no longer in the cart.
+ // This handles cases where removing one item causes dependent items
+ // to also be removed server-side (e.g., bundled product children
+ // removed when their parent bundle is deleted).
+ if ( cartItemsPendingDelete.length > 0 ) {
+ const newCartItemKeys = new Set(
+ newCart.items.map( ( item ) => item.key )
+ );
+ cartItemsPendingDelete.forEach( ( key ) => {
+ if ( ! newCartItemKeys.has( key ) ) {
+ dispatch.itemIsPendingDelete( key, false );
+ }
+ } );
+ }
+
updateCartErrorNotices( newCart.errors, oldCartErrors );
dispatch.setErrorData( null );
};