Commit 1985a2981c for woocommerce
commit 1985a2981ca5e8d4adff1986bf4d417750bcb5b1
Author: Michael Pretty <prettyboymp@users.noreply.github.com>
Date: Mon Dec 22 08:04:42 2025 -0500
Wooplug 5884/product object cache (#62041)
* Implement Object Caching for Products
* Update handling to set clone mode
* add tests
* add more tests, switch to a constant for the feature
* fix lint issues
* more lint fixes
* lint fixes and logic change around cache clearing for meta changes
* more lint adjustments
* add changelog
* Move ProductCache to the internal namespace
* Cast the id before using it for safety.
* Override remove to cast the $id first
* Add @since docs
* Some hardening
* Add more type conversions to the ProductController
* fix lint issues
* Address feedback.
Fix tests that were being skipped.
Clear the Product instance cache after clearing other transients since some call the factory indirectly causing re-caching.
Only load metadata for DUPLICATE clone mode since most cases don't need access to the extended meta.
* Clean post cache after direct deletion of placholder post
* Avoid calls to get_post() during clean_post_cache hook to avoid re-priming cache for code that (incorrectly) depends on the cache being cleaned prior to a db write.
* fix test after removing delete_post hook
* improve teh test_invalidation_respects_feature_flag test
* Address PHPStan issues.
* Only check the feature once before registering hooks instead of each callback.
* Delay the feature check later to avoid early textdomain warnings.
* test cleanup
* fix typo
* lint fix after merge conflict
diff --git a/plugins/woocommerce/changelog/WOOPLUG-5884-product-object-cache b/plugins/woocommerce/changelog/WOOPLUG-5884-product-object-cache
new file mode 100644
index 0000000000..70d968494b
--- /dev/null
+++ b/plugins/woocommerce/changelog/WOOPLUG-5884-product-object-cache
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Introduce Product Object feature for improved performance.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
index c54719f749..6fcacd5576 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
@@ -177,13 +177,15 @@ abstract class WC_Data {
* When the object is cloned, make sure meta is cloned correctly.
*
* Meta ID handling depends on the clone mode:
- * - CLONE_MODE_DUPLICATE (default): Clears meta IDs for duplication (backward compatible).
+ * - CLONE_MODE_DUPLICATE (default): Forces reading of Meta and clears meta IDs for duplication (backward compatible).
* - CLONE_MODE_CACHE: Preserves meta IDs for caching purposes.
*
* @since 3.0.2
*/
public function __clone() {
- $this->maybe_read_meta_data();
+ if ( self::CLONE_MODE_DUPLICATE === $this->clone_mode ) {
+ $this->maybe_read_meta_data();
+ }
if ( ! empty( $this->meta_data ) ) {
foreach ( $this->meta_data as $array_key => $meta ) {
$this->meta_data[ $array_key ] = clone $meta;
diff --git a/plugins/woocommerce/includes/class-wc-product-factory.php b/plugins/woocommerce/includes/class-wc-product-factory.php
index b08bbee812..0e60d91444 100644
--- a/plugins/woocommerce/includes/class-wc-product-factory.php
+++ b/plugins/woocommerce/includes/class-wc-product-factory.php
@@ -8,6 +8,7 @@
* @version 3.0.0
*/
+use Automattic\WooCommerce\Internal\Caches\ProductCache;
use Automattic\WooCommerce\Enums\ProductType;
defined( 'ABSPATH' ) || exit;
@@ -31,6 +32,16 @@ class WC_Product_Factory {
return false;
}
+ $use_product_cache = \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_instance_caching' );
+ if ( $use_product_cache && empty( $deprecated ) ) {
+ // Nothing should be using the $deprecated argument still, but avoid using cache if they are.
+ $product_cache = wc_get_container()->get( ProductCache::class );
+ $product = $product_cache->get( (int) $product_id );
+ if ( $product ) {
+ return $product;
+ }
+ }
+
$product_type = self::get_product_type( $product_id );
// Backwards compatibility.
@@ -45,7 +56,11 @@ class WC_Product_Factory {
$classname = self::get_product_classname( $product_id, $product_type );
try {
- return new $classname( $product_id, $deprecated );
+ $product = new $classname( $product_id, $deprecated );
+ if ( $use_product_cache && isset( $product_cache ) && $product instanceof \WC_Product ) {
+ $product_cache->set( $product );
+ }
+ return $product;
} catch ( Exception $e ) {
return false;
}
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 5dd564d80d..5ba29aebc5 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\AddressProvider\AddressProviderController;
use Automattic\WooCommerce\Internal\AssignDefaultCategory;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
+use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonAdminBarBadge;
use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonCacheInvalidator;
use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonRequestHandler;
@@ -345,6 +346,7 @@ final class WooCommerce {
$container->get( MatchImageBySKU::class );
$container->get( RestockRefundedItemsAdjuster::class );
$container->get( CustomOrdersTableController::class );
+ $container->get( ProductCacheController::class );
$container->get( OptionSanitizer::class );
$container->get( BatchProcessingController::class );
$container->get( FeaturesController::class );
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index 45aeda8dce..0670793d38 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -6,6 +6,7 @@
*/
use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\Caches\ProductCache;
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
@@ -1101,6 +1102,19 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
}
WC_Cache_Helper::invalidate_attribute_count( array_keys( $product->get_attributes() ) );
WC_Cache_Helper::invalidate_cache_group( 'product_' . $product->get_id() );
+
+ // Clear product object cache last, as operations above may call wc_get_product() and re-cache.
+ if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_instance_caching' ) ) {
+ $product_cache = wc_get_container()->get( ProductCache::class );
+ $product_cache->remove( $product->get_id() );
+
+ // Also clear parent's cache if this is a variation, as operations above may have cached
+ // the parent with stale children data.
+ $parent_id = $product->get_parent_id( 'edit' );
+ if ( $parent_id ) {
+ $product_cache->remove( $parent_id );
+ }
+ }
}
/*
diff --git a/plugins/woocommerce/src/Internal/Caches/ProductCache.php b/plugins/woocommerce/src/Internal/Caches/ProductCache.php
new file mode 100644
index 0000000000..ce13736682
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/ProductCache.php
@@ -0,0 +1,125 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+use Automattic\WooCommerce\Caching\ObjectCache;
+use WC_Product;
+
+/**
+ * A class to cache Product objects.
+ *
+ * @since 10.5.0
+ */
+class ProductCache extends ObjectCache {
+
+ /**
+ * Get the cache key and prefix to use for Products.
+ *
+ * @since 10.5.0
+ *
+ * @return string
+ */
+ public function get_object_type(): string {
+ return 'product_objects';
+ }
+
+ /**
+ * Get the id of an object to be cached.
+ *
+ * @since 10.5.0
+ *
+ * @param WC_Product $product The product to be cached.
+ *
+ * @return int The id of the object.
+ */
+ protected function get_object_id( $product ) {
+ return $product->get_id();
+ }
+
+ /**
+ * Validate an object before caching it.
+ *
+ * @since 10.5.0
+ *
+ * @param WC_Product $product The product to validate.
+ *
+ * @return string[]|null An array of error messages, or null if the object is valid.
+ */
+ protected function validate( $product ): ?array {
+ if ( ! $product instanceof WC_Product ) {
+ return array( 'The supplied product is not an instance of WC_Product' );
+ }
+
+ return null;
+ }
+
+ /**
+ * Add a product to the cache, or update an already cached product.
+ *
+ * Sets the clone mode to CACHE before storing to ensure meta IDs are preserved
+ * when WordPress object cache clones the object.
+ *
+ * @since 10.5.0
+ *
+ * @param WC_Product $product The product to be cached.
+ * @param int|string|null $id Id of the product to be cached, if null, get_object_id will be used to get it.
+ * @param int $expiration Expiration of the cached data in seconds from the current time, or DEFAULT_EXPIRATION to use the default value.
+ *
+ * @return bool True on success, false on error.
+ * @throws \Automattic\WooCommerce\Caching\CacheException Invalid parameter, or null id was passed and get_object_id returns null too.
+ */
+ public function set( $product, $id = null, int $expiration = self::DEFAULT_EXPIRATION ): bool {
+ if ( null !== $id ) {
+ $id = (int) $id;
+ }
+
+ $original_mode = $product->get_clone_mode();
+ $product->set_clone_mode( \WC_Data::CLONE_MODE_CACHE );
+ $result = parent::set( $product, $id, $expiration );
+ $product->set_clone_mode( $original_mode );
+
+ return $result;
+ }
+
+ /**
+ * Remove a product from the cache.
+ *
+ * @since 10.5.0
+ *
+ * @param int|string $id The id of the product to remove.
+ *
+ * @return bool True if the product is removed successfully, false otherwise.
+ */
+ public function remove( $id ): bool {
+ return parent::remove( (int) $id );
+ }
+
+ /**
+ * Retrieve a cached product, and if no product is cached with the given id,
+ * try to get one via get_from_datastore callback and then cache it.
+ *
+ * After retrieval, resets the clone mode to DUPLICATE to maintain backward compatibility
+ * for code that expects cloning to clear meta IDs.
+ *
+ * @since 10.5.0
+ *
+ * @param int|string $id The id of the product to retrieve.
+ * @param int $expiration Expiration of the cached data in seconds from the current time, used if a product is retrieved from datastore and cached.
+ * @param callable|null $get_from_datastore_callback Optional callback to get the product if it's not cached, it must return a WC_Product or null.
+ *
+ * @return WC_Product|null Cached product, or null if it's not cached and can't be retrieved from datastore or via callback.
+ * @throws \Automattic\WooCommerce\Caching\CacheException Invalid id parameter.
+ */
+ public function get( $id, int $expiration = self::DEFAULT_EXPIRATION, ?callable $get_from_datastore_callback = null ): ?WC_Product {
+ $id = (int) $id;
+ $product = parent::get( $id, $expiration, $get_from_datastore_callback );
+
+ if ( $product instanceof WC_Product ) {
+ $product->set_clone_mode( \WC_Data::CLONE_MODE_DUPLICATE );
+ return $product;
+ }
+
+ return null;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Caches/ProductCacheController.php b/plugins/woocommerce/src/Internal/Caches/ProductCacheController.php
new file mode 100644
index 0000000000..d526eafcc6
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/ProductCacheController.php
@@ -0,0 +1,175 @@
+<?php
+/**
+ * ProductCacheController class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+/**
+ * Controller for product caching functionality.
+ *
+ * @since 10.5.0
+ */
+class ProductCacheController {
+
+ /**
+ * Feature flag name for product instance caching.
+ *
+ * @since 10.5.0
+ *
+ * @var string
+ */
+ public const FEATURE_NAME = 'product_instance_caching';
+
+ /**
+ * The product cache instance.
+ *
+ * @since 10.5.0
+ *
+ * @var ProductCache
+ */
+ private ProductCache $product_cache;
+
+ /**
+ * Class initialization, invoked by the DI container.
+ *
+ * @since 10.5.0
+ *
+ * @internal
+ *
+ * @param ProductCache $product_cache The product cache instance.
+ *
+ * @return void
+ */
+ final public function init( ProductCache $product_cache ): void {
+ $this->product_cache = $product_cache;
+
+ // Mark cache group as non-persistent immediately to ensure it's set
+ // regardless of when this controller is instantiated relative to other hooks.
+ $this->set_product_cache_group_as_non_persistent();
+
+ // Defer feature check to 'init' to avoid triggering translations too early.
+ add_action( 'init', array( $this, 'on_init' ), 0 );
+ }
+
+ /**
+ * Check feature flag and register hooks on WordPress init.
+ *
+ * @since 10.5.0
+ *
+ * @return void
+ */
+ public function on_init(): void {
+ if ( ! FeaturesUtil::feature_is_enabled( self::FEATURE_NAME ) ) {
+ return;
+ }
+
+ $this->register_hooks();
+ }
+
+ /**
+ * Register the cache invalidation hooks.
+ *
+ * This method is separated from on_init() to allow tests to call it directly
+ * after enabling the feature flag.
+ *
+ * @since 10.5.0
+ *
+ * @return void
+ */
+ public function register_hooks(): void {
+ // Handle direct WordPress post updates (bypassing CRUD).
+ add_action( 'clean_post_cache', array( $this, 'invalidate_product_cache_on_clean' ), 10, 2 );
+
+ // Handle post meta updates (third-party plugins updating via postmeta API).
+ add_action( 'updated_post_meta', array( $this, 'invalidate_product_cache_by_meta' ), 10, 2 );
+ add_action( 'added_post_meta', array( $this, 'invalidate_product_cache_by_meta' ), 10, 2 );
+ add_action( 'deleted_post_meta', array( $this, 'invalidate_product_cache_by_meta' ), 10, 2 );
+
+ // Handle direct stock/sales updates (which uses direct SQL and cache manipulation, bypassing standard meta hooks)
+ // In the future, update WC_Product_Data_Store_CPT::update_product_stock() and
+ // update_product_sales() to trigger standard WordPress updated_post_meta hooks instead
+ // of requiring specific hooks here.
+ add_action( 'woocommerce_updated_product_stock', array( $this, 'invalidate_product_cache' ), 10, 1 );
+ add_action( 'woocommerce_updated_product_sales', array( $this, 'invalidate_product_cache' ), 10, 1 );
+ }
+
+ /**
+ * Set the `product_objects` cache group as non-persistent.
+ *
+ * With product instance caching enabled, products are cached in-memory during a request
+ * rather than being persisted to external cache backends. If WC_Data::__sleep()/::__wakeup() methods are eventually
+ * removed or changed so that the entire object is stored instead of just the ID, this should be revisited and evaluated
+ * performance impact.
+ *
+ * @since 10.5.0
+ *
+ * @return void
+ */
+ public function set_product_cache_group_as_non_persistent(): void {
+ wp_cache_add_non_persistent_groups( array( $this->product_cache->get_object_type() ) );
+ }
+
+ /**
+ * Invalidate the product cache when the post cache is cleaned.
+ *
+ * @since 10.5.0
+ *
+ * @param int $post_id The post ID.
+ * @param \WP_Post $post The post object.
+ *
+ * @return void
+ */
+ public function invalidate_product_cache_on_clean( $post_id, $post ): void {
+ $post_id = (int) $post_id;
+ /**
+ * It's important not to trigger get_post() during this callback as some extensions may attempt to clean cache
+ * prior to updating the database and a call to get_post() would cause the post to be added back to cache before the update.
+ */
+ if ( ! ( $post instanceof \WP_Post ) || ! in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) {
+ return;
+ }
+
+ $this->product_cache->remove( $post_id );
+ }
+
+ /**
+ * Invalidate the product cache for a given post ID if it's a product or product variation.
+ *
+ * @since 10.5.0
+ *
+ * @param int $post_id The post ID to check and invalidate.
+ *
+ * @return void
+ */
+ public function invalidate_product_cache( $post_id ): void {
+ $post_id = (int) $post_id;
+ $post_type = get_post_type( $post_id );
+ if ( ! $post_type || ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
+ return;
+ }
+
+ $this->product_cache->remove( $post_id );
+ }
+
+ /**
+ * Invalidate the product cache when post meta is updated.
+ *
+ * @since 10.5.0
+ *
+ * @param int $meta_id The ID of the metadata entry.
+ * @param int $object_id The ID of the object the metadata is for.
+ *
+ * @return void
+ */
+ public function invalidate_product_cache_by_meta( $meta_id, $object_id ): void {
+ $object_id = (int) $object_id;
+ if ( in_array( get_post_type( $object_id ), array( 'product', 'product_variation' ), true ) ) {
+ $this->invalidate_product_cache( $object_id );
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index cf4ec799b7..21c0087b7c 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -2568,6 +2568,7 @@ FROM $order_meta_table
$order_id
)
);
+ clean_post_cache( $order_id );
} else {
// phpcs:disable WordPress.DB.SlowDBQuery
$wpdb->insert(
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index b659a9cd43..4634939e8f 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -12,6 +12,7 @@ use WC_Tracks;
use WC_Site_Tracking;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Analytics;
+use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
@@ -284,7 +285,7 @@ class FeaturesController {
$tracking_enabled = WC_Site_Tracking::is_tracking_enabled();
$legacy_features = array(
- 'analytics' => array(
+ 'analytics' => array(
'name' => __( 'Analytics', 'woocommerce' ),
'description' => __( 'Enable WooCommerce Analytics', 'woocommerce' ),
'option_key' => Analytics::TOGGLE_OPTION_NAME,
@@ -294,7 +295,7 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
- 'product_block_editor' => array(
+ 'product_block_editor' => array(
'name' => __( 'New product editor', 'woocommerce' ),
'description' => __( 'Try the new product editor (Beta)', 'woocommerce' ),
'is_experimental' => true,
@@ -302,14 +303,14 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
- 'cart_checkout_blocks' => array(
+ 'cart_checkout_blocks' => array(
'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ),
'description' => __( 'Optimize for faster checkout', 'woocommerce' ),
'is_experimental' => false,
'disable_ui' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
- 'rate_limit_checkout' => array(
+ 'rate_limit_checkout' => array(
'name' => __( 'Rate limit Checkout', 'woocommerce' ),
'description' => sprintf(
// translators: %s is the URL to the rate limiting documentation.
@@ -322,7 +323,7 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
- 'marketplace' => array(
+ 'marketplace' => array(
'name' => __( 'Marketplace', 'woocommerce' ),
'description' => __(
'New, faster way to find extensions and themes for your WooCommerce store',
@@ -336,7 +337,7 @@ class FeaturesController {
),
// Marked as a legacy feature to avoid compatibility checks, which aren't really relevant to this feature.
// https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959.
- 'order_attribution' => array(
+ 'order_attribution' => array(
'name' => __( 'Order Attribution', 'woocommerce' ),
'description' => __(
'Enable this feature to track and credit channels and campaigns that contribute to orders on your site',
@@ -348,7 +349,7 @@ class FeaturesController {
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_experimental' => false,
),
- 'site_visibility_badge' => array(
+ 'site_visibility_badge' => array(
'name' => __( 'Site visibility badge', 'woocommerce' ),
'description' => __(
'Enable the site visibility badge in the WordPress admin bar',
@@ -361,7 +362,7 @@ class FeaturesController {
'is_experimental' => false,
'disabled' => false,
),
- 'hpos_fts_indexes' => array(
+ 'hpos_fts_indexes' => array(
'name' => __( 'HPOS Full text search indexes', 'woocommerce' ),
'description' => __(
'Create and use full text search indexes for orders. This feature only works with high-performance order storage.',
@@ -373,7 +374,7 @@ class FeaturesController {
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'option_key' => CustomOrdersTableController::HPOS_FTS_INDEX_OPTION,
),
- 'hpos_datastore_caching' => array(
+ 'hpos_datastore_caching' => array(
'name' => __( 'HPOS Data Caching', 'woocommerce' ),
'description' => __(
'Enable order data caching in the datastore. This feature only works with high-performance order storage and is recommended for stores using object caching.',
@@ -386,7 +387,7 @@ class FeaturesController {
'disable_ui' => false,
'option_key' => CustomOrdersTableController::HPOS_DATASTORE_CACHING_ENABLED_OPTION,
),
- 'remote_logging' => array(
+ 'remote_logging' => array(
'name' => __( 'Remote Logging', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: opening link tag, %2$s: closing link tag */
@@ -421,7 +422,7 @@ class FeaturesController {
},
),
),
- 'email_improvements' => array(
+ 'email_improvements' => array(
'name' => __( 'Email improvements', 'woocommerce' ),
'description' => __(
'Enable modern email design for transactional emails',
@@ -441,7 +442,7 @@ class FeaturesController {
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_experimental' => false,
),
- 'blueprint' => array(
+ 'blueprint' => array(
'name' => __( 'Blueprint (beta)', 'woocommerce' ),
'description' => __(
'Enable blueprint to import and export settings in bulk',
@@ -462,7 +463,7 @@ class FeaturesController {
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_experimental' => false,
),
- 'block_email_editor' => array(
+ 'block_email_editor' => array(
'name' => __( 'Block Email Editor (alpha)', 'woocommerce' ),
'description' => __(
'Enable the block-based email editor for transactional emails.',
@@ -482,7 +483,7 @@ class FeaturesController {
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'enabled_by_default' => false,
),
- 'point_of_sale' => array(
+ 'point_of_sale' => array(
'name' => __( 'Point of Sale', 'woocommerce' ),
'description' => __(
'Enable Point of Sale functionality in the WooCommerce mobile apps.',
@@ -503,7 +504,7 @@ class FeaturesController {
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_experimental' => true,
),
- 'fulfillments' => array(
+ 'fulfillments' => array(
'name' => __( 'Order Fulfillments', 'woocommerce' ),
'description' => __(
'Enable the Order Fulfillments feature to manage order fulfillment and shipping.',
@@ -514,7 +515,7 @@ class FeaturesController {
'is_experimental' => false,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
- 'mcp_integration' => array(
+ 'mcp_integration' => array(
'name' => __( 'WooCommerce MCP', 'woocommerce' ),
'description' => $this->get_mcp_integration_description(),
'enabled_by_default' => false,
@@ -523,7 +524,7 @@ class FeaturesController {
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_legacy' => false,
),
- 'destroy-empty-sessions' => array(
+ 'destroy-empty-sessions' => array(
'name' => __( 'Clear Customer Sessions When Empty', 'woocommerce' ),
'description' => __(
'[Performance] Removes session cookies for non-logged in customers when session data is empty, improving page caching performance. May cause compatibility issues with extensions that depend on the session cookie without using session data.',
@@ -534,7 +535,7 @@ class FeaturesController {
'disable_ui' => false,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
- 'agentic_checkout' => array(
+ 'agentic_checkout' => array(
'name' => __( 'Agentic Checkout API', 'woocommerce' ),
'description' => __(
'Enable the Agentic Checkout API for AI-powered checkout experiences (e.g., ChatGPT). This adds REST API endpoints that allow AI agents to create and manage checkout sessions.',
@@ -546,7 +547,7 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
- PushNotifications::FEATURE_NAME => array(
+ PushNotifications::FEATURE_NAME => array(
'name' => __( 'Push Notifications', 'woocommerce' ),
'description' => __(
'Enable push notifications for the WooCommerce mobile apps to receive order notifications and store updates.',
@@ -558,7 +559,7 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
- 'rest_api_caching' => array(
+ 'rest_api_caching' => array(
'name' => __( 'REST API Caching', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s and %2$s are opening and closing <a> tags */
@@ -572,7 +573,18 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
- 'fraud_protection' => array(
+ ProductCacheController::FEATURE_NAME => array(
+ 'name' => __( 'Cache Product Objects', 'woocommerce' ),
+ 'description' => __(
+ '[Performance] Speeds up your store by caching product objects during each request, preventing duplicate product loads. Can improve page load times on product-heavy pages.',
+ 'woocommerce'
+ ),
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::INCOMPATIBLE,
+ 'enabled_by_default' => false,
+ 'is_experimental' => true,
+ 'disable_ui' => false,
+ ),
+ 'fraud_protection' => array(
'name' => __( 'Fraud protection', 'woocommerce' ),
'description' => __(
'Enable fraud protection features for your store.',
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/product/factory.php b/plugins/woocommerce/tests/legacy/unit-tests/product/factory.php
index 5881bcbdb7..8b58937312 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/product/factory.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/product/factory.php
@@ -68,4 +68,187 @@ class WC_Tests_Product_Factory extends WC_Unit_Test_Case {
$this->assertFalse( $product );
}
+ /**
+ * Test that products returned from factory have DUPLICATE clone mode.
+ * This ensures backward compatibility for code that clones products for duplication.
+ */
+ public function test_factory_returns_product_with_duplicate_mode() {
+ $test_product = WC_Helper_Product::create_simple_product();
+ $product = WC()->product_factory->get_product( $test_product->get_id() );
+
+ $this->assertEquals( WC_Data::CLONE_MODE_DUPLICATE, $product->get_clone_mode(), 'Products from factory should have DUPLICATE mode for backward compatibility' );
+ }
+
+ /**
+ * Test that products returned from cache preserve their meta IDs.
+ * This is critical for the caching feature to work correctly.
+ */
+ public function test_factory_cached_product_preserves_meta_ids() {
+ // Enable the feature for this test.
+ $option_name = 'woocommerce_feature_' . \Automattic\WooCommerce\Internal\Caches\ProductCacheController::FEATURE_NAME . '_enabled';
+ $original_value = get_option( $option_name );
+ update_option( $option_name, 'yes' );
+
+ try {
+ // Create product with meta.
+ $test_product = WC_Helper_Product::create_simple_product();
+ $test_product->add_meta_data( 'test_cache_key', 'test_cache_value', true );
+ $test_product->save();
+
+ $product_id = $test_product->get_id();
+
+ // Get original meta ID.
+ $original_meta = $test_product->get_meta_data();
+ $original_meta_id = null;
+ foreach ( $original_meta as $meta ) {
+ if ( 'test_cache_key' === $meta->key ) {
+ $original_meta_id = $meta->id;
+ break;
+ }
+ }
+
+ // Clear cache to ensure fresh retrieval.
+ $product_cache = wc_get_container()->get( \Automattic\WooCommerce\Internal\Caches\ProductCache::class );
+ $product_cache->remove( $product_id );
+
+ // Get product via factory (first time - will cache it).
+ $product1 = WC()->product_factory->get_product( $product_id );
+
+ // Get product again (second time - should come from cache).
+ $product2 = WC()->product_factory->get_product( $product_id );
+
+ // Verify both retrievals preserve meta ID.
+ $meta1 = $product1->get_meta_data();
+ $meta_id_1 = null;
+ foreach ( $meta1 as $meta ) {
+ if ( 'test_cache_key' === $meta->key ) {
+ $meta_id_1 = $meta->id;
+ break;
+ }
+ }
+
+ $meta2 = $product2->get_meta_data();
+ $meta_id_2 = null;
+ foreach ( $meta2 as $meta ) {
+ if ( 'test_cache_key' === $meta->key ) {
+ $meta_id_2 = $meta->id;
+ break;
+ }
+ }
+
+ $this->assertEquals( $original_meta_id, $meta_id_1, 'First retrieval should preserve meta ID' );
+ $this->assertEquals( $original_meta_id, $meta_id_2, 'Cached retrieval should preserve meta ID' );
+ $this->assertEquals( 'test_cache_value', $product1->get_meta( 'test_cache_key' ) );
+ $this->assertEquals( 'test_cache_value', $product2->get_meta( 'test_cache_key' ) );
+ } finally {
+ // Restore original option value.
+ if ( false === $original_value ) {
+ delete_option( $option_name );
+ } else {
+ update_option( $option_name, $original_value );
+ }
+ }
+ }
+
+ /**
+ * Test that cloning a product from factory clears meta IDs (duplicate mode).
+ * This verifies backward compatibility for product duplication.
+ */
+ public function test_factory_product_clone_clears_meta_ids() {
+ $test_product = WC_Helper_Product::create_simple_product();
+ $test_product->add_meta_data( 'test_key', 'test_value', true );
+ $test_product->save();
+
+ // Get product from factory.
+ $product = WC()->product_factory->get_product( $test_product->get_id() );
+
+ // Clone the product (should use DUPLICATE mode).
+ $cloned_product = clone $product;
+
+ // Get meta from cloned product.
+ $cloned_meta = $cloned_product->get_meta_data();
+
+ // Find the test meta.
+ $cloned_meta_id = null;
+ foreach ( $cloned_meta as $meta ) {
+ if ( 'test_key' === $meta->key ) {
+ $cloned_meta_id = $meta->id;
+ break;
+ }
+ }
+
+ // Verify meta ID is cleared (backward compatible duplication behavior).
+ $this->assertNull( $cloned_meta_id, 'Cloning a product from factory should clear meta IDs for duplication' );
+
+ // Verify meta value is preserved.
+ $this->assertEquals( 'test_value', $cloned_product->get_meta( 'test_key' ) );
+ }
+
+ /**
+ * Test that cache is used when retrieving the same product multiple times.
+ */
+ public function test_factory_uses_cache_for_repeated_retrievals() {
+ // Enable the feature for this test.
+ $option_name = 'woocommerce_feature_' . \Automattic\WooCommerce\Internal\Caches\ProductCacheController::FEATURE_NAME . '_enabled';
+ $original_value = get_option( $option_name );
+ update_option( $option_name, 'yes' );
+
+ try {
+ $test_product = WC_Helper_Product::create_simple_product();
+ $product_id = $test_product->get_id();
+
+ // Clear cache.
+ $product_cache = wc_get_container()->get( \Automattic\WooCommerce\Internal\Caches\ProductCache::class );
+ $product_cache->remove( $product_id );
+
+ // Verify not cached initially.
+ $this->assertFalse( $product_cache->is_cached( $product_id ), 'Product should not be cached initially' );
+
+ // Get product (should cache it).
+ $product1 = WC()->product_factory->get_product( $product_id );
+
+ // Verify it's now cached.
+ $this->assertTrue( $product_cache->is_cached( $product_id ), 'Product should be cached after first retrieval' );
+
+ // Get product again (should use cache).
+ $product2 = WC()->product_factory->get_product( $product_id );
+
+ // Verify both are valid products with same ID.
+ $this->assertEquals( $product_id, $product1->get_id() );
+ $this->assertEquals( $product_id, $product2->get_id() );
+
+ // Verify they are different instances (cache returns clones).
+ $this->assertNotSame( $product1, $product2, 'Cached products should be different instances' );
+ } finally {
+ // Restore original option value.
+ if ( false === $original_value ) {
+ delete_option( $option_name );
+ } else {
+ update_option( $option_name, $original_value );
+ }
+ }
+ }
+
+ /**
+ * Test that cache is bypassed when feature is disabled.
+ */
+ public function test_factory_bypasses_cache_when_feature_disabled() {
+ if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( \Automattic\WooCommerce\Internal\Caches\ProductCacheController::FEATURE_NAME ) ) {
+ $this->markTestSkipped( 'Product instance caching feature is enabled. This test requires it to be disabled.' );
+ }
+
+ $test_product = WC_Helper_Product::create_simple_product();
+ $product_id = $test_product->get_id();
+
+ // Get product via factory.
+ $product = WC()->product_factory->get_product( $product_id );
+
+ // Verify product is retrieved but not cached.
+ $product_cache = wc_get_container()->get( \Automattic\WooCommerce\Internal\Caches\ProductCache::class );
+ $this->assertFalse( $product_cache->is_cached( $product_id ), 'Product should not be cached when feature is disabled' );
+
+ // Verify product is still valid.
+ $this->assertEquals( $product_id, $product->get_id() );
+ }
+
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheControllerTest.php
new file mode 100644
index 0000000000..df70f42b03
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheControllerTest.php
@@ -0,0 +1,502 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Caches\ProductCache;
+use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+use WC_Helper_Product;
+
+/**
+ * Tests for ProductCacheController.
+ */
+class ProductCacheControllerTest extends \WC_Unit_Test_Case {
+
+ /**
+ * System under test.
+ *
+ * @var ProductCacheController
+ */
+ private $sut;
+
+ /**
+ * Product cache instance.
+ *
+ * @var ProductCache
+ */
+ private $product_cache;
+
+ /**
+ * Original feature option value.
+ *
+ * @var mixed
+ */
+ private $original_feature_value;
+
+ /**
+ * Feature option name.
+ *
+ * @var string
+ */
+ private $feature_option_name;
+
+ /**
+ * Setup test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->product_cache = wc_get_container()->get( ProductCache::class );
+ $this->sut = wc_get_container()->get( ProductCacheController::class );
+ $this->feature_option_name = 'woocommerce_feature_' . ProductCacheController::FEATURE_NAME . '_enabled';
+ }
+
+ /**
+ * Teardown test.
+ */
+ public function tearDown(): void {
+ $this->product_cache->flush();
+ $this->unhook_controller( $this->sut );
+ $this->restore_feature_option();
+ parent::tearDown();
+ }
+
+ /**
+ * Remove all hooks registered by a controller instance.
+ *
+ * @param ProductCacheController $controller The controller to unhook.
+ */
+ private function unhook_controller( ProductCacheController $controller ): void {
+ remove_action( 'init', array( $controller, 'on_init' ), 0 );
+ remove_action( 'clean_post_cache', array( $controller, 'invalidate_product_cache_on_clean' ), 10 );
+ remove_action( 'updated_post_meta', array( $controller, 'invalidate_product_cache_by_meta' ), 10 );
+ remove_action( 'added_post_meta', array( $controller, 'invalidate_product_cache_by_meta' ), 10 );
+ remove_action( 'deleted_post_meta', array( $controller, 'invalidate_product_cache_by_meta' ), 10 );
+ remove_action( 'woocommerce_updated_product_stock', array( $controller, 'invalidate_product_cache' ), 10 );
+ remove_action( 'woocommerce_updated_product_sales', array( $controller, 'invalidate_product_cache' ), 10 );
+ }
+
+ /**
+ * Enable the product instance caching feature for a test.
+ */
+ private function enable_feature(): void {
+ $this->original_feature_value = get_option( $this->feature_option_name );
+ update_option( $this->feature_option_name, 'yes' );
+ $this->sut->register_hooks();
+ }
+
+ /**
+ * Disable the product instance caching feature for a test.
+ */
+ private function disable_feature(): void {
+ $this->original_feature_value = get_option( $this->feature_option_name );
+ update_option( $this->feature_option_name, 'no' );
+ }
+
+ /**
+ * Restore the original feature option value.
+ */
+ private function restore_feature_option(): void {
+ if ( isset( $this->original_feature_value ) ) {
+ if ( false === $this->original_feature_value ) {
+ delete_option( $this->feature_option_name );
+ } else {
+ update_option( $this->feature_option_name, $this->original_feature_value );
+ }
+ $this->original_feature_value = null;
+ }
+ }
+
+ /**
+ * @testdox Controller does not register hooks when feature is disabled.
+ */
+ public function test_controller_does_not_register_hooks_when_disabled() {
+ $this->disable_feature();
+
+ // Create a fresh controller instance to test hook registration.
+ $controller = new ProductCacheController();
+ $controller->init( $this->product_cache );
+ $controller->on_init();
+
+ try {
+ // Verify hooks are NOT registered when feature is disabled.
+ $this->assertFalse(
+ has_action( 'clean_post_cache', array( $controller, 'invalidate_product_cache_on_clean' ) ),
+ 'clean_post_cache hook should NOT be registered when feature is disabled'
+ );
+
+ $this->assertFalse(
+ has_action( 'updated_post_meta', array( $controller, 'invalidate_product_cache_by_meta' ) ),
+ 'updated_post_meta hook should NOT be registered when feature is disabled'
+ );
+ } finally {
+ $this->unhook_controller( $controller );
+ }
+ }
+
+ /**
+ * @testdox Controller registers cache invalidation hooks when feature is enabled.
+ */
+ public function test_controller_registers_hooks_when_enabled() {
+ $this->enable_feature();
+
+ // Create a fresh controller instance to test hook registration.
+ $controller = new ProductCacheController();
+ $controller->init( $this->product_cache );
+ $controller->on_init();
+
+ try {
+ // Verify clean_post_cache hook is registered.
+ $this->assertNotFalse(
+ has_action( 'clean_post_cache', array( $controller, 'invalidate_product_cache_on_clean' ) ),
+ 'clean_post_cache hook should be registered'
+ );
+
+ // Verify meta update hooks are registered.
+ $this->assertNotFalse(
+ has_action( 'updated_post_meta', array( $controller, 'invalidate_product_cache_by_meta' ) ),
+ 'updated_post_meta hook should be registered'
+ );
+
+ $this->assertNotFalse(
+ has_action( 'added_post_meta', array( $controller, 'invalidate_product_cache_by_meta' ) ),
+ 'added_post_meta hook should be registered'
+ );
+
+ $this->assertNotFalse(
+ has_action( 'deleted_post_meta', array( $controller, 'invalidate_product_cache_by_meta' ) ),
+ 'deleted_post_meta hook should be registered'
+ );
+
+ // Verify stock/sales hooks are registered.
+ $this->assertNotFalse(
+ has_action( 'woocommerce_updated_product_stock', array( $controller, 'invalidate_product_cache' ) ),
+ 'woocommerce_updated_product_stock hook should be registered'
+ );
+
+ $this->assertNotFalse(
+ has_action( 'woocommerce_updated_product_sales', array( $controller, 'invalidate_product_cache' ) ),
+ 'woocommerce_updated_product_sales hook should be registered'
+ );
+ } finally {
+ $this->unhook_controller( $controller );
+ }
+ }
+
+ /**
+ * @testdox Product cache is invalidated when product is saved via CRUD.
+ */
+ public function test_cache_invalidated_on_product_save() {
+ $this->enable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Original Name' );
+ $product->save();
+
+ $product_id = $product->get_id();
+
+ // Cache the product.
+ $this->product_cache->remove( $product_id );
+ wc_get_product( $product_id );
+ $this->assertTrue( $this->product_cache->is_cached( $product_id ), 'Product should be cached after retrieval' );
+
+ // Update and save product.
+ $product->set_name( 'Updated Name' );
+ $product->save();
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $product_id ), 'Cache should be invalidated after save' );
+
+ // Verify fresh data is returned.
+ $fresh_product = wc_get_product( $product_id );
+ $this->assertEquals( 'Updated Name', $fresh_product->get_name(), 'Should return updated name' );
+ }
+
+ /**
+ * @testdox Product cache is invalidated when stock is updated via direct SQL.
+ */
+ public function test_cache_invalidated_on_stock_update() {
+ $this->enable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( 10 );
+ $product->save();
+
+ $product_id = $product->get_id();
+
+ // Cache the product.
+ $this->product_cache->remove( $product_id );
+ $cached_product = wc_get_product( $product_id );
+ $this->assertEquals( 10, $cached_product->get_stock_quantity() );
+ $this->assertTrue( $this->product_cache->is_cached( $product_id ) );
+
+ // Update stock directly (uses SQL, bypasses CRUD).
+ $data_store = \WC_Data_Store::load( 'product' );
+ $data_store->update_product_stock( $product_id, 5, 'set' );
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $product_id ), 'Cache should be invalidated after stock update' );
+
+ // Verify fresh stock is returned.
+ $fresh_product = wc_get_product( $product_id );
+ $this->assertEquals( 5, $fresh_product->get_stock_quantity(), 'Should return updated stock quantity' );
+ }
+
+ /**
+ * @testdox Product cache is invalidated when stock is increased.
+ */
+ public function test_cache_invalidated_on_stock_increase() {
+ $this->enable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( 10 );
+ $product->save();
+
+ $product_id = $product->get_id();
+
+ // Cache the product.
+ $this->product_cache->remove( $product_id );
+ wc_get_product( $product_id );
+ $this->assertTrue( $this->product_cache->is_cached( $product_id ) );
+
+ // Increase stock.
+ $data_store = \WC_Data_Store::load( 'product' );
+ $data_store->update_product_stock( $product_id, 5, 'increase' );
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $product_id ), 'Cache should be invalidated after stock increase' );
+
+ // Verify fresh stock is returned.
+ $fresh_product = wc_get_product( $product_id );
+ $this->assertEquals( 15, $fresh_product->get_stock_quantity(), 'Stock should be increased to 15' );
+ }
+
+ /**
+ * @testdox Product cache is invalidated when stock is decreased.
+ */
+ public function test_cache_invalidated_on_stock_decrease() {
+ $this->enable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( 10 );
+ $product->save();
+
+ $product_id = $product->get_id();
+
+ // Cache the product.
+ $this->product_cache->remove( $product_id );
+ wc_get_product( $product_id );
+ $this->assertTrue( $this->product_cache->is_cached( $product_id ) );
+
+ // Decrease stock (simulates order placement).
+ $data_store = \WC_Data_Store::load( 'product' );
+ $data_store->update_product_stock( $product_id, 3, 'decrease' );
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $product_id ), 'Cache should be invalidated after stock decrease' );
+
+ // Verify fresh stock is returned.
+ $fresh_product = wc_get_product( $product_id );
+ $this->assertEquals( 7, $fresh_product->get_stock_quantity(), 'Stock should be decreased to 7' );
+ }
+
+ /**
+ * @testdox Product cache is invalidated when total sales is updated.
+ */
+ public function test_cache_invalidated_on_sales_update() {
+ $this->enable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_total_sales( 0 );
+ $product->save();
+
+ $product_id = $product->get_id();
+
+ // Cache the product.
+ $this->product_cache->remove( $product_id );
+ $cached_product = wc_get_product( $product_id );
+ $this->assertEquals( 0, $cached_product->get_total_sales() );
+ $this->assertTrue( $this->product_cache->is_cached( $product_id ) );
+
+ // Update sales directly (uses SQL, bypasses CRUD).
+ $data_store = \WC_Data_Store::load( 'product' );
+ $data_store->update_product_sales( $product_id, 10, 'set' );
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $product_id ), 'Cache should be invalidated after sales update' );
+
+ // Verify fresh sales count is returned.
+ $fresh_product = wc_get_product( $product_id );
+ $this->assertEquals( 10, $fresh_product->get_total_sales(), 'Should return updated total sales' );
+ }
+
+ /**
+ * @testdox Product cache is invalidated when product meta is added directly.
+ */
+ public function test_cache_invalidated_on_meta_add() {
+ $this->enable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+
+ // Cache the product.
+ $this->product_cache->remove( $product_id );
+ $cached_product = wc_get_product( $product_id );
+ $this->assertEmpty( $cached_product->get_meta( '_test_meta' ) );
+ $this->assertTrue( $this->product_cache->is_cached( $product_id ) );
+
+ // Add meta directly (bypasses CRUD).
+ add_post_meta( $product_id, '_test_meta', 'test_value' );
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $product_id ), 'Cache should be invalidated after meta add' );
+
+ // Verify fresh meta is returned.
+ $fresh_product = wc_get_product( $product_id );
+ $this->assertEquals( 'test_value', $fresh_product->get_meta( '_test_meta' ), 'Should return new meta value' );
+ }
+
+ /**
+ * @testdox Product cache is invalidated when product meta is updated directly.
+ */
+ public function test_cache_invalidated_on_meta_update() {
+ $this->enable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->add_meta_data( '_test_meta', 'original_value', true );
+ $product->save();
+
+ $product_id = $product->get_id();
+
+ // Cache the product.
+ $this->product_cache->remove( $product_id );
+ $cached_product = wc_get_product( $product_id );
+ $this->assertEquals( 'original_value', $cached_product->get_meta( '_test_meta' ) );
+ $this->assertTrue( $this->product_cache->is_cached( $product_id ) );
+
+ // Update meta directly (bypasses CRUD).
+ update_post_meta( $product_id, '_test_meta', 'updated_value' );
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $product_id ), 'Cache should be invalidated after meta update' );
+
+ // Verify fresh meta is returned.
+ $fresh_product = wc_get_product( $product_id );
+ $this->assertEquals( 'updated_value', $fresh_product->get_meta( '_test_meta' ), 'Should return updated meta value' );
+ }
+
+ /**
+ * @testdox Product cache is invalidated when product meta is deleted directly.
+ */
+ public function test_cache_invalidated_on_meta_delete() {
+ $this->enable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->add_meta_data( '_test_meta', 'test_value', true );
+ $product->save();
+
+ $product_id = $product->get_id();
+
+ // Cache the product.
+ $this->product_cache->remove( $product_id );
+ $cached_product = wc_get_product( $product_id );
+ $this->assertEquals( 'test_value', $cached_product->get_meta( '_test_meta' ) );
+ $this->assertTrue( $this->product_cache->is_cached( $product_id ) );
+
+ // Delete meta directly (bypasses CRUD).
+ delete_post_meta( $product_id, '_test_meta' );
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $product_id ), 'Cache should be invalidated after meta delete' );
+
+ // Verify meta is gone.
+ $fresh_product = wc_get_product( $product_id );
+ $this->assertEmpty( $fresh_product->get_meta( '_test_meta' ), 'Meta should be deleted' );
+ }
+
+ /**
+ * @testdox Product cache is invalidated when product is deleted.
+ */
+ public function test_cache_invalidated_on_product_delete() {
+ $this->enable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+
+ // Cache the product.
+ $this->product_cache->remove( $product_id );
+ wc_get_product( $product_id );
+ $this->assertTrue( $this->product_cache->is_cached( $product_id ) );
+
+ // Delete the product.
+ $product->delete( true );
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $product_id ), 'Cache should be invalidated after delete' );
+
+ // Verify product is gone.
+ $deleted_product = wc_get_product( $product_id );
+ $this->assertFalse( $deleted_product, 'Deleted product should return false' );
+ }
+
+ /**
+ * @testdox Variation cache is invalidated when variation is updated.
+ */
+ public function test_cache_invalidated_on_variation_update() {
+ $this->enable_feature();
+
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variations = $variable_product->get_children();
+ $variation_id = $variations[0];
+
+ // Cache the variation.
+ $this->product_cache->remove( $variation_id );
+ $variation = wc_get_product( $variation_id );
+ $this->assertTrue( $this->product_cache->is_cached( $variation_id ) );
+
+ // Update variation.
+ $variation->set_regular_price( 19.99 );
+ $variation->save();
+
+ // Verify cache was invalidated.
+ $this->assertFalse( $this->product_cache->is_cached( $variation_id ), 'Variation cache should be invalidated after save' );
+
+ // Verify fresh data is returned.
+ $fresh_variation = wc_get_product( $variation_id );
+ $this->assertEquals( '19.99', $fresh_variation->get_regular_price(), 'Should return updated price' );
+ }
+
+ /**
+ * @testdox Cache invalidation respects feature flag being disabled.
+ */
+ public function test_invalidation_respects_feature_flag() {
+ $this->disable_feature();
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Test Product' );
+ $product->save();
+
+ $product_id = $product->get_id();
+
+ // Prime the normal retrieval path and verify it does not populate the cache when the feature is disabled.
+ wc_get_product( $product_id );
+ $this->assertFalse(
+ $this->product_cache->is_cached( $product_id ),
+ 'Product should not be cached when feature is disabled, even after retrieval'
+ );
+
+ // Update product.
+ $product->set_name( 'Updated Product' );
+ $product->save();
+
+ // After an update and another retrieval, the cache should still not be used.
+ wc_get_product( $product_id );
+ $this->assertFalse(
+ $this->product_cache->is_cached( $product_id ),
+ 'Product should still not be cached after update when feature is disabled'
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheTest.php
new file mode 100644
index 0000000000..3cb8118b60
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheTest.php
@@ -0,0 +1,280 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Caches\ProductCache;
+use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+use WC_Data;
+use WC_Helper_Product;
+
+/**
+ * Tests for the ProductCache class.
+ */
+class ProductCacheTest extends \WC_Unit_Test_Case {
+
+ /**
+ * System under test.
+ *
+ * @var ProductCache
+ */
+ private $sut;
+
+ /**
+ * Setup test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new ProductCache();
+ }
+
+ /**
+ * Teardown test.
+ */
+ public function tearDown(): void {
+ $this->sut->flush();
+ parent::tearDown();
+ }
+
+ /**
+ * Test that set() preserves and restores the original clone mode.
+ */
+ public function test_set_preserves_and_restores_clone_mode() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->add_meta_data( 'test_key', 'test_value' );
+ $product->save();
+
+ // Verify product starts with default DUPLICATE mode.
+ $this->assertEquals( WC_Data::CLONE_MODE_DUPLICATE, $product->get_clone_mode() );
+
+ // Cache the product.
+ $this->sut->set( $product );
+
+ // Verify mode is restored to DUPLICATE after caching.
+ $this->assertEquals( WC_Data::CLONE_MODE_DUPLICATE, $product->get_clone_mode() );
+ }
+
+ /**
+ * Test that set() preserves CACHE mode if explicitly set.
+ */
+ public function test_set_with_cache_mode_restores_cache() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->add_meta_data( 'test_key', 'test_value' );
+ $product->save();
+
+ // Explicitly set to CACHE mode.
+ $product->set_clone_mode( WC_Data::CLONE_MODE_CACHE );
+
+ // Cache the product.
+ $this->sut->set( $product );
+
+ // Verify mode is still CACHE after caching.
+ $this->assertEquals( WC_Data::CLONE_MODE_CACHE, $product->get_clone_mode() );
+ }
+
+ /**
+ * Test that get() resets clone mode to DUPLICATE.
+ */
+ public function test_get_resets_to_duplicate_mode() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->add_meta_data( 'test_key', 'test_value' );
+ $product->save();
+
+ // Set to CACHE mode and cache it.
+ $product->set_clone_mode( WC_Data::CLONE_MODE_CACHE );
+ $this->sut->set( $product );
+
+ // Retrieve from cache.
+ $retrieved_product = $this->sut->get( $product->get_id() );
+
+ // Verify retrieved product has DUPLICATE mode.
+ $this->assertEquals( WC_Data::CLONE_MODE_DUPLICATE, $retrieved_product->get_clone_mode() );
+ }
+
+ /**
+ * Test that caching preserves meta IDs through WordPress double-clone.
+ *
+ * This tests the complete workflow:
+ * 1. Product with meta is cached (wp_cache_set clones with CACHE mode)
+ * 2. Product is retrieved (wp_cache_get clones with CACHE mode)
+ * 3. Meta IDs are preserved through both clones
+ * 4. Retrieved product has DUPLICATE mode for backward compatibility
+ */
+ public function test_cache_preserves_meta_ids_through_double_clone() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->add_meta_data( 'test_key', 'test_value', true );
+ $product->save();
+
+ // Get original meta ID.
+ $original_meta = $product->get_meta_data();
+ $original_meta_id = $original_meta[0]->id;
+
+ // Cache the product.
+ $this->sut->set( $product );
+
+ // Retrieve from cache.
+ $retrieved_product = $this->sut->get( $product->get_id() );
+
+ // Get meta from retrieved product.
+ $retrieved_meta = $retrieved_product->get_meta_data();
+
+ // Verify meta ID is preserved through caching.
+ $this->assertEquals( $original_meta_id, $retrieved_meta[0]->id, 'Meta ID should be preserved through cache double-clone' );
+
+ // Verify meta key and value are correct.
+ $this->assertEquals( 'test_key', $retrieved_meta[0]->key );
+ $this->assertEquals( 'test_value', $retrieved_meta[0]->value );
+
+ // Verify retrieved product has DUPLICATE mode.
+ $this->assertEquals( WC_Data::CLONE_MODE_DUPLICATE, $retrieved_product->get_clone_mode() );
+ }
+
+ /**
+ * Test that cloning a retrieved product uses DUPLICATE mode.
+ *
+ * This verifies backward compatibility - products retrieved from cache
+ * should behave like normal products when cloned for duplication.
+ */
+ public function test_cloning_cached_product_clears_meta_ids() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->add_meta_data( 'test_key', 'test_value', true );
+ $product->save();
+
+ // Cache and retrieve the product.
+ $this->sut->set( $product );
+ $retrieved_product = $this->sut->get( $product->get_id() );
+
+ // Clone the retrieved product (should use DUPLICATE mode).
+ $cloned_product = clone $retrieved_product;
+
+ // Get meta from cloned product.
+ $cloned_meta = $cloned_product->get_meta_data();
+
+ // Verify meta IDs are cleared (DUPLICATE mode behavior).
+ $this->assertNull( $cloned_meta[0]->id, 'Meta IDs should be null when cloning in DUPLICATE mode' );
+
+ // Verify meta key and value are preserved.
+ $this->assertEquals( 'test_key', $cloned_meta[0]->key );
+ $this->assertEquals( 'test_value', $cloned_meta[0]->value );
+ }
+
+ /**
+ * Test integration with product_instance_caching feature flag.
+ *
+ * This test verifies the complete integration with WC_Product_Factory
+ * when the feature is enabled.
+ */
+ public function test_integration_with_feature_enabled() {
+ // Enable the feature for this test.
+ $original_value = get_option( 'woocommerce_feature_' . ProductCacheController::FEATURE_NAME . '_enabled' );
+ update_option( 'woocommerce_feature_' . ProductCacheController::FEATURE_NAME . '_enabled', 'yes' );
+
+ try {
+ // Create a product with meta.
+ $product = WC_Helper_Product::create_simple_product();
+ $product->add_meta_data( 'test_key', 'test_value', true );
+ $product->save();
+
+ $product_id = $product->get_id();
+
+ // Clear any existing cache.
+ $this->sut->remove( $product_id );
+
+ // Get product via factory (should cache it).
+ $factory_product = wc_get_product( $product_id );
+
+ // Verify product was cached.
+ $this->assertTrue( $this->sut->is_cached( $product_id ), 'Product should be cached after retrieval' );
+
+ // Verify product has DUPLICATE mode (ready for normal use).
+ $this->assertEquals( WC_Data::CLONE_MODE_DUPLICATE, $factory_product->get_clone_mode() );
+
+ // Get product again (should come from cache).
+ $cached_product = wc_get_product( $product_id );
+
+ // Verify it's a different instance (cloned from cache).
+ $this->assertNotSame( $factory_product, $cached_product, 'Cached retrieval should return a new instance' );
+
+ // Verify meta is preserved.
+ $cached_meta = $cached_product->get_meta_data();
+ $this->assertNotNull( $cached_meta[0]->id, 'Meta ID should be preserved from cache' );
+ $this->assertEquals( 'test_value', $cached_product->get_meta( 'test_key' ) );
+ } finally {
+ // Restore original option value.
+ if ( false === $original_value ) {
+ delete_option( 'woocommerce_feature_' . ProductCacheController::FEATURE_NAME . '_enabled' );
+ } else {
+ update_option( 'woocommerce_feature_' . ProductCacheController::FEATURE_NAME . '_enabled', $original_value );
+ }
+ }
+ }
+
+ /**
+ * Test that caching respects the feature flag being disabled.
+ */
+ public function test_respects_feature_flag_disabled() {
+ if ( FeaturesUtil::feature_is_enabled( ProductCacheController::FEATURE_NAME ) ) {
+ $this->markTestSkipped( 'Product instance caching feature is enabled. This test requires it to be disabled.' );
+ }
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+
+ // Get product via factory (should NOT cache when feature disabled).
+ $factory_product = wc_get_product( $product_id );
+
+ // Verify product was NOT cached.
+ $this->assertFalse( $this->sut->is_cached( $product_id ), 'Product should not be cached when feature is disabled' );
+ }
+
+ /**
+ * Test that variable products with variations preserve meta correctly.
+ */
+ public function test_variable_product_with_variations() {
+ $product = WC_Helper_Product::create_variation_product();
+ $product->add_meta_data( 'parent_meta', 'parent_value', true );
+ $product->save();
+
+ $variations = $product->get_children();
+ $variation = wc_get_product( $variations[0] );
+ $variation->add_meta_data( 'variation_meta', 'variation_value', true );
+ $variation->save();
+
+ // Get original meta IDs.
+ $parent_meta = $product->get_meta_data();
+ $parent_meta_id = $parent_meta[0]->id;
+
+ $variation_meta = $variation->get_meta_data();
+ $variation_meta_id = null;
+ foreach ( $variation_meta as $meta ) {
+ if ( 'variation_meta' === $meta->key ) {
+ $variation_meta_id = $meta->id;
+ break;
+ }
+ }
+
+ // Cache both products.
+ $this->sut->set( $product );
+ $this->sut->set( $variation );
+
+ // Retrieve from cache.
+ $cached_product = $this->sut->get( $product->get_id() );
+ $cached_variation = $this->sut->get( $variation->get_id() );
+
+ // Verify parent meta ID preserved.
+ $cached_parent_meta = $cached_product->get_meta_data();
+ $this->assertEquals( $parent_meta_id, $cached_parent_meta[0]->id );
+
+ // Verify variation meta ID preserved.
+ $cached_variation_meta = $cached_variation->get_meta_data();
+ $found_meta_id = null;
+ foreach ( $cached_variation_meta as $meta ) {
+ if ( 'variation_meta' === $meta->key ) {
+ $found_meta_id = $meta->id;
+ break;
+ }
+ }
+ $this->assertEquals( $variation_meta_id, $found_meta_id );
+ }
+}