Commit 16d7b866f1 for woocommerce

commit 16d7b866f1d202fce7cc3f65826b38dcb8553c54
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Fri Jan 30 12:45:19 2026 +0100

    Add caching for the taxes REST API endpoints (#62931)

diff --git a/plugins/woocommerce/changelog/pr-62931 b/plugins/woocommerce/changelog/pr-62931
new file mode 100644
index 0000000000..d57c5ca234
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-62931
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add caching for the taxes REST API endpoints
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 8d61cbdfba..8efb7e9b3e 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -35,6 +35,7 @@ use Automattic\WooCommerce\Utilities\{LoggingUtil, TimeUtil};
 use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
 use Automattic\WooCommerce\Caches\OrderCountCacheService;
 use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
+use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\StockNotifications\StockNotifications;
 use Automattic\Jetpack\Constants;

@@ -360,6 +361,7 @@ final class WooCommerce {
 		$container->get( AbilitiesRegistry::class );
 		$container->get( MCPAdapterProvider::class );
 		$container->get( ProductVersionStringInvalidator::class );
+		$container->get( TaxRateVersionStringInvalidator::class );

 		// Feature flags.
 		if ( Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-taxes-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-taxes-v1-controller.php
index 575f4a934b..22f23f9ed1 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-taxes-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-taxes-v1-controller.php
@@ -8,6 +8,8 @@
  * @since    3.0.0
  */

+use Automattic\WooCommerce\Internal\Traits\RestApiCache;
+
 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
 }
@@ -20,6 +22,8 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class WC_REST_Taxes_V1_Controller extends WC_REST_Controller {

+	use RestApiCache;
+
 	/**
 	 * Endpoint namespace.
 	 *
@@ -34,6 +38,13 @@ class WC_REST_Taxes_V1_Controller extends WC_REST_Controller {
 	 */
 	protected $rest_base = 'taxes';

+	/**
+	 * Constructor.
+	 */
+	public function __construct() {
+		$this->initialize_rest_api_cache();
+	}
+
 	/**
 	 * Register the routes for taxes.
 	 */
@@ -44,7 +55,13 @@ class WC_REST_Taxes_V1_Controller extends WC_REST_Controller {
 			array(
 				array(
 					'methods'             => WP_REST_Server::READABLE,
-					'callback'            => array( $this, 'get_items' ),
+					'callback'            => $this->with_cache(
+						array( $this, 'get_items' ),
+						array(
+							'endpoint_id'              => 'get_tax_rates',
+							'relevant_version_strings' => array( 'list_tax_rates' ),
+						)
+					),
 					'permission_callback' => array( $this, 'get_items_permissions_check' ),
 					'args'                => $this->get_collection_params(),
 				),
@@ -70,7 +87,7 @@ class WC_REST_Taxes_V1_Controller extends WC_REST_Controller {
 				),
 				array(
 					'methods'             => WP_REST_Server::READABLE,
-					'callback'            => array( $this, 'get_item' ),
+					'callback'            => $this->with_cache( array( $this, 'get_item' ) ),
 					'permission_callback' => array( $this, 'get_item_permissions_check' ),
 					'args'                => array(
 						'context' => $this->get_context_param( array( 'default' => 'view' ) ),
@@ -765,4 +782,38 @@ class WC_REST_Taxes_V1_Controller extends WC_REST_Controller {

 		return $params;
 	}
+
+	/**
+	 * Get the default entity type for response caching.
+	 *
+	 * @return string|null The entity type.
+	 */
+	protected function get_default_response_entity_type(): ?string {
+		return 'tax_rate';
+	}
+
+	/**
+	 * Whether the response cache should vary by user.
+	 *
+	 * @param WP_REST_Request<array<string, mixed>> $request     The request object.
+	 * @param string|null                           $endpoint_id Optional endpoint identifier.
+	 * @return bool False since tax data doesn't vary by user.
+	 */
+	protected function response_cache_vary_by_user( WP_REST_Request $request, ?string $endpoint_id = null ): bool { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+		return false;
+	}
+
+	/**
+	 * Get the hooks relevant to response caching.
+	 *
+	 * @param WP_REST_Request<array<string, mixed>> $request     The request object.
+	 * @param string|null                           $endpoint_id Optional endpoint identifier.
+	 * @return array Array of hook names to track for cache invalidation.
+	 */
+	protected function get_hooks_relevant_to_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+		return array(
+			'woocommerce_rest_prepare_tax',
+			'woocommerce_rest_tax_query',
+		);
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Caches/TaxRateVersionStringInvalidator.php b/plugins/woocommerce/src/Internal/Caches/TaxRateVersionStringInvalidator.php
new file mode 100644
index 0000000000..bb4883e2c8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/TaxRateVersionStringInvalidator.php
@@ -0,0 +1,129 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+/**
+ * Tax rate version string invalidation handler.
+ *
+ * This class provides an 'invalidate' method that will invalidate
+ * the version string for a given tax rate, which in turn invalidates
+ * any cached REST API responses containing that tax rate.
+ *
+ * @since 10.6.0
+ */
+class TaxRateVersionStringInvalidator {
+
+	/**
+	 * Initialize the invalidator and register hooks.
+	 *
+	 * Hooks are only registered when both conditions are met:
+	 * - The REST API caching feature is enabled
+	 * - The backend caching setting is active
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	final public function init(): void {
+		// We can't use FeaturesController::feature_is_enabled at this point
+		// (before the 'init' action is triggered) because that would cause
+		// "Translation loading for the woocommerce domain was triggered too early" warnings.
+		if ( 'yes' !== get_option( 'woocommerce_feature_rest_api_caching_enabled' ) ) {
+			return;
+		}
+
+		if ( 'yes' === get_option( 'woocommerce_rest_api_enable_backend_caching', 'no' ) ) {
+			$this->register_hooks();
+		}
+	}
+
+	/**
+	 * Register all tax rate-related hooks.
+	 *
+	 * Registers hooks for tax rate CRUD operations fired by WC_Tax class.
+	 *
+	 * @return void
+	 */
+	private function register_hooks(): void {
+		add_action( 'woocommerce_tax_rate_added', array( $this, 'handle_woocommerce_tax_rate_added' ), 10, 1 );
+		add_action( 'woocommerce_tax_rate_updated', array( $this, 'handle_woocommerce_tax_rate_updated' ), 10, 1 );
+		add_action( 'woocommerce_tax_rate_deleted', array( $this, 'handle_woocommerce_tax_rate_deleted' ), 10, 1 );
+	}
+
+	/**
+	 * Handle the woocommerce_tax_rate_added hook.
+	 *
+	 * @param int $tax_rate_id The tax rate ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_tax_rate_added( $tax_rate_id ): void {
+		$this->invalidate( (int) $tax_rate_id );
+		$this->invalidate_tax_rates_list();
+	}
+
+	/**
+	 * Handle the woocommerce_tax_rate_updated hook.
+	 *
+	 * @param int $tax_rate_id The tax rate ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_tax_rate_updated( $tax_rate_id ): void {
+		$this->invalidate( (int) $tax_rate_id );
+		$this->invalidate_tax_rates_list();
+	}
+
+	/**
+	 * Handle the woocommerce_tax_rate_deleted hook.
+	 *
+	 * @param int $tax_rate_id The tax rate ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 *
+	 * @internal
+	 */
+	public function handle_woocommerce_tax_rate_deleted( $tax_rate_id ): void {
+		$this->invalidate( (int) $tax_rate_id );
+		$this->invalidate_tax_rates_list();
+	}
+
+	/**
+	 * Invalidate the tax rates list version string.
+	 *
+	 * Called when tax rates are added, updated, or deleted,
+	 * as these operations affect collection/list endpoints.
+	 *
+	 * @return void
+	 */
+	private function invalidate_tax_rates_list(): void {
+		wc_get_container()->get( VersionStringGenerator::class )->delete_version( 'list_tax_rates' );
+	}
+
+	/**
+	 * Invalidate a tax rate version string.
+	 *
+	 * @param int $tax_rate_id The tax rate ID.
+	 *
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 */
+	public function invalidate( int $tax_rate_id ): void {
+		wc_get_container()->get( VersionStringGenerator::class )->delete_version( "tax_rate_{$tax_rate_id}" );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/TaxRateVersionStringInvalidatorTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/TaxRateVersionStringInvalidatorTest.php
new file mode 100644
index 0000000000..a1d0caf632
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/TaxRateVersionStringInvalidatorTest.php
@@ -0,0 +1,283 @@
+<?php
+/**
+ * TaxRateVersionStringInvalidatorTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
+use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+
+/**
+ * Tests for the TaxRateVersionStringInvalidator class.
+ */
+class TaxRateVersionStringInvalidatorTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var TaxRateVersionStringInvalidator
+	 */
+	private $sut;
+
+	/**
+	 * Version string generator.
+	 *
+	 * @var VersionStringGenerator
+	 */
+	private $version_generator;
+
+	/**
+	 * Setup test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut               = new TaxRateVersionStringInvalidator();
+		$this->version_generator = wc_get_container()->get( VersionStringGenerator::class );
+	}
+
+	/**
+	 * Tear down test.
+	 */
+	public function tearDown(): void {
+		delete_option( 'woocommerce_feature_rest_api_caching_enabled' );
+		delete_option( 'woocommerce_rest_api_enable_backend_caching' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Enable the feature and backend caching, and initialize a new invalidator with hooks registered.
+	 *
+	 * @return TaxRateVersionStringInvalidator The initialized invalidator.
+	 */
+	private function get_invalidator_with_hooks_enabled(): TaxRateVersionStringInvalidator {
+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+
+		$invalidator = new TaxRateVersionStringInvalidator();
+		$invalidator->init();
+
+		return $invalidator;
+	}
+
+	/**
+	 * @testdox Invalidate method deletes the tax rate version string from cache.
+	 */
+	public function test_invalidate_deletes_version_string() {
+		$tax_rate_id = 123;
+
+		$this->version_generator->generate_version( "tax_rate_{$tax_rate_id}" );
+
+		$version_before = $this->version_generator->get_version( "tax_rate_{$tax_rate_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before invalidation' );
+
+		$this->sut->invalidate( $tax_rate_id );
+
+		$version_after = $this->version_generator->get_version( "tax_rate_{$tax_rate_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after invalidation' );
+	}
+
+	/**
+	 * @testdox Hooks are registered when feature is enabled and backend caching is active.
+	 */
+	public function test_hooks_registered_when_feature_and_setting_enabled() {
+		$invalidator = $this->get_invalidator_with_hooks_enabled();
+
+		$this->assertNotFalse( has_action( 'woocommerce_tax_rate_added', array( $invalidator, 'handle_woocommerce_tax_rate_added' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_tax_rate_updated', array( $invalidator, 'handle_woocommerce_tax_rate_updated' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_tax_rate_deleted', array( $invalidator, 'handle_woocommerce_tax_rate_deleted' ) ) );
+	}
+
+	/**
+	 * @testdox Hooks are not registered when feature is disabled.
+	 */
+	public function test_hooks_not_registered_when_feature_disabled() {
+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'no' );
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+
+		$invalidator = new TaxRateVersionStringInvalidator();
+		$invalidator->init();
+
+		$this->assertFalse( has_action( 'woocommerce_tax_rate_added', array( $invalidator, 'handle_woocommerce_tax_rate_added' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_tax_rate_updated', array( $invalidator, 'handle_woocommerce_tax_rate_updated' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_tax_rate_deleted', array( $invalidator, 'handle_woocommerce_tax_rate_deleted' ) ) );
+	}
+
+	/**
+	 * @testdox Hooks are not registered when backend caching setting is disabled.
+	 */
+	public function test_hooks_not_registered_when_backend_caching_disabled() {
+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+
+		$invalidator = new TaxRateVersionStringInvalidator();
+		$invalidator->init();
+
+		$this->assertFalse( has_action( 'woocommerce_tax_rate_added', array( $invalidator, 'handle_woocommerce_tax_rate_added' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_tax_rate_updated', array( $invalidator, 'handle_woocommerce_tax_rate_updated' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_tax_rate_deleted', array( $invalidator, 'handle_woocommerce_tax_rate_deleted' ) ) );
+	}
+
+	/**
+	 * @testdox Hooks are not registered when backend caching setting is not set (defaults to no).
+	 */
+	public function test_hooks_not_registered_when_backend_caching_not_set() {
+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'yes' );
+		delete_option( 'woocommerce_rest_api_enable_backend_caching' );
+
+		$invalidator = new TaxRateVersionStringInvalidator();
+		$invalidator->init();
+
+		$this->assertFalse( has_action( 'woocommerce_tax_rate_added', array( $invalidator, 'handle_woocommerce_tax_rate_added' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_tax_rate_updated', array( $invalidator, 'handle_woocommerce_tax_rate_updated' ) ) );
+		$this->assertFalse( has_action( 'woocommerce_tax_rate_deleted', array( $invalidator, 'handle_woocommerce_tax_rate_deleted' ) ) );
+	}
+
+	/**
+	 * @testdox Creating a new tax rate invalidates the version string via hook.
+	 */
+	public function test_tax_rate_creation_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$tax_rate_id = 456;
+
+		// Create version string that should be deleted when the hook fires.
+		$this->version_generator->generate_version( "tax_rate_{$tax_rate_id}" );
+		$version_before = $this->version_generator->get_version( "tax_rate_{$tax_rate_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before hook fires' );
+
+		// Trigger the hook.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'woocommerce_tax_rate_added', $tax_rate_id );
+
+		$version_after = $this->version_generator->get_version( "tax_rate_{$tax_rate_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after tax rate added hook fires' );
+	}
+
+	/**
+	 * @testdox Updating a tax rate invalidates the version string via hook.
+	 */
+	public function test_tax_rate_update_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$tax_rate_id = 789;
+
+		$this->version_generator->generate_version( "tax_rate_{$tax_rate_id}" );
+		$version_before = $this->version_generator->get_version( "tax_rate_{$tax_rate_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before update' );
+
+		// Trigger the hook.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'woocommerce_tax_rate_updated', $tax_rate_id );
+
+		$version_after = $this->version_generator->get_version( "tax_rate_{$tax_rate_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after tax rate updated hook fires' );
+	}
+
+	/**
+	 * @testdox Deleting a tax rate invalidates the version string via hook.
+	 */
+	public function test_tax_rate_deletion_invalidates_version_string() {
+		$this->get_invalidator_with_hooks_enabled();
+
+		$tax_rate_id = 101;
+
+		$this->version_generator->generate_version( "tax_rate_{$tax_rate_id}" );
+		$version_before = $this->version_generator->get_version( "tax_rate_{$tax_rate_id}", false );
+		$this->assertNotNull( $version_before, 'Version string should exist before deletion' );
+
+		// Trigger the hook.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'woocommerce_tax_rate_deleted', $tax_rate_id );
+
+		$version_after = $this->version_generator->get_version( "tax_rate_{$tax_rate_id}", false );
+		$this->assertNull( $version_after, 'Version string should be deleted after tax rate deleted hook fires' );
+	}
+
+	/**
+	 * @testdox Hook handlers accept string IDs and cast them to integers.
+	 */
+	public function test_handlers_accept_string_ids() {
+		$tax_rate_id = '123';
+
+		$this->version_generator->generate_version( 'tax_rate_123' );
+		$version_before = $this->version_generator->get_version( 'tax_rate_123', false );
+		$this->assertNotNull( $version_before, 'Version string should exist before invalidation' );
+
+		$this->sut->handle_woocommerce_tax_rate_added( $tax_rate_id );
+
+		$version_after = $this->version_generator->get_version( 'tax_rate_123', false );
+		$this->assertNull( $version_after, 'Version string should be deleted after invalidation with string ID' );
+	}
+
+	/**
+	 * @testdox All hook handlers correctly invalidate version strings.
+	 */
+	public function test_all_handlers_invalidate_correctly() {
+		$this->version_generator->generate_version( 'tax_rate_111' );
+		$this->sut->handle_woocommerce_tax_rate_added( 111 );
+		$this->assertNull(
+			$this->version_generator->get_version( 'tax_rate_111', false ),
+			'Added handler should invalidate version string'
+		);
+
+		$this->version_generator->generate_version( 'tax_rate_222' );
+		$this->sut->handle_woocommerce_tax_rate_updated( 222 );
+		$this->assertNull(
+			$this->version_generator->get_version( 'tax_rate_222', false ),
+			'Updated handler should invalidate version string'
+		);
+
+		$this->version_generator->generate_version( 'tax_rate_333' );
+		$this->sut->handle_woocommerce_tax_rate_deleted( 333 );
+		$this->assertNull(
+			$this->version_generator->get_version( 'tax_rate_333', false ),
+			'Deleted handler should invalidate version string'
+		);
+	}
+
+	/**
+	 * @testdox Creating a new tax rate invalidates the list version string.
+	 */
+	public function test_tax_rate_creation_invalidates_list_version_string() {
+		$this->version_generator->generate_version( 'list_tax_rates' );
+		$version_before = $this->version_generator->get_version( 'list_tax_rates', false );
+		$this->assertNotNull( $version_before, 'List version string should exist before creation' );
+
+		$this->sut->handle_woocommerce_tax_rate_added( 456 );
+
+		$version_after = $this->version_generator->get_version( 'list_tax_rates', false );
+		$this->assertNull( $version_after, 'List version string should be deleted after tax rate added' );
+	}
+
+	/**
+	 * @testdox Updating a tax rate invalidates the list version string.
+	 */
+	public function test_tax_rate_update_invalidates_list_version_string() {
+		$this->version_generator->generate_version( 'list_tax_rates' );
+		$version_before = $this->version_generator->get_version( 'list_tax_rates', false );
+		$this->assertNotNull( $version_before, 'List version string should exist before update' );
+
+		$this->sut->handle_woocommerce_tax_rate_updated( 789 );
+
+		$version_after = $this->version_generator->get_version( 'list_tax_rates', false );
+		$this->assertNull( $version_after, 'List version string should be deleted after tax rate updated' );
+	}
+
+	/**
+	 * @testdox Deleting a tax rate invalidates the list version string.
+	 */
+	public function test_tax_rate_deletion_invalidates_list_version_string() {
+		$this->version_generator->generate_version( 'list_tax_rates' );
+		$version_before = $this->version_generator->get_version( 'list_tax_rates', false );
+		$this->assertNotNull( $version_before, 'List version string should exist before deletion' );
+
+		$this->sut->handle_woocommerce_tax_rate_deleted( 101 );
+
+		$version_after = $this->version_generator->get_version( 'list_tax_rates', false );
+		$this->assertNull( $version_after, 'List version string should be deleted after tax rate deleted' );
+	}
+}