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;