Commit ebbfd10f27 for woocommerce

commit ebbfd10f279792681de4d18b316ff7249c3f0222
Author: Sam Seay <samueljseay@gmail.com>
Date:   Thu Jan 29 16:47:29 2026 +1300

    Finish integrating Add to Cart with Options with the new product store (#62880)

diff --git a/plugins/woocommerce/changelog/62880-dev-improve-iapi-product-store b/plugins/woocommerce/changelog/62880-dev-improve-iapi-product-store
new file mode 100644
index 0000000000..efcedadcf9
--- /dev/null
+++ b/plugins/woocommerce/changelog/62880-dev-improve-iapi-product-store
@@ -0,0 +1,4 @@
+Significance: minor
+Type: dev
+
+Update Add to Cart with Options to use server hydrated product state.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/product-data.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/product-data.ts
index ff025b1daf..c2fbf351d8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/product-data.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/product-data.ts
@@ -3,6 +3,10 @@
  */
 import { getContext, store } from '@wordpress/interactivity';

+// Stores are locked to prevent 3PD usage until the API is stable.
+const universalLock =
+	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
 type ProductRef = {
 	productId: number;
 	variationId: number | null;
@@ -62,7 +66,7 @@ const productDataStore = store< {
 			},
 		},
 	},
-	{ lock: true }
+	{ lock: universalLock }
 );

 export type ProductDataStore = typeof productDataStore;
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/products.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/products.ts
new file mode 100644
index 0000000000..38104ebad1
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/products.ts
@@ -0,0 +1,55 @@
+/**
+ * External dependencies
+ */
+import { store } from '@wordpress/interactivity';
+import type { ProductResponseItem } from '@woocommerce/types';
+
+/**
+ * The state shape for the products store.
+ * This matches the server-side ProductsStore state structure.
+ */
+export type ProductsStoreState = {
+	/**
+	 * Products keyed by product ID.
+	 * These are in Store API format (ProductResponseItem).
+	 */
+	products: Record< number, ProductResponseItem >;
+	/**
+	 * Product variations keyed by variation ID.
+	 * These are in Store API format (ProductResponseItem).
+	 */
+	productVariations: Record< number, ProductResponseItem >;
+};
+
+/**
+ * The products store type definition.
+ */
+export type ProductsStore = {
+	state: ProductsStoreState;
+};
+
+// Stores are locked to prevent 3PD usage until the API is stable.
+const universalLock =
+	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+/**
+ * The woocommerce/products store.
+ *
+ * This store manages product data in Store API format for use with the
+ * Interactivity API. Data is hydrated server-side via PHP ProductsStore.
+ * Consumers access it via store() call with the namespace.
+ *
+ * State structure:
+ * - products: Record<productId, ProductResponseItem>
+ * - productVariations: Record<variationId, ProductResponseItem>
+ */
+store< ProductsStore >(
+	'woocommerce/products',
+	{
+		state: {
+			products: {},
+			productVariations: {},
+		},
+	},
+	{ lock: universalLock }
+);
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts
new file mode 100644
index 0000000000..4daf7b9977
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts
@@ -0,0 +1,106 @@
+/**
+ * External dependencies
+ */
+import type { SelectedAttributes } from '@woocommerce/stores/woocommerce/cart';
+import type {
+	ProductResponseItem,
+	ProductResponseVariationsItem,
+} from '@woocommerce/types';
+
+/**
+ * Normalize attribute name by stripping the 'attribute_' or 'attribute_pa_' prefix
+ * that WooCommerce adds for variation attributes.
+ *
+ * @param name The attribute name (e.g., 'attribute_color' or 'attribute_pa_color').
+ * @return The normalized name (e.g., 'color').
+ */
+export const normalizeAttributeName = ( name: string ): string => {
+	return name.replace( /^attribute_(pa_)?/, '' );
+};
+
+/**
+ * Check if two attribute names match, using case-insensitive comparison.
+ *
+ * This handles the mismatch between Store API labels (e.g., "Color") and
+ * PHP context slugs (e.g., "attribute_pa_color").
+ *
+ * @param name1 First attribute name (may be label or slug format).
+ * @param name2 Second attribute name (may be label or slug format).
+ * @return True if the names match after normalization.
+ */
+export const attributeNamesMatch = (
+	name1: string,
+	name2: string
+): boolean => {
+	return (
+		normalizeAttributeName( name1 ).toLowerCase() ===
+		normalizeAttributeName( name2 ).toLowerCase()
+	);
+};
+
+/**
+ * Get the attribute value from a variation's attributes array.
+ *
+ * The Store API returns the attribute label (e.g., "Color") in the name field,
+ * while the PHP context uses the attribute slug (e.g., "attribute_pa_color").
+ * We do a case-insensitive comparison to match "color" with "Color".
+ *
+ * @param variation     The variation in Store API format.
+ * @param attributeName The attribute name to find (may include 'attribute_' prefix).
+ * @return The attribute value, or undefined if not found.
+ */
+export const getVariationAttributeValue = (
+	variation: ProductResponseVariationsItem,
+	attributeName: string
+): string | undefined => {
+	const normalizedName =
+		normalizeAttributeName( attributeName ).toLowerCase();
+	const attr = variation.attributes.find(
+		( a ) => a.name.toLowerCase() === normalizedName
+	);
+	return attr?.value;
+};
+
+/**
+ * Find the matching variation from a product's variations based on selected attributes.
+ *
+ * Uses case-insensitive comparison since Store API returns labels (e.g., "Color")
+ * while PHP context uses slugs (e.g., "attribute_pa_color" → "color").
+ *
+ * @param product            The product in Store API format.
+ * @param selectedAttributes The selected attributes.
+ * @return The matching variation, or null if no match.
+ */
+export const findMatchingVariation = (
+	product: ProductResponseItem,
+	selectedAttributes: SelectedAttributes[]
+): ProductResponseVariationsItem | null => {
+	if ( ! product.variations?.length || ! selectedAttributes?.length ) {
+		return null;
+	}
+
+	const matchedVariation = product.variations.find(
+		( variation: ProductResponseVariationsItem ) => {
+			return variation.attributes.every( ( attr ) => {
+				const attrNameLower = attr.name.toLowerCase();
+				const selectedAttr = selectedAttributes.find(
+					( selected ) =>
+						normalizeAttributeName(
+							selected.attribute
+						).toLowerCase() === attrNameLower
+				);
+
+				// If variation attribute has empty value, it accepts "Any" value.
+				if ( attr.value === '' ) {
+					return (
+						selectedAttr !== undefined && selectedAttr.value !== ''
+					);
+				}
+
+				return selectedAttr?.value === attr.value;
+			} );
+		}
+	);
+
+	return matchedVariation ?? null;
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/does-cart-item-match-attributes.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/does-cart-item-match-attributes.ts
index 95a8ac820c..e71fe60b53 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/does-cart-item-match-attributes.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/does-cart-item-match-attributes.ts
@@ -6,6 +6,11 @@ import type {
 	SelectedAttributes,
 } from '@woocommerce/stores/woocommerce/cart';

+/**
+ * Internal dependencies
+ */
+import { attributeNamesMatch } from './attribute-matching';
+
 export const doesCartItemMatchAttributes = (
 	cartItem: OptimisticCartItem,
 	selectedAttributes: SelectedAttributes[]
@@ -32,7 +37,7 @@ export const doesCartItemMatchAttributes = (
 		} ) =>
 			selectedAttributes.some( ( item: SelectedAttributes ) => {
 				return (
-					item.attribute === raw_attribute &&
+					attributeNamesMatch( item.attribute, raw_attribute ) &&
 					( item.value.toLowerCase() === value.toLowerCase() ||
 						( item.value && value === '' ) ) // Handle "any" attribute type
 				);
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/get-matched-variation.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/get-matched-variation.ts
deleted file mode 100644
index 900a84b639..0000000000
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/get-matched-variation.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * External dependencies
- */
-import type {
-	SelectedAttributes,
-	ProductData,
-} from '@woocommerce/stores/woocommerce/cart';
-
-export const getMatchedVariation = (
-	availableVariations: ProductData[ 'variations' ],
-	selectedAttributes: SelectedAttributes[]
-) => {
-	if (
-		! availableVariations ||
-		! Object.keys( availableVariations ).length ||
-		! Array.isArray( selectedAttributes ) ||
-		selectedAttributes.length === 0
-	) {
-		return null;
-	}
-
-	const matchingVariation = Object.entries( availableVariations ).find(
-		// eslint-disable-next-line @typescript-eslint/no-unused-vars
-		( [ _, availableVariation ] ) => {
-			return Object.entries( availableVariation.attributes ).every(
-				( [ attributeName, attributeValue ] ) => {
-					const attributeMatched = selectedAttributes.some(
-						( variationAttribute ) => {
-							const isSameAttribute =
-								variationAttribute.attribute === attributeName;
-							if ( ! isSameAttribute ) {
-								return false;
-							}
-
-							return (
-								variationAttribute.value === attributeValue ||
-								( variationAttribute.value &&
-									attributeValue === '' )
-							);
-						}
-					);
-
-					return attributeMatched;
-				}
-			);
-		}
-	);
-
-	if ( ! matchingVariation ) {
-		return null;
-	}
-
-	return {
-		...matchingVariation[ 1 ],
-		variation_id: Number( matchingVariation[ 0 ] ),
-	};
-};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts
new file mode 100644
index 0000000000..f3eb3c3245
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts
@@ -0,0 +1,205 @@
+/**
+ * Internal dependencies
+ */
+import {
+	normalizeAttributeName,
+	attributeNamesMatch,
+	getVariationAttributeValue,
+	findMatchingVariation,
+} from '../attribute-matching';
+
+describe( 'normalizeAttributeName', () => {
+	it( 'strips attribute_ prefix', () => {
+		expect( normalizeAttributeName( 'attribute_color' ) ).toBe( 'color' );
+	} );
+
+	it( 'strips attribute_pa_ prefix', () => {
+		expect( normalizeAttributeName( 'attribute_pa_color' ) ).toBe(
+			'color'
+		);
+	} );
+
+	it( 'returns unchanged name without prefix', () => {
+		expect( normalizeAttributeName( 'Color' ) ).toBe( 'Color' );
+	} );
+} );
+
+describe( 'attributeNamesMatch', () => {
+	it( 'matches case-insensitively', () => {
+		expect( attributeNamesMatch( 'Color', 'color' ) ).toBe( true );
+	} );
+
+	it( 'matches after stripping prefix', () => {
+		expect( attributeNamesMatch( 'attribute_pa_color', 'Color' ) ).toBe(
+			true
+		);
+	} );
+
+	it( 'matches when both have prefixes', () => {
+		expect(
+			attributeNamesMatch( 'attribute_pa_color', 'attribute_color' )
+		).toBe( true );
+	} );
+
+	it( 'returns false for different names', () => {
+		expect( attributeNamesMatch( 'color', 'size' ) ).toBe( false );
+	} );
+} );
+
+describe( 'getVariationAttributeValue', () => {
+	const variation = {
+		id: 123,
+		attributes: [
+			{ name: 'Color', value: 'Blue' },
+			{ name: 'Size', value: 'Large' },
+		],
+	};
+
+	it( 'finds attribute value by exact name', () => {
+		expect( getVariationAttributeValue( variation, 'Color' ) ).toBe(
+			'Blue'
+		);
+	} );
+
+	it( 'finds attribute value case-insensitively', () => {
+		expect( getVariationAttributeValue( variation, 'color' ) ).toBe(
+			'Blue'
+		);
+	} );
+
+	it( 'finds attribute value when using prefix', () => {
+		expect(
+			getVariationAttributeValue( variation, 'attribute_pa_color' )
+		).toBe( 'Blue' );
+	} );
+
+	it( 'returns undefined for non-existent attribute', () => {
+		expect(
+			getVariationAttributeValue( variation, 'material' )
+		).toBeUndefined();
+	} );
+} );
+
+describe( 'findMatchingVariation', () => {
+	const product = {
+		id: 1,
+		type: 'variable',
+		variations: [
+			{
+				id: 101,
+				attributes: [
+					{ name: 'Color', value: 'Blue' },
+					{ name: 'Size', value: 'Small' },
+				],
+			},
+			{
+				id: 102,
+				attributes: [
+					{ name: 'Color', value: 'Blue' },
+					{ name: 'Size', value: 'Large' },
+				],
+			},
+			{
+				id: 103,
+				attributes: [
+					{ name: 'Color', value: 'Red' },
+					{ name: 'Size', value: 'Small' },
+				],
+			},
+		],
+	};
+
+	it( 'returns null when product has no variations', () => {
+		const productNoVariations = { id: 1, type: 'variable', variations: [] };
+		const selectedAttributes = [ { attribute: 'Color', value: 'Blue' } ];
+		expect(
+			findMatchingVariation( productNoVariations, selectedAttributes )
+		).toBeNull();
+	} );
+
+	it( 'returns null when no attributes are selected', () => {
+		expect( findMatchingVariation( product, [] ) ).toBeNull();
+	} );
+
+	it( 'finds exact match with all attributes', () => {
+		const selectedAttributes = [
+			{ attribute: 'Color', value: 'Blue' },
+			{ attribute: 'Size', value: 'Large' },
+		];
+		const result = findMatchingVariation( product, selectedAttributes );
+		expect( result?.id ).toBe( 102 );
+	} );
+
+	it( 'matches with attribute prefix in selected attributes', () => {
+		const selectedAttributes = [
+			{ attribute: 'attribute_pa_color', value: 'Blue' },
+			{ attribute: 'attribute_pa_size', value: 'Small' },
+		];
+		const result = findMatchingVariation( product, selectedAttributes );
+		expect( result?.id ).toBe( 101 );
+	} );
+
+	it( 'returns null when no variation matches', () => {
+		const selectedAttributes = [
+			{ attribute: 'Color', value: 'Green' },
+			{ attribute: 'Size', value: 'Small' },
+		];
+		expect(
+			findMatchingVariation( product, selectedAttributes )
+		).toBeNull();
+	} );
+
+	describe( 'Any attribute handling', () => {
+		const productWithAny = {
+			id: 2,
+			type: 'variable',
+			variations: [
+				{
+					id: 201,
+					attributes: [
+						{ name: 'Color', value: '' }, // "Any" color
+						{ name: 'Size', value: 'Small' },
+					],
+				},
+				{
+					id: 202,
+					attributes: [
+						{ name: 'Color', value: 'Blue' },
+						{ name: 'Size', value: '' }, // "Any" size
+					],
+				},
+			],
+		};
+
+		it( 'matches variation with "Any" attribute when value is selected', () => {
+			const selectedAttributes = [
+				{ attribute: 'Color', value: 'Red' },
+				{ attribute: 'Size', value: 'Small' },
+			];
+			const result = findMatchingVariation(
+				productWithAny,
+				selectedAttributes
+			);
+			expect( result?.id ).toBe( 201 );
+		} );
+
+		it( 'does not match "Any" attribute when selected value is empty', () => {
+			const selectedAttributes = [
+				{ attribute: 'Color', value: '' },
+				{ attribute: 'Size', value: 'Small' },
+			];
+			expect(
+				findMatchingVariation( productWithAny, selectedAttributes )
+			).toBeNull();
+		} );
+
+		it( 'does not match "Any" attribute when attribute is not selected', () => {
+			const selectedAttributes = [
+				{ attribute: 'Size', value: 'Small' },
+			];
+			expect(
+				findMatchingVariation( productWithAny, selectedAttributes )
+			).toBeNull();
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
index e397461b09..8eb4ca19cc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
@@ -5,23 +5,23 @@ import { store, getContext, getConfig } from '@wordpress/interactivity';
 import type {
 	Store as WooCommerce,
 	SelectedAttributes,
-	ProductData,
-	WooCommerceConfig,
 } from '@woocommerce/stores/woocommerce/cart';
 import '@woocommerce/stores/woocommerce/product-data';
+import '@woocommerce/stores/woocommerce/products';
 import type { Store as StoreNotices } from '@woocommerce/stores/store-notices';
 import type { ProductDataStore } from '@woocommerce/stores/woocommerce/product-data';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
+import type { ProductResponseItem } from '@woocommerce/types';

 /**
  * Internal dependencies
  */
-import { getMatchedVariation } from '../../base/utils/variations/get-matched-variation';
 import { doesCartItemMatchAttributes } from '../../base/utils/variations/does-cart-item-match-attributes';
+import { findMatchingVariation } from '../../base/utils/variations/attribute-matching';
 import type { GroupedProductAddToCartWithOptionsStore } from './grouped-product-selector/frontend';
 import type { Context as QuantitySelectorContext } from './quantity-selector/frontend';
 import type { VariableProductAddToCartWithOptionsStore } from './variation-selector/frontend';
 import type { NormalizedProductData, NormalizedVariationData } from './types';
-import type { ProductResponseItem } from '../../types';

 export type Context = {
 	selectedAttributes: SelectedAttributes[];
@@ -37,6 +37,38 @@ export type AddToCartError = {
 	message: string;
 };

+/**
+ * Quantity constraints normalized from the Store API format.
+ */
+type QuantityConstraints = {
+	min: number;
+	max: number;
+	step: number;
+};
+
+/**
+ * Extract quantity constraints from a product in Store API format.
+ *
+ * @param product The product in Store API format.
+ * @return Normalized quantity constraints.
+ */
+const getQuantityConstraints = (
+	product: ProductResponseItem | null
+): QuantityConstraints => {
+	if ( ! product ) {
+		return { min: 1, max: Number.MAX_SAFE_INTEGER, step: 1 };
+	}
+
+	const addToCart = product.add_to_cart;
+	const maximum = addToCart?.maximum ?? 0;
+
+	return {
+		min: addToCart?.minimum ?? 1,
+		max: maximum > 0 ? maximum : Number.MAX_SAFE_INTEGER,
+		step: addToCart?.multiple_of ?? 1,
+	};
+};
+
 /**
  * Manually dispatches a 'change' event on the quantity input element.
  *
@@ -70,37 +102,29 @@ const { state: productDataState } = store< ProductDataStore >(
 	{ lock: universalLock }
 );

-const { state: productsStoreState } = store( 'woocommerce/products' );
+const { state: productsState } = store< ProductsStore >(
+	'woocommerce/products',
+	{},
+	{ lock: universalLock }
+);

 /**
- * Helper to extract quantity constraints from product data.
- * Supports both REST API format (new shared store) and config format (legacy).
+ * Normalize a Store API product into the format expected by consumers.
  *
- * @param product Product data in either format
- * @return Quantity constraints
+ * @param product The product in Store API format.
+ * @return Normalized product data.
  */
-const getQuantityConstraints = ( product: ProductResponseItem ) => {
-	// New format (REST API from shared store)
-	if ( product.add_to_cart ) {
-		const maximum = product.add_to_cart.maximum ?? Number.MAX_SAFE_INTEGER;
-		return {
-			min: product.add_to_cart.minimum ?? 1,
-			max: maximum > 0 ? maximum : Number.MAX_SAFE_INTEGER,
-			step: product.add_to_cart.multiple_of ?? 1,
-		};
-	}
+const normalizeProductFromStore = (
+	product: ProductResponseItem
+): NormalizedProductData | NormalizedVariationData => {
+	const constraints = getQuantityConstraints( product );

-	// Old format (config)
 	return {
-		min: typeof product.min === 'number' ? product.min : 1,
-		max:
-			typeof product.max === 'number'
-				? Math.max( product.max, 0 )
-				: Number.MAX_SAFE_INTEGER,
-		step:
-			typeof product.step === 'number' && product.step > 0
-				? product.step
-				: 1,
+		id: product.id,
+		type: product.type,
+		is_in_stock: product.is_purchasable && product.is_in_stock,
+		sold_individually: product.sold_individually,
+		...constraints,
 	};
 };

@@ -108,64 +132,35 @@ export const getProductData = (
 	id: number,
 	selectedAttributes: SelectedAttributes[]
 ): NormalizedProductData | NormalizedVariationData | null => {
-	// Try to get product from the new shared store first (for simple products)
-	try {
-		if ( productsStoreState?.products?.[ id ] ) {
-			const productFromStore = productsStoreState.products[ id ];
-
-			// Return REST API format directly with normalized constraints
-			const constraints = getQuantityConstraints( productFromStore );
-
-			return {
-				id,
-				type: productFromStore.type,
-				is_in_stock:
-					productFromStore.is_purchasable &&
-					productFromStore.is_in_stock,
-				sold_individually: productFromStore.sold_individually,
-				...constraints,
-			};
-		}
-	} catch ( error ) {
-		// If the store doesn't exist or there's an error, fall through to config
-	}
+	const productFromStore = productsState.products[ id ];

-	// Fall back to existing config approach for variable/grouped products
-	const { products } = getConfig( 'woocommerce' ) as WooCommerceConfig;
-
-	if ( ! products || ! products[ id ] ) {
+	if ( ! productFromStore ) {
 		return null;
 	}

-	let product = {
-		id,
-		...products[ id ],
-	} as ProductData & { id: number };
-
+	// For variable products with selected attributes, find the matching variation.
 	if (
-		product.type === 'variable' &&
-		selectedAttributes &&
-		selectedAttributes.length > 0
+		productFromStore.type === 'variable' &&
+		selectedAttributes?.length > 0
 	) {
-		const matchedVariation = getMatchedVariation(
-			product.variations,
+		const matchedVariation = findMatchingVariation(
+			productFromStore,
 			selectedAttributes
 		);
+
 		if ( matchedVariation ) {
-			product = {
-				...matchedVariation,
-				id: matchedVariation.variation_id,
-				type: 'variation',
-			};
+			const variation =
+				productsState.productVariations[ matchedVariation.id ];
+			if ( variation ) {
+				return normalizeProductFromStore( variation );
+			}
+			// Variation was matched but its data isn't in the store.
+			// Return null to prevent using stale parent product data.
+			return null;
 		}
 	}

-	const constraints = getQuantityConstraints( product );
-
-	return {
-		...product,
-		...constraints,
-	};
+	return normalizeProductFromStore( productFromStore );
 };

 export const getNewQuantity = (
@@ -240,6 +235,13 @@ const { actions, state } = store<
 			get allowsAddingToCart(): boolean {
 				const { productData } = state;

+				// For grouped products, the button should always be visible.
+				// Its enabled/disabled state is controlled by isFormValid which
+				// checks whether any child products are selected.
+				if ( productData?.type === 'grouped' ) {
+					return true;
+				}
+
 				return productData?.is_in_stock ?? true;
 			},
 			get quantity(): Record< number, number > {
@@ -297,14 +299,14 @@ const { actions, state } = store<
 						'woocommerce/add-to-cart-with-options-quantity-selector'
 					);
 				const inputElement = quantitySelectorContext?.inputElement;
-				const { products } = getConfig(
-					'woocommerce'
-				) as WooCommerceConfig;
-				const variations = products?.[ productId ].variations;
 				const isValueNaN = Number.isNaN( inputElement?.valueAsNumber );

-				if ( variations ) {
-					const variationIds = Object.keys( variations );
+				// Get variations from the products store.
+				const productFromStore = productsState.products[ productId ];
+				const variationIds =
+					productFromStore?.variations?.map( ( v ) => v.id ) ?? [];
+
+				if ( variationIds.length > 0 ) {
 					// Set the quantity for all variations, so when switching
 					// variations the quantity persists.
 					const idsToUpdate = [ productId, ...variationIds ];
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
index 7b155e8de9..069a35bcb2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
@@ -7,12 +7,12 @@ import {
 	getConfig,
 	getElement,
 } from '@wordpress/interactivity';
-import {
-	SelectedAttributes,
-	VariationData,
-} from '@woocommerce/stores/woocommerce/cart';
+import { SelectedAttributes } from '@woocommerce/stores/woocommerce/cart';
 import type { ChangeEvent } from 'react';
 import type { ProductDataStore } from '@woocommerce/stores/woocommerce/product-data';
+import '@woocommerce/stores/woocommerce/products';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
+import type { ProductResponseItem } from '@woocommerce/types';

 /**
  * Internal dependencies
@@ -22,8 +22,12 @@ import type {
 	AddToCartWithOptionsStore,
 	Context as AddToCartWithOptionsStoreContext,
 } from '../frontend';
-import type { NormalizedProductData } from '../types';
-import { getMatchedVariation } from '../../../base/utils/variations/get-matched-variation';
+import {
+	normalizeAttributeName,
+	attributeNamesMatch,
+	getVariationAttributeValue,
+	findMatchingVariation,
+} from '../../../base/utils/variations/attribute-matching';
 import setStyles from './set-styles';

 type Option = {
@@ -47,6 +51,18 @@ setStyles();
 const universalLock =
 	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';

+const { state: productDataState } = store< ProductDataStore >(
+	'woocommerce/product-data',
+	{},
+	{ lock: universalLock }
+);
+
+const { state: productsState } = store< ProductsStore >(
+	'woocommerce/products',
+	{},
+	{ lock: universalLock }
+);
+
 /**
  * Check if the attribute value is valid given the other selected attributes and
  * the available variations.
@@ -78,29 +94,31 @@ const isAttributeValueValid = ( {
 	// valid, that's why we subtract one from the total number of attributes to
 	// match.
 	const isCurrentAttributeSelected = selectedAttributes.some(
-		( selectedAttribute ) => selectedAttribute.attribute === attributeName
+		( selectedAttribute ) =>
+			attributeNamesMatch( selectedAttribute.attribute, attributeName )
 	);
 	const attributesToMatch = isCurrentAttributeSelected
 		? selectedAttributes.length - 1
 		: selectedAttributes.length;

-	const { products } = getConfig( 'woocommerce' );
+	const product = productsState.products[ productDataState.productId ];

-	if ( ! products || ! products[ productDataState.productId ] ) {
+	if ( ! product?.variations?.length ) {
 		return false;
 	}

-	const availableVariations = Object.values(
-		products[ productDataState.productId ].variations || {}
-	);
-
 	// Check if there is at least one available variation matching the current
 	// selected attributes and the attribute value being checked.
-	return availableVariations.some( ( availableVariation ) => {
+	return product.variations.some( ( variation ) => {
+		const variationAttrValue = getVariationAttributeValue(
+			variation,
+			attributeName
+		);
+
 		// Skip variations that don't match the current attribute value.
 		if (
-			availableVariation.attributes[ attributeName ] !== attributeValue &&
-			availableVariation.attributes[ attributeName ] !== '' // "" is used for "any".
+			variationAttrValue !== attributeValue &&
+			variationAttrValue !== '' // "" is used for "any".
 		) {
 			return false;
 		}
@@ -109,9 +127,10 @@ const isAttributeValueValid = ( {
 		const matchingAttributes = selectedAttributes.filter(
 			( selectedAttribute ) => {
 				const availableVariationAttributeValue =
-					availableVariation.attributes[
+					getVariationAttributeValue(
+						variation,
 						selectedAttribute.attribute
-					];
+					);
 				// If the current available variation matches the selected
 				// value, count it.
 				if (
@@ -125,7 +144,10 @@ const isAttributeValueValid = ( {
 				// selection.
 				if ( availableVariationAttributeValue === '' ) {
 					if (
-						selectedAttribute.attribute !== attributeName ||
+						! attributeNamesMatch(
+							selectedAttribute.attribute,
+							attributeName
+						) ||
 						attributeValue === selectedAttribute.value
 					) {
 						return true;
@@ -140,32 +162,31 @@ const isAttributeValueValid = ( {
 };

 /**
- * Return the product attributes and options.
+ * Return the product attributes and options from Store API format.
+ *
+ * @param product The product in Store API format.
+ * @return Record of attribute names to their available option values.
  */
 const getProductAttributesAndOptions = (
-	productObject: NormalizedProductData | null
+	product: ProductResponseItem | null
 ): Record< string, string[] > => {
-	if ( ! productObject?.variations ) {
+	if ( ! product?.variations?.length ) {
 		return {};
 	}

-	const variations: VariationData[] = Object.values(
-		productObject.variations
-	);
 	const productAttributesAndOptions = {} as Record< string, string[] >;
-	variations.forEach( ( variation: VariationData ) => {
-		if ( ! variation?.attributes ) {
-			return;
-		}
-		Object.entries( variation.attributes ).forEach( ( [ key, value ] ) => {
-			if ( typeof key !== 'string' || typeof value !== 'string' ) {
-				return;
-			}
-			if ( ! Array.isArray( productAttributesAndOptions[ key ] ) ) {
-				productAttributesAndOptions[ key ] = [];
+	product.variations.forEach( ( variation ) => {
+		variation.attributes.forEach( ( attr ) => {
+			if ( ! Array.isArray( productAttributesAndOptions[ attr.name ] ) ) {
+				productAttributesAndOptions[ attr.name ] = [];
 			}
-			if ( ! productAttributesAndOptions[ key ].includes( value ) ) {
-				productAttributesAndOptions[ key ].push( value );
+			if (
+				attr.value &&
+				! productAttributesAndOptions[ attr.name ].includes(
+					attr.value
+				)
+			) {
+				productAttributesAndOptions[ attr.name ].push( attr.value );
 			}
 		} );
 	} );
@@ -200,12 +221,6 @@ export type VariableProductAddToCartWithOptionsStore =
 		};
 	};

-const { state: productDataState } = store< ProductDataStore >(
-	'woocommerce/product-data',
-	{},
-	{ lock: universalLock }
-);
-
 const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 	'woocommerce/add-to-cart-with-options',
 	{
@@ -223,7 +238,7 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(

 				return selectedAttributes.some( ( attrObject ) => {
 					return (
-						attrObject.attribute === name &&
+						attributeNamesMatch( attrObject.attribute, name ) &&
 						attrObject.value === option.value
 					);
 				} );
@@ -248,7 +263,10 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 				const { selectedAttributes } = getContext< Context >();
 				const index = selectedAttributes.findIndex(
 					( selectedAttribute ) =>
-						selectedAttribute.attribute === attribute
+						attributeNamesMatch(
+							selectedAttribute.attribute,
+							attribute
+						)
 				);

 				if ( value === '' ) {
@@ -274,7 +292,10 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 				const { selectedAttributes } = getContext< Context >();
 				const index = selectedAttributes.findIndex(
 					( selectedAttribute ) =>
-						selectedAttribute.attribute === attribute
+						attributeNamesMatch(
+							selectedAttribute.attribute,
+							attribute
+						)
 				);
 				if ( index >= 0 ) {
 					selectedAttributes.splice( index, 1 );
@@ -319,24 +340,35 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 					return;
 				}

-				const productObject: NormalizedProductData | null =
-					getProductData( productDataState.productId, [] );
-				if ( ! productObject ) {
+				const product =
+					productsState.products[ productDataState.productId ];
+				if ( ! product ) {
 					return;
 				}
+
+				// Normalize included/excluded attributes to lowercase for comparison
+				// with Store API labels (e.g., "Color" vs "attribute_pa_color" → "color").
+				const normalizedIncluded = includedAttributes.map( ( attr ) =>
+					normalizeAttributeName( attr ).toLowerCase()
+				);
+				const normalizedExcluded = excludedAttributes.map( ( attr ) =>
+					normalizeAttributeName( attr ).toLowerCase()
+				);
+
 				const productAttributesAndOptions: Record< string, string[] > =
-					getProductAttributesAndOptions( productObject );
+					getProductAttributesAndOptions( product );
 				Object.entries( productAttributesAndOptions ).forEach(
 					( [ attribute, options ] ) => {
+						const attributeLower = attribute.toLowerCase();
 						if (
-							includedAttributes.length !== 0 &&
-							! includedAttributes.includes( attribute )
+							normalizedIncluded.length !== 0 &&
+							! normalizedIncluded.includes( attributeLower )
 						) {
 							return;
 						}
 						if (
-							excludedAttributes.length !== 0 &&
-							excludedAttributes.includes( attribute )
+							normalizedExcluded.length !== 0 &&
+							normalizedExcluded.includes( attributeLower )
 						) {
 							return;
 						}
@@ -349,7 +381,16 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 						);
 						if ( validOptions.length === 1 ) {
 							const validOption = validOptions[ 0 ];
-							actions.setAttribute( attribute, validOption );
+							// Use the context's attribute name format for consistency.
+							// Find the matching context name by comparing normalized versions.
+							const contextName =
+								includedAttributes.find(
+									( attr ) =>
+										normalizeAttributeName(
+											attr
+										).toLowerCase() === attributeLower
+								) || attribute;
+							actions.setAttribute( contextName, validOption );
 						}
 					}
 				);
@@ -367,15 +408,16 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 				} );
 			},
 			setSelectedVariationId: () => {
-				const { products } = getConfig( 'woocommerce' );
+				const product =
+					productsState.products[ productDataState.productId ];

-				const variations =
-					products?.[ productDataState.productId ].variations;
+				if ( ! product?.variations?.length ) {
+					return;
+				}

 				const { selectedAttributes } = getContext< Context >();
-
-				const matchedVariation = getMatchedVariation(
-					variations,
+				const matchedVariation = findMatchingVariation(
+					product,
 					selectedAttributes
 				);

@@ -385,32 +427,29 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 						{},
 						{ lock: universalLock }
 					);
-				const matchedVariationId =
-					matchedVariation?.variation_id || null;
-				productDataActions.setVariationId( matchedVariationId );
+				productDataActions.setVariationId(
+					matchedVariation?.id ?? null
+				);
 			},
 			validateVariation() {
 				actions.clearErrors( 'variable-product' );

-				const { products } = getConfig( 'woocommerce' );
+				const product =
+					productsState.products[ productDataState.productId ];

-				if ( ! products || ! products[ productDataState.productId ] ) {
+				if ( ! product?.variations?.length ) {
 					return;
 				}

-				const variations =
-					products[ productDataState.productId ].variations;
-
 				const { selectedAttributes } = getContext< Context >();
-
-				const matchedVariation = getMatchedVariation(
-					variations,
+				const matchedVariation = findMatchingVariation(
+					product,
 					selectedAttributes
 				);

 				const { errorMessages } = getConfig();

-				if ( ! matchedVariation?.variation_id ) {
+				if ( ! matchedVariation?.id ) {
 					actions.addError( {
 						code: 'variableProductMissingAttributes',
 						message:
@@ -421,7 +460,18 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 					return;
 				}

-				if ( ! matchedVariation?.is_in_stock ) {
+				// Check stock status from productVariations store.
+				const variationData =
+					productsState.productVariations[ matchedVariation.id ];
+
+				if ( ! variationData ) {
+					// Variation data not loaded - this is a data consistency issue.
+					// Return early; getProductData already returns null for this case,
+					// which prevents add-to-cart from proceeding.
+					return;
+				}
+
+				if ( ! variationData.is_in_stock ) {
 					actions.addError( {
 						code: 'variableProductOutOfStock',
 						message: errorMessages?.variableProductOutOfStock || '',
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
index 384bca96fb..a95c9e31f8 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
@@ -45,6 +45,8 @@ const entries = {
 		'./assets/js/base/stores/store-notices.ts',
 	'@woocommerce/stores/woocommerce/product-data':
 		'./assets/js/base/stores/woocommerce/product-data.ts',
+	'@woocommerce/stores/woocommerce/products':
+		'./assets/js/base/stores/woocommerce/products.ts',
 };

 module.exports = {
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
index 2e53309bb4..b0cbe70f88 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
@@ -295,7 +295,7 @@ test.describe( 'Add to Cart + Options Block', () => {
 			isOnlyCurrentEntityDirty: true,
 		} );

-		await page.goto( '/logo-collection' );
+		await page.goto( '/product/logo-collection' );

 		const addToCartButton = page
 			.getByRole( 'button', { name: 'Add to cart' } )
@@ -1442,5 +1442,66 @@ test.describe( 'Add to Cart + Options Block', () => {
 				}
 			} );
 		}
+
+		test( `Pills: "X in cart" text displays correctly after auto-selection`, async ( {
+			page,
+			pageObject,
+			editor,
+		} ) => {
+			await pageObject.updateSingleProductTemplate();
+			await setAddToCartWithOptionsBlockAttributes( pageObject, editor, {
+				optionStyle: 'Pills',
+				autoselect: true,
+			} );
+
+			await test.step( 'Add the Blue/XL variation to cart', async () => {
+				await page.goto( productPermalink );
+
+				// Select Blue and XL to match the T-shirt, Blue, XL variation
+				await pageObject.selectVariationSelectorOptionsBlockAttribute(
+					'Color',
+					'Blue',
+					'Pills'
+				);
+
+				// Type and Size should auto-select to T-shirt and XL
+				await pageObject.expectSelectedAttributes(
+					productAttributes,
+					{ Type: 'T-shirt', Color: 'Blue', Size: 'XL' },
+					'Pills'
+				);
+
+				// Add to cart
+				const addToCartButton = page
+					.locator( '.wp-block-add-to-cart-with-options' )
+					.getByRole( 'button', { name: 'Add to cart' } );
+				await addToCartButton.click();
+
+				// Wait for the item to be added
+				await expect( page.getByText( '1 in cart' ) ).toBeVisible();
+			} );
+
+			await test.step( 'Verify "X in cart" displays after auto-selection on fresh page load', async () => {
+				// Reload the page to start fresh
+				await page.goto( productPermalink );
+
+				// Initially, only Type should be auto-selected (it's the only single option)
+				// The "1 in cart" text should NOT be visible yet because we haven't
+				// selected the Blue/XL variation
+				await expect( page.getByText( '1 in cart' ) ).toBeHidden();
+
+				// Now select Blue - this should auto-select Size to XL
+				// (since Blue only has one valid size: XL)
+				await pageObject.selectVariationSelectorOptionsBlockAttribute(
+					'Color',
+					'Blue',
+					'Pills'
+				);
+
+				// After auto-selection completes, the button should show "1 in cart"
+				// because we now have the same variation (T-shirt, Blue, XL) selected
+				await expect( page.getByText( '1 in cart' ) ).toBeVisible();
+			} );
+		} );
 	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/tsconfig.base.json b/plugins/woocommerce/client/blocks/tsconfig.base.json
index bcc3aff6ef..c488a1b448 100644
--- a/plugins/woocommerce/client/blocks/tsconfig.base.json
+++ b/plugins/woocommerce/client/blocks/tsconfig.base.json
@@ -124,6 +124,9 @@
 			"@woocommerce/stores/woocommerce/product-data": [
 				"assets/js/base/stores/woocommerce/product-data"
 			],
+			"@woocommerce/stores/woocommerce/products": [
+				"assets/js/base/stores/woocommerce/products"
+			],
 			"@woocommerce/type-defs/*": [
 				"assets/js/types/type-defs/*"
 			],
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index bd51a4b02d..86c3d4f138 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -57087,24 +57087,12 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/AddToCartForm.php

-		-
-			message: '#^Call to an undefined method WC_Product\:\:get_available_variations\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
-
 		-
 			message: '#^Call to an undefined method WC_Product\:\:get_variation_attributes\(\)\.$#'
 			identifier: method.notFound
 			count: 1
 			path: src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartWithOptions\\AddToCartWithOptions\:\:enqueue_data\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartWithOptions\\AddToCartWithOptions\:\:render_interactivity_notices_region\(\) should return string but returns string\|false\.$#'
 			identifier: return.type
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
index 51114d64c1..e1eabf047f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
@@ -82,8 +82,9 @@ class AddToCartWithOptions extends AbstractBlock {
 	 * @param array $attributes  Any attributes that currently are available from the block.
 	 *                           Note, this will be empty in the editor context when the block is
 	 *                           not in the post content on editor load.
+	 * @return void
 	 */
-	protected function enqueue_data( array $attributes = array() ) {
+	protected function enqueue_data( array $attributes = array() ): void {
 		parent::enqueue_data( $attributes );

 		if ( is_admin() ) {
@@ -150,26 +151,6 @@ class AddToCartWithOptions extends AbstractBlock {
 		return false;
 	}

-	/**
-	 * Check if a child product is purchasable.
-	 *
-	 * @param \WC_Product $product The product to check.
-	 * @return bool True if the product is purchasable, false otherwise.
-	 */
-	private function is_child_product_purchasable( \WC_Product $product ) {
-		// Skip variable products.
-		if ( $product->is_type( ProductType::VARIABLE ) ) {
-			return false;
-		}
-
-		// Skip grouped products.
-		if ( $product->is_type( ProductType::GROUPED ) ) {
-			return false;
-		}
-
-		return $product->is_purchasable() && $product->is_in_stock();
-	}
-
 	/**
 	 * Render the block.
 	 *
@@ -280,28 +261,11 @@ class AddToCartWithOptions extends AbstractBlock {
 				)
 			);

-			// Load product data based on product type.
-			if ( ProductType::SIMPLE === $product_type ) {
-				// Simple products use the new shared store with full REST API data.
-				wc_interactivity_api_load_product(
-					'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce',
-					$product->get_id()
-				);
-			} else {
-				// Variable/grouped products continue using config.
-				wp_interactivity_config(
-					'woocommerce',
-					array(
-						'products' => array(
-							$product->get_id() => array(
-								'type'              => $product->get_type(),
-								'is_in_stock'       => $product->is_in_stock(),
-								'sold_individually' => $product->is_sold_individually(),
-							),
-						),
-					)
-				);
-			}
+			// Load product into the shared store with full REST API data.
+			wc_interactivity_api_load_product(
+				'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce',
+				$product->get_id()
+			);

 			$context = array(
 				'quantity'         => array( $product->get_id() => $default_quantity ),
@@ -309,34 +273,21 @@ class AddToCartWithOptions extends AbstractBlock {
 			);

 			if ( $product->is_type( ProductType::VARIABLE ) ) {
-				$variations_data               = array();
 				$context['selectedAttributes'] = array();
-				$available_variations          = $product->get_available_variations( 'objects' );
-				foreach ( $available_variations as $variation ) {
-					// We intentionally set the default quantity to the product's min purchase quantity
-					// instead of the variation's min purchase quantity. That's because we use the same
-					// input for all variations, so we want quantities to be in sync.
-					$context['quantity'][ $variation->get_id() ] = $default_quantity;
-
-					$variation_data = array(
-						'attributes'        => $variation->get_variation_attributes(),
-						'is_in_stock'       => $variation->is_in_stock(),
-						'sold_individually' => $variation->is_sold_individually(),
-					);
-
-					$variations_data[ $variation->get_id() ] = $variation_data;
-				}

-				wp_interactivity_config(
-					'woocommerce',
-					array(
-						'products' => array(
-							$product->get_id() => array(
-								'variations' => $variations_data,
-							),
-						),
-					)
+				// Load all variations into the shared store with full REST API data.
+				$variations = wc_interactivity_api_load_variations(
+					'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce',
+					$product->get_id()
 				);
+
+				// Set up quantity context for each variation.
+				// We intentionally set the default quantity to the product's min purchase quantity
+				// instead of the variation's min purchase quantity. That's because we use the same
+				// input for all variations, so we want quantities to be in sync.
+				foreach ( array_keys( $variations ) as $variation_id ) {
+					$context['quantity'][ $variation_id ] = $default_quantity;
+				}
 			} elseif ( $product->is_type( ProductType::VARIATION ) ) {
 				$variation_attributes = $product->get_variation_attributes();
 				$formatted_attributes = array_map(
@@ -352,32 +303,14 @@ class AddToCartWithOptions extends AbstractBlock {

 				$context['selectedAttributes'] = $formatted_attributes;
 			} elseif ( $product->is_type( ProductType::GROUPED ) ) {
-				// Add context for purchasable child products.
-				$children_product_data = array();
-				foreach ( $product->get_children() as $child_product_id ) {
-					$child_product = wc_get_product( $child_product_id );
-					if ( $child_product && $this->is_child_product_purchasable( $child_product ) ) {
-						$child_product_quantity_constraints = Utils::get_product_quantity_constraints( $child_product );
-
-						$children_product_data[ $child_product_id ] = array(
-							'min'               => $child_product_quantity_constraints['min'],
-							'max'               => $child_product_quantity_constraints['max'],
-							'step'              => $child_product_quantity_constraints['step'],
-							'type'              => $child_product->get_type(),
-							'is_in_stock'       => $child_product->is_in_stock(),
-							'sold_individually' => $child_product->is_sold_individually(),
-						);
-					}
-				}
-
-				$context['groupedProductIds'] = array_keys( $children_product_data );
-				wp_interactivity_config(
-					'woocommerce',
-					array(
-						'products' => $children_product_data,
-					)
+				// Load purchasable child products into the shared store with full REST API data.
+				$child_products = wc_interactivity_api_load_purchasable_child_products(
+					'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce',
+					$product->get_id()
 				);

+				$context['groupedProductIds'] = array_keys( $child_products );
+
 				// Add quantity context for purchasable child products.
 				$context['quantity'] = array_fill_keys(
 					$context['groupedProductIds'],
@@ -385,18 +318,14 @@ class AddToCartWithOptions extends AbstractBlock {
 				);

 				// Set default quantity for each child product.
-				foreach ( $context['groupedProductIds'] as $child_product_id ) {
-					$child_product = wc_get_product( $child_product_id );
-					if ( $child_product ) {
+				foreach ( $child_products as $child_product_id => $child_product_data ) {
+					$default_child_quantity = isset( $_POST['quantity'][ $child_product_id ] ) ? wc_stock_amount( wc_clean( wp_unslash( $_POST['quantity'][ $child_product_id ] ) ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing

-						$default_child_quantity = isset( $_POST['quantity'][ $child_product->get_id() ] ) ? wc_stock_amount( wc_clean( wp_unslash( $_POST['quantity'][ $child_product->get_id() ] ) ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
+					$context['quantity'][ $child_product_id ] = $default_child_quantity;

-						$context['quantity'][ $child_product_id ] = $default_child_quantity;
-
-						// Check for any "sold individually" products and set their default quantity to 0.
-						if ( $child_product->is_sold_individually() ) {
-							$context['quantity'][ $child_product_id ] = 0;
-						}
+					// Check for any "sold individually" products and set their default quantity to 0.
+					if ( $child_product_data['sold_individually'] ) {
+						$context['quantity'][ $child_product_id ] = 0;
 					}
 				}
 			}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeOptions.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeOptions.php
index fd637c3ba9..b27a5fafa6 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeOptions.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeOptions.php
@@ -169,7 +169,6 @@ class VariationSelectorAttributeOptions extends AbstractBlock {
 						'data-wp-bind--checked'  => 'state.isOptionSelected',
 						'data-wp-bind--disabled' => 'state.isOptionDisabled',
 						'data-wp-bind--hidden'   => 'hide' === $disabled_attributes_action ? 'state.isOptionDisabled' : null,
-						'data-wp-watch'          => 'callbacks.watchSelected',
 						'data-wp-on--click'      => 'actions.handlePillClick',
 						'data-wp-on--keydown'    => 'actions.handleKeyDown',
 						'data-wp-context'        => array(
diff --git a/plugins/woocommerce/src/Blocks/SharedStores/ProductsStore.php b/plugins/woocommerce/src/Blocks/SharedStores/ProductsStore.php
index 528ce4ba77..c78a1255cf 100644
--- a/plugins/woocommerce/src/Blocks/SharedStores/ProductsStore.php
+++ b/plugins/woocommerce/src/Blocks/SharedStores/ProductsStore.php
@@ -115,7 +115,28 @@ class ProductsStore {
 	public static function load_purchasable_child_products( string $consent_statement, int $parent_id ): array {
 		self::check_consent( $consent_statement );

-		$response = Package::container()->get( Hydration::class )->get_rest_api_response_data( '/wc/store/v1/products?parent[]=' . $parent_id );
+		// Get the parent product to retrieve child IDs.
+		$parent_product = wc_get_product( $parent_id );
+		if ( ! $parent_product ) {
+			return array();
+		}
+
+		// Get child product IDs (for grouped products, these are linked products).
+		$child_ids = $parent_product->get_children();
+		if ( empty( $child_ids ) ) {
+			return array();
+		}
+
+		// Query child products using include[] filter.
+		// The parent[] filter doesn't work for grouped products because
+		// their children are standalone products, not variations.
+		$include_params = array_map(
+			fn( $id ) => 'include[]=' . $id,
+			$child_ids
+		);
+		$query_string   = implode( '&', $include_params );
+
+		$response = Package::container()->get( Hydration::class )->get_rest_api_response_data( '/wc/store/v1/products?' . $query_string );

 		if ( empty( $response['body'] ) ) {
 			return array();
@@ -128,8 +149,9 @@ class ProductsStore {
 		);

 		// Re-key array by product ID and merge into state.
+		// Use array_replace instead of array_merge to preserve numeric keys.
 		$keyed_products = array_column( $purchasable_products, null, 'id' );
-		self::$products = array_merge( self::$products, $keyed_products );
+		self::$products = array_replace( self::$products, $keyed_products );
 		self::register_state();

 		return $keyed_products;
@@ -153,8 +175,9 @@ class ProductsStore {
 		}

 		// Re-key array by variation ID and merge into state.
+		// Use array_replace instead of array_merge to preserve numeric keys.
 		$keyed_variations         = array_column( $response['body'], null, 'id' );
-		self::$product_variations = array_merge( self::$product_variations, $keyed_variations );
+		self::$product_variations = array_replace( self::$product_variations, $keyed_variations );
 		self::register_state();

 		return $keyed_variations;