Commit 01c919014e8 for woocommerce

commit 01c919014e848ee5453cbd3b24f07211cfbeb24b
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Wed May 13 13:59:11 2026 -0400

    Fix invalid WooCommerce cache prefixes (#64808)

    * Fix invalid WooCommerce cache prefixes

    * Fix cache prefix PHPStan failure

    * Simplify cache prefix regeneration

    * Relax cached prefix validation

    * Remove stale cache prefix PHPStan baselines

    * Add invalid cache prefix detection hook

    * Simplify cache prefix recovery

    * Remove redundant cache prefix found cast

    * Document concurrent cache prefix initialization

diff --git a/plugins/woocommerce/changelog/fix-cache-prefix-invalid-values b/plugins/woocommerce/changelog/fix-cache-prefix-invalid-values
new file mode 100644
index 00000000000..219872b8de2
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-cache-prefix-invalid-values
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent invalid cache prefixes from causing fatal errors on the My Account orders page.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 9fbecb56f3e..e4bc5e40ff0 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -9702,12 +9702,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-cache-helper.php

-		-
-			message: '#^Method WC_Cache_Helper\:\:invalidate_cache_group\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: includes/class-wc-cache-helper.php
-
 		-
 			message: '#^Method WC_Cache_Helper\:\:notices\(\) has no return type specified\.$#'
 			identifier: missingType.return
@@ -56661,12 +56655,6 @@ parameters:
 			count: 1
 			path: src/Caching/WPCacheEngine.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Caching\\WPCacheEngine\:\:invalidate_cache_group\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Caching/WPCacheEngine.php
-
 		-
 			message: '#^Parameter \#1 \$data of function wp_cache_set_multiple expects array, array\<string, mixed\>\|false given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Caching/CacheNameSpaceTrait.php b/plugins/woocommerce/src/Caching/CacheNameSpaceTrait.php
index 7c4f4cbb8c2..18f364249dd 100644
--- a/plugins/woocommerce/src/Caching/CacheNameSpaceTrait.php
+++ b/plugins/woocommerce/src/Caching/CacheNameSpaceTrait.php
@@ -22,13 +22,44 @@ trait CacheNameSpaceTrait {
 	 */
 	public static function get_cache_prefix( $group ) {
 		// Get cache key - uses cache key wc_orders_cache_prefix to invalidate when needed.
-		$prefix = wp_cache_get( 'wc_' . $group . '_cache_prefix', $group );
+		$cache_key = 'wc_' . $group . '_cache_prefix';
+		$found     = false;
+		$prefix    = wp_cache_get( $cache_key, $group, false, $found );

-		if ( false === $prefix ) {
-			$prefix = microtime();
-			wp_cache_set( 'wc_' . $group . '_cache_prefix', $prefix, $group );
+		if ( self::is_valid_cache_prefix( $prefix ) ) {
+			return 'wc_cache_' . $prefix . '_';
 		}

+		if ( $found ) {
+			/**
+			 * Fires when WooCommerce detects an invalid cache prefix before replacing it.
+			 *
+			 * @since 10.8.0
+			 *
+			 * @param string $group  Cache group.
+			 * @param mixed  $prefix Invalid cached prefix value.
+			 */
+			do_action( 'woocommerce_invalid_cache_prefix_detected', $group, $prefix );
+		}
+
+		$prefix = self::generate_cache_prefix();
+
+		if ( ! $found ) {
+			// Use add on cold prefixes so concurrent requests converge on the first
+			// persisted value instead of writing competing cache namespaces.
+			if ( wp_cache_add( $cache_key, $prefix, $group ) ) {
+				return 'wc_cache_' . $prefix . '_';
+			}
+
+			$cached_prefix = wp_cache_get( $cache_key, $group );
+
+			if ( self::is_valid_cache_prefix( $cached_prefix ) ) {
+				return 'wc_cache_' . $cached_prefix . '_';
+			}
+		}
+
+		wp_cache_set( $cache_key, $prefix, $group );
+
 		return 'wc_cache_' . $prefix . '_';
 	}

@@ -46,10 +77,12 @@ trait CacheNameSpaceTrait {
 	 * Invalidate cache group.
 	 *
 	 * @param string $group Group of cache to clear.
+	 * @return bool True when the new prefix was persisted to the object cache,
+	 *              false otherwise.
 	 * @since 3.9.0
 	 */
 	public static function invalidate_cache_group( $group ) {
-		return wp_cache_set( 'wc_' . $group . '_cache_prefix', microtime(), $group );
+		return wp_cache_set( 'wc_' . $group . '_cache_prefix', self::generate_cache_prefix(), $group );
 	}

 	/**
@@ -63,4 +96,23 @@ trait CacheNameSpaceTrait {
 	public static function get_prefixed_key( $key, $group ) {
 		return self::get_cache_prefix( $group ) . $key;
 	}
+
+	/**
+	 * Generate a cache-safe prefix value.
+	 *
+	 * @return string Cache prefix.
+	 */
+	private static function generate_cache_prefix() {
+		return str_replace( ' ', '_', microtime() ) . '_' . bin2hex( random_bytes( 8 ) );
+	}
+
+	/**
+	 * Check whether a cached prefix can be used as a cache-key namespace.
+	 *
+	 * @param mixed $prefix Cached prefix value.
+	 * @return bool True if the prefix is valid.
+	 */
+	private static function is_valid_cache_prefix( $prefix ) {
+		return is_string( $prefix ) && '' !== trim( $prefix );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cache-helper-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cache-helper-test.php
index f1923887cda..80dcbc90f50 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cache-helper-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cache-helper-test.php
@@ -7,6 +7,24 @@ declare( strict_types = 1 );
  */
 class WC_Cache_Helper_Tests extends WC_Unit_Test_Case {

+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->delete_orders_cache_prefixes();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$this->delete_orders_cache_prefixes();
+
+		parent::tearDown();
+	}
+
 	/**
 	 * Data provider for test_geolocation_ajax_get_location_hash.
 	 *
@@ -74,4 +92,189 @@ class WC_Cache_Helper_Tests extends WC_Unit_Test_Case {
 			WC_Cache_Helper::geolocation_ajax_get_location_hash()
 		);
 	}
+
+	/**
+	 * @testdox Get cache prefix should generate string prefixes for empty cache groups.
+	 */
+	public function test_get_cache_prefix_generates_string_prefix(): void {
+		$prefix = WC_Cache_Helper::get_cache_prefix( 'orders' );
+
+		$this->assert_prefixed_cache_key_namespace( $prefix );
+	}
+
+	/**
+	 * @testdox Get cache prefix should store string prefixes.
+	 */
+	public function test_get_cache_prefix_stores_string_prefix(): void {
+		WC_Cache_Helper::get_cache_prefix( 'orders' );
+
+		$stored_prefix = wp_cache_get( 'wc_orders_cache_prefix', 'orders' );
+
+		$this->assert_valid_stored_prefix( $stored_prefix );
+	}
+
+	/**
+	 * Data provider for invalid cache prefix values.
+	 *
+	 * @return array<string,array{0:mixed}>
+	 */
+	public function data_provider_invalid_cache_prefixes(): array {
+		return array(
+			'null'              => array( null ),
+			'array'             => array( array( 'invalid' => true ) ),
+			'true'              => array( true ),
+			'false'             => array( false ),
+			'integer'           => array( 123 ),
+			'float'             => array( 123.45 ),
+			'stdClass'          => array( (object) array( 'invalid' => true ) ),
+			'stringable object' => array(
+				new class() {
+					/**
+					 * Convert the object to a string.
+					 *
+					 * @return string
+					 */
+					public function __toString() {
+						return '0.12345600_1778592656_deadbeefdeadbeef';
+					}
+				},
+			),
+			'empty string'      => array( '' ),
+			'spaces'            => array( '   ' ),
+			'tab'               => array( "\t" ),
+			'newline'           => array( "\n" ),
+			'null byte'         => array( "\0" ),
+		);
+	}
+
+	/**
+	 * @testdox Get cache prefix should replace invalid cached values.
+	 *
+	 * @dataProvider data_provider_invalid_cache_prefixes
+	 *
+	 * @param mixed $invalid_prefix Invalid cache prefix.
+	 */
+	public function test_get_cache_prefix_replaces_invalid_cached_prefixes( $invalid_prefix ): void {
+		wp_cache_set( 'wc_orders_cache_prefix', $invalid_prefix, 'orders' );
+
+		$prefix        = WC_Cache_Helper::get_cache_prefix( 'orders' );
+		$stored_prefix = wp_cache_get( 'wc_orders_cache_prefix', 'orders' );
+
+		$this->assert_prefixed_cache_key_namespace( $prefix );
+		$this->assert_valid_stored_prefix( $stored_prefix );
+		$this->assertNotSame( $invalid_prefix, $stored_prefix );
+	}
+
+	/**
+	 * @testdox Get cache prefix should fire an action when replacing an invalid cached prefix.
+	 */
+	public function test_get_cache_prefix_fires_action_when_replacing_invalid_cached_prefix(): void {
+		$invalid_prefix = array( 'invalid' => true );
+		$detected       = array();
+		$callback       = function ( $group, $prefix ) use ( &$detected ) {
+			$detected[] = array(
+				'group'  => $group,
+				'prefix' => $prefix,
+			);
+		};
+
+		wp_cache_set( 'wc_orders_cache_prefix', $invalid_prefix, 'orders' );
+		add_action( 'woocommerce_invalid_cache_prefix_detected', $callback, 10, 2 );
+
+		try {
+			WC_Cache_Helper::get_cache_prefix( 'orders' );
+		} finally {
+			remove_action( 'woocommerce_invalid_cache_prefix_detected', $callback, 10 );
+		}
+
+		$this->assertCount( 1, $detected );
+		$this->assertSame( 'orders', $detected[0]['group'] );
+		$this->assertSame( $invalid_prefix, $detected[0]['prefix'] );
+	}
+
+	/**
+	 * Data provider for valid cache prefix values.
+	 *
+	 * @return array<string,array{0:string}>
+	 */
+	public function data_provider_valid_cache_prefixes(): array {
+		return array(
+			'generated format'    => array( '0.12345600_1778592656_deadbeefdeadbeef' ),
+			'old microtime'       => array( '0.84069400 1778478731' ),
+			'plain string'        => array( 'extension-prefix' ),
+			'string with spaces'  => array( 'extension prefix' ),
+			'numeric string zero' => array( '0' ),
+		);
+	}
+
+	/**
+	 * @testdox Get cache prefix should reuse valid cached values.
+	 *
+	 * @dataProvider data_provider_valid_cache_prefixes
+	 *
+	 * @param string $stored_prefix Stored cache prefix.
+	 */
+	public function test_get_cache_prefix_reuses_valid_cached_prefix( string $stored_prefix ): void {
+		wp_cache_set( 'wc_orders_cache_prefix', $stored_prefix, 'orders' );
+
+		$prefix = WC_Cache_Helper::get_cache_prefix( 'orders' );
+
+		$this->assertSame( 'wc_cache_' . $stored_prefix . '_', $prefix );
+		$this->assertSame( $stored_prefix, wp_cache_get( 'wc_orders_cache_prefix', 'orders' ) );
+	}
+
+	/**
+	 * @testdox Invalidate cache group should generate string prefixes.
+	 */
+	public function test_invalidate_cache_group_generates_string_prefix(): void {
+		WC_Cache_Helper::invalidate_cache_group( 'orders' );
+
+		$prefix        = WC_Cache_Helper::get_cache_prefix( 'orders' );
+		$stored_prefix = wp_cache_get( 'wc_orders_cache_prefix', 'orders' );
+
+		$this->assert_prefixed_cache_key_namespace( $prefix );
+		$this->assert_valid_stored_prefix( $stored_prefix );
+	}
+
+	/**
+	 * @testdox Get cache prefix should recover when a cached prefix is not stringable.
+	 */
+	public function test_get_cache_prefix_recovers_non_stringable_cached_prefix(): void {
+		wp_cache_set( 'wc_orders_cache_prefix', (object) array( 'invalid' => true ), 'orders' );
+
+		$cache_key     = WC_Order::generate_meta_cache_key( 123, 'orders' );
+		$stored_prefix = wp_cache_get( 'wc_orders_cache_prefix', 'orders' );
+
+		$this->assertStringStartsWith( 'wc_cache_', $cache_key );
+		$this->assertStringContainsString( 'object_meta_123', $cache_key );
+		$this->assert_valid_stored_prefix( $stored_prefix );
+	}
+
+	/**
+	 * Assert that a namespaced cache key contains a valid prefix.
+	 *
+	 * @param string $prefix Namespaced cache key prefix.
+	 */
+	private function assert_prefixed_cache_key_namespace( string $prefix ): void {
+		$this->assertStringStartsWith( 'wc_cache_', $prefix );
+		$this->assertStringEndsWith( '_', $prefix );
+		$this->assert_valid_stored_prefix( substr( $prefix, strlen( 'wc_cache_' ), -1 ) );
+	}
+
+	/**
+	 * Assert that a stored cache prefix can be used as a cache-key namespace.
+	 *
+	 * @param mixed $prefix Stored cache prefix.
+	 */
+	private function assert_valid_stored_prefix( $prefix ): void {
+		$this->assertIsString( $prefix );
+		$this->assertNotSame( '', trim( $prefix ) );
+	}
+
+	/**
+	 * Delete cache prefix fixtures.
+	 */
+	private function delete_orders_cache_prefixes(): void {
+		wp_cache_delete( 'wc_orders_cache_prefix', 'orders' );
+	}
 }