Commit 2f852efb84e for woocommerce
commit 2f852efb84e569d785241d82f4d83700269d901c
Author: Luis Herranz <luisherranz@gmail.com>
Date: Fri Oct 9 15:54:29 2026 +0200
Exclude child cart items from the Product Button's in-cart count (#69204)
* Add parent item key to cart item responses
* Add parent_item_key to cart item types
* Add Store API cart quantity counter
* Seed Product Button count from published cart state
* Add product cart quantity counter
* Count ProductButton cart quantities with utility
* Send minimal keyed cart updates
* Add declared-child cart count e2e flow
* Add version tags to cart item schema methods
* Assert declared child cart lines in e2e
* Wait for cart updates in identity e2e flows
* Add Store API child cart-item guide
* Link parent-item guide from Store API index
* Show parent item key in Cart response examples
* Document parent key in cart-line filter reference
* Add Store API parent item key release entry
* Document Store API cart item parent filter
* Show parent item keys in Cart Items responses
* Document parent item key for checkout button filters
* Document cart parent item key in order summary filters
* Add parent item key to totals footer cart item reference
* Document parent key in cart store item return
* Add changelog entry for product button count fix
* Simplify Store API parent item guide
* Simplify parent item key field descriptions
* Drop redundant cart items type check in ProductButton
* Inline in-cart quantity lookup in ProductButton
* Revert minimal keyed cart update body
This reverts "Send minimal keyed cart updates". The update-item endpoint only reads key
and quantity, so sending the full cart line is harmless and the change is
not needed for the in-cart count fix.
* Drop defensive cart line checks from in-cart quantity utilities
* Remove mocked ProductButton frontend store tests
* Trim parent item key Store API tests
* Consolidate in-cart quantity tests
* Revert cart-line identity e2e synchronization changes
* Focus parent item key e2e test on the button count
* Fix in-cart quantity import path after the button block folder move
* Name changelog entries after the branch
* Fix in-cart quantity counting for empty cart entries
* Test same-ID variation attribute matching
* Clarify declared parents and interactive button scope
* Clarify declared parent keys in cart item references
* Move InCartQuantity to Internal/Utilities and read the published cart there
* Keep a single changelog entry for the field, filter, and count fix
* Trim redundant parent item key and in-cart quantity tests
* Wait for hydration before checking the hydrated button count
* Keep cart parent keys in the session cart
* Add parent item key e2e coverage
* Document parent item key membership and orphan lifecycle
* Sync parent item key hook reference with the hook docblock
* Clarify parent_item_key null cases in cart item field summaries
* Explain parent key responsibility and button counting once in parent item guide
* Simplify the parent item guide for extension developers
* Shorten the parent_item_key description in cart item field lists
* Apply batched suggestions from code review
Co-authored-by: Raluca Stan <ralucastn@gmail.com>
* docs: keep parent cart items distinct
Co-authored-by: luisherranz <3305402+luisherranz@users.noreply.github.com>
* Reject a cart item declared as its own parent
* Fix Prettier formatting in Product Button frontend store
* Read WP-CLI post IDs from the last output line in parent item e2e tests
---------
Co-authored-by: luisherranz <luis.herranz@automattic.com>
Co-authored-by: Raluca Stan <ralucastn@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: luisherranz <3305402+luisherranz@users.noreply.github.com>
diff --git a/docs/apis/store-api/extending-store-api/README.md b/docs/apis/store-api/extending-store-api/README.md
index e8c95132587..d77d87399a6 100644
--- a/docs/apis/store-api/extending-store-api/README.md
+++ b/docs/apis/store-api/extending-store-api/README.md
@@ -16,3 +16,4 @@ The documents listed below contain further details on how to achieve the above.
| [Available Formatters](./extend-store-api-formatters/) | Available `Formatters` to format data for use in the Store API. |
| [Updating the cart on-demand](./extend-store-api-update-cart/) | Update the server-side cart following an action from the front-end. |
| [Adding fields and passing values](./extend-store-api-add-custom-fields/) | How to add custom fields to Store API endpoints. |
+| [Marking a cart item as a child](./extend-store-api-parent-item/) | Declare a parent for a child cart item in a Store API response. |
diff --git a/docs/apis/store-api/extending-store-api/extend-store-api-parent-item.md b/docs/apis/store-api/extending-store-api/extend-store-api-parent-item.md
new file mode 100644
index 00000000000..acaeb3e8ba0
--- /dev/null
+++ b/docs/apis/store-api/extending-store-api/extend-store-api-parent-item.md
@@ -0,0 +1,78 @@
+# Marking a cart item as a child of another cart item
+
+If your extension adds cart items that belong to another cart item, such as the contents of a bundle or a product add-on, you can mark them as children of that parent item. Store API cart-item responses then include the parent's key in the readonly `parent_item_key` field.
+
+Blocks will use this field to treat child items as part of their parent. For example, in the Add to cart with options block, the product button's "X in cart" count leaves child items out, so adding a bundle doesn't make the products inside it look like they were added on their own.
+
+WooCommerce never marks an item as a child by itself. Your extension decides which items are children.
+
+## Declaring a parent
+
+Use the `woocommerce_store_api_cart_item_parent_item_key` filter. It runs for each cart item in a Store API response and receives:
+
+- `$parent_item_key`: `null`, or the value returned by an earlier callback.
+- `$cart_item`: the cart item array.
+- `$cart_item_key`: the cart item key.
+
+For a child item your extension added, return the parent's cart item key. This is the key that `WC()->cart->add_to_cart()` returned when the parent was added, not a product ID. Your extension needs to store it, usually in the child's cart item data.
+
+WooCommerce returns `null` instead if the key you return is the item's own key, because an item can't be its own parent.
+
+For any other item, return `$parent_item_key` unchanged so you don't erase a parent declared by another extension.
+
+See the [generated filter reference](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md#woocommerce_store_api_cart_item_parent_item_key) for the full signature.
+
+## Example
+
+This example gives the parent its own cart item data so it won't merge with the same product added separately. It stores the parent's key in the child's cart item data and returns that key from the filter.
+
+```php
+<?php
+const MY_EXTENSION_PARENT_ITEM_KEY = '_my_extension_parent_item_key';
+const MY_EXTENSION_IS_PARENT_ITEM = '_my_extension_is_parent_item';
+
+function my_extension_add_parent_and_child( int $parent_product_id, int $child_product_id ): void {
+ $parent_item_key = WC()->cart->add_to_cart(
+ $parent_product_id,
+ 1,
+ 0,
+ array(),
+ array( MY_EXTENSION_IS_PARENT_ITEM => true )
+ );
+
+ if ( ! $parent_item_key ) {
+ return;
+ }
+
+ WC()->cart->add_to_cart(
+ $child_product_id,
+ 1,
+ 0,
+ array(),
+ array( MY_EXTENSION_PARENT_ITEM_KEY => $parent_item_key )
+ );
+}
+
+add_filter(
+ 'woocommerce_store_api_cart_item_parent_item_key',
+ function ( $parent_item_key, $cart_item ) {
+ return $cart_item[ MY_EXTENSION_PARENT_ITEM_KEY ] ?? $parent_item_key;
+ },
+ 10,
+ 2
+);
+```
+
+If the parent and child are added in separate requests, store the parent key somewhere your extension can read it later, such as the customer session, and add it to the child's cart item data when you add the child.
+
+## When the parent leaves the cart
+
+WooCommerce only returns a parent key while that parent item is in the cart. If the shopper removes the parent, or it's dropped from the cart, the child's `parent_item_key` becomes `null`. The child stays in the cart and counts as a standalone item again.
+
+You don't need to clear the stored key. Your filter can keep returning the stored key, and WooCommerce handles the rest.
+If child items shouldn't be sold without their parent, remove them when the parent is removed.
+
+## Related documentation
+
+- [Available extensible endpoints](./available-endpoints-to-extend.md#cart-items) explains the Store API cart-item extension context.
+- [Exposing your data](./extend-store-api-add-data.md) explains how to add extension-owned data to Store API responses.
diff --git a/docs/apis/store-api/resources-endpoints/cart-items.md b/docs/apis/store-api/resources-endpoints/cart-items.md
index bb049916c14..f09ff3634de 100644
--- a/docs/apis/store-api/resources-endpoints/cart-items.md
+++ b/docs/apis/store-api/resources-endpoints/cart-items.md
@@ -18,6 +18,7 @@ curl "https://example-store.com/wp-json/wc/store/v1/cart/items"
[
{
"key": "c74d97b01eae257e44aa9d5bade97baf",
+ "parent_item_key": null,
"id": 16,
"quantity": 1,
"type": "simple",
@@ -98,6 +99,7 @@ curl "https://example-store.com/wp-json/wc/store/v1/cart/items"
},
{
"key": "e03e407f41901484125496b5ec69a76f",
+ "parent_item_key": null,
"id": 29,
"quantity": 1,
"type": "variation",
@@ -240,6 +242,7 @@ curl "https://example-store.com/wp-json/wc/store/v1/cart/items/c74d97b01eae257e4
```json
{
"key": "c74d97b01eae257e44aa9d5bade97baf",
+ "parent_item_key": null,
"id": 16,
"quantity": 1,
"quantity_limits": {
diff --git a/docs/apis/store-api/resources-endpoints/cart.md b/docs/apis/store-api/resources-endpoints/cart.md
index f3ec22e67ca..fd47513736c 100644
--- a/docs/apis/store-api/resources-endpoints/cart.md
+++ b/docs/apis/store-api/resources-endpoints/cart.md
@@ -29,6 +29,7 @@ All endpoints under `/cart` (listed in this doc) return responses in the same fo
"items": [
{
"key": "a5771bce93e200c36f7cd9dfd0e5deaa",
+ "parent_item_key": null,
"id": 38,
"quantity": 1,
"quantity_limits": {
@@ -96,6 +97,7 @@ All endpoints under `/cart` (listed in this doc) return responses in the same fo
},
{
"key": "b6d767d2f8ed5d21a44b0e5886680cb9",
+ "parent_item_key": null,
"id": 22,
"quantity": 1,
"quantity_limits": {
diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/cart-line-items.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/cart-line-items.md
index 6ed681f9bae..ecfda1a044a 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/cart-line-items.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/cart-line-items.md
@@ -583,6 +583,7 @@ The Cart Item object of the filters above has the following keys:
- _low_stock_remaining_ `number` - The low stock remaining.
- _name_ `string` - The item name.
- _permalink_ `string` - The item permalink.
+- _parent_item_key_ `string | null` - The parent cart item key, or `null` if the item has no parent in the cart.
- _prices_ `object` - The item prices object with the following keys:
- _currency_code_ `string` - The currency code.
- _currency_decimal_separator_ `string` - The currency decimal separator.
diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/checkout-and-place-order-button.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/checkout-and-place-order-button.md
index 8e22ef1069a..7253bf2d419 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/checkout-and-place-order-button.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/checkout-and-place-order-button.md
@@ -283,6 +283,7 @@ The Cart Item object of the filters above has the following keys:
- _low_stock_remaining_ `number` - The low stock remaining.
- _name_ `string` - The item name.
- _permalink_ `string` - The item permalink.
+- _parent_item_key_ `string | null` - The parent cart item key, or `null` if the item has no parent in the cart.
- _prices_ `object` - The item prices object with the following keys:
- _currency_code_ `string` - The currency code.
- _currency_decimal_separator_ `string` - The currency decimal separator.
diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/order-summary-items.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/order-summary-items.md
index 2da450be68e..e943d428baf 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/order-summary-items.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/order-summary-items.md
@@ -504,6 +504,7 @@ The Cart Item object of the filters above has the following keys:
- _low_stock_remaining_ `number` - The low stock remaining.
- _name_ `string` - The item name.
- _permalink_ `string` - The item permalink.
+- _parent_item_key_ `string | null` - The parent cart item key, or `null` if the item has no parent in the cart.
- _prices_ `object` - The item prices object with the following keys:
- _currency_code_ `string` - The currency code.
- _currency_decimal_separator_ `string` - The currency decimal separator.
diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/totals-footer-item.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/totals-footer-item.md
index e9edbd9d8f9..4f307a81a00 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/totals-footer-item.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/filters-in-cart-and-checkout/totals-footer-item.md
@@ -172,6 +172,7 @@ The Cart Item object of the filters above has the following keys:
- _low_stock_remaining_ `number` - The low stock remaining.
- _name_ `string` - The item name.
- _permalink_ `string` - The item permalink.
+- _parent_item_key_ `string | null` - The parent cart item key, or `null` if the item has no parent in the cart.
- _prices_ `object` - The item prices object with the following keys:
- _currency_code_ `string` - The currency code.
- _currency_decimal_separator_ `string` - The currency decimal separator.
diff --git a/docs/block-development/reference/data-store/cart.md b/docs/block-development/reference/data-store/cart.md
index 2d41d158052..df184049f01 100644
--- a/docs/block-development/reference/data-store/cart.md
+++ b/docs/block-development/reference/data-store/cart.md
@@ -777,6 +777,7 @@ Returns a cart item from the state.
- `object`: The cart item with the following keys:
- _key_ `string`: The cart item key.
+ - _parent_item_key_ `string | null`: The parent cart item key, or `null` if the item has no parent in the cart.
- _id_ `number`: The cart item id.
- _catalog_visibility_ `string`: The catalog visibility.
- _quantity_limits_ `object`: The quantity limits.
diff --git a/plugins/woocommerce/changelog/wooplug-6096-productbutton-block-quantity-issue-in-cart-count-2 b/plugins/woocommerce/changelog/wooplug-6096-productbutton-block-quantity-issue-in-cart-count-2
new file mode 100644
index 00000000000..dc1e0b39190
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6096-productbutton-block-quantity-issue-in-cart-count-2
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Exclude child cart items from interactive Add to cart button counts; extensions declare them with the new Store API `parent_item_key` field and `woocommerce_store_api_cart_item_parent_item_key` filter.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/frontend.ts
index 162b07cd4a4..42fc432adaf 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/frontend.ts
@@ -13,6 +13,7 @@ import type {
Context as AddToCartWithOptionsContext,
AddToCartWithOptionsStore,
} from '../../add-to-cart-with-options/frontend';
+import { getInCartQuantity } from './utils';
// Stores are locked to prevent 3PD usage until the API is stable.
const universalLock =
@@ -63,6 +64,7 @@ const { state: productsState } = store< ProductsStore >(
const productButtonStore = {
state: {
+ /** Get the quantity of the current product in the cart. */
get quantity(): number {
const product = productsState.productInContext;
@@ -74,12 +76,16 @@ const productButtonStore = {
'woocommerce/add-to-cart-with-options'
);
- const item = wooState.findItemInCart( {
+ const selectedAttributes = productsState.productVariationInContext
+ ? ( formContext?.selectedAttributes ?? [] )
+ : undefined;
+
+ return getInCartQuantity( wooState.cart?.items ?? [], {
id: product.id,
- variation: formContext?.selectedAttributes,
+ ...( selectedAttributes !== undefined && {
+ selectedAttributes,
+ } ),
} );
-
- return item?.quantity ?? 0;
},
get slideInAnimation() {
const { animationStatus } = getContext< Context >();
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/test/utils.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/test/utils.test.ts
new file mode 100644
index 00000000000..d3c57cefc76
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/test/utils.test.ts
@@ -0,0 +1,131 @@
+/**
+ * External dependencies
+ */
+import type { OptimisticCartItem } from '@woocommerce/stores/woocommerce/cart';
+
+/**
+ * Internal dependencies
+ */
+import { getInCartQuantity } from '../utils';
+
+jest.mock(
+ '@wordpress/interactivity',
+ () => ( {
+ store: jest.fn( () => ( {
+ state: {
+ productVariations: {},
+ products: {},
+ },
+ } ) ),
+ } ),
+ { virtual: true }
+);
+
+jest.mock( '@woocommerce/stores/woocommerce/products', () => ( {} ) );
+
+/**
+ * An optimistic cart item with optional response-only fields used by these tests.
+ */
+type TestCartItem = OptimisticCartItem & {
+ /** Cart key of the parent line, when this line is a declared child. */
+ parent_item_key?: string | null;
+ /** Custom item data attached to the line. */
+ item_data?: unknown[];
+};
+
+/**
+ * Create an optimistic cart line for quantity-counting tests.
+ *
+ * @param id Product or variation ID.
+ * @param quantity Quantity on the cart line.
+ * @param overrides Additional cart-line properties.
+ * @return The cart line with simple-product defaults.
+ */
+const makeCartItem = (
+ id: number,
+ quantity: number,
+ overrides: Partial< TestCartItem > = {}
+): TestCartItem => ( {
+ id,
+ quantity,
+ type: 'simple',
+ ...overrides,
+} );
+
+describe( 'getInCartQuantity', () => {
+ it( 'sums lines for the product and skips declared children', () => {
+ const items = [
+ makeCartItem( 10, 1 ),
+ makeCartItem( 10, 2, { parent_item_key: null } ),
+ makeCartItem( 10, 3, { parent_item_key: '' } ),
+ makeCartItem( 10, 4, { item_data: [ { key: 'engraving' } ] } ),
+ makeCartItem( 10, 5, { parent_item_key: 'parent-a' } ),
+ makeCartItem( 11, 6 ),
+ ];
+
+ expect( getInCartQuantity( items, { id: 10 } ) ).toBe( 10 );
+ } );
+
+ it( 'matches simple and variation lines by ID when no selection is provided', () => {
+ const items = [
+ makeCartItem( 10, 3 ),
+ makeCartItem( 21, 2, { type: 'variation' } ),
+ ];
+
+ expect( getInCartQuantity( items, { id: 10 } ) ).toBe( 3 );
+ expect( getInCartQuantity( items, { id: 21 } ) ).toBe( 2 );
+ expect( getInCartQuantity( items, { id: 20 } ) ).toBe( 0 );
+ } );
+
+ it( 'filters variation lines by selected attributes and excludes declared children', () => {
+ const items = [
+ makeCartItem( 21, 2, {
+ type: 'variation',
+ variation: [
+ {
+ attribute: 'Color',
+ value: 'Blue',
+ raw_attribute: 'attribute_pa_color',
+ },
+ ],
+ } ),
+ makeCartItem( 21, 7, {
+ type: 'variation',
+ variation: [
+ {
+ attribute: 'Color',
+ value: 'Green',
+ raw_attribute: 'attribute_pa_color',
+ },
+ ],
+ } ),
+ makeCartItem( 21, 100, {
+ type: 'variation',
+ parent_item_key: 'parent-a',
+ variation: [
+ {
+ attribute: 'Color',
+ value: 'Blue',
+ raw_attribute: 'attribute_pa_color',
+ },
+ ],
+ } ),
+ ];
+
+ expect(
+ getInCartQuantity( items, {
+ id: 21,
+ selectedAttributes: [ { attribute: 'Color', value: 'blue' } ],
+ } )
+ ).toBe( 2 );
+ expect(
+ getInCartQuantity( items, {
+ id: 21,
+ selectedAttributes: [ { attribute: 'Color', value: 'green' } ],
+ } )
+ ).toBe( 7 );
+ expect(
+ getInCartQuantity( items, { id: 21, selectedAttributes: [] } )
+ ).toBe( 0 );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/utils.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/utils.ts
new file mode 100644
index 00000000000..f13cf733eda
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/button/utils.ts
@@ -0,0 +1,59 @@
+/**
+ * External dependencies
+ */
+import type { CartItem } from '@woocommerce/types';
+import type {
+ OptimisticCartItem,
+ SelectedAttributes,
+} from '@woocommerce/stores/woocommerce/cart';
+
+/**
+ * Internal dependencies
+ */
+import { doesCartItemMatchAttributes } from '../../../base/utils/variations/does-cart-item-match-attributes';
+
+/** Product ID and optional selected attributes used to count cart lines. */
+type InCartQuantityTarget = {
+ /** Product or variation ID to count. */
+ id: number;
+ /** When provided, variation lines must match these shopper selections. */
+ selectedAttributes?: SelectedAttributes[];
+};
+
+/**
+ * Sum eligible cart-line quantities for a product or variation.
+ *
+ * When selected attributes are provided, variation lines must match them.
+ *
+ * @param items Cart response or optimistic cart lines.
+ * @param target Product ID and optional shopper-selected variation attributes.
+ * @return The sum of matching quantities, or zero when none match.
+ */
+export const getInCartQuantity = (
+ items: ReadonlyArray< CartItem | OptimisticCartItem >,
+ target: InCartQuantityTarget
+): number => {
+ let quantity = 0;
+
+ for ( const item of items ) {
+ if ( item.id !== target.id ) {
+ continue;
+ }
+
+ if ( 'parent_item_key' in item && item.parent_item_key ) {
+ continue;
+ }
+
+ if (
+ target.selectedAttributes !== undefined &&
+ item.type === 'variation' &&
+ ! doesCartItemMatchAttributes( item, target.selectedAttributes )
+ ) {
+ continue;
+ }
+
+ quantity += item.quantity;
+ }
+
+ return quantity;
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/previews/cart.ts b/plugins/woocommerce/client/blocks/assets/js/previews/cart.ts
index 7fa92d80cc6..80f7c7fe2cd 100644
--- a/plugins/woocommerce/client/blocks/assets/js/previews/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/previews/cart.ts
@@ -42,6 +42,7 @@ export const previewCart: CartResponse = {
items: [
{
key: '1',
+ parent_item_key: null,
id: 1,
type: 'simple',
quantity: 2,
@@ -115,6 +116,7 @@ export const previewCart: CartResponse = {
},
{
key: '2',
+ parent_item_key: null,
id: 2,
type: 'simple',
quantity: 1,
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index fb9fc11d76f..7ddd6a3128b 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -70,6 +70,7 @@
- [woocommerce_sortable_taxonomies](#woocommerce_sortable_taxonomies)
- [woocommerce_store_api_add_to_cart_data](#woocommerce_store_api_add_to_cart_data)
- [woocommerce_store_api_cart_item_images](#woocommerce_store_api_cart_item_images)
+- [woocommerce_store_api_cart_item_parent_item_key](#woocommerce_store_api_cart_item_parent_item_key)
- [woocommerce_store_api_cart_item_quantity_validation](#woocommerce_store_api_cart_item_quantity_validation)
- [woocommerce_store_api_disable_nonce_check](#woocommerce_store_api_disable_nonce_check)
- [woocommerce_store_api_expose_error_details](#woocommerce_store_api_expose_error_details)
@@ -1737,6 +1738,42 @@ This hook allows the cart item images to be changed. This is specific to the car
---
+## woocommerce_store_api_cart_item_parent_item_key
+
+
+Filter to declare the parent cart item of a cart line.
+
+```php
+apply_filters( 'woocommerce_store_api_cart_item_parent_item_key', string|null $parent_item_key, array $cart_item, string $cart_item_key )
+```
+
+### Description
+
+Only a non-empty string is kept; empty strings and other values become null. A key that names no line in the current cart, or the line's own key, also becomes null. A line with no declared parent counts as a standalone line. Callbacks that declare no parent for a line should return the value unchanged.
+
+### Parameters
+
+| Argument | Type | Description |
+| -------- | ---- | ----------- |
+| $parent_item_key | string, null | Initially null; may be a value returned by an earlier callback. |
+| $cart_item | array | The raw cart item. |
+| $cart_item_key | string | The cart item key. |
+
+### Returns
+
+
+`string, null` The parent item key, or null when no parent is declared or it is not in the cart.
+
+### See
+
+- <https://github.com/woocommerce/woocommerce/blob/trunk/docs/apis/store-api/extending-store-api/extend-store-api-parent-item.md>
+
+### Source
+
+- [StoreApi/Schemas/V1/CartItemSchema.php](../../../../../../src/StoreApi/Schemas/V1/CartItemSchema.php)
+
+---
+
## woocommerce_store_api_cart_item_quantity_validation
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/cart.ts b/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/cart.ts
index 998f6e759ad..0569159942f 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/cart.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/cart.ts
@@ -129,6 +129,8 @@ export type CatalogVisibility = 'catalog' | 'hidden' | 'search' | 'visible';
export interface CartItem {
key: string;
+ /** Parent cart item's key, or null when this item has no parent. */
+ parent_item_key?: string | null;
id: number;
type: string;
quantity: number;
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 170f7064401..3b2201780c3 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -50319,18 +50319,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductButton.php
- -
- message: '#^Property WooCommerce\:\:\$cart \(WC_Cart\) in isset\(\) is not nullable\.$#'
- identifier: isset.property
- count: 1
- path: src/Blocks/BlockTypes/ProductButton.php
-
- -
- message: '#^Static property Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductButton\:\:\$cart is never read, only written\.$#'
- identifier: property.onlyWritten
- count: 1
- path: src/Blocks/BlockTypes/ProductButton.php
-
-
message: '#^Access to an undefined property object\:\:\$count\.$#'
identifier: property.notFound
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
index 0b6f83220f6..2858eaf7218 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
use Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\Utils;
use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Utilities\InCartQuantity;
/**
* ProductButton class.
@@ -22,14 +23,6 @@ class ProductButton extends AbstractBlock {
*/
protected $block_name = 'product-button';
-
- /**
- * Cart.
- *
- * @var array
- */
- private static $cart = null;
-
/**
* Register the context.
*/
@@ -100,7 +93,7 @@ class ProductButton extends AbstractBlock {
BlocksSharedState::load_cart_state( 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce' );
- $number_of_items_in_cart = $this->get_cart_item_quantities_by_product_id( $product->get_id() );
+ $number_of_items_in_cart = InCartQuantity::for_product( $product->get_id() );
$is_product_purchasable = $this->is_product_purchasable( $product );
$cart_redirect_after_add = get_option( 'woocommerce_cart_redirect_after_add' ) === 'yes';
$ajax_add_to_cart_enabled = get_option( 'woocommerce_enable_ajax_add_to_cart' ) === 'yes';
@@ -328,21 +321,6 @@ class ProductButton extends AbstractBlock {
return $html;
}
- /**
- * Get the number of items in the cart for a given product id.
- *
- * @param number $product_id The product id.
- * @return number The number of items in the cart.
- */
- private function get_cart_item_quantities_by_product_id( $product_id ) {
- if ( ! isset( WC()->cart ) ) {
- return 0;
- }
-
- $cart = WC()->cart->get_cart_item_quantities();
- return isset( $cart[ $product_id ] ) ? $cart[ $product_id ] : 0;
- }
-
/**
* Check if a product is purchasable.
*
diff --git a/plugins/woocommerce/src/Internal/Utilities/InCartQuantity.php b/plugins/woocommerce/src/Internal/Utilities/InCartQuantity.php
new file mode 100644
index 00000000000..041b1fc7b3f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/InCartQuantity.php
@@ -0,0 +1,36 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+/**
+ * Counts a product's quantity in the cart published to the `woocommerce` Interactivity API state.
+ */
+final class InCartQuantity {
+
+ /**
+ * Sum the product's quantity in the published cart, excluding declared child lines.
+ * Reads the cart that `BlocksSharedState::load_cart_state()` publishes, so call it after that.
+ * An entry without an id key, such as the `[]` placeholder, matches nothing.
+ *
+ * @since 11.3.0
+ *
+ * @param int $product_id Product or variation ID to count.
+ *
+ * @return int|float The sum of eligible cart-item quantities.
+ */
+ public static function for_product( int $product_id ) {
+ $woocommerce_state = wp_interactivity_state( 'woocommerce' );
+ $total = 0;
+
+ foreach ( $woocommerce_state['cart']['items'] ?? array() as $item ) {
+ if ( ( $item['id'] ?? null ) !== $product_id || '' !== ( $item['parent_item_key'] ?? '' ) ) {
+ continue;
+ }
+
+ $total += $item['quantity'];
+ }
+
+ return $total;
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartItemSchema.php
index d01650dd22f..938bdc963e4 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartItemSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartItemSchema.php
@@ -25,7 +25,27 @@ class CartItemSchema extends ItemSchema {
const IDENTIFIER = 'cart-item';
/**
- * Convert a WooCommerce cart item to an object suitable for the response.
+ * Get the cart item schema properties.
+ *
+ * @since 11.3.0
+ *
+ * @return array
+ */
+ public function get_properties() {
+ $properties = parent::get_properties();
+ $properties['parent_item_key'] = [
+ 'description' => __( 'Key of the parent cart item, or null if this item is standalone.', 'woocommerce' ),
+ 'type' => [ 'string', 'null' ],
+ 'context' => [ 'view', 'edit' ],
+ 'readonly' => true,
+ 'default' => null,
+ ];
+
+ return $properties;
+ }
+
+ /**
+ * Convert a WooCommerce cart item to a response object with its declared parent item key.
*
* @param array $cart_item Cart item array.
* @return array
@@ -54,6 +74,7 @@ class CartItemSchema extends ItemSchema {
return [
'key' => $cart_item['key'],
+ 'parent_item_key' => $this->get_parent_item_key( $cart_item ),
'id' => $product->get_id(),
'type' => $product->get_type(),
'quantity' => wc_stock_amount( $cart_item['quantity'] ),
@@ -84,6 +105,45 @@ class CartItemSchema extends ItemSchema {
];
}
+ /**
+ * Get a cart line's declared parent key.
+ *
+ * @since 11.3.0
+ *
+ * @param array $cart_item Cart item array.
+ * @return string|null Parent cart item key, or null when no parent is declared or it is not in the cart.
+ */
+ protected function get_parent_item_key( $cart_item ) {
+ /**
+ * Filter to declare the parent cart item of a cart line.
+ *
+ * Only a non-empty string is kept; empty strings and other values become null. A key that names no line in the
+ * current cart, or the line's own key, also becomes null. A line with no declared parent counts as a standalone
+ * line. Callbacks that declare no parent for a line should return the value unchanged.
+ *
+ * @since 11.3.0
+ * @see https://github.com/woocommerce/woocommerce/blob/trunk/docs/apis/store-api/extending-store-api/extend-store-api-parent-item.md
+ *
+ * @param string|null $parent_item_key Initially null; may be a value returned by an earlier callback.
+ * @param array $cart_item The raw cart item.
+ * @param string $cart_item_key The cart item key.
+ * @return string|null The parent item key, or null when no parent is declared or it is not in the cart.
+ */
+ $parent_item_key = apply_filters( 'woocommerce_store_api_cart_item_parent_item_key', null, $cart_item, $cart_item['key'] );
+
+ if ( ! is_string( $parent_item_key ) || '' === $parent_item_key || $cart_item['key'] === $parent_item_key ) {
+ return null;
+ }
+
+ $cart = wc()->cart;
+
+ if ( ! ( $cart instanceof \WC_Cart ) || empty( $cart->get_cart_item( $parent_item_key ) ) ) {
+ return null;
+ }
+
+ return $parent_item_key;
+ }
+
/**
* Get list of product images for the cart item.
*
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/blocks/cart-line-identity.php b/plugins/woocommerce/tests/e2e/test-plugins/blocks/cart-line-identity.php
index 49c43bac0ac..e1baa8594a9 100644
--- a/plugins/woocommerce/tests/e2e/test-plugins/blocks/cart-line-identity.php
+++ b/plugins/woocommerce/tests/e2e/test-plugins/blocks/cart-line-identity.php
@@ -1,7 +1,7 @@
<?php
/**
* Plugin Name: WooCommerce Blocks Test Cart Line Identity
- * Description: Simulates a meta-differentiated cart line for blocks e2e tests by marking flagged add-to-cart requests.
+ * Description: Simulates meta-differentiated and declared-child cart lines for blocks e2e tests.
* Plugin URI: https://github.com/woocommerce/woocommerce
* Author: WooCommerce
*
@@ -39,6 +39,10 @@
* test, and core resolves it as a separate standalone line. Toggling the marker
* is what produces the meta-differentiated line: a flagged add creates/extends a
* meta line, an unflagged add follows the normal standalone-line identity.
+ *
+ * A second request flag declares a seeded line to be a child by providing its
+ * parent cart key. The key is published through the Store API parent-item-key
+ * filter so cart count tests can verify child-line exclusion.
*/
declare(strict_types=1);
@@ -60,6 +64,16 @@ const CART_LINE_IDENTITY_FLAG = 'cart_line_identity_marker';
*/
const CART_LINE_IDENTITY_KEY = '_cart_line_identity';
+/**
+ * Request flag a test toggles to declare an added cart line as a child.
+ */
+const CART_LINE_PARENT_ITEM_KEY_FLAG = 'cart_line_parent_item_key';
+
+/**
+ * cart_item_data key used to carry the declared parent cart key.
+ */
+const CART_LINE_PARENT_ITEM_KEY = '_cart_line_parent_item_key';
+
add_filter(
'woocommerce_add_cart_item_data',
/**
@@ -97,3 +111,55 @@ add_filter(
10,
1
);
+
+add_filter(
+ 'woocommerce_add_cart_item_data',
+ /**
+ * Store a declared parent key on a flagged cart line.
+ *
+ * @param array $cart_item_data Existing cart item data.
+ * @return array Cart item data, with a parent key when the request supplies one.
+ */
+ function ( $cart_item_data ) {
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only test marker; nonce handled by the underlying add-to-cart request.
+ if ( ! isset( $_REQUEST[ CART_LINE_PARENT_ITEM_KEY_FLAG ] ) || ! is_string( $_REQUEST[ CART_LINE_PARENT_ITEM_KEY_FLAG ] ) ) {
+ return $cart_item_data;
+ }
+
+ $parent_item_key = sanitize_text_field( wp_unslash( $_REQUEST[ CART_LINE_PARENT_ITEM_KEY_FLAG ] ) );
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ if ( '' !== $parent_item_key ) {
+ $cart_item_data[ CART_LINE_PARENT_ITEM_KEY ] = $parent_item_key;
+ }
+
+ return $cart_item_data;
+ },
+ 10,
+ 1
+);
+
+add_filter(
+ 'woocommerce_store_api_cart_item_parent_item_key',
+ /**
+ * Publish the declared parent key for a flagged cart line.
+ *
+ * @param string|null $parent_item_key Parent key declared by an earlier callback.
+ * @param mixed $cart_item Raw cart item.
+ * @param string $cart_item_key Cart item key.
+ * @return string|null The declared parent key or the incoming value.
+ */
+ function ( $parent_item_key, $cart_item, $cart_item_key ) {
+ unset( $cart_item_key );
+
+ if ( ! is_array( $cart_item ) ) {
+ return $parent_item_key;
+ }
+
+ $declared_parent_item_key = $cart_item[ CART_LINE_PARENT_ITEM_KEY ] ?? null;
+
+ return is_string( $declared_parent_item_key ) && '' !== $declared_parent_item_key ? $declared_parent_item_key : $parent_item_key;
+ },
+ 10,
+ 3
+);
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/parent-item-key.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/parent-item-key.block_theme.spec.ts
new file mode 100644
index 00000000000..9ec81420843
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/parent-item-key.block_theme.spec.ts
@@ -0,0 +1,333 @@
+/**
+ * External dependencies
+ */
+import type { Page } from '@playwright/test';
+import { test as base, expect, guestFile, wpCLI } from '@woocommerce/e2e-utils';
+
+/**
+ * Internal dependencies
+ */
+import AddToCartWithOptionsPage from '../add-to-cart-with-options/add-to-cart-with-options.page';
+import {
+ CART_LINE_IDENTITY_PLUGIN,
+ PRODUCT_X,
+ productButton,
+ seedDeclaredChildLine,
+} from './utils';
+
+type StoreApiCartItem = {
+ id: number;
+ key: string;
+ parent_item_key: string | null;
+};
+
+/** Reads the current session cart through the browser's Store API session. */
+const readCartItems = async ( page: Page ): Promise< StoreApiCartItem[] > => {
+ const cart = await page.evaluate( async () => {
+ const response = await fetch( '/wp-json/wc/store/v1/cart' );
+ return {
+ ok: response.ok,
+ items: ( await response.json() ).items,
+ };
+ } );
+ expect( cart.ok ).toBe( true );
+ return cart.items as StoreApiCartItem[];
+};
+
+/**
+ * Runs a WP-CLI `post list --format=ids` query that must match exactly one post
+ * and returns its ID. The ID is read from the last non-empty output line, so
+ * numbers printed by the wp-env wrapper can't be mistaken for it.
+ */
+const getSinglePostId = async ( command: string ): Promise< number > => {
+ const result = await wpCLI( command );
+ const lastLine =
+ result.stdout
+ .split( '\n' )
+ .map( ( line ) => line.trim() )
+ .filter( Boolean )
+ .pop() ?? '';
+ const postId = Number( lastLine );
+ expect( Number.isInteger( postId ) && postId > 0 ).toBe( true );
+ return postId;
+};
+
+/** Resolves a sample product by its WordPress post slug. */
+const getProductIdBySlug = ( slug: string ): Promise< number > =>
+ getSinglePostId(
+ `post list --post_type=product --field=ID --name="${ slug }" --format=ids`
+ );
+
+/** Adds Cap through the legacy URL and returns its session cart line. */
+const addCapToCart = async ( page: Page ) => {
+ const capId = await getProductIdBySlug( 'cap' );
+ await page.goto( `/?add-to-cart=${ capId }` );
+ const capLine = ( await readCartItems( page ) ).find(
+ ( item ) => item.id === capId
+ );
+ expect( capLine ).toBeDefined();
+ return {
+ capId,
+ parentItemKey: ( capLine as StoreApiCartItem ).key,
+ };
+};
+
+/** Seeds Beanie as Cap's child and verifies the declared cart relationship. */
+const seedBeanieAsCapChild = async ( page: Page ) => {
+ const { capId, parentItemKey } = await addCapToCart( page );
+ await seedDeclaredChildLine( page, PRODUCT_X.id, parentItemKey );
+
+ const items = await readCartItems( page );
+ expect( items ).toHaveLength( 2 );
+ expect( items.find( ( item ) => item.id === capId )?.key ).toBe(
+ parentItemKey
+ );
+ expect(
+ items.find( ( item ) => item.id === PRODUCT_X.id )?.parent_item_key
+ ).toBe( parentItemKey );
+
+ return { capId };
+};
+
+/** Reads the shop's server-rendered ProductButton without executing its scripts. */
+const readServerRenderedProductButtonText = async (
+ page: Page,
+ productId: number
+) => {
+ const response = await page.request.get( '/shop/' );
+ expect( response.ok() ).toBe( true );
+ const html = await response.text();
+ const buttonText = await page.evaluate(
+ ( { pageHtml, id } ) => {
+ const documentWithoutScripts = new DOMParser().parseFromString(
+ pageHtml,
+ 'text/html'
+ );
+ const button = documentWithoutScripts
+ .querySelector( `li.post-${ id }` )
+ ?.querySelector( 'button' );
+ return button?.textContent?.trim() ?? null;
+ },
+ { pageHtml: html, id: productId }
+ );
+ expect( buttonText ).not.toBeNull();
+ return buttonText;
+};
+
+/** Confirms the hydrated ProductButton responds to a cart mutation. */
+const expectHydratedProductButton = async (
+ page: Page,
+ productId: number,
+ initialText: string,
+ updatedText: string
+) => {
+ const button = productButton( page, productId );
+ await expect( button ).toBeVisible();
+ await expect( button ).toHaveText( initialText );
+
+ const addResponsePromise = page.waitForResponse(
+ ( response ) =>
+ response.url().includes( '/wc/store/v1/batch' ) &&
+ response.request().method() === 'POST'
+ );
+ await button.click();
+ const addResponse = await addResponsePromise;
+ expect( addResponse.ok() ).toBe( true );
+ await expect( button ).toHaveText( updatedText );
+};
+
+const test = base.extend< {
+ addToCartWithOptionsPage: AddToCartWithOptionsPage;
+} >( {
+ addToCartWithOptionsPage: async (
+ { page, admin, editor },
+ provideFixture
+ ) => {
+ await provideFixture(
+ new AddToCartWithOptionsPage( { page, admin, editor } )
+ );
+ },
+} );
+
+test.describe( 'ProductButton in-cart count', () => {
+ test.beforeEach( async ( { requestUtils } ) => {
+ await requestUtils.activatePlugin( CART_LINE_IDENTITY_PLUGIN );
+ } );
+
+ test( 'does not count a child cart line in the add to cart button', async ( {
+ page,
+ frontendUtils,
+ addToCartWithOptionsPage,
+ } ) => {
+ await addToCartWithOptionsPage.createPostWithProductBlock(
+ 'hoodie',
+ 'hoodie-blue-yes'
+ );
+ const postUrl = page.url();
+
+ await frontendUtils.emptyCart();
+
+ const variationId = await getSinglePostId(
+ 'post list --post_type=product_variation --field=ID --name="Hoodie - Blue, Yes" --format=ids'
+ );
+
+ const capId = await getProductIdBySlug( 'cap' );
+ await page.goto( `/?add-to-cart=${ capId }` );
+ const parentCartItems = await readCartItems( page );
+ expect( parentCartItems ).toHaveLength( 1 );
+ const parentLine = parentCartItems.find(
+ ( item ) => item.id === capId
+ );
+ expect( parentLine ).toBeDefined();
+ const parentItemKey = ( parentLine as StoreApiCartItem ).key;
+
+ await seedDeclaredChildLine( page, variationId, parentItemKey );
+ const cartItems = await readCartItems( page );
+ expect( cartItems ).toHaveLength( 2 );
+ expect(
+ cartItems.find( ( item ) => item.id === variationId )
+ ?.parent_item_key
+ ).toBe( parentItemKey );
+ expect(
+ cartItems.find( ( item ) => item.key === parentItemKey )
+ ?.parent_item_key
+ ).toBeNull();
+
+ // Reads the server-rendered HTML without running scripts.
+ const readServerRenderedButtonText = async () => {
+ const response = await page.request.get( postUrl );
+ const button = ( await response.text() ).match(
+ /<button\b[^>]*class="[^"]*\bsingle_add_to_cart_button\b[^"]*"[^>]*>([\s\S]*?)<\/button>/
+ );
+ expect( button ).not.toBeNull();
+
+ return button?.[ 1 ]?.replace( /<[^>]*>/g, '' ).trim();
+ };
+
+ const addToCartButton = page
+ .locator( '.wp-block-add-to-cart-with-options' )
+ .locator( '.single_add_to_cart_button' );
+
+ expect( await readServerRenderedButtonText() ).toBe( 'Add to cart' );
+ await page.goto( postUrl );
+ // The server renders the button hidden and the client store reveals it, so
+ // waiting for it to be visible makes the next text check read the hydrated count.
+ await expect( addToCartButton ).toBeVisible();
+ await expect( addToCartButton ).toHaveText( 'Add to cart' );
+
+ await addToCartButton.click();
+ await expect( addToCartButton ).toHaveText( '1 in cart' );
+
+ expect( await readServerRenderedButtonText() ).toBe( '1 in cart' );
+ await page.reload();
+ await expect( addToCartButton ).toBeVisible();
+ await expect( addToCartButton ).toHaveText( '1 in cart' );
+ } );
+
+ test.describe( 'as a guest', () => {
+ test.use( { storageState: guestFile } );
+
+ test( 'counts a child whose declared parent is not in the cart', async ( {
+ page,
+ } ) => {
+ await seedDeclaredChildLine(
+ page,
+ PRODUCT_X.id,
+ 'not-a-cart-line-key'
+ );
+
+ expect(
+ await readServerRenderedProductButtonText( page, PRODUCT_X.id )
+ ).toBe( '1 in cart' );
+
+ await page.goto( '/shop/' );
+ await expectHydratedProductButton(
+ page,
+ PRODUCT_X.id,
+ '1 in cart',
+ '2 in cart'
+ );
+ } );
+
+ test( 'counts a child after its parent is removed from the Mini-Cart', async ( {
+ page,
+ miniCartUtils,
+ } ) => {
+ const { capId } = await seedBeanieAsCapChild( page );
+
+ await page.goto( '/shop/' );
+ const button = productButton( page, PRODUCT_X.id );
+ await expect( button ).toBeVisible();
+ await expect( button ).toHaveText( 'Add to cart' );
+
+ let mainFrameNavigations = 0;
+ page.on( 'framenavigated', ( frame ) => {
+ if ( frame === page.mainFrame() ) {
+ mainFrameNavigations++;
+ }
+ } );
+ const shopUrl = page.url();
+
+ await miniCartUtils.openMiniCart();
+ const removeResponsePromise = page.waitForResponse(
+ ( response ) =>
+ response.url().includes( '/wc/store/v1/batch' ) &&
+ response.request().method() === 'POST'
+ );
+ await page
+ .getByRole( 'dialog' )
+ .getByRole( 'button', { name: 'Remove Cap from cart' } )
+ .click();
+ const removeResponse = await removeResponsePromise;
+ expect( removeResponse.ok() ).toBe( true );
+ const items = await readCartItems( page );
+ expect(
+ items.find( ( item ) => item.id === capId )
+ ).toBeUndefined();
+ expect(
+ items.find( ( item ) => item.id === PRODUCT_X.id )
+ ?.parent_item_key
+ ).toBeNull();
+ await expect( button ).toHaveText( '1 in cart' );
+ expect( page.url() ).toBe( shopUrl );
+ expect( mainFrameNavigations ).toBe( 0 );
+ } );
+
+ test( 'counts a child when its declared parent product is drafted', async ( {
+ page,
+ } ) => {
+ const { capId } = await seedBeanieAsCapChild( page );
+
+ try {
+ await wpCLI( `post update ${ capId } --post_status=draft` );
+
+ const items = await readCartItems( page );
+ expect(
+ items.find( ( item ) => item.id === capId )
+ ).toBeUndefined();
+ const childLine = items.find(
+ ( item ) => item.id === PRODUCT_X.id
+ );
+ expect( childLine ).toBeDefined();
+ expect( childLine?.parent_item_key ).toBeNull();
+
+ expect(
+ await readServerRenderedProductButtonText(
+ page,
+ PRODUCT_X.id
+ )
+ ).toBe( '1 in cart' );
+
+ await page.goto( '/shop/' );
+ await expectHydratedProductButton(
+ page,
+ PRODUCT_X.id,
+ '1 in cart',
+ '2 in cart'
+ );
+ } finally {
+ await wpCLI( `post update ${ capId } --post_status=publish` );
+ }
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts
index c3c2ab7c309..5942829ca6a 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts
@@ -21,6 +21,13 @@ export const CART_LINE_IDENTITY_PLUGIN =
*/
export const CART_LINE_IDENTITY_FLAG = 'cart_line_identity_marker';
+/**
+ * Request flag the helper plugin reads to declare a seeded line's parent.
+ *
+ * Kept identical to `CART_LINE_PARENT_ITEM_KEY_FLAG` in the helper plugin.
+ */
+export const CART_LINE_PARENT_ITEM_KEY_FLAG = 'cart_line_parent_item_key';
+
/**
* Sample-data product used as "product X" throughout the simple-product flows.
*
@@ -61,6 +68,28 @@ export const seedMetaLine = async (
);
};
+/**
+ * Seeds a product or variation as a child line with a declared parent key.
+ *
+ * The declared key is published as the line's `parent_item_key` only when it
+ * names a line in the cart; otherwise, `null` is emitted.
+ *
+ * @param page The Playwright page.
+ * @param variationId The product or variation id to add.
+ * @param parentItemKey The parent key to declare for the line.
+ */
+export const seedDeclaredChildLine = async (
+ page: Page,
+ variationId: number,
+ parentItemKey: string
+) => {
+ await page.goto(
+ `/?add-to-cart=${ variationId }&${ CART_LINE_PARENT_ITEM_KEY_FLAG }=${ encodeURIComponent(
+ parentItemKey
+ ) }`
+ );
+};
+
/**
* The ProductButton for a given product on the shop archive.
*
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
index 0b1f9ca1023..7d33239f648 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
@@ -246,6 +246,45 @@ class Cart extends ControllerTestCase {
);
}
+ /**
+ * @testdox A child keeps its parent key while the parent is in the cart and becomes standalone after removal.
+ */
+ public function test_parent_item_key_is_null_after_removing_parent_item() {
+ $parent_item_key_filter = function ( $parent_item_key, $cart_item, $cart_item_key ) {
+ unset( $cart_item );
+
+ return $this->keys[1] === $cart_item_key ? $this->keys[0] : $parent_item_key;
+ };
+ add_filter( 'woocommerce_store_api_cart_item_parent_item_key', $parent_item_key_filter, 10, 3 );
+
+ try {
+ $cart_response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+ $this->assertSame( 200, $cart_response->get_status() );
+ $cart_items = array_column( $cart_response->get_data()['items'], null, 'key' );
+ $this->assertSame( $this->keys[0], $cart_items[ $this->keys[1] ]['parent_item_key'] );
+ $this->assertNull( $cart_items[ $this->keys[0] ]['parent_item_key'] );
+
+ $remove_request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/remove-item' );
+ $remove_request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $remove_request->set_body_params( array( 'key' => $this->keys[0] ) );
+ $remove_response = rest_get_server()->dispatch( $remove_request );
+
+ $this->assertSame( 200, $remove_response->get_status() );
+ $remaining_items = array_column( $remove_response->get_data()['items'], null, 'key' );
+ $this->assertArrayNotHasKey( $this->keys[0], $remaining_items );
+ $this->assertArrayHasKey( $this->keys[1], $remaining_items );
+ $this->assertNull( $remaining_items[ $this->keys[1] ]['parent_item_key'] );
+ $this->assertSame( 1, $remaining_items[ $this->keys[1] ]['quantity'] );
+
+ $this->assertSame( $this->keys[0], wc()->cart->add_to_cart( $this->products[0]->get_id(), 2 ) );
+ $restored_response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+ $restored_items = array_column( $restored_response->get_data()['items'], null, 'key' );
+ $this->assertSame( $this->keys[0], $restored_items[ $this->keys[1] ]['parent_item_key'] );
+ } finally {
+ remove_filter( 'woocommerce_store_api_cart_item_parent_item_key', $parent_item_key_filter, 10 );
+ }
+ }
+
/**
* Test changing the quantity of a cart item.
*/
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php
index 46a6931a026..a440fa90d34 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php
@@ -381,6 +381,147 @@ class CartItems extends ControllerTestCase {
$this->assertArrayHasKey( 'catalog_visibility', $data );
}
+ /**
+ * @testdox Cart lines include a null parent item key by default, declared in the schema as a readonly string or null.
+ */
+ public function test_parent_item_key_is_null_by_default() {
+ $routes = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+ $controller = $routes->get( 'cart-items', 'v1' );
+ $data = $controller->prepare_item_for_response( current( WC()->cart->get_cart() ), new \WP_REST_Request() )->get_data();
+ $property = $controller->get_item_schema()['properties']['parent_item_key'];
+
+ $this->assertArrayHasKey( 'parent_item_key', $data );
+ $this->assertNull( $data['parent_item_key'] );
+ $this->assertSame( array( 'string', 'null' ), $property['type'] );
+ $this->assertTrue( $property['readonly'] );
+ }
+
+ /**
+ * @testdox The parent item key filter receives null, the raw cart line, and its key.
+ */
+ public function test_parent_item_key_filter_receives_arguments() {
+ $routes = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+ $controller = $routes->get( 'cart-items', 'v1' );
+ $cart_item = current( WC()->cart->get_cart() );
+ $calls = array();
+
+ add_filter(
+ 'woocommerce_store_api_cart_item_parent_item_key',
+ function ( $parent_item_key, $cart_item, $cart_item_key ) use ( &$calls ) {
+ $calls[] = array( $parent_item_key, $cart_item, $cart_item_key );
+ return $parent_item_key;
+ },
+ 10,
+ 3
+ );
+
+ $controller->prepare_item_for_response( $cart_item, new \WP_REST_Request() );
+
+ $this->assertSame( array( array( null, $cart_item, $cart_item['key'] ) ), $calls );
+ }
+
+ /**
+ * @testdox Cart-item routes return null when the declared parent key is not in the cart.
+ */
+ public function test_parent_item_key_is_null_when_key_is_not_in_cart() {
+ $parent_item_key_filter = static function () {
+ return 'not-a-cart-line-key';
+ };
+ add_filter( 'woocommerce_store_api_cart_item_parent_item_key', $parent_item_key_filter );
+
+ try {
+ $cart_response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+ $this->assertSame( 200, $cart_response->get_status() );
+ $cart_items = array_column( $cart_response->get_data()['items'], null, 'key' );
+ $this->assertArrayHasKey( $this->keys[0], $cart_items );
+ $this->assertNull( $cart_items[ $this->keys[0] ]['parent_item_key'] );
+
+ $items_response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart/items' ) );
+ $this->assertSame( 200, $items_response->get_status() );
+ $items = array_column( $items_response->get_data(), null, 'key' );
+ $this->assertArrayHasKey( $this->keys[0], $items );
+ $this->assertNull( $items[ $this->keys[0] ]['parent_item_key'] );
+
+ $item_response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart/items/' . $this->keys[0] ) );
+ $this->assertSame( 200, $item_response->get_status() );
+ $this->assertArrayHasKey( 'parent_item_key', $item_response->get_data() );
+ $this->assertNull( $item_response->get_data()['parent_item_key'] );
+ } finally {
+ remove_filter( 'woocommerce_store_api_cart_item_parent_item_key', $parent_item_key_filter );
+ }
+ }
+
+ /**
+ * @testdox Cart-item responses use a null parent key when the session cart is unavailable.
+ */
+ public function test_parent_item_key_is_null_when_session_cart_is_unavailable() {
+ $routes = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+ $controller = $routes->get( 'cart-items', 'v1' );
+ $cart = WC()->cart;
+ $cart_item = current( $cart->get_cart() );
+ $filter = function () {
+ return $this->keys[1];
+ };
+
+ add_filter( 'woocommerce_store_api_cart_item_parent_item_key', $filter );
+ WC()->cart = null;
+
+ try {
+ $response = $controller->prepare_item_for_response( $cart_item, new \WP_REST_Request() );
+ $this->assertNull( $response->get_data()['parent_item_key'] );
+ } finally {
+ WC()->cart = $cart;
+ remove_filter( 'woocommerce_store_api_cart_item_parent_item_key', $filter );
+ }
+ }
+
+ /**
+ * @testdox The parent item key filter normalizes empty strings and non-string values to null.
+ * @testWith ["", null]
+ * [42, null]
+ *
+ * @param mixed $filtered_value Value returned by the filter.
+ * @param mixed $expected_value Value expected in the response.
+ */
+ public function test_parent_item_key_filter_normalizes_empty_and_non_string_values( $filtered_value, $expected_value ) {
+ $routes = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+ $controller = $routes->get( 'cart-items', 'v1' );
+
+ add_filter(
+ 'woocommerce_store_api_cart_item_parent_item_key',
+ static function () use ( $filtered_value ) {
+ return $filtered_value;
+ }
+ );
+
+ $response = $controller->prepare_item_for_response( current( WC()->cart->get_cart() ), new \WP_REST_Request() );
+
+ $this->assertSame( $expected_value, $response->get_data()['parent_item_key'] );
+ }
+
+ /**
+ * @testdox The parent item key filter cannot declare a cart line as its own parent.
+ */
+ public function test_parent_item_key_is_null_when_key_is_the_line_own_key(): void {
+ $routes = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+ $controller = $routes->get( 'cart-items', 'v1' );
+ $cart_item = current( WC()->cart->get_cart() );
+
+ add_filter(
+ 'woocommerce_store_api_cart_item_parent_item_key',
+ static function ( $parent_item_key, $cart_item, $cart_item_key ) {
+ unset( $parent_item_key, $cart_item ); // Avoid parameter not used PHPCS errors.
+ return $cart_item_key;
+ },
+ 10,
+ 3
+ );
+
+ $response = $controller->prepare_item_for_response( $cart_item, new \WP_REST_Request() );
+
+ $this->assertNull( $response->get_data()['parent_item_key'] );
+ }
+
/**
* `raw_key` gives extensions a name to match on that is never translated.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/InCartQuantityTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/InCartQuantityTest.php
new file mode 100644
index 00000000000..06d23bf8d06
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/InCartQuantityTest.php
@@ -0,0 +1,89 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Utilities;
+
+use Automattic\WooCommerce\Internal\Utilities\InCartQuantity;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Internal\Utilities\InCartQuantity class.
+ */
+class InCartQuantityTest extends WC_Unit_Test_Case {
+
+ /**
+ * Remove the published `woocommerce` state, which the base class does not reset.
+ */
+ public function tearDown(): void {
+ try {
+ $interactivity = wp_interactivity();
+ $property = new \ReflectionProperty( $interactivity, 'state_data' );
+ $property->setAccessible( true );
+ $state_data = $property->getValue( $interactivity );
+ unset( $state_data['woocommerce'] );
+ $property->setValue( $interactivity, $state_data );
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox Should sum the product's published cart lines and skip declared children and empty entries.
+ */
+ public function test_sums_product_lines_and_skips_declared_children(): void {
+ wp_interactivity_state(
+ 'woocommerce',
+ array(
+ 'cart' => array(
+ 'items' => array(
+ array(),
+ array(
+ 'id' => 10,
+ 'quantity' => 1.5,
+ ),
+ array(
+ 'id' => 10,
+ 'quantity' => 2,
+ 'parent_item_key' => null,
+ ),
+ array(
+ 'id' => 10,
+ 'quantity' => 3,
+ 'parent_item_key' => '',
+ ),
+ array(
+ 'id' => 10,
+ 'quantity' => 4,
+ 'item_data' => array( 'name' => 'Gift wrap' ),
+ 'parent_item_key' => null,
+ ),
+ array(
+ 'id' => 10,
+ 'quantity' => 5,
+ 'parent_item_key' => 'parent-a',
+ ),
+ array(
+ 'id' => 11,
+ 'quantity' => 6,
+ ),
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame(
+ 10.5,
+ InCartQuantity::for_product( 10 ),
+ 'Lines without a non-empty parent key should count, keeping fractional quantities; child lines, empty entries, and other products should not'
+ );
+ }
+
+ /**
+ * @testdox Should return zero when the published cart has no items, as after a failed cart hydration.
+ */
+ public function test_returns_zero_without_published_cart_items(): void {
+ wp_interactivity_state( 'woocommerce', array( 'cart' => array() ) );
+
+ $this->assertSame( 0, InCartQuantity::for_product( 10 ), 'A cart without items should count nothing' );
+ }
+}