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.
*