Commit 5c3f240f14b for woocommerce
commit 5c3f240f14bf563660053871811c1bf6f9de421c
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Mon Sep 14 18:29:22 2026 +0100
Fix reentrant feature definition initialization (#68417)
diff --git a/plugins/woocommerce/changelog/fix-features-controller-reentrancy b/plugins/woocommerce/changelog/fix-features-controller-reentrancy
new file mode 100644
index 00000000000..de5f765067b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-features-controller-reentrancy
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent recursive feature definition initialization when hooks check WooCommerce features during translation or option callbacks.
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 63320e87469..959d45f541b 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -124,6 +124,13 @@ class FeaturesController {
*/
private bool $registered_additional_features_via_class_calls = false;
+ /**
+ * Flag indicating if hardcoded feature definitions are currently being initialized.
+ *
+ * @var bool
+ */
+ private bool $initializing_feature_definitions = false;
+
/**
* Flag indicating if we are currently delaying plugin normalization.
*
@@ -260,9 +267,7 @@ class FeaturesController {
* @return array[]
*/
private function get_feature_definitions() {
- if ( empty( $this->features ) ) {
- $this->init_feature_definitions();
- }
+ $this->maybe_init_feature_definitions();
if ( ! $this->registered_additional_features_via_class_calls ) {
// This needs to be set to true *before* additional feature definition calls are made,
@@ -282,6 +287,23 @@ class FeaturesController {
return $this->features;
}
+ /**
+ * Initialize hardcoded feature definitions if they have not been initialized yet.
+ */
+ private function maybe_init_feature_definitions(): void {
+ if ( ! empty( $this->features ) || $this->initializing_feature_definitions ) {
+ return;
+ }
+
+ $this->initializing_feature_definitions = true;
+
+ try {
+ $this->init_feature_definitions();
+ } finally {
+ $this->initializing_feature_definitions = false;
+ }
+ }
+
/**
* Initialize the hardcoded feature definitions array.
* This doesn't include:
@@ -783,9 +805,7 @@ class FeaturesController {
return;
}
- if ( empty( $this->features ) ) {
- $this->init_feature_definitions();
- }
+ $this->maybe_init_feature_definitions();
/**
* The action for registering features.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php
index 6fbc87c4d5a..dae69d05401 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php
@@ -282,6 +282,48 @@ class FeaturesControllerTest extends \WC_Unit_Test_Case {
$this->assertEquals( $expected_to_be_enabled, $this->sut->feature_is_enabled( $feature_id ) );
}
+ /**
+ * @testdox Feature definition initialization is safe when a translation callback checks a feature.
+ */
+ public function test_feature_definition_initialization_is_reentrant_safe() {
+ $reflection_class = new \ReflectionClass( $this->sut );
+
+ $features_property = $reflection_class->getProperty( 'features' );
+ $features_property->setAccessible( true );
+ $features_property->setValue( $this->sut, array() );
+
+ $compat_property = $reflection_class->getProperty( 'compatibility_info_by_feature' );
+ $compat_property->setAccessible( true );
+ $compat_property->setValue( $this->sut, array() );
+
+ $reentrant_feature_enabled = null;
+ $translation_count = 0;
+
+ $gettext_filter = function ( $translation, $text, $domain ) use ( &$reentrant_feature_enabled, &$translation_count ) {
+ unset( $text, $domain ); // Avoid parameter not used PHPCS errors.
+
+ ++$translation_count;
+
+ if ( null === $reentrant_feature_enabled ) {
+ $reentrant_feature_enabled = $this->sut->feature_is_enabled( 'order_withdrawal' );
+ }
+
+ return $translation;
+ };
+
+ add_filter( 'gettext', $gettext_filter, 10, 3 );
+
+ try {
+ $this->sut->get_features( true, false );
+ } finally {
+ remove_filter( 'gettext', $gettext_filter, 10 );
+ }
+
+ $this->assertFalse( $reentrant_feature_enabled );
+ $this->assertGreaterThan( 0, $translation_count );
+ $this->assertNotNull( $this->sut->get_feature_definition( 'order_withdrawal' ) );
+ }
+
/**
* @testdox 'change_feature_enable' does nothing and returns false for an invalid feature id.
*/