Commit b93a4c73e62 for woocommerce

commit b93a4c73e62e45e6320a45bf72bb00b044f35d46
Author: Sam Seay <samueljseay@gmail.com>
Date:   Sat Mar 28 00:47:20 2026 +1300

    Refactor product in context #1, add woocommerce/product-context store alongside existing product-data (#63208)

    * 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

    * Fix logic context vs state

    * Adapt unit tests to the context changes

    * Use lock in products store

    * Fix context logic in the server

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com>
    Co-authored-by: David Arenas <david.arenas@automattic.com>

diff --git a/plugins/woocommerce/changelog/63208-wooplug-6204-refactor-product-in-context b/plugins/woocommerce/changelog/63208-wooplug-6204-refactor-product-in-context
new file mode 100644
index 00000000000..0569a4befbb
--- /dev/null
+++ b/plugins/woocommerce/changelog/63208-wooplug-6204-refactor-product-in-context
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add a woocommerce/product-context iAPI store for use in Add to Cart with Options
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/.eslintrc.js b/plugins/woocommerce/client/blocks/.eslintrc.js
index b5ccc8fb675..c3df0236170 100644
--- a/plugins/woocommerce/client/blocks/.eslintrc.js
+++ b/plugins/woocommerce/client/blocks/.eslintrc.js
@@ -133,6 +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/tracks',
 	'@woocommerce/data',
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
new file mode 100644
index 00000000000..7812e29c69c
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/index.ts
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 00000000000..b6612bf221d
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/product-context.ts
@@ -0,0 +1,81 @@
+/**
+ * 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/test/product-context.test.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/product-context.test.ts
new file mode 100644
index 00000000000..74b09d5edfc
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/product-context.test.ts
@@ -0,0 +1,226 @@
+/**
+ * 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/bin/webpack-config-interactive-blocks.js b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
index a95c9e31f85..527ea850b56 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
@@ -43,6 +43,8 @@ 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':
diff --git a/plugins/woocommerce/client/blocks/tsconfig.base.json b/plugins/woocommerce/client/blocks/tsconfig.base.json
index 684922494ef..3d46cf08100 100644
--- a/plugins/woocommerce/client/blocks/tsconfig.base.json
+++ b/plugins/woocommerce/client/blocks/tsconfig.base.json
@@ -121,6 +121,9 @@
 			"@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"
 			],
diff --git a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
index be76b1dea27..f7c85f52b47 100644
--- a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
@@ -1,6 +1,7 @@
 <?php
 namespace Automattic\WooCommerce\Blocks\Templates;

+use Automattic\WooCommerce\Blocks\SharedStores\ProductsStore;
 use Automattic\WooCommerce\Blocks\Templates\SingleProductTemplateCompatibility;
 use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;

@@ -84,6 +85,16 @@ class SingleProductTemplate extends AbstractTemplate {

 			$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() );
+
+				// 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.
 				wp_interactivity_state(
 					'woocommerce/product-data',
 					array(
@@ -93,6 +104,43 @@ class SingleProductTemplate extends AbstractTemplate {
 						),
 					)
 				);
+
+				// 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;
+						},
+					)
+				);
 			}
 		}
 	}