Commit edb0b6bd1e0 for woocommerce

commit edb0b6bd1e0c22499eaab130040695b7bc5d9e04
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date:   Wed Sep 9 12:52:41 2026 +0100

    Memoize the payment extension suggestions per country and context (#66341)

    * perf(payments): memoize the incentive WooPayments account data check

    Incentives\WooPayments::has_wcpay_account_data() read and unserialised
    the WooPayments account cache option on every call, and is_visible()
    invokes it once per incentive candidate. Filtering incentives on an
    admin page load produced over twenty redundant reads of a sizeable
    serialized blob.

    Memoize the boolean result for the request and reset it through the
    class's existing reset_memo(), matching the incentives memo pattern
    already in place.

    Refs #66317

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * revert(payments): drop the incentive account data memo

    The memoized account data check cached the discriminator for incentive
    visibility with no production invalidation path: clear_cache() on the
    incentives class has no callers, and the Payments service cache clearing
    never reaches it. Review traces also showed the repeated option reads
    were a symptom of an upstream rebuild, not of the incentive check
    itself.

    Drop the memo and its test. The rebuild is fixed one layer up by
    memoizing the country extension suggestions instead.

    Refs #66341

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * perf(payments): memoize the country extension suggestions

    PaymentsProviders::group_gateways_by_extension() resolves a suggestion
    for every registered gateway through get_by_plugin_slug(), and each call
    rebuilt the whole country extensions list from scratch: per-extension
    allow checks, base details, store state details and incentive filtering,
    around twenty times per admin page load.

    Memoize the processed list per country and context for the duration of
    the request, and clear it through a new
    PaymentsExtensionSuggestions::clear_cache() that
    PaymentsProviders::clear_cache() cascades into, so the existing Payments
    service invalidation path reaches it.

    On a store with WooPayments active, wcpay_account_data option reads on a
    wp-admin dashboard load drop from 21 to 1 on the incentive path.

    Refs #66317

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Fix country extensions memo not being scoped to the current user

    The memo key was country + context, but the cached list embeds
    user-scoped state: incentive visibility checks current_user_can()
    and dismissals are read from the current user's meta. The first
    caller's result was pinned for the rest of the request, so a
    mid-request user switch (e.g. user switching plugins) could leak
    one user's dismissals to another.

    Fold get_current_user_id() into the memo key, matching the
    user-keyed request caches directly above it in the same call
    chain (Payments and PaymentsProviders). Also correct the
    clear_cache() since tag to 11.2.0 and note that it only clears
    this class's memos.

    Adds a per-user regression test and varies the country in the
    existing memoization test, which previously only varied context.

    * Update clear_cache cascade test to expect exactly one call

    atLeastOnce() was used to tolerate the tearDown clearing the cache
    again, but PHPUnit resets the invocation mocker before teardown
    runs, so once() passes and is the stronger assertion. As written
    the test would not catch a future bug firing the cascade twice.

    * fix: cache empty payment extension suggestions

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
    Co-authored-by: Vlad Olaru <vlad.olaru@automattic.com>

diff --git a/plugins/woocommerce/changelog/66317-perf-memoize-country-extensions b/plugins/woocommerce/changelog/66317-perf-memoize-country-extensions
new file mode 100644
index 00000000000..85f82b2927b
--- /dev/null
+++ b/plugins/woocommerce/changelog/66317-perf-memoize-country-extensions
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Memoize the payment extension suggestions list per country and context so it is built once per request instead of once per registered gateway.
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders.php b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders.php
index 56da82bbbe9..56fdb78404f 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders.php
@@ -1276,6 +1276,8 @@ class PaymentsProviders {
 		wp_cache_delete( self::GATEWAY_DETAILS_REQUEST_CACHE_KEY, self::GATEWAY_DETAILS_REQUEST_CACHE_GROUP );
 		// The Payments service owns and also clears this cache; deleting it here keeps direct callers of this service from reading stale provider lists.
 		wp_cache_delete( self::PROVIDER_LISTS_REQUEST_CACHE_KEY, self::PROVIDER_LISTS_REQUEST_CACHE_GROUP );
+		// Suggestion details are embedded in the gateway details, so clear their cache too.
+		$this->extension_suggestions->clear_cache();
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Admin/Suggestions/PaymentsExtensionSuggestions.php b/plugins/woocommerce/src/Internal/Admin/Suggestions/PaymentsExtensionSuggestions.php
index c8bdb8aa4bc..978241df143 100644
--- a/plugins/woocommerce/src/Internal/Admin/Suggestions/PaymentsExtensionSuggestions.php
+++ b/plugins/woocommerce/src/Internal/Admin/Suggestions/PaymentsExtensionSuggestions.php
@@ -104,6 +104,15 @@ class PaymentsExtensionSuggestions {
 	 */
 	private ?array $extensions_base_details_memo = null;

+	/**
+	 * The memoized processed country extensions to avoid rebuilding the list multiple times during a request.
+	 *
+	 * Keyed by user, country code, and context. Cleared via clear_cache().
+	 *
+	 * @var array
+	 */
+	private array $country_extensions_memo = array();
+
 	/**
 	 * The payment extension list for each country.
 	 *
@@ -2866,6 +2875,8 @@ class PaymentsExtensionSuggestions {
 	/**
 	 * Get the list of payment extensions details for a specific country.
 	 *
+	 * The list is memoized per user, country, and context for the duration of the request. Use clear_cache() to force a rebuild.
+	 *
 	 * @param string $country_code The two-letter country code.
 	 * @param string $context      Optional. The context ID of where these extensions are being used.
 	 *
@@ -2876,10 +2887,17 @@ class PaymentsExtensionSuggestions {
 	public function get_country_extensions( string $country_code, string $context = '' ): array {
 		$country_code = strtoupper( $country_code );

+		// Key on the user since incentive visibility and dismissals are user-specific.
+		$memo_key = get_current_user_id() . '__' . $country_code . '__' . $context;
+		if ( isset( $this->country_extensions_memo[ $memo_key ] ) ) {
+			return $this->country_extensions_memo[ $memo_key ];
+		}
+
 		if ( empty( $this->country_extensions[ $country_code ] ) ||
 			! is_array( $this->country_extensions[ $country_code ] ) ) {

-			return array();
+			$this->country_extensions_memo[ $memo_key ] = array();
+			return $this->country_extensions_memo[ $memo_key ];
 		}

 		// Process the extensions.
@@ -2928,6 +2946,8 @@ class PaymentsExtensionSuggestions {
 			$processed_extensions[] = $this->standardize_extension_details( $extension_details );
 		}

+		$this->country_extensions_memo[ $memo_key ] = $processed_extensions;
+
 		return $processed_extensions;
 	}

@@ -3015,6 +3035,24 @@ class PaymentsExtensionSuggestions {
 		return $this->suggestion_incentives->dismiss_incentive( $incentive_id, $suggestion_id, $context );
 	}

+	/**
+	 * Clear the cached extension suggestions data.
+	 *
+	 * Call after changing store state that influences the suggestions list during a request.
+	 * Also useful for testing purposes.
+	 *
+	 * This only clears this class's memos, not the incentives provider's own caches.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @internal
+	 * @return void
+	 */
+	public function clear_cache(): void {
+		$this->country_extensions_memo      = array();
+		$this->extensions_base_details_memo = null;
+	}
+
 	/**
 	 * Determine if a payment extension is allowed to be suggested.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProvidersTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProvidersTest.php
index 6a0df086ce5..b9d5148d3cd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProvidersTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProvidersTest.php
@@ -1146,6 +1146,17 @@ class PaymentsProvidersTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox clear_cache cascades to the extension suggestions service.
+	 */
+	public function test_clear_cache_cascades_to_extension_suggestions(): void {
+		$this->mock_extension_suggestions
+			->expects( $this->once() )
+			->method( 'clear_cache' );
+
+		$this->sut->clear_cache();
+	}
+
 	/**
 	 * Test that get_payment_gateway_details does not override gateway details with those from the suggestion
 	 * when they exist.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Suggestions/PaymentsExtensionSuggestionsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Suggestions/PaymentsExtensionSuggestionsTest.php
index 600cdb270cf..1ba59473b91 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Suggestions/PaymentsExtensionSuggestionsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Suggestions/PaymentsExtensionSuggestionsTest.php
@@ -58,6 +58,96 @@ class PaymentsExtensionSuggestionsTest extends WC_Unit_Test_Case {
 		$this->assertNotEmpty( $extensions );
 	}

+	/**
+	 * Test that the payment extension suggestions list is memoized per country and context.
+	 */
+	public function test_get_country_extensions_memoizes_per_country_and_context() {
+		// Arrange.
+		$incentives_calls = 0;
+		$this->suggestion_incentives
+			->method( 'get_incentives' )
+			->willReturnCallback(
+				function () use ( &$incentives_calls ) {
+					++$incentives_calls;
+					return array();
+				}
+			);
+
+		// Act.
+		$first       = $this->sut->get_country_extensions( 'US' );
+		$build_calls = $incentives_calls;
+		$second      = $this->sut->get_country_extensions( 'US' );
+
+		// Assert.
+		$this->assertGreaterThan( 0, $build_calls, 'Building the list should query incentives for each extension.' );
+		$this->assertSame( $build_calls, $incentives_calls, 'A repeated call should be served from the memo, without rebuilding the list.' );
+		$this->assertSame( $first, $second );
+
+		// Act.
+		$this->sut->get_country_extensions( 'US', 'another_context' );
+
+		// Assert.
+		$this->assertGreaterThan( $build_calls, $incentives_calls, 'A different context should build its own list.' );
+
+		// Act.
+		$calls_before_country_change = $incentives_calls;
+		$this->sut->get_country_extensions( 'GB' );
+
+		// Assert.
+		$this->assertGreaterThan( $calls_before_country_change, $incentives_calls, 'A different country should build its own list.' );
+	}
+
+	/**
+	 * Test that the payment extension suggestions list is memoized per user, since incentive visibility and dismissals are user-specific.
+	 */
+	public function test_get_country_extensions_memoizes_per_user() {
+		// Arrange.
+		$incentives_calls = 0;
+		$this->suggestion_incentives
+			->method( 'get_incentives' )
+			->willReturnCallback(
+				function () use ( &$incentives_calls ) {
+					++$incentives_calls;
+					return array();
+				}
+			);
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+		$this->sut->get_country_extensions( 'US' );
+		$build_calls = $incentives_calls;
+
+		// Act.
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+		$this->sut->get_country_extensions( 'US' );
+
+		// Assert.
+		$this->assertGreaterThan( $build_calls, $incentives_calls, 'A different user should build its own list.' );
+	}
+
+	/**
+	 * Test that clear_cache forces the payment extension suggestions list to be rebuilt.
+	 */
+	public function test_clear_cache_forces_country_extensions_rebuild() {
+		// Arrange.
+		$incentives_calls = 0;
+		$this->suggestion_incentives
+			->method( 'get_incentives' )
+			->willReturnCallback(
+				function () use ( &$incentives_calls ) {
+					++$incentives_calls;
+					return array();
+				}
+			);
+		$this->sut->get_country_extensions( 'US' );
+		$build_calls = $incentives_calls;
+
+		// Act.
+		$this->sut->clear_cache();
+		$this->sut->get_country_extensions( 'US' );
+
+		// Assert.
+		$this->assertGreaterThan( $build_calls, $incentives_calls, 'After clear_cache the list should be rebuilt.' );
+	}
+
 	/**
 	 * @testdox Should tag Square as preferred (and preferred for offline) only when the merchant self-identified as selling offline.
 	 *