Commit 6322a72459a for woocommerce
commit 6322a72459ad1a5e6c1faa000e56b0e34baf5bfd
Author: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com>
Date: Fri Apr 24 23:09:18 2026 +0200
Refactor iAPI blocks to read product in context (#63662)
* Add woocommerce/product-context store alongside existing product-data
Introduce a new iAPI store that uses direct state hydration via
wp_interactivity_state() instead of reading from block context. This is
purely additive - the old product-data store remains untouched and all
existing consumers continue using it.
New store provides computed getters (product, variation, selectedProduct)
that look up full product data from the woocommerce/products store,
and actions (setProductId, setVariationId) for state mutations.
Server-side hydration is added in SingleProductTemplate and
AddToCartWithOptions alongside the existing product-data hydration.
* Add changefile(s) from automation for the following project(s): woocommerce
* Fix prettier formatting in product-context store files
* Remove ProductsStoreTest — will be added in a separate PR
* Add tearDown to reset ProductsStore static state between tests
* Rename product-context getters for clarity
- product → parentProduct (specifically the parent, for variation matching)
- variation → selectedVariation (the variation the shopper picked)
- selectedProduct → product (the resolved active product, most common use case)
* Rename product getter to currentProduct for clarity
* Fix grouped products batch update missing cart item key
The update-item endpoint requires `key` to identify the cart item, but
Preact's reactive proxy filters `key` from property enumeration since
it's a reserved prop. This caused JSON.stringify to omit the key when
serializing the proxy object as the request body, resulting in "Cart
item does not exist" errors when adding grouped products a second time
without reloading.
Explicitly construct the body with `key` and `quantity` instead of
passing the proxy object directly. Also adds an e2e test that exercises
the add-twice-without-reload scenario.
* Revert "Fix grouped products batch update missing cart item key"
This reverts commit 9d3919717fc917022653ffb7972e93dd35555ab9.
* Simplify product-context store API based on review feedback
Remove actions and old getters. The store now exposes plain writable
state (productId, variationId) and two computed getters (currentProduct,
parentProduct). Consumers write state directly instead of calling actions.
* Remove consent string test from ProductContextStoreTest
* Fix prettier formatting in product-context store and tests
* Rename currentProduct/parentProduct to product/selectedVariation
The previous getter names were confusing — parentProduct returned the
variable product (not intuitive) and currentProduct changed identity
depending on whether a variation was selected.
The new API is clearer:
- product: always the main product, never a variation
- selectedVariation: the variation if one is selected, null otherwise
Also renames test file to .test.ts for jest discovery.
* Remove non-existent ProductContextState re-export
* Pass empty object when referencing products store
The products store already initializes its own state in products.ts.
The store() call in product-context.ts is just grabbing a reference,
so re-declaring the initial state was unnecessary duplication.
* Simplify context productId resolution to use || instead of !== undefined
* Read variationId from block context with fallback to store state
Same pattern as productId — context takes precedence so each product
in a loop can have its own variation selection.
* Fix prettier formatting for productId and variationId assignments
* Fix formatting via prettier
* Remove ProductContextStore class, add derived state closures to SingleProductTemplate
Replace the ProductContextStore PHP class with inline derived state
closures in SingleProductTemplate. The closures mirror the JS getters
for state.product and state.selectedVariation so that directives
referencing these properties resolve during server-side rendering.
This also removes the global state side-effect from load_context,
which was problematic for nested product contexts. Nested blocks
should set data-wp-context directly instead.
* Keep product-data store hydrated alongside new product-context store
Existing consumers (add-to-cart-with-options, variation-selector) still
read from woocommerce/product-data templateState. Without it, productId
resolved to undefined, disabling all variation selectors and breaking
add-to-cart. The product-data hydration will be removed when consumers
are migrated to product-context in the follow-up PR.
* Use explicit namespace for getContext, return null consistently
Address review feedback:
- Pass 'woocommerce/single-product' namespace to getContext() calls so
the store reads context from the correct namespace (matching the
pattern in product-data.ts)
- Return null instead of undefined from the product getter for
consistency with the PHP closures and selectedVariation getter
* Adapt unit tests
* Adapt "Add to cart with Options" block to `product-context`
* Init "product-context" context in Add to Cart With Options
* Adapt "variation selector" block
* Improve context encode
* Adapt quantity selectors to `product-context`
* Move `getProduct` to `woocommerce/products` store
* Use `getProduct` in Grouped Product Selector
* Remove `getProductData` from Add to Cart With Options
* Move unit tests to `getProduct`
* Adapt Product Gallery to `product-context`
* Adapt product-elements to `product-context`
* Adapt Product Button block and Product Template
* Remove `woocommerce/product-data` initialization
* Rely on `state.selectedVariation.id`
* Modify `context` or `state` from Add to cart with options
* Fix logic context vs state
* Add `product-context` in SingleProduct block
* Remove usage of `config.products`
* Remove dead code
* Fix ProductSpecifications CSS classes
* Fix formatting
* Add back Product Gallery check for has_images
* Adapt unit tests to the context changes
* Remove min, max, step client-side normalization
* Load product in SingleProduct
* Fix quantity selector min/max/step
* Add back ProductPrice check
* Use lock in products store
* Fix context logic in the server
* Fix lint errors
* Use NaN instead of null to avoid TS errors
* Import product-context in product button
* Handle extra product context for GroupedProductItemSelector
* Enqueue add-to-cart-with-options for product variations
* Add changelog entry
* Add batching to load_variations in ProductsStore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Rename getProduct to findProductVariation in products store
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix PHPStan errors
* Implement `productInContext`
* Fix action types in add-to-cart-with-options frontend
* Merge `products` and `product-context`
* Move closures registration inside ProductStore
* Get rid of unnecessary `update_state`
* Fix `productInContext` closure
* Fix linting issues
* Add documentation for `products`
* Add missing tests
* Fixes documentation
* Fix lint:php errors
* Update patch description
* Remove product-data from README.md
* Pass product.type instead of context.productType
* Use productInContext inside handleQuantityCheckboxChange
* Add context only when product exists in make_quantity_input_interactive
* Hide variation description when it doesn't exist
* Prevent potential 'undefined array key' warnings
* Use WC_Unit_Test_Case and testdox tags in ProductStore
* Rename products store getters and reimplement findProduct
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Migrate block consumers to new products store surface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove findMatchingVariation from attribute-matching
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update tests for renamed products store surface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update products store README for new surface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix variation-selector to distinguish parent from matched variation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix lint error
* Fix lint warnings
* Address review feedback: fix context fallback, remove productType, update docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix README
---------
Co-authored-by: Sam Seay <samueljseay@gmail.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
Co-authored-by: David <david.arenas@automattic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/63662-refactor-iapi-blocks-to-read-product-in-context b/plugins/woocommerce/changelog/63662-refactor-iapi-blocks-to-read-product-in-context
new file mode 100644
index 00000000000..ece39c4db8f
--- /dev/null
+++ b/plugins/woocommerce/changelog/63662-refactor-iapi-blocks-to-read-product-in-context
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Migrate all interactive blocks from the old `woocommerce/product-data` store to the new `woocommerce/products` store.
diff --git a/plugins/woocommerce/client/blocks/.eslintrc.js b/plugins/woocommerce/client/blocks/.eslintrc.js
index c3df0236170..fcf80dc83cb 100644
--- a/plugins/woocommerce/client/blocks/.eslintrc.js
+++ b/plugins/woocommerce/client/blocks/.eslintrc.js
@@ -133,8 +133,7 @@ const coreModules = [
'@woocommerce/shared-hocs',
'@woocommerce/stores/store-notices',
'@woocommerce/stores/woocommerce/cart',
- '@woocommerce/stores/woocommerce/product-context',
- '@woocommerce/stores/woocommerce/product-data',
+ '@woocommerce/stores/woocommerce/products',
'@woocommerce/tracks',
'@woocommerce/data',
'@woocommerce/customer-effort-score',
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
index 922dab937fd..25e8e0ae32c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
@@ -2,8 +2,9 @@
* External dependencies
*/
import { store, getContext, useLayoutEffect } from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/products';
import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
-import type { ProductDataStore } from '@woocommerce/stores/woocommerce/product-data';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
/**
* Internal dependencies
@@ -19,8 +20,6 @@ const universalLock =
interface Context {
addToCartText: string;
- productId: number;
- productType: string;
groupedProductIds?: number[];
displayViewCart: boolean;
quantityToAdd: number;
@@ -56,8 +55,8 @@ const { state: addToCartWithOptionsState } = store< AddToCartWithOptionsStore >(
{ lock: universalLock }
);
-const { state: productDataState } = store< ProductDataStore >(
- 'woocommerce/product-data',
+const { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
{},
{ lock: universalLock }
);
@@ -65,12 +64,18 @@ const { state: productDataState } = store< ProductDataStore >(
const productButtonStore = {
state: {
get quantity(): number {
+ const product = productsState.productInContext;
+
+ if ( ! product ) {
+ return 0;
+ }
+
const formContext = getContext< AddToCartWithOptionsContext >(
'woocommerce/add-to-cart-with-options'
);
const item = wooState.findItemInCart( {
- id: state.productId,
+ id: product.id,
variation: formContext?.selectedAttributes,
} );
@@ -89,7 +94,6 @@ const productButtonStore = {
animationStatus,
tempQuantity,
addToCartText,
- productType,
groupedProductIds,
hasPressedButton,
inTheCartText,
@@ -104,7 +108,7 @@ const productButtonStore = {
? tempQuantity || 0
: state.quantity;
- if ( productType === 'grouped' ) {
+ if ( productsState.productInContext?.type === 'grouped' ) {
const groupedProductIdsInCart = groupedProductIds?.map(
( productId ) => {
const product = wooState.findItemInCart( {
@@ -133,20 +137,14 @@ const productButtonStore = {
if ( ! displayViewCart ) return false;
return state.quantity > 0;
},
- get productId() {
- const { productId } = getContext< Context >();
-
- const isDescendantOfAddToCartWithOptions =
- productId === productDataState?.productId;
-
- return isDescendantOfAddToCartWithOptions
- ? productDataState?.variationId || productId
- : productId;
- },
},
actions: {
*addCartItem(): Generator< unknown, void > {
- const context = getContext< Context >();
+ const product = productsState.productInContext;
+
+ if ( ! product ) {
+ return;
+ }
// Todo: Use the module exports instead of `store()` once the
// woocommerce store is public.
@@ -158,13 +156,15 @@ const productButtonStore = {
{ lock: universalLock }
);
+ const context = getContext< Context >();
+
// Pass quantityToAdd as a delta. The cart store will add this
// to the current quantity, ensuring rapid clicks compound correctly.
yield actions.addCartItem(
{
- id: state.productId,
+ id: product.id,
quantityToAdd: context.quantityToAdd,
- type: context.productType,
+ type: product.type,
},
{
showCartUpdatesNotices: false,
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/frontend.ts
index 62277559400..44c31d5f0fc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/frontend.ts
@@ -1,26 +1,18 @@
/**
* External dependencies
*/
-import {
- getElement,
- store,
- getContext,
- getConfig,
-} from '@wordpress/interactivity';
-import '@woocommerce/stores/woocommerce/product-data';
-import type { ProductDataStore } from '@woocommerce/stores/woocommerce/product-data';
-import type {
- ProductData,
- WooCommerceConfig,
-} from '@woocommerce/stores/woocommerce/cart';
+import { getElement, store, getContext } from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/products';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
+import type { ProductResponseItem } from '@woocommerce/types';
import { sanitizeHTML } from '@woocommerce/sanitize';
// 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.';
-const { state: productDataState } = store< ProductDataStore >(
- 'woocommerce/product-data',
+const { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
{},
{ lock: universalLock }
);
@@ -48,53 +40,25 @@ const ALLOWED_ATTR = [
'aria-hidden',
];
-export type Context = {
- productElementKey:
- | 'price_html'
- | 'availability'
- | 'sku'
- | 'weight'
- | 'dimensions';
+type Context = {
+ productElementKey: keyof ProductResponseItem;
};
-const productElementStore = store(
+store(
'woocommerce/product-elements',
{
- state: {
- get productData(): ProductData | undefined {
- if ( ! productDataState?.productId ) {
- return undefined;
- }
-
- const { products } = getConfig(
- 'woocommerce'
- ) as WooCommerceConfig;
-
- if ( ! products ) {
- return undefined;
- }
-
- return (
- products?.[ productDataState.productId ]?.variations?.[
- productDataState?.variationId || 0
- ] || products?.[ productDataState.productId ]
- );
- },
- },
callbacks: {
updateValue: () => {
const element = getElement();
+ const product = productsState.productInContext;
- if ( ! element.ref || ! productDataState?.productId ) {
+ if ( ! element.ref || ! product ) {
return;
}
const { productElementKey } = getContext< Context >();
- const productElementHtml =
- productElementStore?.state?.productData?.[
- productElementKey
- ];
+ const productElementHtml = product[ productElementKey ];
if ( typeof productElementHtml === 'string' ) {
element.ref.innerHTML = sanitizeHTML( productElementHtml, {
@@ -107,5 +71,3 @@ const productElementStore = store(
},
{ lock: true }
);
-
-export type ProductElementStore = typeof productElementStore;
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md
new file mode 100644
index 00000000000..02d752b0700
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md
@@ -0,0 +1,234 @@
+# WooCommerce Interactivity API stores
+
+This folder contains the Interactivity API (iAPI) stores that WooCommerce blocks use on the frontend. All stores here are **locked** (`lock: true`) and private by design — they are not intended for third-party extension, and removing or changing their state is **not** a breaking change. See the "Interactivity API Stores" section in `client/blocks/CLAUDE.md` and the [WordPress Private Stores reference](https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/api-reference#private-stores).
+
+Stores in this folder:
+
+- [`woocommerce/products`](#woocommerceproducts-store) — server-populated cache of product and variation data in Store API format.
+- `woocommerce/cart` — cart state and actions (with mutation batching for performance).
+
+---
+
+## `woocommerce/products` store
+
+A locked, server-populated iAPI store that exposes WooCommerce products and variations in Store API format (`ProductResponseItem`) to interactive blocks. PHP loaders populate the raw data during render; JS and PHP derived getters expose the "current" product for the surrounding context so that directives like `data-wp-text="state.productInContext.sku"` resolve correctly on both the server (SSR) and the client.
+
+**Source files:**
+
+- JS: `plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/products.ts`
+- PHP: `plugins/woocommerce/src/Blocks/SharedStores/ProductsStore.php`
+- PHP procedural wrappers: `plugins/woocommerce/includes/wc-interactivity-api-functions.php`
+- Behavioral tests: `plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/products.test.ts`
+
+### When to use it
+
+Use this store when an interactive block needs to read product fields (price, SKU, stock, images, attributes, …) inside a directive, and the surrounding markup implies a single "current" product — a single product page, a product in a product-collection loop, a grouped-product child, a variation inside a variable product, etc.
+
+### Architecture at a glance
+
+```text
+PHP Client
+┌───────────────────────────────────┐ ┌───────────────────────────────────┐
+│ ProductsStore::load_product() │ │ store<ProductsStore>( │
+│ ProductsStore::load_variations() │ populates │ 'woocommerce/products' │
+│ ProductsStore::load_purchasable_ │──────────────▶│ ) │
+│ child_products() │ │ │
+└────────────┬──────────────────────┘ │ state.products │
+ │ │ state.productVariations │
+ ▼ │ │
+ wp_interactivity_state( │ Derived getters: │
+ 'woocommerce/products', │ • state.mainProductInContext │
+ [ 'products' => ..., ... ] │ • state.productVariationInContext │
+ ) │ • state.productInContext │
+ └─────────────────┬─────────────────┘
+ │
+ Selection (one of): ▼
+ • Global: wp_interactivity_state(..., [ Directives bound in markup:
+ 'productId' => N, 'variationId' => null data-wp-interactive="woocommerce/products"
+ ]) data-wp-text="state.productInContext.sku"
+ • Local context: data-wp-context=
+ 'woocommerce/products::{"productId":N}'
+```
+
+Two planes:
+
+1. **Raw data** — `state.products` and `state.productVariations`, both keyed by ID. Populated from PHP.
+2. **Selection** — `state.productId` / `state.variationId` identify the "current" product/variation. Can be set globally via `wp_interactivity_state`, or via local context with `data-wp-context`. **Local context takes precedence over global state.**
+
+Derived getters mirror each other in JS (`products.ts`) and PHP (`ProductsStore::register_getters`) so that directive bindings resolve during SSR as well as on the client.
+
+### State reference
+
+| Property | Type | Origin | Notes |
+| --------------------------- | ------------------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `products` | `Record<number, ProductResponseItem>` | Populated from PHP | Keyed by product ID. |
+| `productVariations` | `Record<number, ProductResponseItem>` | Populated from PHP | Keyed by variation ID. |
+| `productId` | `number` | Populated / local context | Current product ID. |
+| `variationId` | `number \| null` | Populated / local context | Current variation ID, or `null`. |
+| `mainProductInContext` | `ProductResponseItem \| null` | Derived | The top-level product for the current context. Always the parent product, **never** a variation. |
+| `productVariationInContext` | `ProductResponseItem \| null` | Derived | Currently selected variation, or `null` for simple/grouped/non-selected. |
+| `productInContext` | `ProductResponseItem \| null` | Derived | `productVariationInContext ?? mainProductInContext`. Bind to this in the common case. |
+| `findProduct` | `({ id, selectedAttributes }) => ProductResponseItem \| null` | Function | If `id` is a variation ID, returns it directly. For variable products with `selectedAttributes`, resolves to the matching variation. For any other product type (simple, grouped, external, etc.), returns the product as-is. |
+
+### Populating state (PHP)
+
+All loaders require a consent statement (they are experimental APIs). The literal to pass is:
+
+```php
+'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce'
+```
+
+Loaders are idempotent — calling them multiple times for the same ID (or the same variation parent) is cheap.
+
+#### Load a single product
+
+Use `wc_interactivity_api_load_product( $consent, $product_id )`.
+
+From `SingleProduct` block (`src/Blocks/BlockTypes/SingleProduct.php`):
+
+```php
+// Load product into the shared products store.
+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()
+);
+```
+
+#### Load purchasable child products of a grouped product
+
+Use `wc_interactivity_api_load_purchasable_child_products( $consent, $parent_id )`. This uses the Store API `include[]` filter (not `parent[]`) because grouped-product children are standalone products, not variations. Only products whose `is_purchasable` is `true` are added to state.
+
+#### Load variations of a variable product
+
+Use `wc_interactivity_api_load_variations( $consent, $parent_id )`. This fetches `/wc/store/v1/products?parent[]=<id>&type=variation` and populates `state.productVariations`, keyed by variation ID. Variations for a given parent are only loaded once per request.
+
+### Setting the "current" product
+
+There are two ways to point the store at a specific product. **Local context always wins over global state.** Choose based on how the consuming block is rendered.
+
+#### Globally (template-level)
+
+Set `productId` / `variationId` on the store once for the page. Used when there is exactly one product on the page — e.g. the single product template.
+
+From `SingleProductTemplate.php`:
+
+```php
+$product = wc_get_product( $post->ID );
+if ( $product ) {
+ $consent = 'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+
+ // Load the product data into the products store so derived
+ // state closures can resolve it during server-side rendering.
+ ProductsStore::load_product( $consent, $product->get_id() );
+
+ wp_interactivity_state(
+ 'woocommerce/products',
+ array(
+ 'productId' => $product->get_id(),
+ 'variationId' => null,
+ )
+ );
+}
+```
+
+#### Local context (block-level)
+
+Set `productId` / `variationId` on a wrapper element via `data-wp-context`. Use this whenever the same block type can appear multiple times on a page for different products (product loops, grouped product children, variations).
+
+Use `wp_interactivity_data_wp_context()` to generate the properly encoded attribute. From `SingleProduct.php`:
+
+```php
+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(
+ 'productId' => $product->get_id(),
+ 'variationId' => null,
+);
+
+printf(
+ '<div data-wp-interactive="woocommerce/single-product" %s>%s</div>',
+ wp_interactivity_data_wp_context( $context, 'woocommerce/products' ),
+ $content
+);
+```
+
+The second argument to `wp_interactivity_data_wp_context` (`'woocommerce/products'`) namespaces the context to the `woocommerce/products` store; the JS store's `getContext< ProductContext >( 'woocommerce/products' )` calls read from it.
+
+### Reading product data in a block
+
+Once state is populated and a current product is set, blocks read from it either through directives (SSR + client) or through a JS store reference.
+
+#### From PHP / directives (SSR)
+
+The derived getters are registered on the PHP side via `ProductsStore::register_getters()`, so bindings resolve during server render — no client-side flash during hydration.
+
+From `ProductSKU.php`:
+
+```php
+$interactive_attributes = $is_interactive
+ ? 'data-wp-interactive="woocommerce/products" data-wp-text="state.productInContext.sku"'
+ : '';
+```
+
+Any `ProductResponseItem` field can be bound the same way, e.g. `state.productInContext.price_html`, `state.productInContext.stock_availability.text`, `state.mainProductInContext.name`.
+
+#### From JS (client)
+
+Import the store for its side effects and reference it with the `ProductsStore` type.
+
+From `atomic/blocks/product-elements/button/frontend.ts`:
+
+```ts
+import { store } from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/products';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
+
+// 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.';
+
+const { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
+ {},
+ { lock: universalLock }
+);
+
+// Later, in a getter or action:
+const product = productsState.productInContext;
+if ( ! product ) {
+ return;
+}
+// product.id, product.sku, product.prices.price, ...
+```
+
+#### Resolving a variation by attributes
+
+Use `state.findProduct({ id, selectedAttributes })` when you have a product or variation ID. If the ID is a variation, it returns it directly. For variable products with `selectedAttributes`, it resolves to the matching variation. For any other product type (simple, grouped, external, etc.), it returns the product as-is.
+
+From `base/utils/variations/does-cart-item-match-attributes.ts`:
+
+```ts
+const { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
+ {},
+ { lock: universalLock }
+);
+
+const parentProductId = productsState.productVariations[ cartItem.id ]?.parent;
+const productAttributes =
+ productsState.products[ parentProductId ]?.attributes ?? [];
+```
+
+For variable products, `findProduct` returns `null` when no variation matches the given attributes. For simple, grouped, external, or any other non-variable product type, it returns the product itself.
+
+### Patterns and pitfalls
+
+- **Always load before you bind.** If `wc_interactivity_api_load_product` was never called for the current `productId`, `state.mainProductInContext` resolves to `null` and directive bindings silently render empty.
+- **Prefer `productInContext`** for "whatever is currently being shown". Use `mainProductInContext` / `productVariationInContext` only when the distinction matters (e.g. rendering a variation-specific description vs. the parent title).
+- **`data-wp-context` sets local context.** Use it whenever the same block type can appear multiple times on a page for different products.
+- **Local context beats state.** If a block is wrapped in a `data-wp-context="woocommerce/products::{ ... }"` element, its `productId` / `variationId` override any globally-set values for descendants of that element. See `test/products.test.ts` for the exact precedence rules — notably, a context that has `productId` but no `variationId` key does **not** fall back to the global `variationId`.
+- **Keep the consent string in sync.** The literal string is defined in `ProductsStore::$consent_statement` (PHP) and `universalLock` (JS). They are intentionally different (loaders vs. store lock); copy-paste from this README or the source files.
+- **Do not extend this store from third-party code.** It is `lock: true` and private by design; anything here can change or disappear without notice.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
index 3419d58197a..f6570227d1f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
@@ -29,9 +29,6 @@ import {
import { doesCartItemMatchAttributes } from '../../utils/variations/does-cart-item-match-attributes';
export type WooCommerceConfig = {
- products?: {
- [ productId: number ]: ProductData;
- };
messages?: {
addedToCartText?: string;
};
@@ -61,38 +58,6 @@ export type ClientCartItem = Omit<
quantityToAdd?: number;
};
-export type VariationData = {
- attributes: Record< string, string >;
- is_in_stock: boolean;
- sold_individually: boolean;
- price_html?: string;
- image_id?: number;
- availability?: string;
- variation_description?: string;
- sku?: string;
- weight?: string;
- dimensions?: string;
- min?: number;
- max?: number;
- step?: number;
-};
-
-export type ProductData = {
- type: string;
- is_in_stock: boolean;
- sold_individually: boolean;
- price_html?: string;
- image_id?: number;
- availability?: string;
- sku?: string;
- weight?: string;
- dimensions?: string;
- min?: number;
- max?: number;
- step?: number;
- variations?: Record< number, VariationData >;
-};
-
type CartUpdateOptions = { showCartUpdatesNotices?: boolean };
export type Store = {
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/index.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/index.ts
index 7812e29c69c..ebe93b67668 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/index.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/index.ts
@@ -1,6 +1 @@
-export type { ProductContextStore } from './product-context';
-export type {
- ProductDataStore,
- Context as ProductDataContext,
-} from './product-data';
export type { ProductsStore, ProductsStoreState } from './products';
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/product-context.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/product-context.ts
deleted file mode 100644
index b6612bf221d..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/product-context.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * External dependencies
- */
-import { store, getContext } from '@wordpress/interactivity';
-import type { ProductResponseItem } from '@woocommerce/types';
-import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
-
-// 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.';
-
-const productsStore = store< ProductsStore >(
- 'woocommerce/products',
- {},
- { lock: universalLock }
-);
-
-/**
- * Per-element context set via data-wp-context on wrapper elements (e.g. the
- * SingleProduct block). When present, this takes precedence over the
- * server-hydrated state so that each product in a loop gets its own IDs.
- */
-type ProductContext = {
- productId: number;
- variationId?: number | null;
-};
-
-export type ProductContextStore = {
- state: {
- productId: number;
- variationId: number | null;
- /**
- * The main product for this page/block. Always the top-level product
- * (e.g. the variable product "Hoodie"), never a variation.
- * Resolves productId from per-block context when available.
- */
- product: ProductResponseItem | null;
- /**
- * The currently selected variation, or null if none is selected.
- * For simple/grouped products, this is always null.
- */
- selectedVariation: ProductResponseItem | null;
- };
-};
-
-const productContextStore = store< ProductContextStore >(
- 'woocommerce/product-context',
- {
- state: {
- get product(): ProductResponseItem | null {
- const context = getContext< ProductContext >(
- 'woocommerce/product-context'
- );
- const productId = context
- ? context.productId
- : productContextStore.state.productId;
-
- if ( ! productId ) {
- return null;
- }
- return productsStore.state.products[ productId ] ?? null;
- },
-
- get selectedVariation(): ProductResponseItem | null {
- const context = getContext< ProductContext >(
- 'woocommerce/product-context'
- );
- const variationId = context
- ? context.variationId
- : productContextStore.state.variationId;
- if ( ! variationId ) {
- return null;
- }
- return (
- productsStore.state.productVariations[ variationId ] ?? null
- );
- },
- },
- },
- { lock: universalLock }
-);
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
deleted file mode 100644
index 0d5f61db701..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/product-data.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * External dependencies
- */
-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;
-};
-
-export type Context = ProductRef;
-
-type ServerState = {
- templateState: ProductRef;
-};
-
-const productDataStore = store< {
- state: ProductRef & ServerState;
- actions: {
- setVariationId: ( variationId: number | null ) => void;
- };
-} >( 'woocommerce/product-data', {}, { lock: universalLock } );
-store(
- 'woocommerce/product-data',
- {
- state: {
- get productId(): number {
- const context = getContext< Context >(
- 'woocommerce/single-product'
- );
-
- return (
- context?.productId ??
- productDataStore?.state?.templateState?.productId
- );
- },
- get variationId(): number | null {
- const context = getContext< Context >(
- 'woocommerce/single-product'
- );
-
- return (
- context?.variationId ??
- productDataStore?.state?.templateState?.variationId
- );
- },
- },
- actions: {
- setVariationId: ( variationId: number | null ) => {
- const context = getContext< Context >(
- 'woocommerce/single-product'
- );
-
- if ( context?.variationId !== undefined ) {
- context.variationId = variationId;
- } else if (
- productDataStore?.state?.templateState?.variationId !==
- undefined
- ) {
- productDataStore.state.templateState.variationId =
- variationId;
- }
- },
- },
- },
- { 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
index 38104ebad14..8389a19e286 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/products.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/products.ts
@@ -1,8 +1,27 @@
/**
* External dependencies
*/
-import { store } from '@wordpress/interactivity';
+import { store, getContext } from '@wordpress/interactivity';
import type { ProductResponseItem } from '@woocommerce/types';
+import type { SelectedAttributes } from '@woocommerce/stores/woocommerce/cart';
+
+/**
+ * Per-element selection for the current product/variation.
+ *
+ * The "current" product can be set in two ways:
+ * - Globally, via `wp_interactivity_state( 'woocommerce/products', [ ... ] )`
+ * (used by SingleProductTemplate — one product per page).
+ * - Per-element, via `data-wp-context="woocommerce/products::{ ... }"` on a
+ * wrapper element (used by SingleProduct so each product in a loop gets
+ * its own IDs).
+ *
+ * When present, per-element context takes precedence over the global state.
+ * See ./README.md for the full model and precedence rules.
+ */
+type ProductContext = {
+ productId: number;
+ variationId?: number | null;
+};
/**
* The state shape for the products store.
@@ -19,6 +38,48 @@ export type ProductsStoreState = {
* These are in Store API format (ProductResponseItem).
*/
productVariations: Record< number, ProductResponseItem >;
+ /**
+ * Look up a product by ID. If the ID exists in `productVariations`,
+ * returns the variation directly (ignoring `selectedAttributes`).
+ * Otherwise looks in `products`: for variable products with
+ * `selectedAttributes`, returns the matching variation or `null`;
+ * for all other cases returns the product itself.
+ */
+ findProduct: ( args: {
+ id: number;
+ selectedAttributes?: SelectedAttributes[] | null;
+ } ) => ProductResponseItem | null;
+ /**
+ * The current product ID from state or per-element context.
+ */
+ productId: number;
+ /**
+ * The current variation ID from state or per-element context.
+ */
+ variationId: number | null;
+ /**
+ * The main product for this page/block. Always the top-level product
+ * (e.g. the variable product "Hoodie"), never a variation.
+ * Resolves productId from per-block context when available.
+ */
+ mainProductInContext: ProductResponseItem | null;
+ /**
+ * The currently selected variation, or null if none is selected.
+ * For simple/grouped products, this is always null.
+ */
+ productVariationInContext: ProductResponseItem | null;
+ /**
+ * The resolved product for the current context:
+ * `productVariationInContext` if one is set, otherwise
+ * `mainProductInContext`. This is the property most blocks should
+ * bind to — use `mainProductInContext` / `productVariationInContext`
+ * explicitly only when the distinction matters.
+ *
+ * Blocks can bind directly to properties, e.g.:
+ * state.productInContext.stock_availability.text
+ * state.productInContext.sku
+ */
+ productInContext: ProductResponseItem | null;
};
/**
@@ -32,23 +93,124 @@ export type ProductsStore = {
const universalLock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+const normalizeAttributeName = ( name: string ): string =>
+ name
+ .replace( /^attribute_(pa_)?/, '' )
+ .replace( /-/g, ' ' )
+ .toLowerCase();
+
+const attributeNamesMatch = ( a: string, b: string ): boolean =>
+ normalizeAttributeName( a ) === normalizeAttributeName( b );
+
/**
* 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.
+ * Server-hydrated cache of product and variation data in Store API format
+ * (`ProductResponseItem`). PHP loaders populate `products` / `productVariations`;
+ * derived getters below resolve the "current" product from either global state
+ * or per-element context. These getters are mirrored in PHP
+ * (see ProductsStore::register_getters) so directive bindings like
+ * `state.productInContext.sku` resolve during SSR as well as on the client.
*
- * State structure:
- * - products: Record<productId, ProductResponseItem>
- * - productVariations: Record<variationId, ProductResponseItem>
+ * See ./README.md for the complete model, loaders, and consumer patterns.
*/
-store< ProductsStore >(
+const { state: productsState } = store< ProductsStore >(
'woocommerce/products',
{
state: {
products: {},
productVariations: {},
+ findProduct( {
+ id,
+ selectedAttributes,
+ }: {
+ id: number;
+ selectedAttributes?: SelectedAttributes[] | null;
+ } ): ProductResponseItem | null {
+ const variation = productsState.productVariations[ id ];
+ if ( variation ) {
+ return variation;
+ }
+
+ const product = productsState.products[ id ];
+
+ if ( ! product ) {
+ return null;
+ }
+
+ if (
+ product.type !== 'variable' ||
+ ! selectedAttributes?.length
+ ) {
+ return product;
+ }
+
+ const matchedVariation = product.variations?.find( ( v ) =>
+ v.attributes.every( ( attr ) => {
+ const selectedAttr = selectedAttributes.find(
+ ( selected ) =>
+ attributeNamesMatch(
+ attr.name,
+ selected.attribute
+ )
+ );
+
+ if ( attr.value === null ) {
+ return (
+ selectedAttr !== undefined &&
+ selectedAttr.value !== null
+ );
+ }
+
+ return selectedAttr?.value === attr.value;
+ } )
+ );
+
+ if ( ! matchedVariation ) {
+ return null;
+ }
+
+ return (
+ productsState.productVariations[ matchedVariation.id ] ??
+ null
+ );
+ },
+
+ get mainProductInContext(): ProductResponseItem | null {
+ const context = getContext< ProductContext >(
+ 'woocommerce/products'
+ );
+ const productId =
+ context && 'productId' in context
+ ? context.productId
+ : productsState.productId;
+
+ if ( ! productId ) {
+ return null;
+ }
+ return productsState.products[ productId ] ?? null;
+ },
+
+ get productVariationInContext(): ProductResponseItem | null {
+ const context = getContext< ProductContext >(
+ 'woocommerce/products'
+ );
+ const variationId =
+ context && 'variationId' in context
+ ? context.variationId
+ : productsState.variationId;
+ if ( ! variationId ) {
+ return null;
+ }
+ return productsState.productVariations[ variationId ] ?? null;
+ },
+
+ get productInContext(): ProductResponseItem | null {
+ return (
+ productsState.productVariationInContext ||
+ productsState.mainProductInContext
+ );
+ },
},
},
{ lock: universalLock }
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/product-context.test.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/product-context.test.ts
deleted file mode 100644
index 74b09d5edfc..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/product-context.test.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-/**
- * External dependencies
- */
-import type { ProductResponseItem } from '@woocommerce/types';
-
-/**
- * Internal dependencies
- */
-import type { ProductContextStore } from '../product-context';
-
-let mockRegisteredStore: {
- state: ProductContextStore[ 'state' ];
-} | null = null;
-
-let mockProductsState: {
- products: Record< number, ProductResponseItem >;
- productVariations: Record< number, ProductResponseItem >;
-};
-let mockContext: { productId?: number; variationId?: number | null } | null =
- null;
-
-const mockProduct = {
- id: 42,
- name: 'Test Product',
-} as ProductResponseItem;
-
-const mockVariation = {
- id: 99,
- name: 'Test Variation',
-} as ProductResponseItem;
-
-jest.mock(
- '@wordpress/interactivity',
- () => ( {
- store: jest.fn( ( namespace, definition ) => {
- if ( namespace === 'woocommerce/products' ) {
- return {
- state: mockProductsState,
- };
- }
- if ( namespace === 'woocommerce/product-context' ) {
- // Simulate server-hydrated state merged with client definition.
- // Getters from definition.state are preserved, and productId /
- // variationId are added as plain values (simulating
- // wp_interactivity_state hydration).
- const stateBase = {
- productId: 0,
- variationId: null as number | null,
- };
- const descriptors = Object.getOwnPropertyDescriptors(
- definition.state
- );
- Object.defineProperties( stateBase, descriptors );
-
- mockRegisteredStore = {
- state: stateBase as ProductContextStore[ 'state' ],
- };
- return mockRegisteredStore;
- }
- return {};
- } ),
- getContext: jest.fn( () => mockContext ),
- } ),
- { virtual: true }
-);
-
-describe( 'woocommerce/product-context store', () => {
- beforeEach( () => {
- mockRegisteredStore = null;
- mockContext = null;
- mockProductsState = {
- products: { 42: mockProduct },
- productVariations: { 99: mockVariation },
- };
-
- jest.isolateModules( () => require( '../product-context' ) );
- } );
-
- it( 'has writable productId and variationId state', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.productId = 42;
- mockRegisteredStore!.state.variationId = 99;
-
- expect( mockRegisteredStore!.state.productId ).toBe( 42 );
- expect( mockRegisteredStore!.state.variationId ).toBe( 99 );
- } );
-
- describe( 'product', () => {
- it( 'returns the product when variationId is null', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.productId = 42;
- mockRegisteredStore!.state.variationId = null;
-
- expect( mockRegisteredStore!.state.product ).toBe( mockProduct );
- } );
-
- it( 'returns the product even when variationId is set', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.productId = 42;
- mockRegisteredStore!.state.variationId = 99;
-
- // product always returns the main product, never the variation.
- expect( mockRegisteredStore!.state.product ).toBe( mockProduct );
- } );
-
- it( 'returns null when product is not in the store', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.productId = 999;
- mockRegisteredStore!.state.variationId = null;
-
- expect( mockRegisteredStore!.state.product ).toBeNull();
- } );
-
- it( 'returns null when productId is 0', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- expect( mockRegisteredStore!.state.product ).toBeNull();
- } );
-
- it( 'reads from block context when available', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.productId = 1;
- mockContext = { productId: 42 };
-
- expect( mockRegisteredStore!.state.product ).toBe( mockProduct );
- } );
- } );
-
- describe( 'selectedVariation', () => {
- it( 'returns null when variationId is null (simple product)', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.productId = 42;
- mockRegisteredStore!.state.variationId = null;
-
- expect( mockRegisteredStore!.state.selectedVariation ).toBeNull();
- } );
-
- it( 'returns null when variationId is null (variable product, no selection)', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockProductsState.products[ 10 ] = {
- id: 10,
- type: 'variable',
- } as ProductResponseItem;
- mockRegisteredStore!.state.productId = 10;
- mockRegisteredStore!.state.variationId = null;
-
- expect( mockRegisteredStore!.state.selectedVariation ).toBeNull();
- } );
-
- it( 'returns the variation when variationId is set', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.productId = 42;
- mockRegisteredStore!.state.variationId = 99;
-
- expect( mockRegisteredStore!.state.selectedVariation ).toBe(
- mockVariation
- );
- } );
-
- it( 'returns null when variation is not in the store', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.productId = 42;
- mockRegisteredStore!.state.variationId = 999;
-
- expect( mockRegisteredStore!.state.selectedVariation ).toBeNull();
- } );
- } );
-
- describe( 'Product block path (context without variationId)', () => {
- it( 'product reads productId from context', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockContext = { productId: 42 };
- mockRegisteredStore!.state.variationId = null;
-
- expect( mockRegisteredStore!.state.product ).toBe( mockProduct );
- } );
-
- it( 'selectedVariation reads variationId from context when available', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockContext = { productId: 42, variationId: 99 };
- mockRegisteredStore!.state.variationId = null;
-
- expect( mockRegisteredStore!.state.selectedVariation ).toBe(
- mockVariation
- );
- } );
-
- it( 'selectedVariation does not fall back to state when context variationId is null but context exists', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockContext = { productId: 42 };
- mockRegisteredStore!.state.variationId = 99;
-
- expect( mockRegisteredStore!.state.selectedVariation ).toBe( null );
- } );
-
- it( 'selectedVariation falls back to state when context does not exist', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.variationId = 99;
-
- expect( mockRegisteredStore!.state.selectedVariation ).toBe(
- mockVariation
- );
- } );
-
- it( 'selectedVariation returns null when both context and state variationId are null', () => {
- expect( mockRegisteredStore ).not.toBeNull();
-
- mockRegisteredStore!.state.variationId = null;
-
- expect( mockRegisteredStore!.state.selectedVariation ).toBeNull();
- } );
- } );
-} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/products.test.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/products.test.ts
new file mode 100644
index 00000000000..646e66db543
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/products.test.ts
@@ -0,0 +1,703 @@
+/**
+ * External dependencies
+ */
+import type { ProductResponseItem } from '@woocommerce/types';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductsStore } from '../products';
+
+let mockRegisteredStore: {
+ state: ProductsStore[ 'state' ];
+} | null = null;
+
+let mockContext: { productId?: number; variationId?: number | null } | null =
+ null;
+
+const mockProduct = {
+ id: 42,
+ name: 'Test Product',
+} as ProductResponseItem;
+
+const mockVariation = {
+ id: 99,
+ name: 'Test Variation',
+} as ProductResponseItem;
+
+jest.mock(
+ '@wordpress/interactivity',
+ () => ( {
+ store: jest.fn( ( namespace, definition ) => {
+ if ( namespace === 'woocommerce/products' ) {
+ // Simulate server-hydrated state merged with client definition.
+ // Getters from definition.state are preserved, and productId /
+ // variationId are added as plain values (simulating
+ // wp_interactivity_state hydration).
+ const stateBase = {
+ products: {} as Record< number, ProductResponseItem >,
+ productVariations: {} as Record<
+ number,
+ ProductResponseItem
+ >,
+ productId: 0,
+ variationId: null as number | null,
+ };
+ const descriptors = Object.getOwnPropertyDescriptors(
+ definition.state
+ );
+ Object.defineProperties( stateBase, descriptors );
+
+ mockRegisteredStore = {
+ state: stateBase as ProductsStore[ 'state' ],
+ };
+ return mockRegisteredStore;
+ }
+ return {};
+ } ),
+ getContext: jest.fn( () => mockContext ),
+ } ),
+ { virtual: true }
+);
+
+describe( 'woocommerce/products store – product context derived state', () => {
+ beforeEach( () => {
+ mockRegisteredStore = null;
+ mockContext = null;
+
+ jest.isolateModules( () => require( '../products' ) );
+
+ // Hydrate products and variations after store is created.
+ mockRegisteredStore!.state.products = { 42: mockProduct };
+ mockRegisteredStore!.state.productVariations = { 99: mockVariation };
+ } );
+
+ it( 'has writable productId and variationId state', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 42;
+ mockRegisteredStore!.state.variationId = 99;
+
+ expect( mockRegisteredStore!.state.productId ).toBe( 42 );
+ expect( mockRegisteredStore!.state.variationId ).toBe( 99 );
+ } );
+
+ describe( 'mainProductInContext', () => {
+ it( 'returns the product when variationId is null', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 42;
+ mockRegisteredStore!.state.variationId = null;
+
+ expect( mockRegisteredStore!.state.mainProductInContext ).toBe(
+ mockProduct
+ );
+ } );
+
+ it( 'returns the product even when variationId is set', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 42;
+ mockRegisteredStore!.state.variationId = 99;
+
+ // product always returns the main product, never the variation.
+ expect( mockRegisteredStore!.state.mainProductInContext ).toBe(
+ mockProduct
+ );
+ } );
+
+ it( 'returns null when product is not in the store', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 999;
+ mockRegisteredStore!.state.variationId = null;
+
+ expect(
+ mockRegisteredStore!.state.mainProductInContext
+ ).toBeNull();
+ } );
+
+ it( 'returns null when productId is 0', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ expect(
+ mockRegisteredStore!.state.mainProductInContext
+ ).toBeNull();
+ } );
+
+ it( 'reads from block context when available', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 1;
+ mockContext = { productId: 42 };
+
+ expect( mockRegisteredStore!.state.mainProductInContext ).toBe(
+ mockProduct
+ );
+ } );
+ } );
+
+ describe( 'productVariationInContext', () => {
+ it( 'returns null when variationId is null (simple product)', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 42;
+ mockRegisteredStore!.state.variationId = null;
+
+ expect(
+ mockRegisteredStore!.state.productVariationInContext
+ ).toBeNull();
+ } );
+
+ it( 'returns null when variationId is null (variable product, no selection)', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.products[ 10 ] = {
+ id: 10,
+ type: 'variable',
+ } as ProductResponseItem;
+ mockRegisteredStore!.state.productId = 10;
+ mockRegisteredStore!.state.variationId = null;
+
+ expect(
+ mockRegisteredStore!.state.productVariationInContext
+ ).toBeNull();
+ } );
+
+ it( 'returns the variation when variationId is set', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 42;
+ mockRegisteredStore!.state.variationId = 99;
+
+ expect( mockRegisteredStore!.state.productVariationInContext ).toBe(
+ mockVariation
+ );
+ } );
+
+ it( 'returns null when variation is not in the store', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 42;
+ mockRegisteredStore!.state.variationId = 999;
+
+ expect(
+ mockRegisteredStore!.state.productVariationInContext
+ ).toBeNull();
+ } );
+ } );
+
+ describe( 'findProduct', () => {
+ it( 'returns null when product is not in the store', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 999,
+ } );
+
+ expect( result ).toBeNull();
+ } );
+
+ it( 'returns the product itself for a simple product', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const simpleProduct = {
+ id: 1,
+ type: 'simple',
+ } as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 1 ] = simpleProduct;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 1,
+ } );
+
+ expect( result ).toBe( simpleProduct );
+ } );
+
+ it( 'returns the matched variation when selectedAttributes match and the variation is populated', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variableProduct = {
+ id: 1,
+ type: 'variable',
+ variations: [
+ {
+ id: 10,
+ attributes: [ { name: 'Color', value: 'red' } ],
+ },
+ ],
+ } as unknown as ProductResponseItem;
+ const populatedVariation = {
+ id: 10,
+ name: 'Red Variation',
+ } as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 1 ] = variableProduct;
+ mockRegisteredStore!.state.productVariations[ 10 ] =
+ populatedVariation;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 1,
+ selectedAttributes: [ { attribute: 'Color', value: 'red' } ],
+ } );
+
+ expect( result ).toBe( populatedVariation );
+ } );
+
+ it( 'returns null when attributes match but the variation is not populated', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variableProduct = {
+ id: 1,
+ type: 'variable',
+ variations: [
+ {
+ id: 10,
+ attributes: [ { name: 'Color', value: 'red' } ],
+ },
+ ],
+ } as unknown as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 1 ] = variableProduct;
+ // productVariations intentionally empty.
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 1,
+ selectedAttributes: [ { attribute: 'Color', value: 'red' } ],
+ } );
+
+ expect( result ).toBeNull();
+ } );
+
+ it( 'returns the parent product when the product is variable and no attributes are selected', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variableProduct = {
+ id: 1,
+ type: 'variable',
+ variations: [
+ {
+ id: 10,
+ attributes: [ { name: 'Color', value: 'red' } ],
+ },
+ ],
+ } as unknown as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 1 ] = variableProduct;
+
+ expect( mockRegisteredStore!.state.findProduct( { id: 1 } ) ).toBe(
+ variableProduct
+ );
+ expect(
+ mockRegisteredStore!.state.findProduct( {
+ id: 1,
+ selectedAttributes: [],
+ } )
+ ).toBe( variableProduct );
+ } );
+
+ it( 'returns null when attributes do not match any variation', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variableProduct = {
+ id: 1,
+ type: 'variable',
+ variations: [
+ {
+ id: 10,
+ attributes: [ { name: 'Color', value: 'red' } ],
+ },
+ ],
+ } as unknown as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 1 ] = variableProduct;
+ mockRegisteredStore!.state.productVariations[ 10 ] = {
+ id: 10,
+ } as ProductResponseItem;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 1,
+ selectedAttributes: [ { attribute: 'Color', value: 'blue' } ],
+ } );
+
+ expect( result ).toBeNull();
+ } );
+
+ it( 'returns the variation directly when given a variation ID', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variation = {
+ id: 50,
+ name: 'Direct Variation',
+ } as ProductResponseItem;
+ mockRegisteredStore!.state.productVariations[ 50 ] = variation;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 50,
+ } );
+
+ expect( result ).toBe( variation );
+ } );
+
+ it( 'returns the variation directly and ignores selectedAttributes when given a variation ID', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variation = {
+ id: 50,
+ name: 'Direct Variation',
+ } as ProductResponseItem;
+ mockRegisteredStore!.state.productVariations[ 50 ] = variation;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 50,
+ selectedAttributes: [ { attribute: 'Color', value: 'blue' } ],
+ } );
+
+ expect( result ).toBe( variation );
+ } );
+
+ it( 'prefers variation lookup over product lookup when ID exists in both', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const product = {
+ id: 50,
+ type: 'simple',
+ name: 'Product 50',
+ } as ProductResponseItem;
+ const variation = {
+ id: 50,
+ name: 'Variation 50',
+ } as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 50 ] = product;
+ mockRegisteredStore!.state.productVariations[ 50 ] = variation;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 50,
+ } );
+
+ expect( result ).toBe( variation );
+ } );
+
+ describe( 'attribute matching (variable products)', () => {
+ it( 'matches with attribute prefix in selected attributes', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variableProduct = {
+ id: 3,
+ type: 'variable',
+ variations: [
+ {
+ id: 301,
+ attributes: [
+ { name: 'Color', value: 'Blue' },
+ { name: 'Size', value: 'Small' },
+ ],
+ },
+ {
+ id: 302,
+ attributes: [
+ { name: 'Color', value: 'Blue' },
+ { name: 'Size', value: 'Large' },
+ ],
+ },
+ ],
+ } as unknown as ProductResponseItem;
+ const populatedVariation301 = {
+ id: 301,
+ name: 'Blue Small',
+ } as ProductResponseItem;
+ const populatedVariation302 = {
+ id: 302,
+ name: 'Blue Large',
+ } as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 3 ] = variableProduct;
+ mockRegisteredStore!.state.productVariations[ 301 ] =
+ populatedVariation301;
+ mockRegisteredStore!.state.productVariations[ 302 ] =
+ populatedVariation302;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 3,
+ selectedAttributes: [
+ { attribute: 'attribute_pa_color', value: 'Blue' },
+ { attribute: 'attribute_pa_size', value: 'Small' },
+ ],
+ } );
+
+ expect( result ).toBe( populatedVariation301 );
+ } );
+
+ describe( 'multi-word attribute names', () => {
+ it( 'matches when selected attributes use hyphenated slugs', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variableProduct = {
+ id: 3,
+ type: 'variable',
+ variations: [
+ {
+ id: 301,
+ attributes: [
+ { name: 'Color', value: 'Blue' },
+ { name: 'numeric size', value: '42' },
+ ],
+ },
+ {
+ id: 302,
+ attributes: [
+ { name: 'Color', value: 'Red' },
+ { name: 'numeric size', value: '44' },
+ ],
+ },
+ ],
+ } as unknown as ProductResponseItem;
+ const populatedVariation = {
+ id: 301,
+ name: 'Blue 42',
+ } as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 3 ] = variableProduct;
+ mockRegisteredStore!.state.productVariations[ 301 ] =
+ populatedVariation;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 3,
+ selectedAttributes: [
+ {
+ attribute: 'attribute_pa_color',
+ value: 'Blue',
+ },
+ {
+ attribute: 'attribute_pa_numeric-size',
+ value: '42',
+ },
+ ],
+ } );
+
+ expect( result ).toBe( populatedVariation );
+ } );
+ } );
+
+ describe( 'Any attribute handling', () => {
+ it( 'matches variation with "Any" attribute when value is selected', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variableProduct = {
+ id: 2,
+ type: 'variable',
+ variations: [
+ {
+ id: 201,
+ attributes: [
+ { name: 'Color', value: null },
+ { name: 'Size', value: 'Small' },
+ ],
+ },
+ {
+ id: 202,
+ attributes: [
+ { name: 'Color', value: 'Blue' },
+ { name: 'Size', value: null },
+ ],
+ },
+ ],
+ } as unknown as ProductResponseItem;
+ const populatedVariation = {
+ id: 201,
+ name: 'Any Color Small',
+ } as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 2 ] = variableProduct;
+ mockRegisteredStore!.state.productVariations[ 201 ] =
+ populatedVariation;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 2,
+ selectedAttributes: [
+ { attribute: 'Color', value: 'Red' },
+ { attribute: 'Size', value: 'Small' },
+ ],
+ } );
+
+ expect( result ).toBe( populatedVariation );
+ } );
+
+ it( 'does not match "Any" attribute when selected value is null', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variableProduct = {
+ id: 2,
+ type: 'variable',
+ variations: [
+ {
+ id: 201,
+ attributes: [
+ { name: 'Color', value: null },
+ { name: 'Size', value: 'Small' },
+ ],
+ },
+ ],
+ } as unknown as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 2 ] = variableProduct;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 2,
+ selectedAttributes: [
+ {
+ attribute: 'Color',
+ value: null as unknown as string,
+ },
+ { attribute: 'Size', value: 'Small' },
+ ],
+ } );
+
+ expect( result ).toBeNull();
+ } );
+
+ it( 'does not match "Any" attribute when attribute is not selected', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ const variableProduct = {
+ id: 2,
+ type: 'variable',
+ variations: [
+ {
+ id: 201,
+ attributes: [
+ { name: 'Color', value: null },
+ { name: 'Size', value: 'Small' },
+ ],
+ },
+ ],
+ } as unknown as ProductResponseItem;
+ mockRegisteredStore!.state.products[ 2 ] = variableProduct;
+
+ const result = mockRegisteredStore!.state.findProduct( {
+ id: 2,
+ selectedAttributes: [
+ { attribute: 'Size', value: 'Small' },
+ ],
+ } );
+
+ expect( result ).toBeNull();
+ } );
+ } );
+ } );
+ } );
+
+ describe( 'productInContext', () => {
+ it( 'returns product when variationId is null (simple product path)', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 42;
+ mockRegisteredStore!.state.variationId = null;
+
+ expect( mockRegisteredStore!.state.productInContext ).toBe(
+ mockProduct
+ );
+ } );
+
+ it( 'returns productVariationInContext when variationId is set and populated', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 42;
+ mockRegisteredStore!.state.variationId = 99;
+
+ expect( mockRegisteredStore!.state.productInContext ).toBe(
+ mockVariation
+ );
+ } );
+
+ it( 'falls back to product when variation is missing from productVariations', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 42;
+ mockRegisteredStore!.state.variationId = 123;
+
+ expect( mockRegisteredStore!.state.productInContext ).toBe(
+ mockProduct
+ );
+ } );
+
+ it( 'returns null when neither product nor variation resolves', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 0;
+ mockRegisteredStore!.state.variationId = null;
+
+ expect( mockRegisteredStore!.state.productInContext ).toBeNull();
+ } );
+
+ it( 'honors local context over state IDs', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.productId = 1;
+ mockRegisteredStore!.state.variationId = null;
+ mockContext = { productId: 42, variationId: 99 };
+
+ expect( mockRegisteredStore!.state.productInContext ).toBe(
+ mockVariation
+ );
+ } );
+ } );
+
+ describe( 'Product block path (context without variationId)', () => {
+ it( 'mainProductInContext reads productId from context', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockContext = { productId: 42 };
+ mockRegisteredStore!.state.variationId = null;
+
+ expect( mockRegisteredStore!.state.mainProductInContext ).toBe(
+ mockProduct
+ );
+ } );
+
+ it( 'productVariationInContext reads variationId from context when available', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockContext = { productId: 42, variationId: 99 };
+ mockRegisteredStore!.state.variationId = null;
+
+ expect( mockRegisteredStore!.state.productVariationInContext ).toBe(
+ mockVariation
+ );
+ } );
+
+ it( 'productVariationInContext falls back to state when context exists but does not define variationId', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockContext = { productId: 42 };
+ mockRegisteredStore!.state.variationId = 99;
+
+ expect( mockRegisteredStore!.state.productVariationInContext ).toBe(
+ mockVariation
+ );
+ } );
+
+ it( 'productVariationInContext does not fall back to state when context explicitly sets variationId to null', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockContext = { productId: 42, variationId: null };
+ mockRegisteredStore!.state.variationId = 99;
+
+ expect( mockRegisteredStore!.state.productVariationInContext ).toBe(
+ null
+ );
+ } );
+
+ it( 'productVariationInContext falls back to state when context does not exist', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.variationId = 99;
+
+ expect( mockRegisteredStore!.state.productVariationInContext ).toBe(
+ mockVariation
+ );
+ } );
+
+ it( 'productVariationInContext returns null when both context and state variationId are null', () => {
+ expect( mockRegisteredStore ).not.toBeNull();
+
+ mockRegisteredStore!.state.variationId = null;
+
+ expect(
+ mockRegisteredStore!.state.productVariationInContext
+ ).toBeNull();
+ } );
+ } );
+} );
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
index 66c611c35f9..3238772c0ce 100644
--- 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
@@ -1,11 +1,7 @@
/**
* External dependencies
*/
-import type { SelectedAttributes } from '@woocommerce/stores/woocommerce/cart';
-import type {
- ProductResponseItem,
- ProductResponseVariationsItem,
-} from '@woocommerce/types';
+import type { ProductResponseVariationsItem } from '@woocommerce/types';
/**
* Normalize attribute name by stripping the 'attribute_' or 'attribute_pa_' prefix
@@ -59,44 +55,3 @@ export const getVariationAttributeValue = (
);
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 selectedAttr = selectedAttributes.find( ( selected ) =>
- attributeNamesMatch( attr.name, selected.attribute )
- );
-
- // If variation attribute is null, it accepts "Any" value.
- if ( attr.value === null ) {
- return (
- selectedAttr !== undefined &&
- selectedAttr.value !== null
- );
- }
-
- return selectedAttr?.value === attr.value;
- } );
- }
- );
-
- return matchedVariation ?? null;
-};
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
index 6a9a83b91bb..c9995c8d372 100644
--- 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
@@ -5,7 +5,6 @@ import {
normalizeAttributeName,
attributeNamesMatch,
getVariationAttributeValue,
- findMatchingVariation,
} from '../attribute-matching';
describe( 'normalizeAttributeName', () => {
@@ -148,175 +147,3 @@ describe( 'getVariationAttributeValue', () => {
} );
} );
} );
-
-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( 'multi-word attribute names', () => {
- const productWithMultiWord = {
- id: 3,
- type: 'variable',
- variations: [
- {
- id: 301,
- attributes: [
- { name: 'Color', value: 'Blue' },
- { name: 'numeric size', value: '42' },
- ],
- },
- {
- id: 302,
- attributes: [
- { name: 'Color', value: 'Red' },
- { name: 'numeric size', value: '44' },
- ],
- },
- ],
- };
-
- it( 'matches when selected attributes use hyphenated slugs', () => {
- const result = findMatchingVariation( productWithMultiWord, [
- { attribute: 'attribute_pa_color', value: 'Blue' },
- { attribute: 'attribute_pa_numeric-size', value: '42' },
- ] );
- expect( result?.id ).toBe( 301 );
- } );
-
- it( 'matches when Store API uses hyphens instead of spaces', () => {
- const productWithHyphens = {
- id: 4,
- type: 'variable',
- variations: [
- {
- id: 401,
- attributes: [ { name: 'numeric-size', value: '42' } ],
- },
- ],
- };
- const result = findMatchingVariation( productWithHyphens, [
- { attribute: 'attribute_pa_numeric-size', value: '42' },
- ] );
- expect( result?.id ).toBe( 401 );
- } );
- } );
-
- describe( 'Any attribute handling', () => {
- const productWithAny = {
- id: 2,
- type: 'variable',
- variations: [
- {
- id: 201,
- attributes: [
- { name: 'Color', value: null }, // "Any" color
- { name: 'Size', value: 'Small' },
- ],
- },
- {
- id: 202,
- attributes: [
- { name: 'Color', value: 'Blue' },
- { name: 'Size', value: null }, // "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 null', () => {
- const selectedAttributes = [
- { attribute: 'Color', value: null },
- { 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/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/__tests__/frontend.test.ts
deleted file mode 100644
index 15d4eef7926..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/__tests__/frontend.test.ts
+++ /dev/null
@@ -1,277 +0,0 @@
-/**
- * External dependencies
- */
-import type { ProductsStoreState } from '@woocommerce/stores/woocommerce/products';
-
-/**
- * Internal dependencies
- */
-import type { NormalizedProductData } from '../types';
-
-let mockProductsState: {
- products: Record<
- number,
- Partial< ProductsStoreState[ 'products' ][ number ] >
- >;
- productVariations: Record<
- number,
- Partial< ProductsStoreState[ 'productVariations' ][ number ] >
- >;
-};
-
-jest.mock(
- '@wordpress/interactivity',
- () => ( {
- store: jest.fn( ( name ) => {
- if ( name === 'woocommerce/products' ) {
- return { state: mockProductsState };
- }
- return { state: {} };
- } ),
- withSyncEvent: jest.fn( ( fn ) => fn ),
- } ),
- { virtual: true }
-);
-
-jest.mock( '@woocommerce/stores/woocommerce/product-data', () => ( {} ), {
- virtual: true,
-} );
-
-jest.mock( '@woocommerce/stores/woocommerce/products', () => ( {} ), {
- virtual: true,
-} );
-
-describe( 'getProductData', () => {
- let getProductData: (
- id: number,
- selectedAttributes: Array< { attribute: string; value: string } >
- ) => NormalizedProductData | null;
-
- beforeEach( () => {
- jest.resetModules();
- mockProductsState = {
- products: {},
- productVariations: {},
- };
-
- jest.isolateModules( () => {
- //eslint-disable-next-line @typescript-eslint/no-var-requires
- const frontend = require( '../frontend' );
- getProductData = frontend.getProductData;
- } );
- } );
-
- it( 'returns null when product is not in store', () => {
- mockProductsState.products = {};
-
- const result = getProductData( 999, [] );
- expect( result ).toBeNull();
- } );
-
- it( 'returns product data with default quantity constraints', () => {
- mockProductsState.products = {
- 1: {
- id: 1,
- type: 'simple',
- is_purchasable: true,
- is_in_stock: true,
- sold_individually: false,
- },
- };
-
- const result = getProductData( 1, [] );
-
- expect( result ).toEqual( {
- id: 1,
- type: 'simple',
- is_in_stock: true,
- sold_individually: false,
- min: 1,
- max: Number.MAX_SAFE_INTEGER,
- step: 1,
- } );
- } );
-
- it( 'returns product data with custom quantity constraints', () => {
- mockProductsState.products = {
- 1: {
- id: 1,
- type: 'simple',
- is_purchasable: true,
- is_in_stock: true,
- sold_individually: false,
- add_to_cart: {
- text: 'Add to cart',
- description: 'Add to cart',
- url: '',
- minimum: 2,
- maximum: 10,
- multiple_of: 2,
- single_text: 'Add to cart',
- },
- },
- };
-
- const result = getProductData( 1, [] );
-
- expect( result ).toEqual( {
- id: 1,
- type: 'simple',
- is_in_stock: true,
- sold_individually: false,
- min: 2,
- max: 10,
- step: 2,
- } );
- } );
-
- it( 'returns max as MAX_SAFE_INTEGER when maximum is 0', () => {
- mockProductsState.products = {
- 1: {
- id: 1,
- type: 'simple',
- is_purchasable: true,
- is_in_stock: true,
- sold_individually: false,
- add_to_cart: {
- text: 'Add to cart',
- description: 'Add to cart',
- url: '',
- minimum: 1,
- maximum: 0,
- multiple_of: 1,
- single_text: 'Add to cart',
- },
- },
- };
-
- const result = getProductData( 1, [] );
-
- expect( result?.max ).toBe( Number.MAX_SAFE_INTEGER );
- } );
-
- describe( 'variable products', () => {
- it( 'returns variation data when attributes match a variation', () => {
- mockProductsState.products = {
- 1: {
- id: 1,
- type: 'variable',
- is_purchasable: true,
- is_in_stock: true,
- sold_individually: false,
- add_to_cart: {
- text: 'Add to cart',
- description: 'Add to cart',
- url: '',
- minimum: 1,
- maximum: 100,
- multiple_of: 1,
- single_text: 'Add to cart',
- },
- variations: [
- {
- id: 10,
- attributes: [ { name: 'Color', value: 'red' } ],
- },
- ],
- },
- };
- mockProductsState.productVariations = {
- 10: {
- id: 10,
- type: 'variation',
- is_purchasable: true,
- is_in_stock: true,
- sold_individually: false,
- add_to_cart: {
- text: 'Add to cart',
- description: 'Add to cart',
- url: '',
- minimum: 5,
- maximum: 50,
- multiple_of: 5,
- single_text: 'Add to cart',
- },
- },
- };
-
- const result = getProductData( 1, [
- { attribute: 'Color', value: 'red' },
- ] );
-
- expect( result ).toEqual( {
- id: 10,
- type: 'variation',
- is_in_stock: true,
- sold_individually: false,
- min: 5,
- max: 50,
- step: 5,
- } );
- } );
-
- it( 'returns null when variation is matched but not in store', () => {
- mockProductsState.products = {
- 1: {
- id: 1,
- type: 'variable',
- is_purchasable: true,
- is_in_stock: true,
- sold_individually: false,
- variations: [
- {
- id: 10,
- attributes: [ { name: 'Color', value: 'red' } ],
- },
- ],
- },
- };
- mockProductsState.productVariations = {};
-
- const result = getProductData( 1, [
- { attribute: 'Color', value: 'red' },
- ] );
-
- expect( result ).toBeNull();
- } );
-
- it( 'returns parent product data when no attributes are selected', () => {
- mockProductsState.products = {
- 1: {
- id: 1,
- type: 'variable',
- is_purchasable: true,
- is_in_stock: true,
- sold_individually: false,
- add_to_cart: {
- text: 'Add to cart',
- description: 'Add to cart',
- url: '',
- minimum: 1,
- maximum: 100,
- multiple_of: 1,
- single_text: 'Add to cart',
- },
- variations: [
- {
- id: 10,
- attributes: [ { name: 'Color', value: 'red' } ],
- },
- ],
- },
- };
-
- const result = getProductData( 1, [] );
-
- expect( result ).toEqual( {
- id: 1,
- type: 'variable',
- is_in_stock: true,
- sold_individually: false,
- min: 1,
- max: 100,
- step: 1,
- } );
- } );
- } );
-} );
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 e31c6a8746a..a4ca903eb5f 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
@@ -11,20 +11,16 @@ import type {
Store as WooCommerce,
SelectedAttributes,
} 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 '@woocommerce/stores/woocommerce/products';
import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
/**
* Internal dependencies
*/
-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';
export type Context = {
selectedAttributes: SelectedAttributes[];
@@ -61,67 +57,12 @@ const dispatchChangeEvent = ( inputElement: HTMLInputElement ) => {
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 }
);
-export const getProductData = (
- id: number,
- selectedAttributes: SelectedAttributes[]
-): NormalizedProductData | NormalizedVariationData | null => {
- const productFromStore = productsState.products[ id ];
-
- if ( ! productFromStore ) {
- return null;
- }
-
- // Determine which product to use for the response.
- let product = productFromStore;
-
- // For variable products with selected attributes, find the matching variation.
- if (
- productFromStore.type === 'variable' &&
- selectedAttributes?.length > 0
- ) {
- const matchedVariation = findMatchingVariation(
- productFromStore,
- selectedAttributes
- );
-
- if ( matchedVariation ) {
- const variation =
- productsState.productVariations[ matchedVariation.id ];
- if ( ! variation ) {
- // Variation was matched but its data isn't in the store.
- // Return null to prevent using stale parent product data.
- return null;
- }
- product = variation;
- }
- }
-
- const { add_to_cart: addToCart } = product;
- const maximum = addToCart?.maximum ?? 0;
-
- return {
- id: product.id,
- type: product.type,
- is_in_stock: product.is_purchasable && product.is_in_stock,
- sold_individually: product.sold_individually,
- min: addToCart?.minimum ?? 1,
- max: maximum > 0 ? maximum : Number.MAX_SAFE_INTEGER,
- step: addToCart?.multiple_of ?? 1,
- };
-};
-
export type AddToCartWithOptionsStore = {
state: {
noticeIds: string[];
@@ -130,7 +71,6 @@ export type AddToCartWithOptionsStore = {
allowsAddingToCart: boolean;
quantity: Record< number, number >;
selectedAttributes: SelectedAttributes[];
- productData: NormalizedProductData | NormalizedVariationData | null;
};
actions: {
validateQuantity: ( productId: number, value?: number ) => void;
@@ -141,12 +81,16 @@ export type AddToCartWithOptionsStore = {
};
};
-const { state } = store<
- AddToCartWithOptionsStore &
- Partial< GroupedProductAddToCartWithOptionsStore > &
- Partial< VariableProductAddToCartWithOptionsStore >
->( 'woocommerce/add-to-cart-with-options', {}, { lock: universalLock } );
-const { actions } = store(
+type MergedAddToCartWithOptionsStores = AddToCartWithOptionsStore &
+ Partial< GroupedProductAddToCartWithOptionsStore > &
+ Partial< VariableProductAddToCartWithOptionsStore >;
+
+const { state } = store< MergedAddToCartWithOptionsStores >(
+ 'woocommerce/add-to-cart-with-options',
+ {},
+ { lock: universalLock }
+);
+const { actions } = store< MergedAddToCartWithOptionsStores >(
'woocommerce/add-to-cart-with-options',
{
state: {
@@ -164,16 +108,20 @@ const { actions } = store(
return state.validationErrors.length === 0;
},
get allowsAddingToCart(): boolean {
- const { productData } = state;
+ const product = productsState.productInContext;
+
+ if ( ! product ) {
+ return false;
+ }
// 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' ) {
+ if ( product.type === 'grouped' ) {
return true;
}
- return productData?.is_in_stock ?? true;
+ return product.is_purchasable && product.is_in_stock;
},
get quantity(): Record< number, number > {
const context = getContext< Context >();
@@ -183,14 +131,6 @@ const { actions } = store(
const context = getContext< Context >();
return context.selectedAttributes || [];
},
- get productData() {
- const { selectedAttributes } = getContext< Context >();
-
- return getProductData(
- productDataState.productId,
- selectedAttributes
- );
- },
},
actions: {
validateQuantity( productId: number, value?: number ) {
@@ -200,19 +140,14 @@ const { actions } = store(
return;
}
- const { selectedAttributes } = getContext< Context >();
-
// If selected quantity is invalid, add an error.
- const productObject = getProductData(
- productId,
- selectedAttributes
- );
+ const product = productsState.productInContext;
if (
value === 0 ||
- ( productObject &&
- ( value < productObject.min ||
- value > productObject.max ) )
+ ( product &&
+ ( value < product.add_to_cart.minimum ||
+ value > product.add_to_cart.maximum ) )
) {
const { errorMessages } = getConfig();
@@ -232,8 +167,8 @@ const { actions } = store(
const inputElement = quantitySelectorContext?.inputElement;
const isValueNaN = Number.isNaN( inputElement?.valueAsNumber );
- // Get variations from the products store.
- const productFromStore = productsState.products[ productId ];
+ const { mainProductInContext: productFromStore } =
+ productsState;
const variationIds =
productFromStore?.variations?.map( ( v ) => v.id ) ?? [];
@@ -244,20 +179,20 @@ const { actions } = store(
idsToUpdate.forEach( ( id ) => {
if ( isValueNaN ) {
- // Null the value first before setting the real value to ensure that
- // a signal update happens.
- context.quantity[ Number( id ) ] = null;
+ // Modify the value first before setting the real
+ // value to ensure that a signal update happens.
+ context.quantity[ Number( id ) ] = NaN;
}
context.quantity[ Number( id ) ] = value;
} );
} else {
if ( isValueNaN ) {
- // Null the value first before setting the real value to ensure that
- // a signal update happens.
+ // Modify the value first before setting the real value
+ // to ensure that a signal update happens.
context.quantity = {
...context.quantity,
- [ productId ]: null,
+ [ productId ]: NaN,
};
}
@@ -267,7 +202,7 @@ const { actions } = store(
};
}
- if ( state.productData?.type === 'grouped' ) {
+ if ( productsState.mainProductInContext?.type === 'grouped' ) {
actions.validateGroupedProductQuantity();
} else {
actions.validateQuantity( productId, value );
@@ -345,25 +280,19 @@ const { actions } = store(
// woocommerce store is public.
yield import( '@woocommerce/stores/woocommerce/cart' );
- const { selectedAttributes } = getContext< Context >();
-
- const id =
- productDataState.variationId || productDataState.productId;
-
- const productType = productDataState.variationId
- ? 'variation'
- : getProductData( id, selectedAttributes )?.type;
+ const product = productsState.productInContext;
- if ( ! productType ) {
+ if ( ! product ) {
return;
}
- if ( productType === 'grouped' ) {
+ if ( product.type === 'grouped' ) {
yield actions.batchAddToCart();
return;
}
- const { quantity } = getContext< Context >();
+ const { quantity, selectedAttributes } =
+ getContext< Context >();
const { actions: wooActions } = store< WooCommerce >(
'woocommerce',
@@ -372,10 +301,10 @@ const { actions } = store(
);
yield wooActions.addCartItem(
{
- id,
- quantityToAdd: quantity[ id ],
+ id: product.id,
+ quantityToAdd: quantity[ product.id ],
variation: selectedAttributes,
- type: productType,
+ type: product.type,
},
{
showCartUpdatesNotices: false,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts
index 725786f4287..132e6011de1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts
@@ -6,6 +6,8 @@ import type {
ClientCartItem,
Store as WooCommerce,
} from '@woocommerce/stores/woocommerce/cart';
+import '@woocommerce/stores/woocommerce/products';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
/**
* Internal dependencies
@@ -14,12 +16,17 @@ import type {
AddToCartWithOptionsStore,
Context as AddToCartWithOptionsStoreContext,
} from '../frontend';
-import { getProductData } from '../frontend';
// 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.';
+const { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
+ {},
+ { lock: universalLock }
+);
+
export type GroupedProductAddToCartWithOptionsStore =
AddToCartWithOptionsStore & {
actions: {
@@ -63,17 +70,15 @@ const { actions } = store< GroupedProductAddToCartWithOptionsStore >(
const hasInvalidQuantity = Object.entries(
context.quantity
).some( ( [ id, qty ] ) => {
- const productObject = getProductData(
- Number( id ),
- context.selectedAttributes
- );
- if ( ! productObject ) {
+ const product = productsState.findProduct( {
+ id: Number( id ),
+ selectedAttributes: context.selectedAttributes,
+ } );
+ if ( ! product ) {
return false;
}
- return (
- qty !== 0 &&
- ( qty < productObject.min || qty > productObject.max )
- );
+ const { minimum, maximum } = product.add_to_cart;
+ return qty !== 0 && ( qty < minimum || qty > maximum );
} );
if ( hasInvalidQuantity ) {
@@ -99,12 +104,12 @@ const { actions } = store< GroupedProductAddToCartWithOptionsStore >(
continue;
}
- const productObject = getProductData(
- Number( childProductId ),
- selectedAttributes
- );
+ const product = productsState.findProduct( {
+ id: Number( childProductId ),
+ selectedAttributes,
+ } );
- if ( ! productObject ) {
+ if ( ! product ) {
continue;
}
@@ -112,7 +117,7 @@ const { actions } = store< GroupedProductAddToCartWithOptionsStore >(
id: Number( childProductId ),
quantityToAdd: quantity[ childProductId ],
variation: selectedAttributes,
- type: productObject.type,
+ type: product.type,
} );
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/frontend.ts
index 10ba278fff1..46a39b59f13 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/frontend.ts
@@ -2,16 +2,13 @@
* External dependencies
*/
import { store, getContext, getElement } from '@wordpress/interactivity';
-import '@woocommerce/stores/woocommerce/product-data';
-
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
/**
* Internal dependencies
*/
-import { getProductData } from '../frontend';
import type { AddToCartWithOptionsStore } from '../frontend';
export type Context = {
- productId: number;
allowZero?: boolean;
inputElement?: HTMLInputElement | null;
};
@@ -20,6 +17,12 @@ export type Context = {
const universalLock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+const { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
+ {},
+ { lock: universalLock }
+);
+
const addToCartWithOptionsStore = store< AddToCartWithOptionsStore >(
'woocommerce/add-to-cart-with-options',
{},
@@ -49,97 +52,85 @@ store< QuantitySelectorStore >(
{
state: {
get allowsQuantityChange(): boolean {
- const { productData } = addToCartWithOptionsStore.state;
+ const product = productsState.productInContext;
- if ( ! productData ) {
+ if ( ! product ) {
return true;
}
- return (
- productData.is_in_stock && ! productData.sold_individually
- );
+ return product.is_in_stock && ! product.sold_individually;
},
get allowsDecrease() {
- // Note: in grouped products, `productData` will be the parent product.
- // We handle grouped products decrease differently because we
- // allow setting the quantity to 0.
- const { quantity, selectedAttributes } =
- addToCartWithOptionsStore.state;
+ const { quantity } = addToCartWithOptionsStore.state;
- const { allowZero, productId } = getContext< Context >();
+ const product = productsState.productInContext;
- const productObject = getProductData(
- productId,
- selectedAttributes
- );
-
- if ( ! productObject ) {
+ if ( ! product ) {
return true;
}
- const { id, min, step } = productObject;
+ const { id, add_to_cart: addToCart } = product;
const currentQuantity = quantity[ id ] || 0;
+ const { allowZero } = getContext< Context >();
return (
( allowZero && currentQuantity > 0 ) ||
- currentQuantity - step >= min
+ currentQuantity - addToCart.multiple_of >= addToCart.minimum
);
},
get allowsIncrease() {
- const { quantity, selectedAttributes } =
- addToCartWithOptionsStore.state;
+ const { quantity } = addToCartWithOptionsStore.state;
- const { productId } = getContext< Context >();
+ const product = productsState.productInContext;
- const productObject = getProductData(
- productId,
- selectedAttributes
- );
-
- if ( ! productObject ) {
+ if ( ! product ) {
return true;
}
- const { id, max, step } = productObject;
+ const { id, add_to_cart: addToCart } = product;
const currentQuantity = quantity[ id ] || 0;
- return currentQuantity + step <= max;
+ return (
+ currentQuantity + addToCart.multiple_of <= addToCart.maximum
+ );
},
get inputQuantity(): number {
- const { productId } = getContext< Context >();
+ const product = productsState.productInContext;
+
+ if ( ! product ) {
+ return 0;
+ }
const quantity =
- addToCartWithOptionsStore.state.quantity?.[ productId ];
+ addToCartWithOptionsStore.state.quantity?.[ product.id ];
return quantity === undefined ? 0 : quantity;
},
},
actions: {
increaseQuantity: () => {
- const { productId, inputElement } = getContext< Context >();
+ const { inputElement } = getContext< Context >();
if ( ! ( inputElement instanceof HTMLInputElement ) ) {
return;
}
- const currentValue = Number( inputElement.value ) || 0;
-
- const { selectedAttributes } = addToCartWithOptionsStore.state;
+ const product = productsState.productInContext;
- const productObject = getProductData(
- productId,
- selectedAttributes
- );
+ if ( ! product ) {
+ return;
+ }
- let newValue = currentValue + 1;
+ const currentValue = Number( inputElement.value ) || 0;
+ const { id: productId, add_to_cart: addToCart } = product;
+ const { minimum, maximum, multiple_of: multipleOf } = addToCart;
- if ( productObject ) {
- const { max, min, step } = productObject;
- newValue = currentValue + step;
- newValue = Math.max( min, Math.min( max, newValue ) );
- }
+ const newValue = Math.max(
+ minimum,
+ Math.min( maximum, currentValue + multipleOf )
+ );
addToCartWithOptionsStore.actions.setQuantity(
productId,
@@ -147,31 +138,34 @@ store< QuantitySelectorStore >(
);
},
decreaseQuantity: () => {
- const { allowZero, productId, inputElement } =
- getContext< Context >();
+ const { allowZero, inputElement } = getContext< Context >();
if ( ! ( inputElement instanceof HTMLInputElement ) ) {
return;
}
- const currentValue = Number( inputElement.value ) || 0;
- const { selectedAttributes } = addToCartWithOptionsStore.state;
+ const product = productsState.productInContext;
- const productObject = getProductData(
- productId,
- selectedAttributes
- );
+ if ( ! product ) {
+ return;
+ }
- let newValue = currentValue - 1;
+ const currentValue = Number( inputElement.value ) || 0;
+ const { id: productId, add_to_cart: addToCart } = product;
+ const { minimum, maximum, multiple_of: multipleOf } = addToCart;
- if ( productObject ) {
- const { max, min, step } = productObject;
- newValue = currentValue - step;
- if ( allowZero && newValue < min && currentValue === min ) {
- newValue = 0;
- } else {
- newValue = Math.min( max, Math.max( min, newValue ) );
- }
+ let newValue = currentValue - multipleOf;
+ if (
+ allowZero &&
+ newValue < minimum &&
+ currentValue === minimum
+ ) {
+ newValue = 0;
+ } else {
+ newValue = Math.min(
+ maximum,
+ Math.max( minimum, newValue )
+ );
}
if ( newValue !== currentValue ) {
@@ -185,21 +179,16 @@ store< QuantitySelectorStore >(
// the change event isn't triggered in invalid numbers (ie: writing
// letters) if the current value is already invalid or an empty string.
handleQuantityBlur: () => {
- const { allowZero, productId, inputElement } =
- getContext< Context >();
- const { selectedAttributes } = addToCartWithOptionsStore.state;
+ const { allowZero, inputElement } = getContext< Context >();
- const productObject = getProductData(
- productId,
- selectedAttributes
- );
+ const product = productsState.productInContext;
- if ( ! productObject ) {
+ if ( ! product ) {
return;
}
+ const { id: productId, add_to_cart: addToCart } = product;
const isValueNaN = Number.isNaN( inputElement?.valueAsNumber );
- const { min } = productObject;
if (
allowZero &&
@@ -212,10 +201,11 @@ store< QuantitySelectorStore >(
return;
}
- // In other product types, we reset inputs to `min` if they are
- // 0 or NaN.
+ // In other product types, we reset inputs to `minimum` if they
+ // are 0 or NaN.
const value = inputElement?.valueAsNumber ?? NaN;
- const newValue = ! isNaN( value ) && value > 0 ? value : min;
+ const newValue =
+ ! isNaN( value ) && value > 0 ? value : addToCart.minimum;
addToCartWithOptionsStore.actions.setQuantity(
productId,
@@ -229,10 +219,14 @@ store< QuantitySelectorStore >(
return;
}
- const { productId } = getContext< Context >();
+ const product = productsState.productInContext;
+
+ if ( ! product ) {
+ return;
+ }
addToCartWithOptionsStore.actions.setQuantity(
- productId,
+ product.id,
element.ref.checked ? 1 : 0
);
},
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/types.ts
index 0c57d094423..aa9b37b316a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/types.ts
@@ -1,26 +1,3 @@
-/**
- * External dependencies
- */
-import {
- ProductData,
- VariationData,
-} from '@woocommerce/stores/woocommerce/cart';
-
export interface Attributes {
className?: string;
}
-
-export type NormalizedProductData = ProductData & {
- id: number;
- min: number;
- max: number;
- step: number;
-};
-
-export type NormalizedVariationData = VariationData & {
- id: number;
- min: number;
- max: number;
- step: number;
- type: 'variation';
-};
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 4e73669d844..049329d1a9c 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
@@ -9,7 +9,6 @@ import {
} from '@wordpress/interactivity';
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';
@@ -17,7 +16,6 @@ import type { ProductResponseItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
-import { getProductData } from '../frontend';
import type {
AddToCartWithOptionsStore,
Context as AddToCartWithOptionsStoreContext,
@@ -26,7 +24,6 @@ import {
normalizeAttributeName,
attributeNamesMatch,
getVariationAttributeValue,
- findMatchingVariation,
} from '../../../base/utils/variations/attribute-matching';
import setStyles from './set-styles';
@@ -51,12 +48,6 @@ 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',
{},
@@ -101,7 +92,7 @@ const isAttributeValueValid = ( {
? selectedAttributes.length - 1
: selectedAttributes.length;
- const product = productsState.products[ productDataState.productId ];
+ const { mainProductInContext: product } = productsState;
if ( ! product?.variations?.length ) {
return false;
@@ -340,8 +331,7 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
return;
}
- const product =
- productsState.products[ productDataState.productId ];
+ const { mainProductInContext: product } = productsState;
if ( ! product ) {
return;
}
@@ -408,44 +398,51 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
} );
},
setSelectedVariationId: () => {
- const product =
- productsState.products[ productDataState.productId ];
+ const { mainProductInContext: product } = productsState;
if ( ! product?.variations?.length ) {
return;
}
const { selectedAttributes } = getContext< Context >();
- const matchedVariation = findMatchingVariation(
- product,
- selectedAttributes
- );
-
- const { actions: productDataActions } =
- store< ProductDataStore >(
- 'woocommerce/product-data',
- {},
- { lock: universalLock }
- );
- productDataActions.setVariationId(
- matchedVariation?.id ?? null
- );
+ const result = productsState.findProduct( {
+ id: product.id,
+ selectedAttributes,
+ } );
+ // findProduct returns the parent when no variation
+ // matches — only accept an actual variation.
+ const matchedVariation =
+ result && result.id !== product.id ? result : null;
+
+ const variationId = matchedVariation?.id ?? null;
+ const productContext = getContext< {
+ variationId?: number | null;
+ } >( 'woocommerce/products' );
+
+ // If there is context, update the context. Otherwise, update the state directly.
+ ( productContext
+ ? productContext
+ : productsState
+ ).variationId = variationId;
},
validateVariation() {
actions.clearErrors( 'variable-product' );
- const product =
- productsState.products[ productDataState.productId ];
+ const { mainProductInContext: product } = productsState;
if ( ! product?.variations?.length ) {
return;
}
const { selectedAttributes } = getContext< Context >();
- const matchedVariation = findMatchingVariation(
- product,
- selectedAttributes
- );
+ const result = productsState.findProduct( {
+ id: product.id,
+ selectedAttributes,
+ } );
+ // findProduct returns the parent when no variation
+ // matches — only accept an actual variation.
+ const matchedVariation =
+ result && result.id !== product.id ? result : null;
const { errorMessages } = getConfig();
@@ -466,8 +463,6 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
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;
}
@@ -493,34 +488,29 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
return;
}
- const { selectedAttributes } = getContext< Context >();
+ const { productVariationInContext: variation } = productsState;
- const productObject = getProductData(
- productDataState.productId,
- selectedAttributes
- );
+ if ( ! variation ) {
+ return;
+ }
- if ( productObject ) {
- const { quantity } = getContext< Context >();
- const currentValue = quantity[ productObject.id ];
- const { min, max } = productObject;
+ const { minimum, maximum } = variation.add_to_cart;
- let newValue = currentValue;
- if ( currentValue < min ) {
- newValue = min;
- } else if ( currentValue > max ) {
- newValue = max;
- }
+ const { quantity } = getContext< Context >();
+ const currentValue = quantity[ variation.id ];
- if (
- newValue !== ref.valueAsNumber ||
- newValue !== currentValue
- ) {
- actions.setQuantity(
- productDataState.productId,
- newValue
- );
- }
+ let newValue = currentValue;
+ if ( currentValue < minimum ) {
+ newValue = minimum;
+ } else if ( currentValue > maximum ) {
+ newValue = maximum;
+ }
+
+ if (
+ newValue !== ref.valueAsNumber ||
+ newValue !== currentValue
+ ) {
+ actions.setQuantity( variation.id, newValue );
}
},
},
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/frontend.ts
index 02d0245bd76..17980ab4e66 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/frontend.ts
@@ -2,6 +2,7 @@
* External dependencies
*/
import { store, getElement, getContext } from '@wordpress/interactivity';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
/**
* Internal dependencies
@@ -13,9 +14,17 @@ import {
import { CoreCollectionNames } from './types';
import './style.scss';
+// 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.';
+
+const { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
+ {},
+ { lock: universalLock }
+);
+
export type ProductCollectionStoreContext = {
- // Available on the <li/> product element and deeper
- productId?: number;
isPrefetchNextOrPreviousLink: string;
collection: CoreCollectionNames;
// Next/Previous Buttons block context
@@ -196,9 +205,11 @@ const productCollectionStore = {
}
},
*viewProduct() {
- const { collection, productId } =
+ const { collection } =
getContext< ProductCollectionStoreContext >();
+ const productId = productsState.productInContext?.id;
+
if ( productId ) {
triggerViewedProductEvent( { collection, productId } );
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
index 8eca17ffbf6..a9caf549cf6 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
@@ -6,11 +6,10 @@ import {
getContext as getContextFn,
getElement,
withScope,
- getConfig,
withSyncEvent,
} from '@wordpress/interactivity';
-import type { ProductDataStore } from '@woocommerce/stores/woocommerce/product-data';
-import type { WooCommerceConfig } from '@woocommerce/stores/woocommerce/cart';
+import '@woocommerce/stores/woocommerce/products';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
/**
* Internal dependencies
@@ -165,8 +164,8 @@ const scrollThumbnailIntoView = ( imageId: number ) => {
} );
};
-const { state: productDataState } = store< ProductDataStore >(
- 'woocommerce/product-data',
+const { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
{},
{ lock: universalLock }
);
@@ -428,21 +427,13 @@ const productGallery = {
},
callbacks: {
listenToProductDataChanges: () => {
- const productId = productDataState?.productId;
- if ( ! productId ) {
+ const product = productsState.productInContext;
+
+ if ( ! product ) {
return;
}
- const { products } = getConfig(
- 'woocommerce'
- ) as WooCommerceConfig;
-
- const productData =
- products?.[ productId ]?.variations?.[
- productDataState?.variationId || 0
- ] || products?.[ productId ];
-
- const imageId = productData?.image_id;
+ const imageId = product.images?.[ 0 ]?.id;
if ( ! imageId ) {
return;
}
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 e96c704af02..3eb08fdf1c9 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
@@ -44,10 +44,6 @@ const entries = {
'./assets/js/base/stores/woocommerce/cart.ts',
'@woocommerce/stores/store-notices':
'./assets/js/base/stores/store-notices.ts',
- '@woocommerce/stores/woocommerce/product-context':
- './assets/js/base/stores/woocommerce/product-context.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',
};
diff --git a/plugins/woocommerce/client/blocks/tsconfig.base.json b/plugins/woocommerce/client/blocks/tsconfig.base.json
index bd776970083..c923c3f976a 100644
--- a/plugins/woocommerce/client/blocks/tsconfig.base.json
+++ b/plugins/woocommerce/client/blocks/tsconfig.base.json
@@ -121,12 +121,6 @@
"@woocommerce/stores/woocommerce/cart": [
"assets/js/base/stores/woocommerce/cart"
],
- "@woocommerce/stores/woocommerce/product-context": [
- "assets/js/base/stores/woocommerce/product-context"
- ],
- "@woocommerce/stores/woocommerce/product-data": [
- "assets/js/base/stores/woocommerce/product-data"
- ],
"@woocommerce/stores/woocommerce/products": [
"assets/js/base/stores/woocommerce/products"
],
diff --git a/plugins/woocommerce/includes/wc-interactivity-api-functions.php b/plugins/woocommerce/includes/wc-interactivity-api-functions.php
index 5df6113ef96..ae9cc51b1fb 100644
--- a/plugins/woocommerce/includes/wc-interactivity-api-functions.php
+++ b/plugins/woocommerce/includes/wc-interactivity-api-functions.php
@@ -21,6 +21,8 @@ if ( ! defined( 'ABSPATH' ) ) {
*
* This is an experimental API and may change in future versions.
*
+ * @see plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md
+ *
* @param string $consent_statement The consent statement acknowledging this is an experimental API.
* @param int $product_id The product ID to load.
* @return array The product data.
@@ -35,6 +37,8 @@ function wc_interactivity_api_load_product( string $consent_statement, int $prod
*
* This is an experimental API and may change in future versions.
*
+ * @see plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md
+ *
* @param string $consent_statement The consent statement acknowledging this is an experimental API.
* @param int $parent_id The parent product ID.
* @return array The purchasable child products keyed by ID.
@@ -49,6 +53,8 @@ function wc_interactivity_api_load_purchasable_child_products( string $consent_s
*
* This is an experimental API and may change in future versions.
*
+ * @see plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md
+ *
* @param string $consent_statement The consent statement acknowledging this is an experimental API.
* @param int $parent_id The parent product ID.
* @return array The variations keyed by ID.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 4bfb19a359b..1eb3e83066a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -51513,12 +51513,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductSelector.php
- -
- message: '#^Call to an undefined method WC_Product\:\:get_available_variations\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Blocks/BlockTypes/AddToCartWithOptions/QuantitySelector.php
-
-
message: '#^Parameter \#1 \$block of static method Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartWithOptions\\Utils\:\:get_product_from_context\(\) expects WP_Block, Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartWithOptions\\WP_Block given\.$#'
identifier: argument.type
@@ -54615,12 +54609,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductResultsCount.php
- -
- message: '#^Call to an undefined method WC_Product\:\:get_available_variations\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Blocks/BlockTypes/ProductSKU.php
-
-
message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductSKU\:\:get_block_type_uses_context\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -54687,12 +54675,6 @@ parameters:
count: 2
path: src/Blocks/BlockTypes/ProductSpecifications.php
- -
- message: '#^Call to an undefined method WC_Product\:\:get_available_variations\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Blocks/BlockTypes/ProductSpecifications.php
-
-
message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductSpecifications\:\:render\(\) 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 384796b9de9..f2033195f5d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
@@ -567,7 +567,7 @@ class AddToCartWithOptions extends AbstractBlock {
<input type="hidden" name="product_id" value="' . esc_attr( $product_id ) . '" />
<input type="hidden"
name="variation_id"
- data-wp-bind--value="woocommerce/product-data::state.variationId"
+ data-wp-bind--value="woocommerce/products::state.productVariationInContext.id"
/>
</div>';
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductItemSelector.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductItemSelector.php
index a48e8257635..0f8a6e2e587 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductItemSelector.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/GroupedProductItemSelector.php
@@ -83,12 +83,13 @@ class GroupedProductItemSelector extends AbstractBlock {
$quantity_html = AddToCartWithOptionsUtils::add_quantity_stepper_classes( $quantity_html );
$context = array(
- 'productId' => $product->get_id(),
'allowZero' => true, // The item is optional in grouped products.
);
// Add interactive data attribute for the stepper functionality.
- $quantity_html = AddToCartWithOptionsUtils::make_quantity_input_interactive( $quantity_html, array(), array(), $context );
+ // Pass $set_product_context = true because each grouped product child needs its own
+ // products context scope (the inherited context points to the grouped parent).
+ $quantity_html = AddToCartWithOptionsUtils::make_quantity_input_interactive( $quantity_html, array(), array(), $context, true );
return $quantity_html;
}
@@ -143,8 +144,14 @@ class GroupedProductItemSelector extends AbstractBlock {
);
}
- $context_attribute = wp_interactivity_data_wp_context( array( 'productId' => $product->get_id() ) );
- return '<input type="checkbox" name="' . esc_attr( 'quantity[' . $product->get_id() . ']' ) . '" value="1" class="wc-grouped-product-add-to-cart-checkbox" id="' . esc_attr( 'quantity_' . $product->get_id() ) . '" data-wp-interactive="woocommerce/add-to-cart-with-options-quantity-selector" data-wp-on--change="actions.handleQuantityCheckboxChange" ' . $context_attribute . ' aria-label="' . esc_attr( $label ) . '"/>';
+ $product_context_directive = wp_interactivity_data_wp_context(
+ array(
+ 'productId' => $product->get_id(),
+ 'variationId' => null,
+ ),
+ 'woocommerce/products'
+ );
+ return '<input type="checkbox" name="' . esc_attr( 'quantity[' . $product->get_id() . ']' ) . '" value="1" class="wc-grouped-product-add-to-cart-checkbox" id="' . esc_attr( 'quantity_' . $product->get_id() ) . '" data-wp-interactive="woocommerce/add-to-cart-with-options-quantity-selector" data-wp-on--change="actions.handleQuantityCheckboxChange" ' . $product_context_directive . ' aria-label="' . esc_attr( $label ) . '"/>';
}
/**
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/QuantitySelector.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/QuantitySelector.php
index 3c54f6b5d9f..0f49d60144b 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/QuantitySelector.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/QuantitySelector.php
@@ -115,59 +115,13 @@ class QuantitySelector extends AbstractBlock {
$product_quantity_constraints = AddToCartWithOptionsUtils::get_product_quantity_constraints( $product );
- wp_interactivity_config(
- 'woocommerce',
- array(
- 'products' => array(
- $product->get_id() => array(
- 'min' => $product_quantity_constraints['min'],
- 'max' => $product_quantity_constraints['max'],
- 'step' => $product_quantity_constraints['step'],
- ),
- ),
- )
- );
-
if ( $product->is_type( ProductType::VARIABLE ) ) {
wp_enqueue_script_module( 'woocommerce/product-elements' );
- $variations_data = $product->get_available_variations( 'objects' );
- $formatted_variations_data = array();
- foreach ( $variations_data as $variation ) {
- $variation_quantity_constraints = AddToCartWithOptionsUtils::get_product_quantity_constraints( $variation );
- $variation_data = array();
-
- // Only add variation data if it's different than the defaults.
- if ( 1 !== $variation_quantity_constraints['min'] ) {
- $variation_data['min'] = $variation_quantity_constraints['min'];
- }
- if ( null !== $variation_quantity_constraints['max'] ) {
- $variation_data['max'] = $variation_quantity_constraints['max'];
- }
- if ( 1 !== $variation_quantity_constraints['step'] ) {
- $variation_data['step'] = $variation_quantity_constraints['step'];
- }
- if ( $variation->is_sold_individually() ) {
- $variation_data['sold_individually'] = true;
- }
- $formatted_variations_data[ $variation->get_id() ] = $variation_data;
- }
-
- wp_interactivity_config(
- 'woocommerce',
- array(
- 'products' => array(
- $product->get_id() => array(
- 'variations' => $formatted_variations_data,
- ),
- ),
- )
- );
-
$wrapper_attributes['data-wp-bind--hidden'] = 'woocommerce/add-to-cart-with-options-quantity-selector::!state.allowsQuantityChange';
- $input_attributes['data-wp-bind--min'] = 'woocommerce/product-elements::state.productData.min';
- $input_attributes['data-wp-bind--max'] = 'woocommerce/product-elements::state.productData.max';
- $input_attributes['data-wp-bind--step'] = 'woocommerce/product-elements::state.productData.step';
+ $input_attributes['data-wp-bind--min'] = 'woocommerce/products::state.productInContext.add_to_cart.minimum';
+ $input_attributes['data-wp-bind--max'] = 'woocommerce/products::state.productInContext.add_to_cart.maximum';
+ $input_attributes['data-wp-bind--step'] = 'woocommerce/products::state.productInContext.add_to_cart.multiple_of';
$input_attributes['data-wp-watch'] = 'woocommerce/add-to-cart-with-options::callbacks.watchQuantityConstraints';
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
index aa22b0f5305..90182b67b47 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
@@ -105,10 +105,15 @@ class Utils {
* @type int $productId Product ID for context-specific behavior.
* @type bool $allowZero Whether to allow zero quantity.
* }
+ * @param bool $set_product_context Whether to set a local woocommerce/products context on the wrapper.
+ * Only needed when the quantity input belongs to a different product than
+ * the one provided by the inherited context (e.g. child items in grouped products).
+ * Setting this unnecessarily shadows the parent context and prevents
+ * variationId updates from propagating.
*
* @return string The quantity HTML with interactive wrapper.
*/
- public static function make_quantity_input_interactive( $quantity_html, $wrapper_attributes = array(), $input_attributes = array(), $context = array() ) {
+ public static function make_quantity_input_interactive( $quantity_html, $wrapper_attributes = array(), $input_attributes = array(), $context = array(), $set_product_context = false ) {
$processor = new \WP_HTML_Tag_Processor( $quantity_html );
global $product;
@@ -144,19 +149,24 @@ class Utils {
$wrapper_attributes
);
- $context_attribute = wp_interactivity_data_wp_context(
- wp_parse_args(
- $context,
- array(
- 'productId' => $product instanceof \WC_Product ? $product->get_id() : 0,
- )
- )
- );
+ $context_attribute = wp_interactivity_data_wp_context( $context );
+
+ $product_context_directive = '';
+ if ( $set_product_context && $product instanceof \WC_Product ) {
+ $product_context = array(
+ 'productId' => $product->get_id(),
+ 'variationId' => null,
+ );
+
+ // This should use `wp_interactivity_data_wp_context` as well, but it currently doesn't support unique IDs.
+ $product_context_directive = 'data-wp-context---products="woocommerce/products::' . esc_attr( (string) wp_json_encode( $product_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ) . '"';
+ }
return sprintf(
- '<div %1$s %2$s>%3$s</div>',
+ '<div %1$s %2$s %3$s>%4$s</div>',
get_block_wrapper_attributes( $wrapper_attributes ),
$context_attribute,
+ $product_context_directive,
$quantity_html
);
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationDescription.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationDescription.php
index e30ea5ea804..1709a50aac8 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationDescription.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationDescription.php
@@ -36,37 +36,15 @@ class VariationDescription extends AbstractBlock {
return '';
}
- $variations = $product->get_available_variations( 'objects' );
- $formatted_variations_data = array();
- foreach ( $variations as $variation ) {
- $variation_description = $variation->get_description();
- if ( is_string( $variation_description ) && ! empty( $variation_description ) ) {
- $formatted_variations_data[ $variation->get_id() ] = array(
- 'variation_description' => wp_kses_post( wc_format_content( $variation_description ) ),
- );
- }
- }
-
- wp_interactivity_config(
- 'woocommerce',
- array(
- 'products' => array(
- $product->get_id() => array(
- 'variations' => $formatted_variations_data,
- ),
- ),
- )
- );
-
$context_directive = wp_interactivity_data_wp_context(
array(
- 'productElementKey' => 'variation_description',
+ 'productElementKey' => 'description',
)
);
$wrapper_attributes = array(
'data-wp-interactive' => 'woocommerce/product-elements',
- 'data-wp-bind--hidden' => '!state.productData.variation_description',
+ 'data-wp-bind--hidden' => 'woocommerce/products::!state.productVariationInContext.description',
'aria-live' => 'polite',
'aria-atomic' => 'true',
);
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
index 5d056036a2b..6b28dd2e1ff 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
@@ -146,8 +146,6 @@ class ProductButton extends AbstractBlock {
$context = array(
'quantityToAdd' => $default_quantity,
- 'productId' => $product->get_id(),
- 'productType' => $product->get_type(),
'addToCartText' => $add_to_cart_text,
'tempQuantity' => $number_of_items_in_cart,
'animationStatus' => 'IDLE',
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
index c94b0bc18e3..5e80e52a791 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
@@ -155,33 +155,15 @@ class ProductGallery extends AbstractBlock {
);
if ( $product->is_type( ProductType::VARIABLE ) ) {
- $variations_data = $product->get_available_variations( 'objects' );
- $formatted_variations_data = array();
- $has_variation_images = false;
- foreach ( $variations_data as $variation ) {
- $variation_image_id = (int) $variation->get_image_id();
- if ( $variation_image_id ) {
+ $has_variation_images = false;
+ foreach ( $product->get_available_variations( 'objects' ) as $variation ) {
+ if ( (int) $variation->get_image_id() ) {
$has_variation_images = true;
-
- $formatted_variations_data[ $variation->get_id() ] = array(
- 'image_id' => $variation_image_id,
- );
+ break;
}
}
if ( $has_variation_images ) {
- wp_interactivity_config(
- 'woocommerce',
- array(
- 'products' => array(
- $product->get_id() => array(
- 'image_id' => (int) $product->get_image_id(),
- 'variations' => $formatted_variations_data,
- ),
- ),
- )
- );
-
// Support legacy Add to Cart with Options block.
$p->set_attribute( 'data-wp-init--watch-changes-on-add-to-cart-form', 'callbacks.watchForChangesOnAddToCartForm' );
// Support blockified Add to Cart + Options block.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductPrice.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductPrice.php
index a12911b5a8f..7d636a0019f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductPrice.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductPrice.php
@@ -71,79 +71,35 @@ class ProductPrice extends AbstractBlock {
$is_descendant_of_grouped_product_selector = isset( $block->context['isDescendantOfGroupedProductSelector'] );
$is_interactive = ! $is_descendant_of_product_collection && ! $is_descendant_of_grouped_product_selector && $product->is_type( ProductType::VARIABLE );
- $wrapper_attributes = array(
- 'style' => $styles_and_classes['styles'] ?? '',
- 'class' => $styles_and_classes['classes'] ?? '',
- );
- $interactive_attributes = '';
- $context_directive = '';
-
if ( $is_interactive ) {
// phpcs:ignore Generic.Commenting.DocComment.MissingShort -- Type hint for PHPStan.
/** @var \WC_Product_Variable $product */
- // Check if variation prices differ (replicates logic from WC_Product_Variable::get_available_variation).
$prices_vary = $product->get_variation_sale_price( 'min' ) !== $product->get_variation_sale_price( 'max' )
|| $product->get_variation_regular_price( 'min' ) !== $product->get_variation_regular_price( 'max' );
- $formatted_variations_data = array();
-
- if ( $prices_vary ) {
- $variations_data = $product->get_available_variations( 'objects' );
-
- foreach ( $variations_data as $variation ) {
- /**
- * Filter whether to show variation price.
- * Replicates the filter from WC_Product_Variable::get_available_variation().
- *
- * @since 2.4.0
- *
- * @param bool $show_price Whether to show the price.
- * @param \WC_Product_Variable $product The variable product.
- * @param \WC_Product_Variation $variation The variation.
- */
- $show_variation_price = apply_filters(
- 'woocommerce_show_variation_price',
- true,
- $product,
- $variation
- );
-
- if ( ! $show_variation_price ) {
- continue;
- }
-
- $formatted_variations_data[ $variation->get_id() ] = array(
- 'price_html' => '<span class="price">' . $variation->get_price_html() . '</span>',
- );
- }
- }
-
- if ( empty( $formatted_variations_data ) ) {
+ if ( ! $prices_vary ) {
$is_interactive = false;
- } else {
- wp_interactivity_config(
- 'woocommerce',
- array(
- 'products' => array(
- $product->get_id() => array(
- 'price_html' => $product->get_price_html(),
- 'variations' => $formatted_variations_data,
- ),
- ),
- )
- );
-
- wp_enqueue_script_module( 'woocommerce/product-elements' );
- $wrapper_attributes['data-wp-interactive'] = 'woocommerce/product-elements';
- $context_directive = wp_interactivity_data_wp_context(
- array(
- 'productElementKey' => 'price_html',
- )
- );
- $interactive_attributes = 'data-wp-watch="callbacks.updateValue" aria-live="polite" aria-atomic="true"';
}
}
+ $wrapper_attributes = array(
+ 'style' => $styles_and_classes['styles'] ?? '',
+ 'class' => $styles_and_classes['classes'] ?? '',
+ );
+ $interactive_attributes = '';
+ $context_directive = '';
+
+ if ( $is_interactive ) {
+ wp_enqueue_script_module( 'woocommerce/product-elements' );
+ $wrapper_attributes['data-wp-interactive'] = 'woocommerce/product-elements';
+ $context_directive = wp_interactivity_data_wp_context(
+ array(
+ 'productElementKey' => 'price_html',
+ )
+ );
+ $interactive_attributes = 'data-wp-watch="callbacks.updateValue" aria-live="polite" aria-atomic="true"';
+ }
+
return sprintf(
'<div %1$s %2$s><div class="wc-block-components-product-price wc-block-grid__product-price" %3$s>
%4$s
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSKU.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSKU.php
index d10602c624f..eb59c581f3a 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSKU.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSKU.php
@@ -73,25 +73,6 @@ class ProductSKU extends AbstractBlock {
$is_interactive = ! $is_descendant_of_product_collection && $product->is_type( ProductType::VARIABLE );
if ( $is_interactive ) {
- $variations = $product->get_available_variations( 'objects' );
- $formatted_variations_data = array();
- foreach ( $variations as $variation ) {
- $formatted_variations_data[ $variation->get_id() ] = array(
- 'sku' => $variation->get_sku(),
- );
- }
-
- wp_interactivity_config(
- 'woocommerce',
- array(
- 'products' => array(
- $product->get_id() => array(
- 'sku' => $product_sku,
- 'variations' => $formatted_variations_data,
- ),
- ),
- )
- );
wp_enqueue_script_module( 'woocommerce/product-elements' );
}
@@ -107,7 +88,7 @@ class ProductSKU extends AbstractBlock {
$suffix = sprintf( '<span class="wp-block-post-terms__suffix">%s</span>', $suffix );
}
- $interactive_attributes = $is_interactive ? 'data-wp-interactive="woocommerce/product-elements" data-wp-text="state.productData.sku"' : '';
+ $interactive_attributes = $is_interactive ? 'data-wp-interactive="woocommerce/products" data-wp-text="state.productInContext.sku"' : '';
return sprintf(
'<div class="wc-block-components-product-sku wc-block-grid__product-sku wp-block-woocommerce-product-sku product_meta wp-block-post-terms %1$s" style="%2$s">
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSpecifications.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSpecifications.php
index bf6441736bd..bf9efa8be63 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSpecifications.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSpecifications.php
@@ -53,42 +53,23 @@ class ProductSpecifications extends AbstractBlock {
if ( $show_weight && $product->has_weight() ) {
$product_data['weight'] = array(
- 'label' => __( 'Weight', 'woocommerce' ),
- 'value' => wc_format_weight( $product->get_weight() ),
+ 'label' => __( 'Weight', 'woocommerce' ),
+ 'value' => wc_format_weight( $product->get_weight() ),
+ 'api_field' => 'formatted_weight',
);
}
if ( $show_dimensions && $product->has_dimensions() ) {
$product_data['dimensions'] = array(
- 'label' => __( 'Dimensions', 'woocommerce' ),
- 'value' => wc_format_dimensions( $product->get_dimensions( false ) ),
+ 'label' => __( 'Dimensions', 'woocommerce' ),
+ 'value' => wc_format_dimensions( $product->get_dimensions( false ) ),
+ 'api_field' => 'formatted_dimensions',
);
}
$is_interactive = $product->is_type( ProductType::VARIABLE );
if ( $is_interactive ) {
- $variations = $product->get_available_variations( 'objects' );
- $formatted_variations_data = array();
- foreach ( $variations as $variation ) {
- $formatted_variations_data[ $variation->get_id() ] = array(
- 'weight' => wc_format_weight( $variation->get_weight() ),
- 'dimensions' => html_entity_decode( wc_format_dimensions( $variation->get_dimensions( false ) ), ENT_QUOTES, get_bloginfo( 'charset' ) ),
- );
- }
-
- wp_interactivity_config(
- 'woocommerce',
- array(
- 'products' => array(
- $product->get_id() => array(
- 'weight' => $product_data['weight']['value'] ?? '',
- 'dimensions' => html_entity_decode( $product_data['dimensions']['value'] ?? '', ENT_QUOTES, get_bloginfo( 'charset' ) ),
- 'variations' => $formatted_variations_data,
- ),
- ),
- )
- );
wp_enqueue_script_module( 'woocommerce/product-elements' );
}
@@ -148,8 +129,8 @@ class ProductSpecifications extends AbstractBlock {
<th scope="row" class="wp-block-product-specifications-item__label">
<?php echo wp_kses_post( $product_attribute['label'] ); ?>
</th>
- <?php if ( $is_interactive && in_array( $product_attribute_key, array( 'weight', 'dimensions' ), true ) ) : ?>
- <td class="wp-block-product-specifications-item__value" data-wp-interactive="woocommerce/product-elements" data-wp-text="state.productData.<?php echo esc_attr( $product_attribute_key ); ?>">
+ <?php if ( $is_interactive && isset( $product_attribute['api_field'] ) ) : ?>
+ <td class="wp-block-product-specifications-item__value" data-wp-interactive="woocommerce/products" data-wp-text="state.productInContext.<?php echo esc_attr( $product_attribute['api_field'] ); ?>">
<?php echo wp_kses_post( $product_attribute['value'] ); ?>
</td>
<?php else : ?>
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductStockIndicator.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductStockIndicator.php
index 57b58c663e8..2174fe9006a 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductStockIndicator.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductStockIndicator.php
@@ -125,32 +125,9 @@ class ProductStockIndicator extends AbstractBlock {
$watch_attribute = '';
if ( $is_interactive && 'out-of-stock' !== $availability['class'] ) {
- $variations = $product_to_render->get_available_variations( 'objects' );
- $formatted_variations_data = array();
- foreach ( $variations as $variation ) {
- $variation_availability = $variation->get_availability();
- if ( is_string( $variation_availability['availability'] ) && ! empty( $variation_availability['availability'] ) ) {
- $formatted_variations_data[ $variation->get_id() ] = array(
- 'availability' => $variation_availability['availability'],
- );
- }
- }
-
- wp_interactivity_config(
- 'woocommerce',
- array(
- 'products' => array(
- $product_to_render->get_id() => array(
- 'availability' => $availability['availability'],
- 'variations' => $formatted_variations_data,
- ),
- ),
- )
- );
-
wp_enqueue_script_module( 'woocommerce/product-elements' );
- $wrapper_attributes['data-wp-interactive'] = 'woocommerce/product-elements';
- $wrapper_attributes['data-wp-text'] = 'state.productData.availability';
+ $wrapper_attributes['data-wp-interactive'] = 'woocommerce/products';
+ $wrapper_attributes['data-wp-text'] = 'state.productInContext.stock_availability.text';
$wrapper_attributes['aria-live'] = 'polite';
$wrapper_attributes['aria-atomic'] = 'true';
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductTemplate.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductTemplate.php
index 0b8002fe6e1..f9867646ac9 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductTemplate.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductTemplate.php
@@ -92,7 +92,7 @@ class ProductTemplate extends AbstractBlock {
// Get an instance of the current Post Template block.
$block_instance = $block->parsed_block;
- $product_id = get_the_ID();
+ $product_id = (int) get_the_ID();
// Set the block name to one that does not correspond to an existing registered block.
// This ensures that for the inner instances of the Post Template block, we do not render any block supports.
@@ -116,13 +116,22 @@ class ProductTemplate extends AbstractBlock {
)
)->render( array( 'dynamic' => false ) );
- $context = array(
- 'productId' => $product_id,
+ // Load product into the shared products store.
+ 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_id
+ );
+ $product_context_directive = wp_interactivity_data_wp_context(
+ array(
+ 'productId' => $product_id,
+ 'variationId' => null,
+ ),
+ 'woocommerce/products'
);
$li_directives = '
data-wp-interactive="woocommerce/product-collection"
- data-wp-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) . '\'
+ ' . $product_context_directive . '
data-wp-key="product-item-' . $product_id . '"
';
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/SingleProduct.php b/plugins/woocommerce/src/Blocks/BlockTypes/SingleProduct.php
index df22b72d27f..7db69b6efa2 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/SingleProduct.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/SingleProduct.php
@@ -188,6 +188,12 @@ class SingleProduct extends AbstractBlock {
return '';
}
+ // Load product into the shared products store.
+ 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()
+ );
+
$interactivity_context = array(
'productId' => $product->get_id(),
'variationId' => null,
@@ -197,7 +203,7 @@ class SingleProduct extends AbstractBlock {
if ( $html->next_tag( array( 'tag_name' => 'div' ) ) ) {
$html->set_attribute( 'data-wp-interactive', $this->get_full_block_name() );
- $html->set_attribute( 'data-wp-context', wp_json_encode( $interactivity_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) );
+ $html->set_attribute( 'data-wp-context', 'woocommerce/products::' . wp_json_encode( $interactivity_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) );
}
$updated_html = $html->get_updated_html();
diff --git a/plugins/woocommerce/src/Blocks/SharedStores/ProductsStore.php b/plugins/woocommerce/src/Blocks/SharedStores/ProductsStore.php
index c78a1255cfa..6ceea096a36 100644
--- a/plugins/woocommerce/src/Blocks/SharedStores/ProductsStore.php
+++ b/plugins/woocommerce/src/Blocks/SharedStores/ProductsStore.php
@@ -8,9 +8,25 @@ use Automattic\WooCommerce\Blocks\Package;
use InvalidArgumentException;
/**
- * Manages the registration of interactivity state that provides product data
- * to interactive blocks. This is shared store data that is not tied to one
- * specific block.
+ * Shared store that hydrates the `woocommerce/products` Interactivity API
+ * store with product and variation data in Store API format.
+ *
+ * The store exposes two planes:
+ * - Raw data (`products`, `productVariations`) populated by the `load_*`
+ * methods below, each keyed by ID.
+ * - Selection (`productId`, `variationId`) — set by callers via
+ * `wp_interactivity_state` (global) or `data-wp-context` (per-element) —
+ * plus the derived getters (`mainProductInContext`,
+ * `productVariationInContext`, `productInContext`) registered by
+ * `register_getters()`.
+ *
+ * The derived getters are mirrored in the JS store
+ * (client/blocks/assets/js/base/stores/woocommerce/products.ts) so that
+ * directive bindings like `state.productInContext.sku` resolve during
+ * server-side rendering as well as on the client.
+ *
+ * See client/blocks/assets/js/base/stores/woocommerce/README.md for the
+ * full model and consumer examples.
*
* This is an experimental API and may change in future versions.
*/
@@ -44,6 +60,20 @@ class ProductsStore {
*/
private static array $product_variations = array();
+ /**
+ * Parent product IDs whose variations have already been loaded.
+ *
+ * @var array<int, true>
+ */
+ private static array $loaded_variation_parents = array();
+
+ /**
+ * Whether the derived-state getters have been registered.
+ *
+ * @var bool
+ */
+ private static bool $getters_registered = false;
+
/**
* Check that the consent statement was passed.
*
@@ -60,24 +90,70 @@ class ProductsStore {
}
/**
- * Register the interactivity state if products have been loaded.
+ * Register the derived-state getters once.
+ *
+ * These closures mirror the JS getters in
+ * client/blocks/assets/js/base/stores/woocommerce/products.ts so that
+ * directives referencing state.mainProductInContext /
+ * state.productVariationInContext / state.productInContext resolve
+ * during SSR. Because they read from
+ * wp_interactivity_state() at call time, they only need to be
+ * registered once regardless of how many products are added.
*
* @return void
*/
- private static function register_state(): void {
- $state = array();
-
- if ( ! empty( self::$products ) ) {
- $state['products'] = self::$products;
- }
-
- if ( ! empty( self::$product_variations ) ) {
- $state['productVariations'] = self::$product_variations;
+ private static function register_getters(): void {
+ if ( self::$getters_registered ) {
+ return;
}
- if ( ! empty( $state ) ) {
- wp_interactivity_state( self::$store_namespace, $state );
- }
+ self::$getters_registered = true;
+
+ wp_interactivity_state(
+ self::$store_namespace,
+ array(
+ 'mainProductInContext' => function () {
+ $context = wp_interactivity_get_context();
+ $state = wp_interactivity_state( self::$store_namespace );
+ $product_id = array_key_exists( 'productId', $context )
+ ? $context['productId']
+ : ( $state['productId'] ?? null );
+
+ if ( ! $product_id ) {
+ return null;
+ }
+
+ return $state['products'][ $product_id ] ?? null;
+ },
+ 'productVariationInContext' => function () {
+ $context = wp_interactivity_get_context();
+ $state = wp_interactivity_state( self::$store_namespace );
+ $variation_id = array_key_exists( 'variationId', $context )
+ ? $context['variationId']
+ : ( $state['variationId'] ?? null );
+
+ if ( ! $variation_id ) {
+ return null;
+ }
+
+ return $state['productVariations'][ $variation_id ] ?? null;
+ },
+ 'productInContext' => function () {
+ $state = wp_interactivity_state( self::$store_namespace );
+ $selected = $state['productVariationInContext'] instanceof \Closure
+ ? $state['productVariationInContext']()
+ : $state['productVariationInContext'];
+
+ if ( $selected ) {
+ return $selected;
+ }
+
+ return $state['mainProductInContext'] instanceof \Closure
+ ? $state['mainProductInContext']()
+ : $state['mainProductInContext'];
+ },
+ )
+ );
}
/**
@@ -99,7 +175,11 @@ class ProductsStore {
$response = Package::container()->get( Hydration::class )->get_rest_api_response_data( '/wc/store/v1/products/' . $product_id );
self::$products[ $product_id ] = $response['body'] ?? array();
- self::register_state();
+ self::register_getters();
+ wp_interactivity_state(
+ self::$store_namespace,
+ array( 'products' => array( $product_id => self::$products[ $product_id ] ) )
+ );
return self::$products[ $product_id ];
}
@@ -152,7 +232,11 @@ class ProductsStore {
// Use array_replace instead of array_merge to preserve numeric keys.
$keyed_products = array_column( $purchasable_products, null, 'id' );
self::$products = array_replace( self::$products, $keyed_products );
- self::register_state();
+ self::register_getters();
+ wp_interactivity_state(
+ self::$store_namespace,
+ array( 'products' => $keyed_products )
+ );
return $keyed_products;
}
@@ -168,8 +252,18 @@ class ProductsStore {
public static function load_variations( string $consent_statement, int $parent_id ): array {
self::check_consent( $consent_statement );
+ // Skip loading if variations for this parent have already been loaded.
+ if ( isset( self::$loaded_variation_parents[ $parent_id ] ) ) {
+ return array_filter(
+ self::$product_variations,
+ fn( $variation ) => ( $variation['parent'] ?? 0 ) === $parent_id
+ );
+ }
+
$response = Package::container()->get( Hydration::class )->get_rest_api_response_data( '/wc/store/v1/products?parent[]=' . $parent_id . '&type=variation' );
+ self::$loaded_variation_parents[ $parent_id ] = true;
+
if ( empty( $response['body'] ) ) {
return array();
}
@@ -178,7 +272,11 @@ class ProductsStore {
// Use array_replace instead of array_merge to preserve numeric keys.
$keyed_variations = array_column( $response['body'], null, 'id' );
self::$product_variations = array_replace( self::$product_variations, $keyed_variations );
- self::register_state();
+ self::register_getters();
+ wp_interactivity_state(
+ self::$store_namespace,
+ array( 'productVariations' => $keyed_variations )
+ );
return $keyed_variations;
}
diff --git a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
index f7c85f52b47..41d2a2bcb72 100644
--- a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
@@ -91,54 +91,14 @@ class SingleProductTemplate extends AbstractTemplate {
// state closures can resolve it during server-side rendering.
ProductsStore::load_product( $consent, $product->get_id() );
- // Keep the existing product-data store hydrated so current
- // consumers (add-to-cart-with-options, variation-selector)
- // continue to work. This will be removed when consumers are
- // migrated to product-context.
+ // Set the current product context. The derived state
+ // closures (mainProductInContext, productVariationInContext, productInContext)
+ // are registered by ProductsStore::register_state().
wp_interactivity_state(
- 'woocommerce/product-data',
+ 'woocommerce/products',
array(
- 'templateState' => array(
- 'productId' => $product->get_id(),
- 'variationId' => null,
- ),
- )
- );
-
- // Register the product-context store state. The derived state
- // closures (product, selectedVariation) mirror the JS getters
- // so that directives referencing state.product resolve during
- // SSR. If more call sites need to register these closures,
- // consider extracting them into a shared helper.
- wp_interactivity_state(
- 'woocommerce/product-context',
- array(
- 'productId' => $product->get_id(),
- 'variationId' => null,
- 'product' => function () {
- $context = wp_interactivity_get_context();
- $state = wp_interactivity_state( 'woocommerce/product-context' );
- $product_id = ! empty( $context ) ? $context['productId'] : ( $state['productId'] ?? null );
-
- if ( ! $product_id ) {
- return null;
- }
-
- $products_state = wp_interactivity_state( 'woocommerce/products' );
- return $products_state['products'][ $product_id ] ?? null;
- },
- 'selectedVariation' => function () {
- $context = wp_interactivity_get_context();
- $state = wp_interactivity_state( 'woocommerce/product-context' );
- $variation_id = ! empty( $context ) ? $context['variationId'] : ( $state['variationId'] ?? null );
-
- if ( ! $variation_id ) {
- return null;
- }
-
- $products_state = wp_interactivity_state( 'woocommerce/products' );
- return $products_state['productVariations'][ $variation_id ] ?? null;
- },
+ 'productId' => $product->get_id(),
+ 'variationId' => null,
)
);
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/SharedStores/ProductsStore.php b/plugins/woocommerce/tests/php/src/Blocks/SharedStores/ProductsStore.php
new file mode 100644
index 00000000000..d6d6b53b59d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/SharedStores/ProductsStore.php
@@ -0,0 +1,545 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\SharedStores;
+
+use Automattic\WooCommerce\Blocks\Domain\Services\Hydration;
+use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Blocks\SharedStores\ProductsStore as TestedProductsStore;
+use WC_Helper_Product;
+use WC_Product_Grouped;
+
+/**
+ * Tests for the ProductsStore shared store.
+ */
+class ProductsStore extends \WC_Unit_Test_Case {
+
+ /**
+ * Consent string required by the ProductsStore API.
+ *
+ * @var string
+ */
+ protected $consent = 'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+
+ /**
+ * The Interactivity API store namespace under test.
+ *
+ * @var string
+ */
+ protected $store_namespace = 'woocommerce/products';
+
+ /**
+ * Captured original Hydration registry entry for restoration in tearDown.
+ *
+ * @var mixed
+ */
+ protected $original_hydration_registry_entry = null;
+
+ /**
+ * Reset static state on the ProductsStore and the global
+ * WP_Interactivity_API instance between tests so state does not bleed.
+ */
+ public function tearDown(): void {
+ $this->reset_products_store_static_state();
+ $this->reset_interactivity_state();
+ $this->restore_hydration_container_entry();
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox load_product() rejects calls without the consent string.
+ */
+ public function test_load_product_throws_without_consent(): void {
+ $this->expectException( \InvalidArgumentException::class );
+
+ TestedProductsStore::load_product( 'nope', 123 );
+ }
+
+ /**
+ * @testdox load_variations() rejects calls without the consent string.
+ */
+ public function test_load_variations_throws_without_consent(): void {
+ $this->expectException( \InvalidArgumentException::class );
+
+ TestedProductsStore::load_variations( 'nope', 123 );
+ }
+
+ /**
+ * @testdox load_purchasable_child_products() rejects calls without the consent string.
+ */
+ public function test_load_purchasable_child_products_throws_without_consent(): void {
+ $this->expectException( \InvalidArgumentException::class );
+
+ TestedProductsStore::load_purchasable_child_products( 'nope', 123 );
+ }
+
+ /**
+ * @testdox load_product() hydrates interactivity state with the product payload.
+ */
+ public function test_load_product_populates_state(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $result = TestedProductsStore::load_product( $this->consent, $product->get_id() );
+
+ $state = wp_interactivity_state( $this->store_namespace );
+
+ $this->assertArrayHasKey( 'products', $state );
+ $this->assertArrayHasKey( $product->get_id(), $state['products'] );
+ $this->assertSame( $product->get_name(), $state['products'][ $product->get_id() ]['name'] );
+ $this->assertSame( $product->get_name(), $result['name'], 'Return value should contain the product data.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox load_product() fetches each product ID from REST only once.
+ */
+ public function test_load_product_is_memoized_per_id(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $fake_hydration = $this->create_counting_hydration(
+ array(
+ 'body' => array(
+ 'id' => $product->get_id(),
+ 'name' => 'Fake Product',
+ ),
+ )
+ );
+ $this->inject_hydration( $fake_hydration );
+
+ TestedProductsStore::load_product( $this->consent, $product->get_id() );
+ TestedProductsStore::load_product( $this->consent, $product->get_id() );
+
+ $this->assertSame( 1, $fake_hydration->call_count, 'Should fetch only once per product ID.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox load_variations() hydrates interactivity state with every child variation.
+ */
+ public function test_load_variations_populates_state(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $product->get_children();
+
+ $result = TestedProductsStore::load_variations( $this->consent, $product->get_id() );
+
+ $state = wp_interactivity_state( $this->store_namespace );
+
+ $this->assertArrayHasKey( 'productVariations', $state );
+ $this->assertNotEmpty( $result, 'Should return loaded variations.' );
+
+ foreach ( $variation_ids as $variation_id ) {
+ $this->assertArrayHasKey(
+ $variation_id,
+ $state['productVariations'],
+ "Variation {$variation_id} should be in state."
+ );
+ }
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox load_variations() fetches each parent product from REST only once.
+ */
+ public function test_load_variations_is_memoized_per_parent(): void {
+ $product = WC_Helper_Product::create_variation_product();
+
+ $fake_hydration = $this->create_counting_hydration(
+ array(
+ 'body' => array(
+ array(
+ 'id' => 999,
+ 'parent' => $product->get_id(),
+ 'name' => 'Fake Variation',
+ ),
+ ),
+ )
+ );
+ $this->inject_hydration( $fake_hydration );
+
+ TestedProductsStore::load_variations( $this->consent, $product->get_id() );
+ $second = TestedProductsStore::load_variations( $this->consent, $product->get_id() );
+
+ $this->assertSame( 1, $fake_hydration->call_count, 'Should fetch only once per parent.' );
+ $this->assertArrayHasKey( 999, $second, 'Second call should return the cached variations for the parent.' );
+
+ $reflection = new \ReflectionClass( TestedProductsStore::class );
+ $latch = $reflection->getProperty( 'loaded_variation_parents' );
+ $latch->setAccessible( true );
+ $loaded = $latch->getValue();
+ $this->assertArrayHasKey(
+ $product->get_id(),
+ $loaded,
+ 'loaded_variation_parents should record the parent ID.'
+ );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox load_variations() returns only the variations belonging to the requested parent.
+ */
+ public function test_load_variations_second_call_filters_by_parent(): void {
+ $reflection = new \ReflectionClass( TestedProductsStore::class );
+ $variations = $reflection->getProperty( 'product_variations' );
+ $variations->setAccessible( true );
+ $loaded = $reflection->getProperty( 'loaded_variation_parents' );
+ $loaded->setAccessible( true );
+
+ $variations->setValue(
+ null,
+ array(
+ 10 => array(
+ 'id' => 10,
+ 'parent' => 1,
+ ),
+ 20 => array(
+ 'id' => 20,
+ 'parent' => 2,
+ ),
+ 30 => array(
+ 'id' => 30,
+ 'parent' => 1,
+ ),
+ )
+ );
+ $loaded->setValue( null, array( 1 => true ) );
+
+ $result = TestedProductsStore::load_variations( $this->consent, 1 );
+
+ $this->assertCount( 2, $result );
+ $this->assertArrayHasKey( 10, $result );
+ $this->assertArrayHasKey( 30, $result );
+ $this->assertArrayNotHasKey( 20, $result );
+ }
+
+ /**
+ * @testdox load_purchasable_child_products() returns an empty array for an unknown parent ID.
+ */
+ public function test_load_purchasable_child_products_returns_empty_for_bogus_id(): void {
+ $result = TestedProductsStore::load_purchasable_child_products( $this->consent, 999999999 );
+
+ $this->assertSame( array(), $result );
+ }
+
+ /**
+ * @testdox load_purchasable_child_products() returns an empty array for a grouped product with no children.
+ */
+ public function test_load_purchasable_child_products_returns_empty_for_childless_parent(): void {
+ $grouped = new WC_Product_Grouped();
+ $grouped->set_name( 'Empty Grouped' );
+ $grouped->save();
+
+ $result = TestedProductsStore::load_purchasable_child_products( $this->consent, $grouped->get_id() );
+
+ $this->assertSame( array(), $result );
+
+ $grouped->delete( true );
+ }
+
+ /**
+ * @testdox load_purchasable_child_products() excludes non-purchasable children.
+ */
+ public function test_load_purchasable_child_products_filters_non_purchasable(): void {
+ $purchasable = WC_Helper_Product::create_simple_product();
+ $non_purchasable = WC_Helper_Product::create_simple_product();
+
+ $grouped = new WC_Product_Grouped();
+ $grouped->set_name( 'Grouped With Mixed Children' );
+ $grouped->set_children( array( $purchasable->get_id(), $non_purchasable->get_id() ) );
+ $grouped->save();
+
+ $fake_hydration = $this->create_counting_hydration(
+ array(
+ 'body' => array(
+ array(
+ 'id' => $purchasable->get_id(),
+ 'name' => 'Purchasable',
+ 'is_purchasable' => true,
+ ),
+ array(
+ 'id' => $non_purchasable->get_id(),
+ 'name' => 'Not Purchasable',
+ 'is_purchasable' => false,
+ ),
+ ),
+ )
+ );
+ $this->inject_hydration( $fake_hydration );
+
+ $result = TestedProductsStore::load_purchasable_child_products( $this->consent, $grouped->get_id() );
+
+ $this->assertCount( 1, $result, 'Only purchasable children should be returned.' );
+ $this->assertArrayHasKey( $purchasable->get_id(), $result );
+ $this->assertArrayNotHasKey( $non_purchasable->get_id(), $result );
+
+ $grouped->delete( true );
+ $purchasable->delete( true );
+ $non_purchasable->delete( true );
+ }
+
+ /**
+ * @testdox register_getters() registers the derived state closures exactly once.
+ */
+ public function test_register_getters_is_idempotent(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $reflection = new \ReflectionClass( TestedProductsStore::class );
+ $flag = $reflection->getProperty( 'getters_registered' );
+ $flag->setAccessible( true );
+
+ $this->assertFalse( $flag->getValue() );
+
+ TestedProductsStore::load_product( $this->consent, $product->get_id() );
+ $this->assertTrue( $flag->getValue() );
+
+ TestedProductsStore::load_product( $this->consent, $product->get_id() );
+ TestedProductsStore::load_variations( $this->consent, $product->get_id() );
+
+ $this->assertTrue( $flag->getValue(), 'getters_registered should remain true.' );
+
+ $state = wp_interactivity_state( $this->store_namespace );
+ $this->assertArrayHasKey( 'mainProductInContext', $state );
+ $this->assertArrayHasKey( 'productVariationInContext', $state );
+ $this->assertArrayHasKey( 'productInContext', $state );
+ $this->assertInstanceOf( \Closure::class, $state['mainProductInContext'] );
+ $this->assertInstanceOf( \Closure::class, $state['productVariationInContext'] );
+ $this->assertInstanceOf( \Closure::class, $state['productInContext'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox state.mainProductInContext resolves to the hydrated product matching state.productId.
+ */
+ public function test_product_getter_reads_from_state(): void {
+ $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::get_context' );
+
+ $product = WC_Helper_Product::create_simple_product();
+
+ TestedProductsStore::load_product( $this->consent, $product->get_id() );
+
+ wp_interactivity_state(
+ $this->store_namespace,
+ array( 'productId' => $product->get_id() )
+ );
+
+ $state = wp_interactivity_state( $this->store_namespace );
+ $closure = $state['mainProductInContext'];
+ $this->assertInstanceOf( \Closure::class, $closure );
+
+ $resolved = $closure();
+
+ $this->assertIsArray( $resolved );
+ $this->assertSame( $product->get_name(), $resolved['name'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox state.productVariationInContext resolves to the hydrated variation matching state.variationId.
+ */
+ public function test_selected_variation_getter_reads_from_state(): void {
+ $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::get_context' );
+
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $product->get_children();
+ $variation_id = (int) $variation_ids[0];
+
+ TestedProductsStore::load_variations( $this->consent, $product->get_id() );
+
+ wp_interactivity_state(
+ $this->store_namespace,
+ array( 'variationId' => $variation_id )
+ );
+
+ $state = wp_interactivity_state( $this->store_namespace );
+ $closure = $state['productVariationInContext'];
+ $this->assertInstanceOf( \Closure::class, $closure );
+
+ $resolved = $closure();
+
+ $this->assertIsArray( $resolved );
+ $this->assertSame( $variation_id, $resolved['id'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox state.productInContext unwraps closure getters and falls back to the product when no variation is selected.
+ */
+ public function test_product_in_context_unwraps_closure_selected_variation(): void {
+ $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::get_context' );
+
+ $product = WC_Helper_Product::create_simple_product();
+
+ TestedProductsStore::load_product( $this->consent, $product->get_id() );
+
+ wp_interactivity_state(
+ $this->store_namespace,
+ array( 'productId' => $product->get_id() )
+ );
+
+ $state = wp_interactivity_state( $this->store_namespace );
+
+ $this->assertInstanceOf(
+ \Closure::class,
+ $state['productVariationInContext'],
+ 'productVariationInContext should still be a Closure at the point productInContext unwraps it.'
+ );
+
+ $resolved = $state['productInContext']();
+
+ $this->assertIsArray(
+ $resolved,
+ 'productInContext should unwrap the closures and fall through to the product branch when no variation is selected.'
+ );
+ $this->assertSame( $product->get_name(), $resolved['name'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * Create an anonymous Hydration stand-in that counts how many times
+ * get_rest_api_response_data was called and returns a canned response.
+ *
+ * @param array $response The response to return from get_rest_api_response_data.
+ * @return object A fake Hydration with public `$call_count`.
+ */
+ private function create_counting_hydration( array $response ): object {
+ return new class( $response ) {
+ /**
+ * The canned response.
+ *
+ * @var array
+ */
+ private array $response;
+
+ /**
+ * How many times get_rest_api_response_data was called.
+ *
+ * @var int
+ */
+ public int $call_count = 0;
+
+ /**
+ * Constructor.
+ *
+ * @param array $response The canned response.
+ */
+ public function __construct( array $response ) {
+ $this->response = $response;
+ }
+
+ /**
+ * Mimic Hydration::get_rest_api_response_data.
+ *
+ * @param string $path The REST path (ignored).
+ * @return array The canned response.
+ */
+ public function get_rest_api_response_data( string $path ): array {
+ // Avoid parameter not used PHPCS errors.
+ unset( $path );
+ ++$this->call_count;
+ return $this->response;
+ }
+ };
+ }
+
+ /**
+ * Swap the Hydration entry in the Blocks DI container with a fake. Also
+ * captures the original entry so tearDown() can restore it.
+ *
+ * @param object $fake The fake Hydration instance.
+ */
+ private function inject_hydration( object $fake ): void {
+ $container = Package::container();
+ $container_reflection = new \ReflectionClass( $container );
+ $registry_property = $container_reflection->getProperty( 'registry' );
+ $registry_property->setAccessible( true );
+ $registry = $registry_property->getValue( $container );
+
+ if ( null === $this->original_hydration_registry_entry ) {
+ $this->original_hydration_registry_entry = $registry[ Hydration::class ] ?? false;
+ }
+
+ $shared_type_class = 'Automattic\\WooCommerce\\Blocks\\Registry\\SharedType';
+ $registry[ Hydration::class ] = new $shared_type_class(
+ function () use ( $fake ) {
+ return $fake;
+ }
+ );
+
+ $registry_property->setValue( $container, $registry );
+ }
+
+ /**
+ * Restore the original Hydration entry in the container registry, if we
+ * swapped it during a test.
+ */
+ private function restore_hydration_container_entry(): void {
+ if ( null === $this->original_hydration_registry_entry ) {
+ return;
+ }
+
+ $container = Package::container();
+ $container_reflection = new \ReflectionClass( $container );
+ $registry_property = $container_reflection->getProperty( 'registry' );
+ $registry_property->setAccessible( true );
+ $registry = $registry_property->getValue( $container );
+
+ if ( false === $this->original_hydration_registry_entry ) {
+ unset( $registry[ Hydration::class ] );
+ } else {
+ $registry[ Hydration::class ] = $this->original_hydration_registry_entry;
+ }
+
+ $registry_property->setValue( $container, $registry );
+ $this->original_hydration_registry_entry = null;
+ }
+
+ /**
+ * Reset the four private static properties on ProductsStore.
+ */
+ private function reset_products_store_static_state(): void {
+ $reflection = new \ReflectionClass( TestedProductsStore::class );
+
+ foreach ( array( 'products', 'product_variations', 'loaded_variation_parents' ) as $name ) {
+ $property = $reflection->getProperty( $name );
+ $property->setAccessible( true );
+ $property->setValue( null, array() );
+ }
+
+ $flag = $reflection->getProperty( 'getters_registered' );
+ $flag->setAccessible( true );
+ $flag->setValue( null, false );
+ }
+
+ /**
+ * Clear the global WP_Interactivity_API state store so tests do not bleed
+ * state into each other. WordPress core does not expose a public reset
+ * helper, so we reach in via reflection.
+ */
+ private function reset_interactivity_state(): void {
+ if ( ! function_exists( 'wp_interactivity' ) ) {
+ return;
+ }
+
+ $api = wp_interactivity();
+ if ( ! is_object( $api ) ) {
+ return;
+ }
+
+ $reflection = new \ReflectionClass( $api );
+ foreach ( array( 'state_data', 'config_data', 'derived_state_closures' ) as $name ) {
+ if ( ! $reflection->hasProperty( $name ) ) {
+ continue;
+ }
+ $property = $reflection->getProperty( $name );
+ $property->setAccessible( true );
+ $property->setValue( $api, array() );
+ }
+ }
+}