Commit 656a6bfc2e4 for woocommerce

commit 656a6bfc2e42d01d90fc2d8c1d42237beda81271
Author: Luis Herranz <luisherranz@gmail.com>
Date:   Mon Jul 6 10:53:49 2026 +0200

    Fix iAPI's addToCart action mishandling cart lines differentiated by item meta (#65869)

    * Make addCartItem endpoint selection a pure function of caller key-presence

    Derive isUpdate from the caller-supplied key instead of a line matched by
    id/variation, so a keyless add always issues add-item with a delta and never
    an update-item even when an existing keyed line matches by product id. The
    optimistic render and the delta-on-add-item amount derivation are unchanged.

    * Apply keyless-add-item rule to batchAddCartItems endpoint selection

    Derive isUpdate from the caller-supplied item.key instead of a line matched by
    id/variation, mirroring the single-item addCartItem path. A keyless batch item
    now always issues add-item with a delta and never update-item, even when an
    existing keyed line matches by product id, so the server owns cart-line
    identity for grouped/variation adds too. An explicit caller key still issues
    update-item with the absolute quantity for that exact line. The per-item
    optimistic render is unchanged.

    * Add keyless-requires-delta invariant guard to addCartItem

    Throw a clear error when addCartItem is called keyless with an absolute
    quantity but no quantityToAdd. Keyless adds always issue add-item with a
    delta, so an absolute quantity would be misread as a delta and compound
    across rapid clicks. Keyed callers (mini-cart steppers) still pass an
    absolute quantity with a key via update-item and remain unaffected.

    * Suppress spurious notice-diff false positive for keyless meta-only adds

    * Sum over matching cart lines for saved-for-later add-success detection

    Replace the single-line findItemInCart before/after read in onClickMoveToCart
    with a sum of quantity over every cart line matching the product (id for
    simple products; id plus variation attributes via doesCartItemMatchAttributes
    for variations), and remove the saved entry only when the total grew. A correct
    add the server resolves as a new standalone line is no longer misread as a
    failure. The cart store findItemInCart selector is unchanged.

    * Sum over matching cart lines for wishlist add-success detection

    Replace the single-line findItemInCart before/after read in
    onClickAddToCart with a total-quantity sum over every cart line matching
    the product (id for simple products; id + variation attributes for
    variations). The server owns cart-line identity for adds, so a correct
    add can land as a new standalone line; reading one id-matched line would
    misread that as a failure and wrongly keep the wishlist entry. The entry
    is now removed only when the summed quantity grew, mirroring the
    saved-for-later correction so the two consumers stay consistent.

    * Add store-level unit coverage for add-path cap error notice and 2-meta-line notice-absence

    * Add cart-line-identity e2e test-helper plugin

    Simulate a meta-differentiated cart line (bundle child / booking /
    add-on / recipient stand-in) for the add-to-cart cart-line-identity
    e2e flows. The helper hooks woocommerce_add_cart_item_data and, when a
    flagged request carries the cart_line_identity_marker query/request
    flag, attaches a unique cart_item_data entry so core's generate_cart_id
    produces a distinct cart line for the same product id, without
    modifying any product or shipping a real extension. It has effect only
    when activated and changes no WooCommerce source behavior.

    * Add cart-line-identity add-to-cart e2e specs

    Automate E2E Flows 1-9 for the keyless add-to-cart cart-line-identity
    behavior as Playwright specs under the blocks project. The meta-line is
    simulated by the committed cart-line-identity helper plugin via a flagged
    legacy add-to-cart navigation.

    Asserted end-to-end (blocks-chromium): meta-only add creates a new
    standalone line with the meta line unchanged and no error notice (1, 2);
    existing standalone increments (3); standalone+meta only the standalone
    increments (4); not-in-cart creates a line (5); variation re-add
    increments and a different variation creates a new line via Add to Cart
    with Options (6); mini-cart stepper updates the exact line with no
    spurious notice and no extra line (7); meta-only keyless add cart-outcome
    for the notice-showing consumers (8); genuine rejection leaves the cart
    unchanged and surfaces the error (9).

    The saved-for-later and wishlist blocks are gated behind the
    product_wishlist feature flag and are not registered in the e2e
    environment, so their consumer-specific notice/entry guarantees (no
    spurious "quantity changed" info notice on a meta-only add; entry
    preserved on a genuine rejection) are covered authoritatively at the
    store/consumer unit level (Tasks 5, 6, 7); the cart-outcome substrate
    those consumers rely on is asserted here.

    * Align cart-store add/update endpoint-decision comments with the server-delegated rule

    Refresh the inline rationale in addCartItem and batchAddCartItems so a future
    maintainer reads endpoint choice as a pure function of caller key-presence
    (keyless => add-item with a delta; keyed => update-item with an absolute
    quantity) and understands the keyless path delegates cart-line identity to the
    server. Add the iron-rule note at both in-place optimistic bumps: the bump is
    render-only and must never feed back into endpoint selection or the posted
    amount, warning off any future edit that would reintroduce id-based add/update
    matching. Comments only; no code logic, control flow, or JSDoc changed.

    * Document the keyless-requires-delta guard contract

    Refine the inline rationale comment above the keyless-requires-delta
    guard in addCartItem so its contract is self-documenting: a keyless add
    must carry a quantityToAdd delta because rapid-click compounding relies
    on deltas, and an absolute quantity is legitimate only on the keyed
    stepper path (mini-cart / cart-block quantity controls), which targets a
    known line via update-item and is intentionally exempt from the guard.

    * Document the keyless-scoped pre-optimistic baseline notice diff

    * Document sum-over-matching-lines success detection in the list consumers

    Refine the inline rationale comments in saved-for-later's onClickMoveToCart
    and wishlist's onClickAddToCart so a maintainer reading either add handler
    understands that the list entry is removed only when the total quantity
    across all matching cart lines grew, and why a single id-matched-line read
    would misjudge the new-line case (it can resolve to the unchanged
    pre-existing meta line both times and misread a correct add as a failure).
    Preserves the still-accurate error-swallowing note, the server-reconciled
    read note, and the variation-attribute mapping note.

    * Document the cart-line-identity e2e helper plugin inline

    Refine the new helper plugin's header and inline docblocks so a future blocks
    e2e test author understands what it simulates (a meta-differentiated cart line:
    bundle child / booking / add-on / recipient stand-in), how to activate it (the
    woocommerce-blocks-test-cart-line-identity slug), and how toggling the marker
    flag seeds the meta line before a plain add. States it is test-only and changes
    no WooCommerce source. Tightens the header Description to one line to match the
    sibling helper plugins in the same directory.

    * Add changelog entry for the add-to-cart cart-line-identity fix

    * Update mutation-batcher e2e tests to use quantityToAdd deltas

    These tests called the keyless addCartItem with an absolute quantity, which the new keyless-requires-delta store guard now rejects, failing the 5 store-API tests. Switch them to quantityToAdd (the canonical keyless form already used by every production caller). The batcher behavior under test is unchanged: each call still issues one add-item request.

    * Co-locate cart-store e2e suites under tests/blocks/cart-store

    Move the two Interactivity API cart-store e2e suites — the cart-line-identity add-to-cart specs and the mutation-batcher regression suite — into a shared tests/blocks/cart-store/ folder with their helper utils, and drop the redundant "add-to-cart-" prefix from the cart-line-identity spec filename. Both exercise the generic woocommerce/cart store rather than any single block (mutation-batcher already lived as its own non-block folder), so grouping them makes that intent explicit. Pure file moves: the ../add-to-cart-with-options page object and ./utils imports still resolve, the Playwright blocks-chromium project globs the tree, and all 15 tests pass from the new location.

    * Remove internal pipeline references from cart-store e2e comments

    The cart-line-identity e2e spec and its utils referenced an internal development pipeline's artifacts — "Flow N" / "Task N" numbering drawn from its spec, design doc, and task list — that a reader of this repository cannot resolve. Strip those dangling cross-references: drop the "Flow N" prefixes from the test titles (keeping the descriptive part), and reword the header and inline comments to point at concrete things — the store and consumer unit tests, the helper plugin — instead of pipeline task numbers. Comment-only; no behavior change.

    * Drop markdown emphasis from cart-store JSDoc comments

    Remove **bold** markdown from a few JSDoc comments in the cart store and the saved-for-later / wishlist consumers; the emphasis markup renders as literal asterisks and adds noise in plain-text source. Comment-only; no behavior change.

    * Replace per-line keyless-add baseline with per-product total exactness test threading suppressKeys to notice diff

    * Remove private-spec references from cart store tests

    * Fix prettier formatting in cart store and tests

    * Refactor seedMetaLine e2e helper to avoid eslint-plugin-jsdoc crash

    * Reconcile cart-store e2e suite onto trunk tests/e2e layout

    * Drop internal "iron rule" jargon from cart store delta-math comments

    Rephrases four comments in the keyless add / batch-add delta logic to describe
    the invariant directly instead of naming it "the iron rule". Comments only; no
    logic change.

    * Remove forbidden non-null assertion in cart store batch capture

    The batch-add path looked up a per-product capture with
    `batchProductCaptures.get( token )!`, tripping the blocks
    @typescript-eslint/no-non-null-assertion lint rule that failed CI. Replace
    the `!` with a single guarded lookup; behavior is unchanged (in this branch
    the token is always present) and the redundant Map lookup is gone.

    ---------

    Co-authored-by: luisherranz <luis.herranz@automattic.com>

diff --git a/plugins/woocommerce/changelog/billow-69-add-to-cart-cart-line-identity b/plugins/woocommerce/changelog/billow-69-add-to-cart-cart-line-identity
new file mode 100644
index 00000000000..5c5e8a1eb82
--- /dev/null
+++ b/plugins/woocommerce/changelog/billow-69-add-to-cart-cart-line-identity
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Block add to cart: adding a product that is in the cart only inside a bundle (or only as a booking, add-on configuration, or recipient-differentiated line) now adds a new standalone line instead of showing an error or changing the existing line's quantity.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
index f6570227d1f..7fdcf3f9d70 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
@@ -134,9 +134,153 @@ const generateInfoNotice = ( message: string ): Notice => ( {
 	dismissible: true,
 } );

+/**
+ * Computes the canonical product token for accumulating per-product totals
+ * across a batch.
+ *
+ * The token is stable: simple items produce `"<id>"` and variation items
+ * produce `"<id>|<attr1>=<val1>&..."` with attributes sorted alphabetically
+ * by name so insertion order differences do not produce different tokens.
+ *
+ * @param id        The product id.
+ * @param variation The variation attributes, if any.
+ * @return A canonical string token that uniquely identifies this product.
+ */
+function productToken(
+	id: number,
+	variation?: CartVariationItem[] | SelectedAttributes[]
+): string {
+	if ( ! variation || variation.length === 0 ) {
+		return String( id );
+	}
+	const attrs = [ ...variation ]
+		.sort( ( a, b ) => a.attribute.localeCompare( b.attribute ) )
+		.map( ( v ) => `${ v.attribute }=${ v.value }` )
+		.join( '&' );
+	return `${ id }|${ attrs }`;
+}
+
+/**
+ * Returns `true` when the given cart line matches the product identified by
+ * `id` and `variation`, using the same matching logic as `findItemInCart`.
+ *
+ * Simple items match by `id` equality. Variation items additionally require
+ * `variation.length` equality and `doesCartItemMatchAttributes`.
+ *
+ * @param item      The cart line to test.
+ * @param id        The product id to match against.
+ * @param variation The variation attributes to match against, if any.
+ * @return `true` when the line belongs to the specified product.
+ */
+function lineMatchesProduct(
+	item: OptimisticCartItem | CartItem,
+	id: number,
+	variation?: CartVariationItem[] | SelectedAttributes[]
+): boolean {
+	if ( item.type === 'variation' ) {
+		if (
+			id !== item.id ||
+			! item.variation ||
+			! variation ||
+			item.variation.length !== variation.length
+		) {
+			return false;
+		}
+		return doesCartItemMatchAttributes( item, variation );
+	}
+	return id === item.id;
+}
+
+/**
+ * Builds a `Set` of pre-existing cart-line keys to suppress from the
+ * "quantity changed" auto-update notice after a successful keyless add.
+ *
+ * For each product entry in `products`, computes:
+ *   - `serverTotal` = sum of the committed server cart's lines matching that
+ *     product (using the same matcher as `findItemInCart`).
+ *   - `expectedTotal` = pre-add total + sum of posted deltas.
+ *
+ * When `serverTotal === expectedTotal`, the add was exact for that product
+ * (no server-initiated cap, redistribution, or concurrent change), so every
+ * pre-existing line key captured for that product is added to the returned
+ * set and will be skipped in the auto-UPDATE notice diff.
+ *
+ * When the totals diverge, the product's keys are left out of the set and
+ * the diff fires normally, reporting the server's actual quantity.
+ *
+ * Only keyless adds should call this helper. Keyed `update-item` changes
+ * must never populate `products`; leaving their line keys out of the
+ * suppression set ensures the "your change was undone" notice keeps firing.
+ *
+ * @param products   Per-product capture records, one per added product.
+ * @param serverCart The committed server cart to sum against.
+ * @return The flat set of pre-existing line keys to suppress.
+ */
+function computeKeylessAddSuppressKeys(
+	products: Array< {
+		/** The product id used for matching. */
+		id: number;
+		/** The variation attributes used for matching, if any. */
+		variation?: CartVariationItem[] | SelectedAttributes[] | undefined;
+		/** Sum of all pre-add quantities across matching lines, captured before the optimistic bump. */
+		preAddTotal: number;
+		/** Sum of all posted deltas for this product in this add cycle. */
+		deltaTotal: number;
+		/** The pre-existing cart-line keys belonging to this product, captured before the optimistic bump. */
+		preExistingKeys: string[];
+	} >,
+	serverCart: Cart
+): Set< string > {
+	const suppressKeys = new Set< string >();
+	for ( const product of products ) {
+		const serverTotal = serverCart.items
+			.filter( ( item ) =>
+				lineMatchesProduct( item, product.id, product.variation )
+			)
+			.reduce( ( sum, item ) => sum + item.quantity, 0 );
+		const expectedTotal = product.preAddTotal + product.deltaTotal;
+		if ( serverTotal === expectedTotal ) {
+			for ( const key of product.preExistingKeys ) {
+				suppressKeys.add( key );
+			}
+		}
+	}
+	return suppressKeys;
+}
+
+/**
+ * Derives the auto-update and auto-removal info notices from the diff between
+ * the post-optimistic cart and the committed server cart.
+ *
+ * Auto-removal notices fire for lines present in `oldCart` that the server
+ * dropped entirely (stock removal, product deletion, etc.). Because
+ * `oldCart` is the post-optimistic snapshot, user-initiated removals are
+ * already absent and do not produce spurious notices.
+ *
+ * Auto-update notices fire for server lines whose quantity differs from the
+ * post-optimistic snapshot, with one suppression rule: any line whose key
+ * appears in `suppressKeys` is skipped unconditionally. The action populates
+ * `suppressKeys` with the pre-existing keys of products whose keyless add was
+ * exact (server total == pre-add total + posted delta), so a successful keyless
+ * add never emits a spurious "quantity changed" notice regardless of which
+ * server line received the delta. Genuine server changes (cap, clamp, concurrent
+ * mutation) still notify because they make the per-product totals diverge and
+ * the keys are left out of the set.
+ *
+ * Keyed `update-item` changes and `removeCartItem` never populate
+ * `suppressKeys` (the parameter defaults to an empty set), so their notice
+ * behavior is byte-for-byte unchanged.
+ *
+ * @param oldCart      The post-optimistic cart snapshot used as the diff baseline.
+ * @param newCart      The committed server cart to diff against.
+ * @param suppressKeys Keys of pre-existing lines whose product's add was exact;
+ *                     these lines are skipped in the auto-UPDATE filter.
+ * @return The list of info notices to surface to the shopper.
+ */
 const getInfoNoticesFromCartUpdates = (
 	oldCart: Store[ 'state' ][ 'cart' ],
-	newCart: Cart
+	newCart: Cart,
+	suppressKeys: Set< string > = new Set()
 ): Notice[] => {
 	const oldItems = oldCart.items;
 	const newItems = newCart.items;
@@ -151,12 +295,20 @@ const getInfoNoticesFromCartUpdates = (
 	);

 	// Items whose quantity was adjusted by the server (stock cap, sold-individually).
-	// Comparing optimistic → server means intentional user changes are already
-	// reflected in oldItems and will not trigger this notice.
+	// By default a line is compared optimistic → server, so intentional user
+	// changes are already reflected in oldItems and do not trigger this notice.
+	// Lines whose key appears in suppressKeys are skipped: the action proved that
+	// the product's add was exact (server total == expected total), so any
+	// quantity difference on those lines is an intentional add result, not a
+	// server-initiated change. Keyed update-item lines and removeCartItem lines
+	// are never in suppressKeys, so their notice behavior is unchanged.
 	const autoUpdatedToNotify = newItems.filter( ( item ) => {
 		if ( ! isCartItem( item ) ) {
 			return false;
 		}
+		if ( suppressKeys.has( item.key ) ) {
+			return false; // The action proved this product's add was exact.
+		}
 		const old = oldItems.find( ( o ) => o.key === item.key );
 		return old && item.quantity !== old.quantity;
 	} );
@@ -347,6 +499,27 @@ const { actions } = store< Store >(
 					);
 				}

+				// Keyless-requires-delta invariant. A keyless add always issues
+				// `add-item`, whose quantity is a delta added to the existing
+				// line; rapid-click compounding relies on that — each click sends
+				// its own delta and the server sums them (N -> N+1 -> N+2). An
+				// absolute `quantity` on a keyless add would be misread as a delta
+				// and corrupt that compounding, so keyless callers must pass
+				// `quantityToAdd`. An absolute `quantity` is legitimate only when
+				// paired with an explicit `key`: that is the keyed-stepper path
+				// (mini-cart / cart-block quantity controls), which targets one
+				// known line via `update-item` and sets its quantity outright.
+				// Those keyed callers are intentionally exempt from this guard.
+				if (
+					key === undefined &&
+					quantity !== undefined &&
+					quantityToAdd === undefined
+				) {
+					throw new Error(
+						'addCartItem: a keyless add must pass quantityToAdd (a delta), not an absolute quantity.'
+					);
+				}
+
 				const a11yModulePromise = import( '@wordpress/a11y' );

 				// Find existing item
@@ -371,9 +544,13 @@ const { actions } = store< Store >(
 					targetQuantity = 1;
 				}

-				// Only treat as update if the item has a key (server-confirmed item).
-				// Optimistic items don't have keys, so we should add them instead.
-				const isUpdate = !! existingItem?.key;
+				// Endpoint selection is a pure function of the caller-supplied
+				// `key`, never of a line matched by id/variation. A keyless add
+				// always issues `add-item` with a delta, even when an existing
+				// line (including a server-keyed one) matches by product id, so
+				// the server owns cart-line identity for adds. Only an explicit
+				// caller `key` targets a specific line via `update-item`.
+				const isUpdate = !! key;
 				const endpoint = isUpdate ? 'update-item' : 'add-item';

 				// Track what changes we're making for notice comparison.
@@ -388,12 +565,20 @@ const { actions } = store< Store >(
 				// Prepare the item to send.
 				let itemToSend: OptimisticCartItem;
 				if ( isUpdate && existingItem ) {
-					// Server-confirmed item: include the key for update-item endpoint.
+					// Caller-keyed update: target the exact line by key and send
+					// the absolute target quantity to the update-item endpoint.
 					itemToSend = { ...existingItem, quantity: targetQuantity };
 				} else {
-					// New item or optimistic item: build fresh for add-item endpoint.
-					// For optimistic items (existingItem without key), calculate delta
-					// since add-item adds to existing quantity, not sets it.
+					// Keyless add: build a fresh payload for the add-item
+					// endpoint and never copy the matched line's key. The amount
+					// sent is always a delta — add-item adds to the existing
+					// quantity rather than setting it — so a match (by
+					// id/variation, possibly carrying a server key) only tells us
+					// how much delta is already accounted for in the running
+					// optimistic total; with no match we post the full target
+					// quantity. The matched line is never sent as an absolute
+					// quantity: the posted amount is a function of the delta,
+					// not of the match.
 					const quantityToSend = existingItem
 						? targetQuantity - existingItem.quantity
 						: targetQuantity;
@@ -408,6 +593,56 @@ const { actions } = store< Store >(
 				// Capture cart state after optimistic updates for notice comparison.
 				let cartAfterOptimistic: typeof state.cart | null = null;

+				// Per-product capture for the keyless-add exactness test.
+				// On the keyless path (!isUpdate), capture by value — before the
+				// optimistic bump mutates `existingItem.quantity` in place — the
+				// set of pre-existing matching line keys and their summed quantity.
+				// This is the single error-prone hotspot: `existingItem` is a live
+				// reference into `state.cart.items`; reading `.quantity` after the
+				// bump yields the post-bump value and silently corrupts the math.
+				// Stays empty on the keyed `update-item` path so the "your change
+				// was undone" notice keeps firing for steppers.
+				type ProductCapture = {
+					id: number;
+					variation?:
+						| CartVariationItem[]
+						| SelectedAttributes[]
+						| undefined;
+					preAddTotal: number;
+					deltaTotal: number;
+					preExistingKeys: string[];
+				};
+				const productCaptures: ProductCapture[] = [];
+				if ( ! isUpdate ) {
+					// Sum all pre-add quantities across every cart line matching
+					// this product (id + variation). A single product can occupy
+					// multiple lines (e.g. a meta line ordered before a standalone
+					// line). The per-product total lets us verify exactness even
+					// when the server grows a different line than the one the
+					// client bumped optimistically.
+					const preExistingKeys: string[] = [];
+					let preAddTotal = 0;
+					for ( const cartLine of state.cart.items ) {
+						if ( lineMatchesProduct( cartLine, id, variation ) ) {
+							preAddTotal += cartLine.quantity;
+							if ( cartLine.key ) {
+								preExistingKeys.push( cartLine.key );
+							}
+						}
+					}
+					// `itemToSend.quantity` is the posted delta (quantityToSend
+					// computed above). It is already computed before this capture
+					// block and does not depend on the optimistic state, so it is
+					// safe to read here.
+					productCaptures.push( {
+						id,
+						variation,
+						preAddTotal,
+						deltaTotal: itemToSend.quantity,
+						preExistingKeys,
+					} );
+				}
+
 				try {
 					const result = ( yield sendCartRequest( state, {
 						path: `/wc/store/v1/cart/${ endpoint }`,
@@ -415,7 +650,19 @@ const { actions } = store< Store >(
 						body: itemToSend,
 						applyOptimistic: () => {
 							if ( existingItem ) {
-								// Update existing item's quantity (whether server-confirmed or optimistic).
+								// This in-place bump is render-only. It
+								// makes the common re-add flicker-free, but it must
+								// never feed back into endpoint selection or the
+								// posted amount — those are already fixed above as a
+								// pure function of key-presence and the delta. On a
+								// keyless add the match may bump a server-keyed line's
+								// rendered quantity (the accepted, self-correcting
+								// meta-only blip the server reconciles away); it must
+								// not flip the add into `update-item` or supply an
+								// absolute quantity. A future edit that lets this
+								// match drive the endpoint or the posted amount
+								// resurrects the original "cannot update bundle item"
+								// / wrong-line bug.
 								const isSoldIndividually =
 									isCartItem( existingItem ) &&
 									existingItem.sold_individually;
@@ -456,9 +703,18 @@ const { actions } = store< Store >(
 						cart &&
 						cartAfterOptimistic
 					) {
+						// Compute the suppression set: for each added product,
+						// check whether the server total matches the pre-add total
+						// plus the posted delta. If so, the add was exact and the
+						// pre-existing line keys are suppressed in the notice diff.
+						const suppressKeys = computeKeylessAddSuppressKeys(
+							productCaptures,
+							cart
+						);
 						const infoNotices = getInfoNoticesFromCartUpdates(
 							cartAfterOptimistic,
-							cart
+							cart,
+							suppressKeys
 						);
 						const errorNotices =
 							cart.errors.map( generateErrorNotice );
@@ -493,8 +749,30 @@ const { actions } = store< Store >(
 				const quantityChanges: QuantityChanges = {};

 				try {
-					// Submit each item through the batcher. They'll be
-					// collected into a single batch request automatically.
+					// Per-product capture for the keyless-add exactness test,
+					// accumulated across all keyless batch items. The map key is a
+					// canonical product token (productToken(id, variation)) so that
+					// multiple batch items for the same product accumulate into one
+					// entry. The capture runs synchronously in the .map() below,
+					// before applyOptimistic runs (applyOptimistic is gated behind
+					// `await isNonceReady` inside sendCartRequest, so every
+					// existingItem.quantity read during the .map() sees the same
+					// pre-add cart). Keyed `update-item` items never contribute to
+					// this map, so the "your change was undone" notice keeps firing.
+					type BatchProductCapture = {
+						id: number;
+						variation?:
+							| CartVariationItem[]
+							| SelectedAttributes[]
+							| undefined;
+						preAddTotal: number;
+						deltaTotal: number;
+						preExistingKeys: string[];
+					};
+					const batchProductCaptures = new Map<
+						string,
+						BatchProductCapture
+					>();
 					const promises = items.map( ( item, index ) => {
 						const existingItem = state.findItemInCart( {
 							id: item.id,
@@ -509,11 +787,23 @@ const { actions } = store< Store >(
 						} else {
 							quantity = item.quantity ?? 1;
 						}
-						const isUpdate = !! existingItem?.key;
+						// Endpoint selection is a pure function of the
+						// caller-supplied `key`, never of a line matched by
+						// id/variation. This mirrors the single-item
+						// `addCartItem` path: a keyless batch item always
+						// issues `add-item` with a delta, even when an existing
+						// line (including a server-keyed one) matches by product
+						// id, so the server owns cart-line identity for adds.
+						// Only an explicit caller `key` targets a specific line
+						// via `update-item`.
+						const isUpdate = !! item.key;
 						const endpoint = isUpdate ? 'update-item' : 'add-item';

 						let itemToSend: OptimisticCartItem;
 						if ( isUpdate && existingItem ) {
+							// Caller-keyed update: target the exact line by key
+							// and send the absolute target quantity to the
+							// update-item endpoint.
 							itemToSend = {
 								key: existingItem.key,
 								id: existingItem.id,
@@ -525,6 +815,15 @@ const { actions } = store< Store >(
 								existingItem.key as string,
 							];
 						} else {
+							// Keyless add: build a fresh payload for the add-item
+							// endpoint and never copy the matched line's key. As in
+							// addCartItem, the amount sent is always a delta —
+							// add-item adds to the existing quantity rather than
+							// setting it — so a match (by id/variation, possibly
+							// carrying a server key) only tells us how much delta is
+							// already accounted for; with no match we post the full
+							// target quantity. The matched line is never sent as an
+							// absolute quantity.
 							const quantityToSend = existingItem
 								? quantity - existingItem.quantity
 								: quantity;
@@ -539,6 +838,52 @@ const { actions } = store< Store >(
 								...( quantityChanges.productsPendingAdd ?? [] ),
 								item.id,
 							];
+
+							// Accumulate the per-product capture for the exactness
+							// test. On the first encounter of each product token,
+							// sum all pre-add quantities across every matching line
+							// and collect their keys — both captured as primitives
+							// here, before applyOptimistic mutates the cart. On
+							// subsequent encounters of the same product, add only
+							// the posted delta to the running deltaTotal.
+							const token = productToken(
+								item.id,
+								item.variation
+							);
+							if ( ! batchProductCaptures.has( token ) ) {
+								const preExistingKeys: string[] = [];
+								let preAddTotal = 0;
+								for ( const cartLine of state.cart.items ) {
+									if (
+										lineMatchesProduct(
+											cartLine,
+											item.id,
+											item.variation
+										)
+									) {
+										preAddTotal += cartLine.quantity;
+										if ( cartLine.key ) {
+											preExistingKeys.push(
+												cartLine.key
+											);
+										}
+									}
+								}
+								batchProductCaptures.set( token, {
+									id: item.id,
+									variation: item.variation,
+									preAddTotal,
+									deltaTotal: quantityToSend,
+									preExistingKeys,
+								} );
+							} else {
+								// Same product seen again in this batch — add delta.
+								const capture =
+									batchProductCaptures.get( token );
+								if ( capture ) {
+									capture.deltaTotal += quantityToSend;
+								}
+							}
 						}

 						const isLastItem = index === items.length - 1;
@@ -549,6 +894,16 @@ const { actions } = store< Store >(
 							body: itemToSend,
 							applyOptimistic: () => {
 								if ( existingItem ) {
+									// As in addCartItem, this in-place
+									// bump is render-only and must never feed back into
+									// endpoint selection or the posted amount, which are
+									// already fixed above as a pure function of
+									// key-presence and the delta. Bumping a server-keyed
+									// line's rendered quantity on a keyless add is the
+									// accepted meta-only blip the server reconciles; it
+									// must not flip the add into `update-item` or post an
+									// absolute quantity. Letting this match drive the
+									// endpoint or amount reintroduces the bug.
 									existingItem.quantity = quantity;
 								} else {
 									state.cart.items.push( itemToSend );
@@ -599,9 +954,18 @@ const { actions } = store< Store >(
 						const cart = lastSuccess.value.data as Cart;

 						if ( showCartUpdatesNotices ) {
+							// Compute the suppression set from the accumulated
+							// per-product captures. For each product, if the server
+							// total equals preAddTotal + sum of posted deltas, the
+							// add was exact and the pre-existing keys are suppressed.
+							const suppressKeys = computeKeylessAddSuppressKeys(
+								[ ...batchProductCaptures.values() ],
+								cart
+							);
 							const infoNotices = getInfoNoticesFromCartUpdates(
 								cartAfterOptimistic,
-								cart
+								cart,
+								suppressKeys
 							);
 							const errorNotices =
 								cart.errors.map( generateErrorNotice );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
index 4f5384d8612..c7f6abbb0cc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
@@ -1,7 +1,13 @@
+/**
+ * External dependencies
+ */
+import type { Cart, CartItem } from '@woocommerce/types';
+import type { Notice } from '@woocommerce/stores/store-notices';
+
 /**
  * Internal dependencies
  */
-import type { Store } from '../cart';
+import type { Store, OptimisticCartItem } from '../cart';

 type MockStore = { state: Store[ 'state' ]; actions: Store[ 'actions' ] };

@@ -16,10 +22,21 @@ jest.mock(
 	() => ( {
 		getConfig: jest.fn(),
 		store: jest.fn( ( _name, definition ) => {
+			// The cart store calls `store()` twice: once to read `state` and
+			// once to register `actions`. Merge the definition's `state`
+			// descriptors (e.g. the `findItemInCart` selector) onto the shared
+			// mock state so the real selector runs against seeded cart lines,
+			// and carry the action generators through both calls.
+			if ( definition?.state ) {
+				Object.defineProperties(
+					mockState,
+					Object.getOwnPropertyDescriptors( definition.state )
+				);
+			}
 			mockRegisteredStore = {
 				state: mockState,
-				actions: definition.actions,
-			};
+				actions: definition?.actions ?? mockRegisteredStore?.actions,
+			} as MockStore;
 			return mockRegisteredStore;
 		} ),
 	} ),
@@ -30,7 +47,308 @@ jest.mock( '../legacy-events', () => ( {
 	triggerAddedToCartEvent: jest.fn(),
 } ) );

+/**
+ * Captured representation of a single mutation sent through the batch endpoint.
+ */
+type CapturedRequest = {
+	/** The Store API path the mutation targeted, e.g. `/wc/store/v1/cart/add-item`. */
+	path: string;
+	/** The HTTP method of the mutation. */
+	method: string;
+	/** The parsed JSON body posted for the mutation. */
+	body: OptimisticCartItem;
+};
+
+/**
+ * Drives an Interactivity API async action generator to completion.
+ *
+ * Async actions are typed as `void` for consumers but are generators
+ * internally. Each yielded value is awaited (resolving the batched cart
+ * request and any dynamic imports) and the resolved value is fed back into the
+ * generator until it is done.
+ *
+ * When a yielded promise rejects, the rejection is routed back into the
+ * generator via `iterator.throw()` (mirroring the real Interactivity runtime),
+ * so the action's own `try/catch` runs — e.g. `addCartItem` catching a capped
+ * request and emitting an error notice. A rejection the generator does not
+ * catch re-throws here so `await runAction(...)` still rejects.
+ *
+ * @param action The async action return value cast to a generator.
+ * @return A promise that resolves once the generator has finished.
+ */
+async function runAction( action: unknown ): Promise< void > {
+	const iterator = action as Generator< unknown, unknown, unknown >;
+	let next = iterator.next();
+	while ( ! next.done ) {
+		try {
+			// eslint-disable-next-line no-await-in-loop
+			const resolved = await next.value;
+			next = iterator.next( resolved );
+		} catch ( error ) {
+			// Feed the rejection into the generator so its try/catch handles it.
+			next = iterator.throw( error );
+		}
+	}
+}
+
+/**
+ * Installs a `global.fetch` mock that records every mutation routed through the
+ * batch endpoint and replies with canned successful responses.
+ *
+ * The mock answers two request shapes the store issues:
+ * - The initial `GET /cart` refresh (no request body): returns an empty cart
+ *   with a `Nonce` header so the store's nonce-ready gate resolves and queued
+ *   mutations are allowed to flush.
+ * - The batch `POST` (a `{ requests: [...] }` body): records each mutation and
+ *   replies with one successful `responses` entry per request, each carrying
+ *   the current optimistic cart as the server state. Echoing the optimistic
+ *   cart makes the mutation queue commit (rather than roll back), so the
+ *   optimistic line changes the action applied survive reconciliation.
+ *
+ * @return The array that accumulates captured mutation requests.
+ */
+function mockBatchFetch(): CapturedRequest[] {
+	const captured: CapturedRequest[] = [];
+	global.fetch = jest.fn(
+		async ( _url: RequestInfo | URL, init?: RequestInit ) => {
+			// The GET refresh has no body; reply with an empty cart and a nonce.
+			if ( ! init?.body ) {
+				return new Response(
+					JSON.stringify( { items: [], totals: {}, errors: [] } ),
+					{ headers: { Nonce: 'test-nonce-123' } }
+				);
+			}
+			const parsed = JSON.parse( init.body as string ) as {
+				requests: CapturedRequest[];
+			};
+			parsed.requests.forEach( ( request ) => captured.push( request ) );
+			// Echo the post-optimistic cart so the queue commits it as the
+			// server state instead of rolling back.
+			const serverCart = JSON.parse( JSON.stringify( mockState.cart ) );
+			const responses = parsed.requests.map( () => ( {
+				status: 200,
+				body: serverCart,
+			} ) );
+			return new Response( JSON.stringify( { responses } ), {
+				headers: { Nonce: 'test-nonce-123' },
+			} );
+		}
+	) as unknown as typeof fetch;
+	return captured;
+}
+
+/**
+ * Loads a fresh copy of the cart store, resolves its nonce gate, and returns
+ * its registered actions.
+ *
+ * The module is re-required in isolation so each test starts from a clean
+ * mutation queue and a fresh module-level nonce-ready promise. The initial
+ * `refreshCartItems()` is then driven to completion so that the singleton
+ * nonce-ready promise resolves and queued mutations are allowed to flush; tests
+ * seed `state.cart` afterwards via {@link seedCart}.
+ *
+ * @return A promise resolving to the freshly registered cart store actions.
+ */
+async function loadCartStore(): Promise< Store[ 'actions' ] > {
+	jest.isolateModules( () => require( '../cart' ) );
+	const actions = mockRegisteredStore?.actions as Store[ 'actions' ];
+	// Drive the refresh so the module-level nonce-ready promise resolves.
+	await runAction( actions.refreshCartItems() );
+	return actions;
+}
+
+/**
+ * Seeds the shared mock state with the provided cart lines.
+ *
+ * @param items The cart lines to expose via `state.cart.items`.
+ */
+function seedCart( items: ( CartItem | OptimisticCartItem )[] ): void {
+	mockState.cart = {
+		items,
+		totals: {},
+		errors: [],
+	} as unknown as Store[ 'state' ][ 'cart' ];
+}
+
+/**
+ * Builds a minimal successful server cart payload from the provided lines.
+ *
+ * @param items The cart lines the server should report.
+ * @return A cart object shaped like a successful Store API cart response.
+ */
+function makeServerCart( items: CartItem[] ): Cart {
+	return {
+		items,
+		totals: {},
+		errors: [],
+	} as unknown as Cart;
+}
+
+/**
+ * Installs a `global.fetch` mock whose batch responses return a caller-supplied
+ * server cart instead of echoing the post-optimistic cart.
+ *
+ * This lets a test reproduce a server response that diverges from the
+ * optimistic state — e.g. a keyless add that the server resolves as a brand new
+ * standalone line while leaving a matched keyed meta line at its pre-add
+ * quantity. Each successful batch response carries `serverCart` as its body, so
+ * it becomes the action's `result.data` used for the notice diff.
+ *
+ * @param serverCart The cart the batch endpoint should report as server state.
+ */
+function mockBatchFetchReturning( serverCart: Cart ): void {
+	global.fetch = jest.fn(
+		async ( _url: RequestInfo | URL, init?: RequestInit ) => {
+			// The GET refresh has no body; reply with an empty cart and a nonce.
+			if ( ! init?.body ) {
+				return new Response(
+					JSON.stringify( { items: [], totals: {}, errors: [] } ),
+					{ headers: { Nonce: 'test-nonce-123' } }
+				);
+			}
+			const parsed = JSON.parse( init.body as string ) as {
+				requests: CapturedRequest[];
+			};
+			const responses = parsed.requests.map( () => ( {
+				status: 200,
+				body: serverCart,
+			} ) );
+			return new Response( JSON.stringify( { responses } ), {
+				headers: { Nonce: 'test-nonce-123' },
+			} );
+		}
+	) as unknown as typeof fetch;
+}
+
+/**
+ * Replaces the registered `updateNotices` action with a spy and returns the
+ * flat list of notices it receives across all invocations.
+ *
+ * The cart actions funnel every info/error notice through
+ * `actions.updateNotices`, resolved by property access at call time on the
+ * registered actions object. The caller `yield`s the result, and {@link
+ * runAction} only `await`s each yielded value; a yielded generator object would
+ * not be driven, so the spy records synchronously at call time and returns
+ * `undefined`. The spy is installed after {@link loadCartStore}.
+ *
+ * @return The accumulating list of notices passed to `updateNotices`.
+ */
+function spyOnUpdateNotices(): Notice[] {
+	const received: Notice[] = [];
+	const actions = mockRegisteredStore?.actions as Store[ 'actions' ];
+	actions.updateNotices = jest.fn( ( notices: Notice[] = [] ) => {
+		received.push( ...notices );
+		return undefined;
+	} ) as unknown as Store[ 'actions' ][ 'updateNotices' ];
+	return received;
+}
+
+/**
+ * Replaces the registered `showNoticeError` action with a spy and returns the
+ * list of errors it receives.
+ *
+ * The error/`catch` path of the cart actions surfaces a failed mutation by
+ * calling `actions.showNoticeError( error )` — the error-notice boundary,
+ * distinct from the auto-update info-notice boundary `actions.updateNotices`.
+ * Asserting on this spy proves a genuine cap surfaced as an error notice rather
+ * than an auto-update notice. The spy is installed after {@link loadCartStore}.
+ *
+ * @return The accumulating list of errors passed to `showNoticeError`.
+ */
+function spyOnShowNoticeError(): Error[] {
+	const received: Error[] = [];
+	const actions = mockRegisteredStore?.actions as Store[ 'actions' ];
+	actions.showNoticeError = jest.fn( ( error: Error ) => {
+		received.push( error );
+		return undefined;
+	} ) as unknown as Store[ 'actions' ][ 'showNoticeError' ];
+	return received;
+}
+
+/**
+ * Installs a `global.fetch` mock whose batch responses reject one targeted
+ * mutation with an HTTP error status, reproducing a genuine server cap.
+ *
+ * The GET refresh still resolves the nonce gate. Each batch request whose body
+ * targets `failForPath` gets a non-2xx response entry carrying the supplied
+ * error `code`/`message`; every other request echoes the post-optimistic cart
+ * as a success. A failed entry makes the mutation queue roll the optimistic
+ * change back (no successful server state for it) and reject that request's
+ * promise, which surfaces through the action's `catch` path.
+ *
+ * @param options             Failure configuration.
+ * @param options.failForPath The Store API path whose mutation should fail,
+ *                            e.g. `/wc/store/v1/cart/add-item`.
+ * @param options.status      The HTTP status to report for the failed mutation.
+ * @param options.code        The error code carried in the failed response body.
+ * @param options.message     The human-readable error message in the body.
+ * @return The array that accumulates captured mutation requests.
+ */
+function mockBatchFetchFailing( {
+	failForPath,
+	status = 400,
+	code = 'woocommerce_rest_cart_product_no_stock',
+	message = 'You cannot add that amount to the cart.',
+}: {
+	failForPath: string;
+	status?: number;
+	code?: string;
+	message?: string;
+} ): CapturedRequest[] {
+	const captured: CapturedRequest[] = [];
+	global.fetch = jest.fn(
+		async ( _url: RequestInfo | URL, init?: RequestInit ) => {
+			// The GET refresh has no body; reply with an empty cart and a nonce.
+			if ( ! init?.body ) {
+				return new Response(
+					JSON.stringify( { items: [], totals: {}, errors: [] } ),
+					{ headers: { Nonce: 'test-nonce-123' } }
+				);
+			}
+			const parsed = JSON.parse( init.body as string ) as {
+				requests: CapturedRequest[];
+			};
+			parsed.requests.forEach( ( request ) => captured.push( request ) );
+			const serverCart = JSON.parse( JSON.stringify( mockState.cart ) );
+			const responses = parsed.requests.map( ( request ) =>
+				request.path === failForPath
+					? { status, body: { code, message } }
+					: { status: 200, body: serverCart }
+			);
+			return new Response( JSON.stringify( { responses } ), {
+				headers: { Nonce: 'test-nonce-123' },
+			} );
+		}
+	) as unknown as typeof fetch;
+	return captured;
+}
+
+/**
+ * Builds a minimal server-confirmed cart line carrying a key.
+ *
+ * @param overrides Partial cart-line fields to override the defaults.
+ * @return A cart line suitable for seeding `state.cart.items`.
+ */
+function makeKeyedLine( overrides: Partial< CartItem > = {} ): CartItem {
+	return {
+		key: 'server-key-abc',
+		id: 42,
+		type: 'simple',
+		quantity: 3,
+		name: 'Test Product',
+		sold_individually: false,
+		variation: [],
+		item_data: [],
+		...overrides,
+	} as CartItem;
+}
+
 describe( 'WooCommerce Cart Interactivity API Store', () => {
+	afterEach( () => {
+		jest.clearAllMocks();
+		delete ( mockState as Partial< Store[ 'state' ] > ).cart;
+	} );
+
 	it( 'refreshCartItems passes cache: no-store to fetch to prevent browser caching', () => {
 		const mockFetch = jest
 			.fn()
@@ -56,4 +374,1018 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
 			} )
 		);
 	} );
+
+	describe( 'addCartItem endpoint selection', () => {
+		it( 'issues add-item (never update-item) for a keyless add that matches a keyed line by product id', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			expect( captured ).toHaveLength( 1 );
+			expect( captured[ 0 ].path ).toBe( '/wc/store/v1/cart/add-item' );
+			expect( captured[ 0 ].path ).not.toContain( 'update-item' );
+		} );
+
+		it( 'posts the requested delta (not the matched line absolute quantity) for a keyless add against a keyed line', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			expect( captured[ 0 ].body.quantity ).toBe( 1 );
+			expect( captured[ 0 ].body.quantity ).not.toBe( 4 );
+		} );
+
+		it( 'accumulates the running optimistic delta across rapid keyless adds', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			// Two rapid keyless adds queued before the batch flushes. Each must
+			// post its own delta (1) computed against the running optimistic
+			// quantity, never an absolute quantity off the matched line.
+			await Promise.all( [
+				runAction(
+					actions.addCartItem( {
+						id: 42,
+						quantityToAdd: 1,
+						type: 'simple',
+					} )
+				),
+				runAction(
+					actions.addCartItem( {
+						id: 42,
+						quantityToAdd: 1,
+						type: 'simple',
+					} )
+				),
+			] );
+
+			expect( captured ).toHaveLength( 2 );
+			expect(
+				captured.every( ( r ) => r.path.endsWith( 'add-item' ) )
+			).toBe( true );
+			expect( captured[ 0 ].body.quantity ).toBe( 1 );
+			expect( captured[ 1 ].body.quantity ).toBe( 1 );
+		} );
+
+		it( 'never includes the matched line key in the request body for a keyless add', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+			] );
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			expect( captured[ 0 ].body.key ).toBeUndefined();
+		} );
+
+		it( 'issues update-item with the absolute quantity for an explicit key (key-first path unchanged)', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+			] );
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					key: 'server-key-abc',
+					quantity: 5,
+					type: 'simple',
+				} )
+			);
+
+			expect( captured[ 0 ].path ).toBe(
+				'/wc/store/v1/cart/update-item'
+			);
+			expect( captured[ 0 ].body.quantity ).toBe( 5 );
+			expect( captured[ 0 ].body.key ).toBe( 'server-key-abc' );
+		} );
+
+		it( 'optimistically bumps a matched keyed line in place on a keyless re-add (no duplicate line)', async () => {
+			mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			expect( mockState.cart.items ).toHaveLength( 1 );
+			expect( mockState.cart.items[ 0 ].quantity ).toBe( 4 );
+		} );
+
+		it( 'optimistically pushes a new line when no line matches a keyless add', async () => {
+			mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await runAction(
+				actions.addCartItem( {
+					id: 99,
+					quantityToAdd: 2,
+					type: 'simple',
+				} )
+			);
+
+			expect( mockState.cart.items ).toHaveLength( 2 );
+			const added = mockState.cart.items.find(
+				( item ) => item.id === 99
+			);
+			expect( added ).toBeDefined();
+			expect( added?.quantity ).toBe( 2 );
+		} );
+
+		it( 'ignores the matched line item_data when deciding the endpoint and body for a keyless add', async () => {
+			const captured = mockBatchFetch();
+
+			// Same product id and quantity, only item_data differs. The
+			// add/update decision must not depend on item_data, so both adds
+			// must produce an identical endpoint and request body.
+			const richItemData = [
+				{ key: 'subscription', value: 'monthly' },
+			] as CartItem[ 'item_data' ];
+
+			const withEmptyItemData = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { id: 42, quantity: 3, item_data: [] } ),
+			] );
+			await runAction(
+				withEmptyItemData.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			const withRichItemData = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( {
+					id: 42,
+					quantity: 3,
+					item_data: richItemData,
+				} ),
+			] );
+			await runAction(
+				withRichItemData.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			expect( captured ).toHaveLength( 2 );
+			expect( captured[ 0 ].path ).toBe( '/wc/store/v1/cart/add-item' );
+			expect( captured[ 1 ].path ).toBe( captured[ 0 ].path );
+			expect( captured[ 1 ].body ).toEqual( captured[ 0 ].body );
+		} );
+	} );
+
+	describe( 'addCartItem keyless-requires-delta invariant guard', () => {
+		it( 'throws when called keyless with an absolute quantity (no quantityToAdd)', async () => {
+			mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await expect(
+				runAction(
+					actions.addCartItem( {
+						id: 42,
+						quantity: 5,
+						type: 'simple',
+					} )
+				)
+			).rejects.toThrow();
+		} );
+
+		it( 'does not throw and proceeds on the add-item path for a keyless quantityToAdd delta', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await expect(
+				runAction(
+					actions.addCartItem( {
+						id: 42,
+						quantityToAdd: 1,
+						type: 'simple',
+					} )
+				)
+			).resolves.toBeUndefined();
+
+			expect( captured ).toHaveLength( 1 );
+			expect( captured[ 0 ].path ).toBe( '/wc/store/v1/cart/add-item' );
+		} );
+
+		it( 'does not throw for an explicit key with an absolute quantity (key-first stepper path unaffected)', async () => {
+			mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+			] );
+
+			await expect(
+				runAction(
+					actions.addCartItem( {
+						id: 42,
+						key: 'server-key-abc',
+						quantity: 5,
+						type: 'simple',
+					} )
+				)
+			).resolves.toBeUndefined();
+		} );
+
+		it( 'still throws when both quantity and quantityToAdd are passed together', async () => {
+			mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await expect(
+				runAction(
+					actions.addCartItem( {
+						id: 42,
+						quantity: 5,
+						quantityToAdd: 1,
+						type: 'simple',
+					} )
+				)
+			).rejects.toThrow();
+		} );
+	} );
+
+	describe( 'batchAddCartItems endpoint selection', () => {
+		it( 'issues add-item (never update-item) for a keyless batch item that matches a keyed line by product id', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{
+						id: 42,
+						quantityToAdd: 1,
+						type: 'simple',
+					},
+				] )
+			);
+
+			expect( captured ).toHaveLength( 1 );
+			expect( captured[ 0 ].path ).toBe( '/wc/store/v1/cart/add-item' );
+			expect( captured[ 0 ].path ).not.toContain( 'update-item' );
+		} );
+
+		it( 'posts the requested delta (not the matched line absolute quantity) for a keyless batch item against a keyed line', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{
+						id: 42,
+						quantityToAdd: 1,
+						type: 'simple',
+					},
+				] )
+			);
+
+			expect( captured[ 0 ].body.quantity ).toBe( 1 );
+			expect( captured[ 0 ].body.quantity ).not.toBe( 4 );
+		} );
+
+		it( 'never includes the matched line key in the request body for a keyless batch item', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+			] );
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{
+						id: 42,
+						quantityToAdd: 1,
+						type: 'simple',
+					},
+				] )
+			);
+
+			expect( captured[ 0 ].body.key ).toBeUndefined();
+		} );
+
+		it( 'issues update-item with the absolute quantity for a batch item that supplies an explicit key', async () => {
+			const captured = mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+			] );
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{
+						id: 42,
+						key: 'server-key-abc',
+						quantity: 5,
+						type: 'simple',
+					},
+				] )
+			);
+
+			expect( captured[ 0 ].path ).toBe(
+				'/wc/store/v1/cart/update-item'
+			);
+			expect( captured[ 0 ].body.quantity ).toBe( 5 );
+			expect( captured[ 0 ].body.key ).toBe( 'server-key-abc' );
+		} );
+
+		it( 'derives the keyless add-item delta identically to the single-item addCartItem path', async () => {
+			// Same seeded keyed line and same keyless request through both
+			// paths must produce the same endpoint and posted quantity.
+			const singleCaptured = mockBatchFetch();
+			const singleActions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+			await runAction(
+				singleActions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			const batchCaptured = mockBatchFetch();
+			const batchActions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+			await runAction(
+				batchActions.batchAddCartItems( [
+					{
+						id: 42,
+						quantityToAdd: 1,
+						type: 'simple',
+					},
+				] )
+			);
+
+			expect( batchCaptured ).toHaveLength( 1 );
+			expect( singleCaptured ).toHaveLength( 1 );
+			expect( batchCaptured[ 0 ].path ).toBe( singleCaptured[ 0 ].path );
+			expect( batchCaptured[ 0 ].body.quantity ).toBe(
+				singleCaptured[ 0 ].body.quantity
+			);
+			expect( batchCaptured[ 0 ].body.key ).toBe(
+				singleCaptured[ 0 ].body.key
+			);
+		} );
+
+		it( 'optimistically bumps a matched keyed line in place on a keyless batch re-add (no duplicate line)', async () => {
+			mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{
+						id: 42,
+						quantityToAdd: 1,
+						type: 'simple',
+					},
+				] )
+			);
+
+			expect( mockState.cart.items ).toHaveLength( 1 );
+			expect( mockState.cart.items[ 0 ].quantity ).toBe( 4 );
+		} );
+
+		it( 'optimistically pushes a new line when no line matches a keyless batch item', async () => {
+			mockBatchFetch();
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{
+						id: 99,
+						quantityToAdd: 2,
+						type: 'simple',
+					},
+				] )
+			);
+
+			expect( mockState.cart.items ).toHaveLength( 2 );
+			const added = mockState.cart.items.find(
+				( item ) => item.id === 99
+			);
+			expect( added ).toBeDefined();
+			expect( added?.quantity ).toBe( 2 );
+		} );
+	} );
+
+	describe( 'notice-diff suppression for keyless meta-only adds', () => {
+		// The quantity-changed info notice template the auto-UPDATE branch emits.
+		const QUANTITY_CHANGED = 'was changed to';
+
+		it( 'emits no quantity-changed notice for a keyless add resolved server-side as a new standalone line', async () => {
+			// The product is present only as a single keyed meta line at qty 3.
+			// A keyless add optimistically bumps that line to 4, but the server
+			// keeps the meta line at 3 and adds a separate standalone line. The
+			// keyless-scoped baseline (3) must be compared against the server
+			// quantity (3) so no spurious "quantity changed" notice fires.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-abc',
+						id: 42,
+						quantity: 3,
+					} ),
+					makeKeyedLine( {
+						key: 'server-key-new',
+						id: 42,
+						quantity: 1,
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			expect(
+				notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+			).toBe( false );
+		} );
+
+		it( 'emits no quantity-changed notice when only the first of two meta lines for the same product is bumped optimistically', async () => {
+			// The product is present as two distinct keyed meta lines (qty 3 and
+			// qty 2). A keyless add matches and optimistically bumps only the
+			// first line (server-key-1) to 4. The server keeps both meta lines at
+			// their pre-add quantities and adds a separate standalone line. The
+			// bumped line's pre-optimistic baseline (3) must be diffed against the
+			// server quantity (3) so no spurious notice fires; the untouched
+			// second line (still 2 in both snapshots) must not notify either.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-1',
+						id: 42,
+						quantity: 3,
+					} ),
+					makeKeyedLine( {
+						key: 'server-key-2',
+						id: 42,
+						quantity: 2,
+					} ),
+					makeKeyedLine( {
+						key: 'server-key-new',
+						id: 42,
+						quantity: 1,
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { key: 'server-key-1', id: 42, quantity: 3 } ),
+				makeKeyedLine( { key: 'server-key-2', id: 42, quantity: 2 } ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			expect(
+				notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+			).toBe( false );
+		} );
+
+		it( 'still emits the quantity-changed notice for a keyed mini-cart stepper change returned at its pre-stepper quantity', async () => {
+			// A keyed update (explicit key + absolute quantity) is never recorded
+			// in the keyless baseline set, so the override does not apply. The
+			// server returning the line at its pre-stepper quantity (3) must still
+			// diff against the post-optimistic snapshot (5) and notify.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-abc',
+						id: 42,
+						quantity: 3,
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					key: 'server-key-abc',
+					quantity: 5,
+					type: 'simple',
+				} )
+			);
+
+			expect(
+				notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+			).toBe( true );
+		} );
+
+		it( 'still emits the quantity-changed notice when a keyless-add-bumped line diverges from its captured baseline', async () => {
+			// A genuine concurrent server change: the matched keyed line is
+			// reported at quantity 7, which differs from its pre-optimistic
+			// baseline of 3. The notice must still fire for that line.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-abc',
+						id: 42,
+						quantity: 7,
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			expect(
+				notices.some(
+					( n ) =>
+						n.notice.includes( QUANTITY_CHANGED ) &&
+						n.notice.includes( '7' )
+				)
+			).toBe( true );
+		} );
+
+		it( 'suppresses the notice for a keyless batch add resolved server-side as a new standalone line', async () => {
+			// Same meta-only scenario through the batch path: the matched keyed
+			// line is bumped optimistically to 4, the server keeps it at 3 and
+			// adds a standalone line. The batch must capture the baseline (3) and
+			// suppress the spurious notice.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-abc',
+						id: 42,
+						quantity: 3,
+					} ),
+					makeKeyedLine( {
+						key: 'server-key-new',
+						id: 42,
+						quantity: 1,
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{
+						id: 42,
+						quantityToAdd: 1,
+						type: 'simple',
+					},
+				] )
+			);
+
+			expect(
+				notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+			).toBe( false );
+		} );
+
+		it( 'suppresses the quantity-changed notice for a keyless re-add when the server returns the line at pre-add + delta', async () => {
+			// Pre-add: matched line at qty 3. Keyless add delta: +1.
+			// Expected total: 3 + 1 = 4. Server returns the line at 4.
+			// Since serverTotal (4) === expectedTotal (4), the add was exact →
+			// no "quantity changed" notice must fire.
+			// This also indirectly guards the by-value pre-add capture: if
+			// preAddTotal were captured after the optimistic bump (reading 4
+			// instead of 3), expectedTotal would be 4+1=5 ≠ server 4, which
+			// would keep the key un-suppressed and fire the notice, failing
+			// this assertion.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-abc',
+						id: 42,
+						quantity: 4,
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			// Server total (4) === expected total (3+1=4) → suppress.
+			// No "quantity changed" notice must fire.
+			expect(
+				notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+			).toBe( false );
+		} );
+
+		it( 'suppresses the quantity-changed notice for a keyless batch re-add when the server total matches pre-add + sum of deltas', async () => {
+			// Pre-add: matched line at qty 3. Batch deltas: +1 and +1.
+			// A real /batch endpoint compounds server-side: each add-item
+			// sub-request runs sequentially against one WC_Cart session, so the
+			// server accumulates both deltas and lands at 5, not 4.
+			// Expected total: 3 + (1+1) = 5. Server returns the line at 5.
+			// Since serverTotal (5) === expectedTotal (5), suppress → no notice.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-abc',
+						id: 42,
+						quantity: 5,
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{ id: 42, quantityToAdd: 1, type: 'simple' },
+					{ id: 42, quantityToAdd: 1, type: 'simple' },
+				] )
+			);
+
+			// Server total (5) === expected total (3+1+1=5) → suppress.
+			// No "quantity changed" notice must fire.
+			expect(
+				notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+			).toBe( false );
+		} );
+
+		it( 'still emits the quantity-changed notice for a keyless batch re-add when the server total diverges from expected', async () => {
+			// Same setup: pre-add qty 3, batch (+1,+1), expectedTotal = 5.
+			// Server returns 6 (a genuine concurrent change or cap artefact).
+			// Since serverTotal (6) !== expectedTotal (5), do not suppress →
+			// the notice must fire reporting the server quantity 6.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-abc',
+						id: 42,
+						quantity: 6,
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{ id: 42, quantityToAdd: 1, type: 'simple' },
+					{ id: 42, quantityToAdd: 1, type: 'simple' },
+				] )
+			);
+
+			expect(
+				notices.some(
+					( n ) =>
+						n.notice.includes( QUANTITY_CHANGED ) &&
+						n.notice.includes( '6' )
+				)
+			).toBe( true );
+		} );
+
+		it( 'suppresses the notice for a keyless add when the client bumps a meta line but the server grows the standalone line', async () => {
+			// Product 42 occupies two lines: a meta-differentiated line ordered
+			// first (server-key-meta, qty 1) and a plain standalone line second
+			// (server-key-standalone, qty 1). findItemInCart matches the meta line
+			// first, so addCartItem bumps it optimistically. The server, however,
+			// grows the standalone line instead and leaves the meta line unchanged.
+			// Pre-add total: 1+1=2. Delta: +1. Expected total: 3.
+			// Server returns meta(1) + standalone(2) = 3 === expected → suppress
+			// for both pre-existing keys. No "quantity changed" notice must fire.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-meta',
+						id: 42,
+						quantity: 1,
+						name: 'Test Product',
+					} ),
+					makeKeyedLine( {
+						key: 'server-key-standalone',
+						id: 42,
+						quantity: 2,
+						name: 'Test Product',
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( {
+					key: 'server-key-meta',
+					id: 42,
+					quantity: 1,
+				} ),
+				makeKeyedLine( {
+					key: 'server-key-standalone',
+					id: 42,
+					quantity: 1,
+				} ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			// Server total (1+2=3) === expected total (1+1+1=3) → suppress.
+			expect(
+				notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+			).toBe( false );
+		} );
+
+		it( 'suppresses the notice for a keyless batch add when the client bumps a meta line but the server grows the standalone line, through the batch path', async () => {
+			// Same meta-line/standalone-line scenario through the batch path.
+			// Product 42 occupies two
+			// lines: meta first (qty 1) then standalone (qty 1). The batch item
+			// bumps the meta line optimistically; the server grows the standalone
+			// line. Pre-add total: 1+1=2. Delta: +1. Expected total: 3.
+			// Server returns meta(1)+standalone(2)=3 === expected → suppress.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-meta',
+						id: 42,
+						quantity: 1,
+						name: 'Test Product',
+					} ),
+					makeKeyedLine( {
+						key: 'server-key-standalone',
+						id: 42,
+						quantity: 2,
+						name: 'Test Product',
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( {
+					key: 'server-key-meta',
+					id: 42,
+					quantity: 1,
+				} ),
+				makeKeyedLine( {
+					key: 'server-key-standalone',
+					id: 42,
+					quantity: 1,
+				} ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.batchAddCartItems( [
+					{ id: 42, quantityToAdd: 1, type: 'simple' },
+				] )
+			);
+
+			// Server total (1+2=3) === expected total (1+1+1=3) → suppress.
+			expect(
+				notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+			).toBe( false );
+		} );
+
+		it( 'suppresses the quantity-changed notice for a keyless variation re-add when the server returns the line at pre-add + delta', async () => {
+			// A variation line (type: variation, id: 42, variation: [Color:Red])
+			// is matched by id+variation. Keyless add delta: +1. Pre-add qty: 2.
+			// Expected total: 2+1=3. Server returns the variation line at 3.
+			// Since serverTotal (3) === expectedTotal (3) → suppress.
+			const colorRedVariation = [
+				{ attribute: 'Color', value: 'Red' },
+			] as CartItem[ 'variation' ];
+			mockBatchFetchReturning(
+				makeServerCart( [
+					{
+						...makeKeyedLine( {
+							key: 'server-key-var',
+							id: 42,
+							quantity: 3,
+						} ),
+						type: 'variation',
+						variation: colorRedVariation,
+					} as CartItem,
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				{
+					...makeKeyedLine( {
+						key: 'server-key-var',
+						id: 42,
+						quantity: 2,
+					} ),
+					type: 'variation',
+					variation: colorRedVariation,
+				} as CartItem,
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'variation',
+					variation: colorRedVariation,
+				} )
+			);
+
+			// Server total (3) === expected total (2+1=3) → suppress.
+			expect(
+				notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+			).toBe( false );
+		} );
+
+		it( 'leaves removeCartItem notice behavior unchanged (auto-DELETE still fires)', async () => {
+			// removeCartItem must not pass the new baseline; the auto-DELETE
+			// branch is untouched. Removing one of two lines while the server
+			// reports the OTHER line auto-removed must still emit a removal
+			// notice for that server-removed line.
+			mockBatchFetchReturning(
+				makeServerCart( [
+					makeKeyedLine( {
+						key: 'server-key-keep',
+						id: 42,
+						quantity: 3,
+						name: 'Kept Product',
+					} ),
+				] )
+			);
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( {
+					key: 'server-key-keep',
+					id: 42,
+					quantity: 3,
+					name: 'Kept Product',
+				} ),
+				makeKeyedLine( {
+					key: 'server-key-gone',
+					id: 7,
+					quantity: 1,
+					name: 'Vanished Product',
+				} ),
+			] );
+			const notices = spyOnUpdateNotices();
+
+			await runAction( actions.removeCartItem( 'server-key-keep' ) );
+
+			// The server-removed line (server-key-gone) was present in the
+			// post-optimistic snapshot and absent from the server cart, so the
+			// auto-DELETE notice must still fire.
+			expect(
+				notices.some( ( n ) => n.notice.includes( 'Vanished Product' ) )
+			).toBe( true );
+		} );
+	} );
+
+	describe( 'genuine add-path cap surfaces as an error notice (not an auto-update notice)', () => {
+		// The quantity-changed info notice template the auto-UPDATE branch emits.
+		const QUANTITY_CHANGED = 'was changed to';
+
+		it( 'routes an HTTP 400 add-item failure to an error notice and never to an auto-update notice', async () => {
+			// A plain keyless re-add the server caps (e.g. out of stock) returns a
+			// non-2xx batch entry. That rejects the mutation, so the action takes
+			// the throw/catch path: the failure must surface as an error notice
+			// via showNoticeError, not as an auto-update "quantity changed" notice
+			// through updateNotices.
+			mockBatchFetchFailing( {
+				failForPath: '/wc/store/v1/cart/add-item',
+				status: 400,
+				code: 'woocommerce_rest_cart_product_no_stock',
+				message: 'You cannot add that amount to the cart.',
+			} );
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+			const autoUpdateNotices = spyOnUpdateNotices();
+			const errors = spyOnShowNoticeError();
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			// The cap surfaced through the error-notice boundary carrying the
+			// server-supplied message and code.
+			expect( errors ).toHaveLength( 1 );
+			expect( errors[ 0 ].message ).toBe(
+				'You cannot add that amount to the cart.'
+			);
+			expect( ( errors[ 0 ] as Error & { code?: string } ).code ).toBe(
+				'woocommerce_rest_cart_product_no_stock'
+			);
+
+			// No auto-update "quantity changed" notice was emitted for the cap.
+			expect(
+				autoUpdateNotices.some( ( n ) =>
+					n.notice.includes( QUANTITY_CHANGED )
+				)
+			).toBe( false );
+		} );
+
+		it( 'rolls the optimistic bump back when the add-item request is capped (HTTP 400)', async () => {
+			// The optimistic update bumps the matched line 3 -> 4 before the
+			// request flushes. Because the only mutation fails, the queue has no
+			// successful server state and must roll the cart back to its
+			// pre-cycle snapshot, leaving the line at its original quantity 3.
+			mockBatchFetchFailing( {
+				failForPath: '/wc/store/v1/cart/add-item',
+				status: 400,
+			} );
+			const actions = await loadCartStore();
+			seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+			spyOnShowNoticeError();
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					quantityToAdd: 1,
+					type: 'simple',
+				} )
+			);
+
+			expect( mockState.cart.items ).toHaveLength( 1 );
+			expect( mockState.cart.items[ 0 ].quantity ).toBe( 3 );
+		} );
+	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
index c78f8148c4e..616a668a9f7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
@@ -14,9 +14,17 @@ import type {
 	RawShopperListItem,
 	Store as ShopperListsStore,
 } from '@woocommerce/stores/woocommerce/shopper-lists';
-import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
+import type {
+	SelectedAttributes,
+	Store as WooCommerce,
+} from '@woocommerce/stores/woocommerce/cart';
 import { sanitizeHTML } from '@woocommerce/sanitize';

+/**
+ * Internal dependencies
+ */
+import { doesCartItemMatchAttributes } from '../../base/utils/variations/does-cart-item-match-attributes';
+
 const universalLock =
 	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';

@@ -134,6 +142,51 @@ const formatVariationLabel = ( item: RawShopperListItem ): string => {

 const getList = ( slug: string ) => shopperListsState.lists[ slug ] ?? null;

+/** A single line of the shared cart store, optimistic or server-confirmed. */
+type CartLine = WooCommerce[ 'state' ][ 'cart' ][ 'items' ][ number ];
+
+/**
+ * Sums the quantity across every cart line that represents the given product.
+ *
+ * The "did the add succeed?" check for Move to cart cannot read a single line:
+ * the server owns cart-line identity for adds, so a product already present as
+ * one line (e.g. a meta line) can be resolved into a new standalone line rather
+ * than an in-place bump. Reading one id-matched line would then see no growth
+ * and misread a correct add as a failure. Summing the quantity over all
+ * matching lines makes the comparison order-independent and robust to the
+ * server splitting or merging lines.
+ *
+ * Matching mirrors the cart store's `findItemInCart` product-matching: a simple
+ * product matches by `id`; a variation additionally requires the same number of
+ * variation attributes and the same attribute values, evaluated with
+ * `doesCartItemMatchAttributes` (the selector's own semantics).
+ *
+ * @param items     The current cart lines (`cartState.cart.items`).
+ * @param id        The product id to match.
+ * @param variation Selected variation attributes (cart shape). Empty/omitted
+ *                  for a simple product; provided to match variation lines.
+ * @return The total quantity across all matching lines (0 when none match).
+ */
+const sumMatchingCartQuantity = (
+	items: readonly CartLine[],
+	id: number,
+	variation?: SelectedAttributes[]
+): number =>
+	items.reduce( ( total, cartItem ) => {
+		let matches: boolean;
+		if ( cartItem.type === 'variation' ) {
+			matches =
+				id === cartItem.id &&
+				Array.isArray( cartItem.variation ) &&
+				Array.isArray( variation ) &&
+				cartItem.variation.length === variation.length &&
+				doesCartItemMatchAttributes( cartItem, variation );
+		} else {
+			matches = id === cartItem.id;
+		}
+		return matches ? total + cartItem.quantity : total;
+	}, 0 );
+
 store< BlockStore >(
 	'woocommerce/saved-for-later',
 	{
@@ -263,15 +316,26 @@ store< BlockStore >(

 				// `cartActions.addCartItem` catches its own errors and
 				// surfaces them as store notices, so the yield resolves
-				// the same way on success and failure. Snapshot the
-				// matching line's quantity, run the add, then only remove
-				// from the saved list if it actually grew.
-				const lookup = {
-					id: listItem.id,
-					...( isVariation && { variation } ),
-				};
-				const beforeItem = cartState.findItemInCart( lookup );
-				const beforeQuantity = beforeItem?.quantity ?? 0;
+				// the same way on success and failure. To tell success from
+				// failure we have to read the cart ourselves. The server owns
+				// cart-line identity for adds, so a successful add can land as
+				// a brand new standalone line rather than bumping an existing
+				// one — e.g. when this product is in the cart only as a meta
+				// line (a bundle child, booking, or add-on configuration). A
+				// single id-matched read would misjudge that case: it can
+				// resolve to the unchanged pre-existing line both times, see no
+				// growth, and conclude the add failed even though a new line
+				// was correctly created. So instead, compare the total quantity
+				// across every line matching this product before and after the
+				// add, and only remove from the saved list when that total
+				// grew. Both reads observe the server-reconciled cart because
+				// `addCartItem` resolves only after the mutation batcher commits
+				// it, so the comparison is order-independent.
+				const beforeSum = sumMatchingCartQuantity(
+					cartState.cart.items,
+					listItem.id,
+					isVariation ? variation : undefined
+				);

 				pendingKeys[ listItem.key ] = true;
 				try {
@@ -282,10 +346,13 @@ store< BlockStore >(
 						...( isVariation && { variation } ),
 					} );

-					const afterItem = cartState.findItemInCart( lookup );
-					const afterQuantity = afterItem?.quantity ?? 0;
+					const afterSum = sumMatchingCartQuantity(
+						cartState.cart.items,
+						listItem.id,
+						isVariation ? variation : undefined
+					);

-					if ( afterQuantity <= beforeQuantity ) {
+					if ( afterSum <= beforeSum ) {
 						return;
 					}

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/test/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/test/frontend.ts
new file mode 100644
index 00000000000..3115571772a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/test/frontend.ts
@@ -0,0 +1,428 @@
+/**
+ * External dependencies
+ */
+import type {
+	OptimisticCartItem,
+	Store as WooCommerce,
+} from '@woocommerce/stores/woocommerce/cart';
+import type { RawShopperListItem } from '@woocommerce/stores/woocommerce/shopper-lists';
+
+type CartItems = WooCommerce[ 'state' ][ 'cart' ][ 'items' ];
+type BlockActions = {
+	onClickMoveToCart: () => Generator< unknown, void >;
+};
+
+// `frontend.ts` registers its block store under `woocommerce/saved-for-later`
+// and opens the shared `woocommerce` cart store plus the
+// `woocommerce/shopper-lists` store, all routed through the mocked `store()`.
+
+// Single shared context the mocked `getContext` returns for the row under test.
+let mockContext: {
+	listItem?: RawShopperListItem;
+	pendingKeys: Record< string, true >;
+};
+
+// The cart store's mutable line list and selector, controlled per test.
+let mockCartItems: CartItems;
+let mockFindItemInCart: jest.Mock;
+
+// Captured cart-store action spies.
+let mockAddCartItem: jest.Mock;
+
+// Captured shopper-lists `removeItem` spy and the block store's registered
+// actions, populated when `frontend.ts` calls the mocked `store()`.
+let mockRemoveItem: jest.Mock;
+let mockBlockActions: BlockActions | null;
+
+// Stands in for `doesCartItemMatchAttributes`; a test controls which seeded
+// lines count as variation matches.
+let mockAttributeMatcher: ( cartItem: OptimisticCartItem ) => boolean;
+
+jest.mock(
+	'@wordpress/interactivity',
+	() => ( {
+		getConfig: jest.fn(),
+		getContext: jest.fn( () => mockContext ),
+		getElement: jest.fn( () => ( { ref: null } ) ),
+		store: jest.fn( ( name: string, definition ) => {
+			if ( name === 'woocommerce/saved-for-later' ) {
+				mockBlockActions = definition?.actions ?? null;
+				return {
+					state: definition?.state,
+					actions: definition?.actions,
+				};
+			}
+			if ( name === 'woocommerce' ) {
+				return {
+					state: {
+						get cart() {
+							return { items: mockCartItems };
+						},
+						findItemInCart: ( ...args: unknown[] ) =>
+							mockFindItemInCart( ...args ),
+					},
+					actions: { addCartItem: mockAddCartItem },
+				};
+			}
+			// woocommerce/shopper-lists
+			return {
+				state: { lists: {} },
+				actions: { removeItem: mockRemoveItem },
+			};
+		} ),
+	} ),
+	{ virtual: true }
+);
+
+// Side-effect store registrations `frontend.ts` imports for ordering only.
+jest.mock( '@woocommerce/stores/woocommerce/shopper-lists', () => ( {} ), {
+	virtual: true,
+} );
+jest.mock( '@woocommerce/stores/woocommerce/cart', () => ( {} ), {
+	virtual: true,
+} );
+jest.mock( '@woocommerce/sanitize', () => ( { sanitizeHTML: jest.fn() } ), {
+	virtual: true,
+} );
+
+// Matching is delegated to the cart store's `doesCartItemMatchAttributes`
+// semantics; the test controls which lines match via `mockAttributeMatcher`.
+jest.mock(
+	'../../../base/utils/variations/does-cart-item-match-attributes',
+	() => ( {
+		doesCartItemMatchAttributes: ( cartItem: OptimisticCartItem ) =>
+			mockAttributeMatcher( cartItem ),
+	} )
+);
+
+/**
+ * Drives an Interactivity API async action generator to completion.
+ *
+ * Each yielded value is awaited and fed back into the generator until done,
+ * mirroring how the iAPI runtime drives `*onClickMoveToCart`.
+ *
+ * @param action The async action return value, treated as a generator.
+ * @return A promise resolving once the generator finishes.
+ */
+async function runAction( action: unknown ): Promise< void > {
+	const iterator = action as Iterator< unknown, unknown, unknown >;
+	let next = iterator.next();
+	while ( ! next.done ) {
+		// eslint-disable-next-line no-await-in-loop
+		const resolved = await next.value;
+		next = iterator.next( resolved );
+	}
+}
+
+/**
+ * Builds a saved-for-later list item with sensible purchasable defaults.
+ *
+ * @param overrides Partial fields overriding the defaults.
+ * @return A list item suitable for the row context under test.
+ */
+function makeListItem(
+	overrides: Partial< RawShopperListItem > = {}
+): RawShopperListItem {
+	return {
+		key: 'list-key-1',
+		id: 42,
+		product_id: 42,
+		variation_id: 0,
+		quantity: 1,
+		is_live: true,
+		is_purchasable: true,
+		name: 'Saved Product',
+		permalink: null,
+		images: [],
+		variation: [],
+		prices: null,
+		price_html: '',
+		image_html: '',
+		date_added_gmt: '',
+		...overrides,
+	};
+}
+
+/**
+ * Builds a minimal cart line for seeding `cart.items`.
+ *
+ * @param overrides Partial cart-line fields overriding the defaults.
+ * @return A cart line.
+ */
+function makeCartLine(
+	overrides: Partial< OptimisticCartItem > = {}
+): OptimisticCartItem {
+	return {
+		id: 42,
+		type: 'simple',
+		quantity: 1,
+		...overrides,
+	} as OptimisticCartItem;
+}
+
+/**
+ * Loads a fresh copy of the saved-for-later frontend module so it registers its
+ * block store against the mocked `store()` and exposes its actions.
+ *
+ * @return The registered block-store actions.
+ */
+function loadBlockStore(): BlockActions {
+	mockBlockActions = null;
+	jest.isolateModules( () => require( '../frontend' ) );
+	if ( ! mockBlockActions ) {
+		throw new Error( 'Saved-for-later store was not registered.' );
+	}
+	return mockBlockActions;
+}
+
+describe( 'Saved-for-later onClickMoveToCart success detection', () => {
+	beforeEach( () => {
+		mockContext = { pendingKeys: {} };
+		mockCartItems = [];
+		mockFindItemInCart = jest.fn();
+		mockRemoveItem = jest.fn( () => undefined );
+		mockAttributeMatcher = () => false;
+		// By default the add resolves without mutating the cart; individual
+		// tests install an `addCartItem` that mutates `mockCartItems` to model
+		// the server-reconciled cart.
+		mockAddCartItem = jest.fn( () => Promise.resolve() );
+	} );
+
+	afterEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'removes the entry when a meta-only product gains a new standalone line (before-sum 1, after-sum 2)', async () => {
+		// The product exists only as a single meta line at quantity 1; the add
+		// resolves server-side as a separate standalone line, so the total
+		// quantity across matching lines goes 1 -> 2.
+		mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( { id: 42, quantity: 1 } ),
+				makeCartLine( { id: 42, quantity: 1, key: 'new-line' } ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickMoveToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'saved-for-later',
+			'list-key-1'
+		);
+	} );
+
+	it( 'removes the entry when an existing standalone line is incremented (before-sum 1, after-sum 2)', async () => {
+		mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickMoveToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'saved-for-later',
+			'list-key-1'
+		);
+	} );
+
+	it( 'preserves the entry when the server rejects the add and the cart is unchanged (before-sum equals after-sum)', async () => {
+		mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		// addCartItem swallows the error and leaves the cart untouched.
+		mockAddCartItem = jest.fn( () => Promise.resolve() );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickMoveToCart() );
+
+		expect( mockRemoveItem ).not.toHaveBeenCalled();
+	} );
+
+	it( 'sums over all matching lines rather than a single id-matched line', async () => {
+		// Two matching lines exist before (1 + 2 = 3). The add bumps one of them
+		// so the total becomes 4. A single-line read of just one matched line
+		// could miss the growth; summing all matching lines detects it.
+		mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+		mockCartItems = [
+			makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+			makeCartLine( { id: 42, quantity: 2, key: 'b' } ),
+		];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( { id: 42, quantity: 2, key: 'a' } ),
+				makeCartLine( { id: 42, quantity: 2, key: 'b' } ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickMoveToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'saved-for-later',
+			'list-key-1'
+		);
+	} );
+
+	it( 'compares total quantity, not the cart-line count', async () => {
+		// The line count stays at 1 across the add (an in-place increment), so a
+		// count-based check would read no change. The total quantity grows
+		// 1 -> 3, so a quantity sum correctly detects success.
+		mockContext.listItem = makeListItem( { id: 42, quantity: 2 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [ makeCartLine( { id: 42, quantity: 3 } ) ];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickMoveToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'saved-for-later',
+			'list-key-1'
+		);
+	} );
+
+	it( 'is independent of cart-line ordering', async () => {
+		// Same lines as the multi-line case but seeded in reverse order before
+		// and after; the result must not depend on iteration order.
+		mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+		mockCartItems = [
+			makeCartLine( { id: 99, quantity: 5, key: 'other' } ),
+			makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+		];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( { id: 42, quantity: 1, key: 'new' } ),
+				makeCartLine( { id: 99, quantity: 5, key: 'other' } ),
+				makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickMoveToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'saved-for-later',
+			'list-key-1'
+		);
+	} );
+
+	it( 'ignores non-matching product lines when summing', async () => {
+		// A different product (id 99) churns its quantity across the add while
+		// the saved product (id 42) is unchanged. Summing only matching lines
+		// must read no growth and preserve the entry.
+		mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+		mockCartItems = [
+			makeCartLine( { id: 42, quantity: 1 } ),
+			makeCartLine( { id: 99, quantity: 1, key: 'other' } ),
+		];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( { id: 42, quantity: 1 } ),
+				makeCartLine( { id: 99, quantity: 8, key: 'other' } ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickMoveToCart() );
+
+		expect( mockRemoveItem ).not.toHaveBeenCalled();
+	} );
+
+	it( 'sums over lines matching id and variation attributes for a variation entry', async () => {
+		mockContext.listItem = makeListItem( {
+			id: 7,
+			variation_id: 7,
+			quantity: 1,
+			variation: [
+				{
+					raw_attribute: 'attribute_pa_color',
+					attribute: 'Color',
+					value: 'blue',
+				},
+			],
+		} );
+		// Variation lines carry a same-length `variation` array (matching the
+		// selector's length guard); the attribute matcher decides which one is
+		// the saved product. One variation line of the saved product plus a
+		// same-id line with a different (non-matching) variation.
+		const variationAttr = [
+			{
+				raw_attribute: 'attribute_pa_color',
+				attribute: 'Color',
+				value: 'x',
+			},
+		];
+		const matchingLine = makeCartLine( {
+			id: 7,
+			type: 'variation',
+			quantity: 1,
+			key: 'match',
+			variation: variationAttr,
+		} );
+		const otherVariationLine = makeCartLine( {
+			id: 7,
+			type: 'variation',
+			quantity: 4,
+			key: 'other-variation',
+			variation: variationAttr,
+		} );
+		mockCartItems = [ matchingLine, otherVariationLine ];
+		// Only the matching variation line satisfies the attribute matcher.
+		mockAttributeMatcher = ( cartItem ) => cartItem.key === 'match';
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( {
+					id: 7,
+					type: 'variation',
+					quantity: 2,
+					key: 'match',
+					variation: variationAttr,
+				} ),
+				makeCartLine( {
+					id: 7,
+					type: 'variation',
+					quantity: 4,
+					key: 'other-variation',
+					variation: variationAttr,
+				} ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickMoveToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'saved-for-later',
+			'list-key-1'
+		);
+	} );
+
+	it( 'does not call findItemInCart for the success check (consumer sums cart.items directly)', async () => {
+		// The before/after read sums `cart.items` itself; `findItemInCart` must
+		// not be the mechanism for the success comparison.
+		mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickMoveToCart() );
+
+		expect( mockFindItemInCart ).not.toHaveBeenCalled();
+		expect( mockRemoveItem ).toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
index a9d6451fcc3..ae777eb6523 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
@@ -14,9 +14,17 @@ import type {
 	RawShopperListItem,
 	Store as ShopperListsStore,
 } from '@woocommerce/stores/woocommerce/shopper-lists';
-import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
+import type {
+	SelectedAttributes,
+	Store as WooCommerce,
+} from '@woocommerce/stores/woocommerce/cart';
 import { sanitizeHTML } from '@woocommerce/sanitize';

+/**
+ * Internal dependencies
+ */
+import { doesCartItemMatchAttributes } from '../../base/utils/variations/does-cart-item-match-attributes';
+
 const universalLock =
 	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';

@@ -126,6 +134,51 @@ const formatVariationLabel = ( item: RawShopperListItem ): string => {

 const getList = ( slug: string ) => shopperListsState.lists[ slug ] ?? null;

+/** A single line of the shared cart store, optimistic or server-confirmed. */
+type CartLine = WooCommerce[ 'state' ][ 'cart' ][ 'items' ][ number ];
+
+/**
+ * Sums the quantity across every cart line that represents the given product.
+ *
+ * The "did the add succeed?" check for Add to cart cannot read a single line:
+ * the server owns cart-line identity for adds, so a product already present as
+ * one line (e.g. a meta line) can be resolved into a new standalone line rather
+ * than an in-place bump. Reading one id-matched line would then see no growth
+ * and misread a correct add as a failure. Summing the quantity over all
+ * matching lines makes the comparison order-independent and robust to the
+ * server splitting or merging lines.
+ *
+ * Matching mirrors the cart store's `findItemInCart` product-matching: a simple
+ * product matches by `id`; a variation additionally requires the same number of
+ * variation attributes and the same attribute values, evaluated with
+ * `doesCartItemMatchAttributes` (the selector's own semantics).
+ *
+ * @param items     The current cart lines (`cartState.cart.items`).
+ * @param id        The product id to match.
+ * @param variation Selected variation attributes (cart shape). Empty/omitted
+ *                  for a simple product; provided to match variation lines.
+ * @return The total quantity across all matching lines (0 when none match).
+ */
+const sumMatchingCartQuantity = (
+	items: readonly CartLine[],
+	id: number,
+	variation?: SelectedAttributes[]
+): number =>
+	items.reduce( ( total, cartItem ) => {
+		let matches: boolean;
+		if ( cartItem.type === 'variation' ) {
+			matches =
+				id === cartItem.id &&
+				Array.isArray( cartItem.variation ) &&
+				Array.isArray( variation ) &&
+				cartItem.variation.length === variation.length &&
+				doesCartItemMatchAttributes( cartItem, variation );
+		} else {
+			matches = id === cartItem.id;
+		}
+		return matches ? total + cartItem.quantity : total;
+	}, 0 );
+
 store< BlockStore >(
 	'woocommerce/wishlist',
 	{
@@ -240,17 +293,26 @@ store< BlockStore >(
 				// Wishlist always adds quantity 1 (no quantity column).
 				// `cartActions.addCartItem` catches its own errors and
 				// surfaces them as store notices, so the yield resolves
-				// the same way on success and failure. Snapshot the
-				// matching line's quantity, run the add, then only remove
-				// from the wishlist if the cart line actually grew — that
-				// guards against partial-stock and silent-failure paths
-				// where we shouldn't drop the wishlist entry.
-				const lookup = {
-					id: listItem.id,
-					...( isVariation && { variation } ),
-				};
-				const beforeItem = cartState.findItemInCart( lookup );
-				const beforeQuantity = beforeItem?.quantity ?? 0;
+				// the same way on success and failure. To tell success from
+				// failure we have to read the cart ourselves. The server owns
+				// cart-line identity for adds, so a successful add can land as
+				// a brand new standalone line rather than bumping an existing
+				// one — e.g. when this product is in the cart only as a meta
+				// line (a bundle child, booking, or add-on configuration). A
+				// single id-matched read would misjudge that case: it can
+				// resolve to the unchanged pre-existing line both times, see no
+				// growth, and conclude the add failed even though a new line
+				// was correctly created. So instead, compare the total quantity
+				// across every line matching this product before and after the
+				// add, and only remove from the wishlist when that total grew.
+				// Both reads observe the server-reconciled cart because
+				// `addCartItem` resolves only after the mutation batcher commits
+				// it, so the comparison is order-independent.
+				const beforeSum = sumMatchingCartQuantity(
+					cartState.cart.items,
+					listItem.id,
+					isVariation ? variation : undefined
+				);

 				pendingKeys[ listItem.key ] = true;
 				try {
@@ -261,10 +323,13 @@ store< BlockStore >(
 						...( isVariation && { variation } ),
 					} );

-					const afterItem = cartState.findItemInCart( lookup );
-					const afterQuantity = afterItem?.quantity ?? 0;
+					const afterSum = sumMatchingCartQuantity(
+						cartState.cart.items,
+						listItem.id,
+						isVariation ? variation : undefined
+					);

-					if ( afterQuantity <= beforeQuantity ) {
+					if ( afterSum <= beforeSum ) {
 						return;
 					}

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/test/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/test/frontend.ts
new file mode 100644
index 00000000000..b58bc180eaa
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/test/frontend.ts
@@ -0,0 +1,427 @@
+/**
+ * External dependencies
+ */
+import type {
+	OptimisticCartItem,
+	Store as WooCommerce,
+} from '@woocommerce/stores/woocommerce/cart';
+import type { RawShopperListItem } from '@woocommerce/stores/woocommerce/shopper-lists';
+
+type CartItems = WooCommerce[ 'state' ][ 'cart' ][ 'items' ];
+type BlockActions = {
+	onClickAddToCart: () => Generator< unknown, void >;
+};
+
+// `frontend.ts` registers its block store under `woocommerce/wishlist` and opens
+// the shared `woocommerce` cart store plus the `woocommerce/shopper-lists`
+// store, all routed through the mocked `store()`.
+
+// Single shared context the mocked `getContext` returns for the row under test.
+let mockContext: {
+	listItem?: RawShopperListItem;
+	pendingKeys: Record< string, true >;
+};
+
+// The cart store's mutable line list and selector, controlled per test.
+let mockCartItems: CartItems;
+let mockFindItemInCart: jest.Mock;
+
+// Captured cart-store action spies.
+let mockAddCartItem: jest.Mock;
+
+// Captured shopper-lists `removeItem` spy and the block store's registered
+// actions, populated when `frontend.ts` calls the mocked `store()`.
+let mockRemoveItem: jest.Mock;
+let mockBlockActions: BlockActions | null;
+
+// Stands in for `doesCartItemMatchAttributes`; a test controls which seeded
+// lines count as variation matches.
+let mockAttributeMatcher: ( cartItem: OptimisticCartItem ) => boolean;
+
+jest.mock(
+	'@wordpress/interactivity',
+	() => ( {
+		getConfig: jest.fn(),
+		getContext: jest.fn( () => mockContext ),
+		getElement: jest.fn( () => ( { ref: null } ) ),
+		store: jest.fn( ( name: string, definition ) => {
+			if ( name === 'woocommerce/wishlist' ) {
+				mockBlockActions = definition?.actions ?? null;
+				return {
+					state: definition?.state,
+					actions: definition?.actions,
+				};
+			}
+			if ( name === 'woocommerce' ) {
+				return {
+					state: {
+						get cart() {
+							return { items: mockCartItems };
+						},
+						findItemInCart: ( ...args: unknown[] ) =>
+							mockFindItemInCart( ...args ),
+					},
+					actions: { addCartItem: mockAddCartItem },
+				};
+			}
+			// woocommerce/shopper-lists
+			return {
+				state: { lists: {} },
+				actions: { removeItem: mockRemoveItem },
+			};
+		} ),
+	} ),
+	{ virtual: true }
+);
+
+// Side-effect store registrations `frontend.ts` imports for ordering only.
+jest.mock( '@woocommerce/stores/woocommerce/shopper-lists', () => ( {} ), {
+	virtual: true,
+} );
+jest.mock( '@woocommerce/stores/woocommerce/cart', () => ( {} ), {
+	virtual: true,
+} );
+jest.mock( '@woocommerce/sanitize', () => ( { sanitizeHTML: jest.fn() } ), {
+	virtual: true,
+} );
+
+// Matching is delegated to the cart store's `doesCartItemMatchAttributes`
+// semantics; the test controls which lines match via `mockAttributeMatcher`.
+jest.mock(
+	'../../../base/utils/variations/does-cart-item-match-attributes',
+	() => ( {
+		doesCartItemMatchAttributes: ( cartItem: OptimisticCartItem ) =>
+			mockAttributeMatcher( cartItem ),
+	} )
+);
+
+/**
+ * Drives an Interactivity API async action generator to completion.
+ *
+ * Each yielded value is awaited and fed back into the generator until done,
+ * mirroring how the iAPI runtime drives `*onClickAddToCart`.
+ *
+ * @param action The async action return value, treated as a generator.
+ * @return A promise resolving once the generator finishes.
+ */
+async function runAction( action: unknown ): Promise< void > {
+	const iterator = action as Iterator< unknown, unknown, unknown >;
+	let next = iterator.next();
+	while ( ! next.done ) {
+		// eslint-disable-next-line no-await-in-loop
+		const resolved = await next.value;
+		next = iterator.next( resolved );
+	}
+}
+
+/**
+ * Builds a wishlist list item with sensible purchasable defaults.
+ *
+ * @param overrides Partial fields overriding the defaults.
+ * @return A list item suitable for the row context under test.
+ */
+function makeListItem(
+	overrides: Partial< RawShopperListItem > = {}
+): RawShopperListItem {
+	return {
+		key: 'list-key-1',
+		id: 42,
+		product_id: 42,
+		variation_id: 0,
+		quantity: 1,
+		is_live: true,
+		is_purchasable: true,
+		name: 'Wishlist Product',
+		permalink: null,
+		images: [],
+		variation: [],
+		prices: null,
+		price_html: '',
+		image_html: '',
+		date_added_gmt: '',
+		...overrides,
+	};
+}
+
+/**
+ * Builds a minimal cart line for seeding `cart.items`.
+ *
+ * @param overrides Partial cart-line fields overriding the defaults.
+ * @return A cart line.
+ */
+function makeCartLine(
+	overrides: Partial< OptimisticCartItem > = {}
+): OptimisticCartItem {
+	return {
+		id: 42,
+		type: 'simple',
+		quantity: 1,
+		...overrides,
+	} as OptimisticCartItem;
+}
+
+/**
+ * Loads a fresh copy of the wishlist frontend module so it registers its block
+ * store against the mocked `store()` and exposes its actions.
+ *
+ * @return The registered block-store actions.
+ */
+function loadBlockStore(): BlockActions {
+	mockBlockActions = null;
+	jest.isolateModules( () => require( '../frontend' ) );
+	if ( ! mockBlockActions ) {
+		throw new Error( 'Wishlist store was not registered.' );
+	}
+	return mockBlockActions;
+}
+
+describe( 'Wishlist onClickAddToCart success detection', () => {
+	beforeEach( () => {
+		mockContext = { pendingKeys: {} };
+		mockCartItems = [];
+		mockFindItemInCart = jest.fn();
+		mockRemoveItem = jest.fn( () => undefined );
+		mockAttributeMatcher = () => false;
+		// By default the add resolves without mutating the cart; individual
+		// tests install an `addCartItem` that mutates `mockCartItems` to model
+		// the server-reconciled cart.
+		mockAddCartItem = jest.fn( () => Promise.resolve() );
+	} );
+
+	afterEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'removes the entry when a meta-only product gains a new standalone line (before-sum 1, after-sum 2)', async () => {
+		// The product exists only as a single meta line at quantity 1; the add
+		// resolves server-side as a separate standalone line, so the total
+		// quantity across matching lines goes 1 -> 2.
+		mockContext.listItem = makeListItem( { id: 42 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( { id: 42, quantity: 1 } ),
+				makeCartLine( { id: 42, quantity: 1, key: 'new-line' } ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickAddToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'wishlist',
+			'list-key-1'
+		);
+	} );
+
+	it( 'removes the entry when an existing standalone line is incremented (before-sum 1, after-sum 2)', async () => {
+		mockContext.listItem = makeListItem( { id: 42 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickAddToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'wishlist',
+			'list-key-1'
+		);
+	} );
+
+	it( 'preserves the entry when the server rejects the add and the cart is unchanged (before-sum equals after-sum)', async () => {
+		mockContext.listItem = makeListItem( { id: 42 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		// addCartItem swallows the error and leaves the cart untouched.
+		mockAddCartItem = jest.fn( () => Promise.resolve() );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickAddToCart() );
+
+		expect( mockRemoveItem ).not.toHaveBeenCalled();
+	} );
+
+	it( 'sums over all matching lines rather than a single id-matched line', async () => {
+		// Two matching lines exist before (1 + 2 = 3). The add bumps one of them
+		// so the total becomes 4. A single-line read of just one matched line
+		// could miss the growth; summing all matching lines detects it.
+		mockContext.listItem = makeListItem( { id: 42 } );
+		mockCartItems = [
+			makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+			makeCartLine( { id: 42, quantity: 2, key: 'b' } ),
+		];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( { id: 42, quantity: 2, key: 'a' } ),
+				makeCartLine( { id: 42, quantity: 2, key: 'b' } ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickAddToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'wishlist',
+			'list-key-1'
+		);
+	} );
+
+	it( 'compares total quantity, not the cart-line count', async () => {
+		// The line count stays at 1 across the add (an in-place increment), so a
+		// count-based check would read no change. The total quantity grows
+		// 1 -> 2, so a quantity sum correctly detects success.
+		mockContext.listItem = makeListItem( { id: 42 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickAddToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'wishlist',
+			'list-key-1'
+		);
+	} );
+
+	it( 'is independent of cart-line ordering', async () => {
+		// Same lines as the multi-line case but seeded in mixed order before and
+		// after; the result must not depend on iteration order.
+		mockContext.listItem = makeListItem( { id: 42 } );
+		mockCartItems = [
+			makeCartLine( { id: 99, quantity: 5, key: 'other' } ),
+			makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+		];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( { id: 42, quantity: 1, key: 'new' } ),
+				makeCartLine( { id: 99, quantity: 5, key: 'other' } ),
+				makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickAddToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'wishlist',
+			'list-key-1'
+		);
+	} );
+
+	it( 'ignores non-matching product lines when summing', async () => {
+		// A different product (id 99) churns its quantity across the add while
+		// the wishlisted product (id 42) is unchanged. Summing only matching
+		// lines must read no growth and preserve the entry.
+		mockContext.listItem = makeListItem( { id: 42 } );
+		mockCartItems = [
+			makeCartLine( { id: 42, quantity: 1 } ),
+			makeCartLine( { id: 99, quantity: 1, key: 'other' } ),
+		];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( { id: 42, quantity: 1 } ),
+				makeCartLine( { id: 99, quantity: 8, key: 'other' } ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickAddToCart() );
+
+		expect( mockRemoveItem ).not.toHaveBeenCalled();
+	} );
+
+	it( 'sums over lines matching id and variation attributes for a variation entry', async () => {
+		mockContext.listItem = makeListItem( {
+			id: 7,
+			variation_id: 7,
+			variation: [
+				{
+					raw_attribute: 'attribute_pa_color',
+					attribute: 'Color',
+					value: 'blue',
+				},
+			],
+		} );
+		// Variation lines carry a same-length `variation` array (matching the
+		// selector's length guard); the attribute matcher decides which one is
+		// the wishlisted product. One variation line of the wishlisted product
+		// plus a same-id line with a different (non-matching) variation.
+		const variationAttr = [
+			{
+				raw_attribute: 'attribute_pa_color',
+				attribute: 'Color',
+				value: 'x',
+			},
+		];
+		const matchingLine = makeCartLine( {
+			id: 7,
+			type: 'variation',
+			quantity: 1,
+			key: 'match',
+			variation: variationAttr,
+		} );
+		const otherVariationLine = makeCartLine( {
+			id: 7,
+			type: 'variation',
+			quantity: 4,
+			key: 'other-variation',
+			variation: variationAttr,
+		} );
+		mockCartItems = [ matchingLine, otherVariationLine ];
+		// Only the matching variation line satisfies the attribute matcher.
+		mockAttributeMatcher = ( cartItem ) => cartItem.key === 'match';
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [
+				makeCartLine( {
+					id: 7,
+					type: 'variation',
+					quantity: 2,
+					key: 'match',
+					variation: variationAttr,
+				} ),
+				makeCartLine( {
+					id: 7,
+					type: 'variation',
+					quantity: 4,
+					key: 'other-variation',
+					variation: variationAttr,
+				} ),
+			];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickAddToCart() );
+
+		expect( mockRemoveItem ).toHaveBeenCalledWith(
+			'wishlist',
+			'list-key-1'
+		);
+	} );
+
+	it( 'does not call findItemInCart for the success check (consumer sums cart.items directly)', async () => {
+		// The before/after read sums `cart.items` itself; `findItemInCart` must
+		// not be the mechanism for the success comparison.
+		mockContext.listItem = makeListItem( { id: 42 } );
+		mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+		mockAddCartItem = jest.fn( () => {
+			mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+			return Promise.resolve();
+		} );
+
+		const actions = loadBlockStore();
+		await runAction( actions.onClickAddToCart() );
+
+		expect( mockFindItemInCart ).not.toHaveBeenCalled();
+		expect( mockRemoveItem ).toHaveBeenCalled();
+	} );
+} );
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
new file mode 100644
index 00000000000..49c43bac0ac
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/test-plugins/blocks/cart-line-identity.php
@@ -0,0 +1,99 @@
+<?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.
+ * Plugin URI: https://github.com/woocommerce/woocommerce
+ * Author: WooCommerce
+ *
+ * @package woocommerce-blocks-test-cart-line-identity
+ */
+
+/**
+ * What this simulates.
+ *
+ * Some cart lines share a product id with another line but are differentiated
+ * by item metadata: a bundle child, a booking, a Product Add-Ons configuration,
+ * a gift-card recipient. Those extensions are not installed in the e2e
+ * environment, so this helper stands in for one such "meta-differentiated"
+ * line. When the request carries the marker flag below, the added item gets a
+ * unique cart_item_data entry; because core's WC_Cart::generate_cart_id() folds
+ * cart_item_data into the cart-line hash, the flagged add lands as a line whose
+ * cart id differs from a plain (unflagged) add of the same product — exactly as
+ * a real meta-differentiated line does, without shipping a real extension.
+ *
+ * How to activate it.
+ *
+ * This is a test-only helper that ships no real behavior and modifies no
+ * WooCommerce source: with the flag absent (its default state for every
+ * untouched request) it is inert. An e2e test opts in by activating the plugin
+ * via its WordPress slug — the @package value above,
+ * "woocommerce-blocks-test-cart-line-identity" — e.g.
+ * requestUtils.activatePlugin( ... ), as the sibling helper plugins here are.
+ *
+ * How a test uses it.
+ *
+ * To set up the "product X is already in the cart as a meta line" precondition,
+ * a test issues one flagged add (append CART_LINE_IDENTITY_FLAG to the add-to-cart
+ * URL); that seeds the meta-differentiated line. A subsequent plain (unflagged)
+ * add of the same product through the block UI then exercises the behavior under
+ * 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.
+ */
+
+declare(strict_types=1);
+
+/**
+ * Request flag a test toggles to mark an add as a meta line (see file header).
+ *
+ * Append it to the add-to-cart request (the Store API add-item URL or the legacy
+ * add-to-cart URL); when present, the added item receives the unique
+ * cart_item_data entry below. Absent, the add is left untouched.
+ */
+const CART_LINE_IDENTITY_FLAG = 'cart_line_identity_marker';
+
+/**
+ * cart_item_data key used to carry the marker on the cart line.
+ *
+ * The leading underscore keeps it out of the customer-visible item data list,
+ * matching how extensions store internal differentiators.
+ */
+const CART_LINE_IDENTITY_KEY = '_cart_line_identity';
+
+add_filter(
+	'woocommerce_add_cart_item_data',
+	/**
+	 * Attach a unique cart_item_data marker to a flagged add-to-cart request.
+	 *
+	 * Has effect only when the request carries the CART_LINE_IDENTITY_FLAG; a
+	 * plain add returns the cart item data untouched, so it follows the normal
+	 * (standalone-line) identity and increments any existing standalone line.
+	 *
+	 * The marker value is taken from the flag so a test can mint more than one
+	 * distinct meta line if needed; an empty/bare flag falls back to a stable
+	 * default value, which is enough to differentiate one meta line from the
+	 * plain line.
+	 *
+	 * @param array $cart_item_data Existing cart item data.
+	 * @return array Cart item data, with the marker added when the flag is present.
+	 */
+	function ( $cart_item_data ) {
+		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only test marker, no state change; nonce handled by the underlying add-to-cart request.
+		if ( ! isset( $_REQUEST[ CART_LINE_IDENTITY_FLAG ] ) ) {
+			return $cart_item_data;
+		}
+
+		$marker = sanitize_text_field( wp_unslash( $_REQUEST[ CART_LINE_IDENTITY_FLAG ] ) );
+		// phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+		if ( '' === $marker ) {
+			$marker = 'meta-line';
+		}
+
+		$cart_item_data[ CART_LINE_IDENTITY_KEY ] = $marker;
+
+		return $cart_item_data;
+	},
+	10,
+	1
+);
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/cart-line-identity.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/cart-line-identity.block_theme.spec.ts
new file mode 100644
index 00000000000..c61db94138d
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/cart-line-identity.block_theme.spec.ts
@@ -0,0 +1,368 @@
+/**
+ * External dependencies
+ */
+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,
+	cartLineRows,
+	productButton,
+	readCartLineQuantities,
+	seedMetaLine,
+} from './utils';
+
+/**
+ * E2E flows for "Block add-to-cart action respects cart-line identity".
+ *
+ * Background: on a keyless add (every add-to-cart consumer), the iAPI cart
+ * store now always issues `add-item` with the requested quantity delta and lets
+ * WooCommerce core's `generate_cart_id` decide match-or-create, instead of
+ * matching a line by product id on the client and converting the add into an
+ * `update-item`. The flows below exercise that behaviour against a real cart
+ * through the ProductButton, the Add to Cart with Options block, and the
+ * Mini-Cart stepper.
+ *
+ * A "meta line" — a stand-in for a bundle child / booking / add-on / recipient
+ * line — is simulated by the cart-line-identity helper plugin
+ * (`woocommerce-blocks-test-cart-line-identity`), which attaches a unique
+ * `cart_item_data` marker to a flagged add so core mints a distinct cart line
+ * for the same product id. See `./utils.ts`.
+ *
+ * Notice-showing consumers (saved-for-later, wishlist) are gated behind the
+ * `product_wishlist` feature flag and are not registered in this e2e
+ * environment, so their blocks cannot be placed on a page here. The notice
+ * outcomes those consumers depend on — no spurious "quantity changed" info
+ * notice on a meta-only add and entry preservation on a genuine rejection —
+ * are asserted authoritatively at the store/consumer unit level (the cart
+ * store and saved-for-later / wishlist frontend unit tests). The cart-outcome
+ * substrate those consumers rely on is still asserted end-to-end below: a
+ * meta-only keyless add creates a new standalone line with no error notice,
+ * and a genuinely rejected add leaves the cart unchanged and surfaces the
+ * server error.
+ */
+
+const test = base.extend< {
+	addToCartWithOptionsPage: AddToCartWithOptionsPage;
+} >( {
+	addToCartWithOptionsPage: async ( { page, admin, editor }, use ) => {
+		await use( new AddToCartWithOptionsPage( { page, admin, editor } ) );
+	},
+} );
+
+test.describe( 'Add to cart respects cart-line identity', () => {
+	test.describe( 'with a meta-differentiated line present', () => {
+		// Run as a guest so every test gets a brand-new browser context with no
+		// session cookie, i.e. an isolated empty cart — cart-line-identity
+		// outcomes are sensitive to leftover cart state, so no cross-test
+		// cleanup is needed. The same guest session cookie is shared, within a
+		// test, between the meta-line seeding navigation and the Store API add
+		// path.
+		test.use( { storageState: guestFile } );
+
+		// Activate in beforeEach because the helper is deactivated when the DB
+		// is reset; mirrors mini-cart.block_theme.spec.ts's item-data plugin.
+		test.beforeEach( async ( { requestUtils } ) => {
+			await requestUtils.activatePlugin( CART_LINE_IDENTITY_PLUGIN );
+		} );
+
+		test( 'Meta line present, no standalone — add creates a new line, meta line unchanged, no error notice', async ( {
+			page,
+			frontendUtils,
+		} ) => {
+			// Seed: product X is in the cart only as a single meta line (qty 1).
+			await seedMetaLine( page, PRODUCT_X.id );
+
+			await frontendUtils.goToShop();
+
+			// Keyless add of X through the ProductButton.
+			await frontendUtils.addToCart( PRODUCT_X.name );
+
+			// No error notice (in particular no "cannot update bundle
+			// item" / store error) appears on the archive.
+			await expect(
+				page.locator( '.wc-block-components-notice-banner.is-error' )
+			).toHaveCount( 0 );
+
+			// Persisted cart: exactly two lines for X — the meta line still at
+			// quantity 1 plus a new standalone line at quantity 1.
+			await frontendUtils.goToCart();
+			expect(
+				await readCartLineQuantities( page, PRODUCT_X.name )
+			).toEqual( [ 1, 1 ] );
+		} );
+
+		test( 'Both a standalone and a meta line — only the standalone increments', async ( {
+			page,
+			frontendUtils,
+		} ) => {
+			// Seed: product X as a meta line (qty 1)...
+			await seedMetaLine( page, PRODUCT_X.id );
+
+			// ...and as a standalone line (qty 1) via a plain ProductButton add.
+			await frontendUtils.goToShop();
+			await frontendUtils.addToCart( PRODUCT_X.name );
+
+			await frontendUtils.goToCart();
+			expect(
+				await readCartLineQuantities( page, PRODUCT_X.name )
+			).toEqual( [ 1, 1 ] );
+
+			// Add X again through the ProductButton.
+			await frontendUtils.goToShop();
+			await frontendUtils.addToCart( PRODUCT_X.name );
+
+			await expect(
+				page.locator( '.wc-block-components-notice-banner.is-error' )
+			).toHaveCount( 0 );
+
+			// The standalone line becomes quantity 2; the meta line stays at 1.
+			// No new line is created (still two lines total).
+			await frontendUtils.goToCart();
+			expect(
+				await readCartLineQuantities( page, PRODUCT_X.name )
+			).toEqual( [ 1, 2 ] );
+		} );
+
+		test( 'Saved-for-later / wishlist meta-only add (cart outcome) — keyless add creates a new standalone line with no error notice', async ( {
+			page,
+			frontendUtils,
+		} ) => {
+			// Saved-for-later "Move to cart" and wishlist "Add to cart" call the
+			// same keyless `addCartItem` path this exercises (quantityToAdd, no
+			// key). Here we assert the cart outcome those consumers depend on —
+			// a new standalone line beside the untouched meta line, no error
+			// notice. The consumer-specific guarantees (list entry removed, no
+			// spurious "quantity changed" info notice shown to the shopper) are
+			// covered authoritatively by the store/consumer unit tests,
+			// because the saved-for-later and wishlist blocks are gated behind
+			// the `product_wishlist` feature flag and are not registered in
+			// this e2e environment.
+			await seedMetaLine( page, PRODUCT_X.id );
+
+			await frontendUtils.goToShop();
+			await frontendUtils.addToCart( PRODUCT_X.name );
+
+			await expect(
+				page.locator( '.wc-block-components-notice-banner.is-error' )
+			).toHaveCount( 0 );
+
+			await frontendUtils.goToCart();
+			expect(
+				await readCartLineQuantities( page, PRODUCT_X.name )
+			).toEqual( [ 1, 1 ] );
+		} );
+	} );
+
+	test.describe( 'without a meta-differentiated line', () => {
+		// Guest isolation, same rationale as above.
+		test.use( { storageState: guestFile } );
+
+		test( 'Existing standalone line — add increments it', async ( {
+			page,
+			frontendUtils,
+		} ) => {
+			await frontendUtils.goToShop();
+
+			// First add creates the standalone line at quantity 1.
+			await frontendUtils.addToCart( PRODUCT_X.name );
+
+			// Second add increments the same line.
+			await frontendUtils.addToCart( PRODUCT_X.name );
+
+			// One line, quantity 2 — not two lines.
+			await frontendUtils.goToCart();
+			expect(
+				await readCartLineQuantities( page, PRODUCT_X.name )
+			).toEqual( [ 2 ] );
+		} );
+
+		test( 'Not in cart — add creates a new line, no error notice', async ( {
+			page,
+			frontendUtils,
+		} ) => {
+			await frontendUtils.goToShop();
+
+			await frontendUtils.addToCart( PRODUCT_X.name );
+
+			await expect(
+				page.locator( '.wc-block-components-notice-banner.is-error' )
+			).toHaveCount( 0 );
+
+			// A single new standalone line at quantity 1.
+			await frontendUtils.goToCart();
+			expect(
+				await readCartLineQuantities( page, PRODUCT_X.name )
+			).toEqual( [ 1 ] );
+		} );
+
+		test( 'Genuine rejection (cart outcome) — leaves the cart unchanged and surfaces the error', async ( {
+			page,
+			frontendUtils,
+		} ) => {
+			// Saved-for-later / wishlist preserve the list entry when the add is
+			// genuinely rejected (entry preservation is asserted at unit level
+			// since those blocks are not placeable here). The
+			// cart-outcome substrate that decision relies on is asserted
+			// end-to-end: a server-rejected add leaves the cart unchanged and
+			// the server error surfaces. A sold-individually product already in
+			// the cart is rejected by the Store API (HTTP 400), and that
+			// rejection surfaces via the cart store's error-notice path, which
+			// is not suppressed by `showCartUpdatesNotices: false`. A
+			// sold-individually line renders without a quantity input, so cart
+			// presence is asserted by counting cart rows rather than reading the
+			// (absent) quantity stepper.
+			await wpCLI(
+				`wc product update ${ PRODUCT_X.id } --sold_individually=true --user=1`
+			);
+
+			await frontendUtils.goToShop();
+
+			// First add succeeds: a single line for the product.
+			await frontendUtils.addToCart( PRODUCT_X.name );
+			await frontendUtils.goToCart();
+			await expect( cartLineRows( page, PRODUCT_X.name ) ).toHaveCount(
+				1
+			);
+
+			// Second add is rejected by the server.
+			await frontendUtils.goToShop();
+			await productButton( page, PRODUCT_X.id ).click();
+
+			// The server error surfaces as an error notice.
+			await expect(
+				page
+					.locator( '.wc-block-components-notice-banner.is-error' )
+					.first()
+			).toBeVisible();
+
+			// The cart is unchanged: still a single line for the product.
+			await frontendUtils.goToCart();
+			await expect( cartLineRows( page, PRODUCT_X.name ) ).toHaveCount(
+				1
+			);
+		} );
+	} );
+
+	test.describe( 'variation handling (Add to Cart with Options)', () => {
+		test( 'Re-adding a variation increments its line; adding a different variation creates a new line', async ( {
+			page,
+			editor,
+			frontendUtils,
+			addToCartWithOptionsPage,
+		} ) => {
+			// Swap the single-product template's legacy Add to Cart form for the
+			// Add to Cart with Options block, then visit the variable product.
+			await addToCartWithOptionsPage.updateSingleProductTemplate();
+			await editor.saveSiteEditorEntities( {
+				isOnlyCurrentEntityDirty: true,
+			} );
+
+			await page.goto( '/product/v-neck-t-shirt/' );
+
+			const addToCartBlock = page.locator(
+				'.wp-block-add-to-cart-with-options'
+			);
+			const colorBlueOption = addToCartBlock
+				.getByRole( 'radiogroup', { name: 'Color' } )
+				.getByRole( 'radio', { name: 'Blue', exact: true } );
+			const colorRedOption = addToCartBlock
+				.getByRole( 'radiogroup', { name: 'Color' } )
+				.getByRole( 'radio', { name: 'Red', exact: true } );
+			const sizeLargeOption = addToCartBlock
+				.getByRole( 'radiogroup', { name: 'Size' } )
+				.getByRole( 'radio', { name: 'Large', exact: true } );
+			// Scope to the Add to Cart + Options block (so we don't pick the
+			// Related Products block's button) and target the submit button by
+			// its stable class: its accessible name changes from "Add to cart"
+			// to "N in cart" as the selected variation's cart quantity changes,
+			// so a name-based locator would stop matching after the first add.
+			const addToCartButton = addToCartBlock.locator(
+				'.single_add_to_cart_button'
+			);
+
+			// Select variation V (Blue, Large) and add it: creates V's line.
+			await colorBlueOption.click();
+			await sizeLargeOption.click();
+			await expect( addToCartButton ).not.toHaveClass( /\bdisabled\b/ );
+			await addToCartButton.click();
+			await expect( addToCartButton ).toHaveText( '1 in cart' );
+
+			// Add V again: increments V's existing line (no second V line).
+			await addToCartButton.click();
+			await expect( addToCartButton ).toHaveText( '2 in cart' );
+
+			// Select a different variation W (Red, Large), not in the cart: the
+			// button resets to "Add to cart" because W has no line yet.
+			await colorRedOption.click();
+			await expect( addToCartButton ).toHaveText( 'Add to cart' );
+
+			// Add W: creates a new, separate line for W.
+			await addToCartButton.click();
+			await expect( addToCartButton ).toHaveText( '1 in cart' );
+
+			// Persisted cart: V incremented to 2 and W added as a distinct line,
+			// so two lines for the product at quantities 1 and 2.
+			await frontendUtils.goToCart();
+			expect(
+				await readCartLineQuantities( page, 'V-Neck T-Shirt' )
+			).toEqual( [ 1, 2 ] );
+		} );
+	} );
+
+	test.describe( 'known-line quantity change (Mini-Cart stepper)', () => {
+		test( 'Stepper updates the exact line with no spurious notice and no extra line', async ( {
+			page,
+			frontendUtils,
+			miniCartUtils,
+		} ) => {
+			await frontendUtils.goToShop();
+			await frontendUtils.addToCart( PRODUCT_X.name );
+			await miniCartUtils.openMiniCart();
+
+			const quantity = page.getByLabel(
+				`Quantity of ${ PRODUCT_X.name } in your cart.`
+			);
+			await expect( quantity ).toHaveValue( '1' );
+
+			// Increment via the stepper (keyed update-item path).
+			const batchIncrease = page.waitForResponse(
+				'**/wp-json/wc/store/v1/batch**'
+			);
+			await page
+				.getByRole( 'button', {
+					name: `Increase quantity of ${ PRODUCT_X.name }`,
+				} )
+				.click();
+			await batchIncrease;
+			await expect( quantity ).toHaveValue( '2' );
+
+			// Decrement back.
+			const batchReduce = page.waitForResponse(
+				'**/wp-json/wc/store/v1/batch**'
+			);
+			await page
+				.getByRole( 'button', {
+					name: `Reduce quantity of ${ PRODUCT_X.name }`,
+				} )
+				.click();
+			await batchReduce;
+			await expect( quantity ).toHaveValue( '1' );
+
+			// No spurious notice from the stepper change itself.
+			await expect(
+				page.locator( '.wc-block-components-notice-banner' )
+			).toHaveCount( 0 );
+
+			// No extra line was created: exactly one line for the product.
+			await frontendUtils.goToCart();
+			expect(
+				await readCartLineQuantities( page, PRODUCT_X.name )
+			).toEqual( [ 1 ] );
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/mutation-batcher/mutation-batcher.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts
similarity index 87%
rename from plugins/woocommerce/tests/e2e/tests/blocks/mutation-batcher/mutation-batcher.block_theme.spec.ts
rename to plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts
index 9a2153466fb..44ec555c04d 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/mutation-batcher/mutation-batcher.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts
@@ -45,9 +45,9 @@ test.describe( 'Mutation Batcher', () => {
 			const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );

 			// Three calls with no await between them — same microtick.
-			const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
-			const p2 = actions.addCartItem( { id: 16, quantity: 1 } );
-			const p3 = actions.addCartItem( { id: 17, quantity: 1 } );
+			const p1 = actions.addCartItem( { id: 15, quantityToAdd: 1 } );
+			const p2 = actions.addCartItem( { id: 16, quantityToAdd: 1 } );
+			const p3 = actions.addCartItem( { id: 17, quantityToAdd: 1 } );

 			await Promise.all( [ p1, p2, p3 ] );
 		} );
@@ -77,9 +77,9 @@ test.describe( 'Mutation Batcher', () => {
 			const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );

 			// Each await breaks the microtick — each call becomes its own batch.
-			await actions.addCartItem( { id: 18, quantity: 1 } );
-			await actions.addCartItem( { id: 19, quantity: 1 } );
-			await actions.addCartItem( { id: 20, quantity: 1 } );
+			await actions.addCartItem( { id: 18, quantityToAdd: 1 } );
+			await actions.addCartItem( { id: 19, quantityToAdd: 1 } );
+			await actions.addCartItem( { id: 20, quantityToAdd: 1 } );
 		} );

 		// Each call should have produced its own batch request.
@@ -109,17 +109,17 @@ test.describe( 'Mutation Batcher', () => {
 			const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );

 			// Batch 1: two sync calls
-			const p1 = actions.addCartItem( { id: 21, quantity: 1 } );
-			const p2 = actions.addCartItem( { id: 22, quantity: 1 } );
+			const p1 = actions.addCartItem( { id: 21, quantityToAdd: 1 } );
+			const p2 = actions.addCartItem( { id: 22, quantityToAdd: 1 } );
 			await Promise.all( [ p1, p2 ] );

 			// Batch 2: one call after await
-			await actions.addCartItem( { id: 23, quantity: 1 } );
+			await actions.addCartItem( { id: 23, quantityToAdd: 1 } );

 			// Batch 3: three sync calls
-			const p3 = actions.addCartItem( { id: 24, quantity: 1 } );
-			const p4 = actions.addCartItem( { id: 25, quantity: 1 } );
-			const p5 = actions.addCartItem( { id: 26, quantity: 1 } );
+			const p3 = actions.addCartItem( { id: 24, quantityToAdd: 1 } );
+			const p4 = actions.addCartItem( { id: 25, quantityToAdd: 1 } );
+			const p5 = actions.addCartItem( { id: 26, quantityToAdd: 1 } );
 			await Promise.all( [ p3, p4, p5 ] );
 		} );

@@ -157,9 +157,9 @@ test.describe( 'Mutation Batcher', () => {
 			}

 			// Now add 3 products synchronously (one batch).
-			const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
-			const p2 = actions.addCartItem( { id: 16, quantity: 1 } );
-			const p3 = actions.addCartItem( { id: 17, quantity: 1 } );
+			const p1 = actions.addCartItem( { id: 15, quantityToAdd: 1 } );
+			const p2 = actions.addCartItem( { id: 16, quantityToAdd: 1 } );
+			const p3 = actions.addCartItem( { id: 17, quantityToAdd: 1 } );
 			await Promise.all( [ p1, p2, p3 ] );

 			// Return the product IDs now in the cart.
@@ -249,9 +249,9 @@ test.describe( 'Mutation Batcher', () => {
 			await actions.refreshCartItems();

 			// Mix valid and invalid product IDs — all in one microtick.
-			const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
-			const p2 = actions.addCartItem( { id: 999999, quantity: 1 } ); // Invalid
-			const p3 = actions.addCartItem( { id: 16, quantity: 1 } );
+			const p1 = actions.addCartItem( { id: 15, quantityToAdd: 1 } );
+			const p2 = actions.addCartItem( { id: 999999, quantityToAdd: 1 } ); // Invalid
+			const p3 = actions.addCartItem( { id: 16, quantityToAdd: 1 } );

 			// addCartItem catches errors internally so all promises resolve.
 			await Promise.allSettled( [ p1, p2, p3 ] );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts
new file mode 100644
index 00000000000..c3c2ab7c309
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts
@@ -0,0 +1,132 @@
+/**
+ * External dependencies
+ */
+import { Page, Locator } from '@playwright/test';
+
+/**
+ * Activation slug of the cart-line-identity helper plugin
+ * (`test-plugins/blocks/cart-line-identity.php`). When active, an add-to-cart
+ * request that carries the `cart_line_identity_marker` flag receives a unique
+ * `cart_item_data` entry, so core's `generate_cart_id` mints a distinct cart
+ * line for the same product id — a stand-in for a bundle child / booking /
+ * add-on / recipient (meta-differentiated) line.
+ */
+export const CART_LINE_IDENTITY_PLUGIN =
+	'woocommerce-blocks-test-cart-line-identity';
+
+/**
+ * Request flag the helper plugin reads to mark an add as a meta line.
+ *
+ * Kept identical to `CART_LINE_IDENTITY_FLAG` in the helper plugin.
+ */
+export const CART_LINE_IDENTITY_FLAG = 'cart_line_identity_marker';
+
+/**
+ * Sample-data product used as "product X" throughout the simple-product flows.
+ *
+ * Beanie is a simple product present on the first shop page, so its
+ * ProductButton is clickable from `/shop` without pagination, and it is not a
+ * prefix of another product on that page that would make an exact-label match
+ * ambiguous (we still scope clicks to its `li.post-<id>` wrapper to be safe).
+ */
+export const PRODUCT_X = {
+	id: 10,
+	name: 'Beanie',
+} as const;
+
+/**
+ * Seeds "product X is in the cart as a single meta-differentiated line".
+ *
+ * Navigates to a flagged legacy add-to-cart URL. The helper plugin attaches a
+ * unique `cart_item_data` marker to this add, so it lands as a cart line whose
+ * cart id differs from a plain (unflagged) add of the same product. The browser
+ * shares the same WooCommerce session cookie that the Store API add path uses,
+ * so a later keyless add of X operates against this same cart and is resolved
+ * by the server as a separate standalone line.
+ *
+ * The helper plugin must already be active (activate it in `beforeEach`).
+ *
+ * @param page      The Playwright page.
+ * @param productId The product id to seed as a meta line.
+ * @param marker    The marker value (defaults to the helper's own default).
+ */
+export const seedMetaLine = async (
+	page: Page,
+	productId: number,
+	marker?: string
+) => {
+	const markerValue = marker ?? 'meta-line';
+	await page.goto(
+		`/?add-to-cart=${ productId }&${ CART_LINE_IDENTITY_FLAG }=${ markerValue }`
+	);
+};
+
+/**
+ * The ProductButton for a given product on the shop archive.
+ *
+ * The shop renders one list item per product (`li.post-<id>`); scoping the
+ * button to that wrapper targets exactly one product's Add to cart control even
+ * when another product's name shares a prefix.
+ *
+ * @param page      The Playwright page (must be on the shop archive).
+ * @param productId The product id whose button to return.
+ */
+export const productButton = ( page: Page, productId: number ): Locator =>
+	page.locator( `li.post-${ productId }` ).getByRole( 'button' );
+
+/**
+ * All cart-line quantity inputs for a product on the blocks Cart page.
+ *
+ * Every cart line for the product (standalone or meta) renders a quantity input
+ * labelled `Quantity of <name> in your cart.`, so the returned locator's
+ * `count()` is the number of distinct cart lines for that product and each
+ * `nth(i)` exposes a line's quantity via `inputValue()`/`toHaveValue()`.
+ *
+ * @param page        The Playwright page (must be on the Cart page).
+ * @param productName The product display name.
+ */
+export const cartLineQuantities = (
+	page: Page,
+	productName: string
+): Locator => page.getByLabel( `Quantity of ${ productName } in your cart.` );
+
+/**
+ * Reads each cart line's quantity for a product, sorted ascending.
+ *
+ * Sorting makes the assertion order-independent: the cart can render the meta
+ * line and the standalone line in either order, but `[ 1, 1 ]` or `[ 1, 2 ]`
+ * uniquely describes the outcome regardless of which row came first.
+ *
+ * @param page        The Playwright page (must be on the Cart page).
+ * @param productName The product display name.
+ * @return The quantities of every cart line for the product, ascending.
+ */
+export const readCartLineQuantities = async (
+	page: Page,
+	productName: string
+): Promise< number[] > => {
+	const inputs = cartLineQuantities( page, productName );
+	const count = await inputs.count();
+	const quantities: number[] = [];
+	for ( let i = 0; i < count; i++ ) {
+		quantities.push( Number( await inputs.nth( i ).inputValue() ) );
+	}
+	return quantities.sort( ( a, b ) => a - b );
+};
+
+/**
+ * All cart-line rows for a product on the blocks Cart page.
+ *
+ * Unlike {@link cartLineQuantities}, this counts the cart-item rows themselves
+ * (`.wc-block-cart-items__row`), so it works even for a product whose quantity
+ * input is absent — a sold-individually product renders a line with no quantity
+ * stepper. The returned locator's `count()` is the number of cart lines for the
+ * product, regardless of whether each line exposes an editable quantity.
+ *
+ * @param page        The Playwright page (must be on the Cart page).
+ * @param productName The product display name.
+ */
+export const cartLineRows = ( page: Page, productName: string ): Locator =>
+	page.locator( '.wc-block-cart-items__row', {
+		hasText: new RegExp( productName ),
+	} );