Commit 3bbe371ad63 for woocommerce

commit 3bbe371ad63641736bd8ac940555e6071c7c8b5b
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Jul 14 18:04:10 2026 +0300

    Fix early translations during Analytics bootstrap (#66582)

    * fix: Avoid early analytics feature translations

    FeaturePlugin initializes analytics report hooks during plugins_loaded, so its feature gate runs before WooCommerce translations are safe to load.

    PR #65472 routed this gate through FeaturesController. That path eagerly builds translated feature definitions, triggering WordPress 6.7+ early textdomain notices and breaking redirects when debug output is displayed.

    Read the Analytics option and established legacy disabling filters directly during bootstrap. This preserves the existing hook timing and enabled-state behavior without constructing feature definitions, with regression coverage for options, filters, and translation calls.

    Refs #66515

    * chore: Add changelog for early textdomain fix

    Record the user-facing fix that prevents early WooCommerce translation output from interfering with redirects while debugging is enabled.

    Refs #66515

    * fix: Preserve Analytics bootstrap filter order

    [Context]
    The translation-free Analytics bootstrap gate mirrors the canonical feature check without constructing localized feature definitions.

    [Problem]
    Reading the option first short-circuited legacy filter evaluation when Analytics was disabled. The boolean result remained false, but observable filter behavior differed from FeaturesController and the duplicated enabled-by-default fallback lacked a drift guard.

    [Solution]
    Evaluate legacy disabling filters before the option, document the fallback's canonical source, and cover the disabled-option filter invocation with a regression test.

    Refs #66515

    * test: Pin analytics bootstrap gate to canonical FeaturesController result

    The bootstrap gate is_analytics_enabled_during_bootstrap() deliberately
    duplicates FeaturesController's analytics option name and its 'yes' default
    so it can run before init without building translated feature definitions.
    Nothing enforced that this hardcoded default stays in sync with the canonical
    feature definition, so an upstream change to analytics' enabled_by_default
    would let the two paths silently diverge on the 'option absent' case (a
    different answer before vs. after init) with no test failing.

    Add a data-provider-driven test that runs the gate and
    FeaturesController::feature_is_enabled('analytics') under the same option
    state and asserts they agree, turning the inline comment's invariant into a
    CI-enforced guard. Verified it fails on the 'option absent' case when
    enabled_by_default is flipped, so it is a real regression guard, not a
    tautology.

    Refs #66515

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Peter Petrov <peter.petrov89@gmail.com>
    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-66515-early-textdomain-load b/plugins/woocommerce/changelog/fix-66515-early-textdomain-load
new file mode 100644
index 00000000000..7aa21680a8f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66515-early-textdomain-load
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent early WooCommerce translation loading from breaking redirects when debugging is enabled.
diff --git a/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php b/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php
index c48f4839d2d..aedd47243e8 100644
--- a/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php
+++ b/plugins/woocommerce/src/Internal/Admin/FeaturePlugin.php
@@ -22,7 +22,6 @@ use Automattic\WooCommerce\Admin\PluginsHelper;
 use Automattic\WooCommerce\Admin\PluginsInstaller;
 use Automattic\WooCommerce\Admin\ReportExporter;
 use Automattic\WooCommerce\Admin\ReportsSync;
-use Automattic\WooCommerce\Utilities\FeaturesUtil;
 use Automattic\WooCommerce\Internal\Admin\CategoryLookup;
 use Automattic\WooCommerce\Internal\Admin\Events;
 use Automattic\WooCommerce\Internal\Admin\Onboarding\Onboarding;
@@ -174,7 +173,7 @@ class FeaturePlugin {

 		Onboarding::init();

-		if ( FeaturesUtil::feature_is_enabled( 'analytics' ) ) {
+		if ( $this->is_analytics_enabled_during_bootstrap() ) {
 			// Initialize Reports syncing.
 			ReportsSync::init();
 			CategoryLookup::instance()->init();
@@ -194,6 +193,19 @@ class FeaturePlugin {
 		new ScheduledUpdatesPromotion();
 	}

+	/**
+	 * Check whether Analytics should be initialized during plugin bootstrap.
+	 *
+	 * Feature definitions contain translated presentation strings and are not safe to build before init.
+	 *
+	 * @return bool
+	 */
+	private function is_analytics_enabled_during_bootstrap(): bool {
+		// Keep this fallback aligned with `enabled_by_default` for Analytics in FeaturesController.
+		return ! Features::is_analytics_disabled_by_legacy_filters()
+			&& 'yes' === get_option( Analytics::TOGGLE_OPTION_NAME, 'yes' );
+	}
+
 	/**
 	 * Set up our admin hooks and plugin loader.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/FeaturePluginTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/FeaturePluginTest.php
new file mode 100644
index 00000000000..2764d4a9b4f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/FeaturePluginTest.php
@@ -0,0 +1,164 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin;
+
+use Automattic\WooCommerce\Internal\Admin\Analytics;
+use Automattic\WooCommerce\Internal\Admin\FeaturePlugin;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the FeaturePlugin class.
+ */
+class FeaturePluginTest extends WC_Unit_Test_Case {
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		delete_option( Analytics::TOGGLE_OPTION_NAME );
+		remove_filter( 'woocommerce_admin_disabled', '__return_true', PHP_INT_MAX );
+		remove_filter( 'woocommerce_admin_features', array( $this, 'disable_analytics_feature' ), PHP_INT_MAX );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox The bootstrap analytics gate respects the feature option.
+	 * @dataProvider analytics_option_provider
+	 *
+	 * @param string|null $option_value   Analytics option value, or null when absent.
+	 * @param bool        $expected_value Expected gate value.
+	 */
+	public function test_analytics_gate_respects_feature_option( ?string $option_value, bool $expected_value ): void {
+		if ( null === $option_value ) {
+			delete_option( Analytics::TOGGLE_OPTION_NAME );
+		} else {
+			update_option( Analytics::TOGGLE_OPTION_NAME, $option_value );
+		}
+
+		$this->assertSame( $expected_value, $this->is_analytics_enabled_during_bootstrap(), 'The bootstrap gate should match the configured Analytics option.' );
+	}
+
+	/**
+	 * Values for the analytics option test.
+	 *
+	 * @return array<string, array{string|null, bool}>
+	 */
+	public function analytics_option_provider(): array {
+		return array(
+			'option absent'    => array( null, true ),
+			'option enabled'   => array( 'yes', true ),
+			'option disabled'  => array( 'no', false ),
+			'unexpected value' => array( 'invalid', false ),
+		);
+	}
+
+	/**
+	 * @testdox The bootstrap analytics gate agrees with the canonical FeaturesController result.
+	 * @dataProvider analytics_option_provider
+	 *
+	 * The bootstrap gate deliberately hardcodes the analytics option name and its 'yes' default so it can run
+	 * before init without building translated feature definitions. This pins that duplication to the canonical
+	 * path: if `enabled_by_default` for analytics ever flips upstream, the two would silently disagree on the
+	 * "option absent" case and this test fails.
+	 *
+	 * @param string|null $option_value   Analytics option value, or null when absent.
+	 * @param bool        $expected_value Expected gate value.
+	 */
+	public function test_analytics_gate_agrees_with_features_controller( ?string $option_value, bool $expected_value ): void {
+		if ( null === $option_value ) {
+			delete_option( Analytics::TOGGLE_OPTION_NAME );
+		} else {
+			update_option( Analytics::TOGGLE_OPTION_NAME, $option_value );
+		}
+
+		$canonical_value = wc_get_container()->get( FeaturesController::class )->feature_is_enabled( 'analytics' );
+
+		$this->assertSame( $expected_value, $canonical_value, 'FeaturesController::feature_is_enabled( "analytics" ) drifted from the documented expectation the bootstrap gate mirrors.' );
+		$this->assertSame( $canonical_value, $this->is_analytics_enabled_during_bootstrap(), 'The bootstrap gate must agree with FeaturesController::feature_is_enabled( "analytics" ) under the same option state.' );
+	}
+
+	/**
+	 * @testdox The bootstrap analytics gate evaluates legacy filters when the option is disabled.
+	 */
+	public function test_analytics_gate_evaluates_legacy_filters_when_option_disabled(): void {
+		update_option( Analytics::TOGGLE_OPTION_NAME, 'no' );
+		$filter_call_count = 0;
+		$filter            = static function ( $disabled ) use ( &$filter_call_count ) {
+			++$filter_call_count;
+
+			return $disabled;
+		};
+		add_filter( 'woocommerce_admin_disabled', $filter, PHP_INT_MAX );
+
+		try {
+			$this->assertFalse( $this->is_analytics_enabled_during_bootstrap(), 'The disabled Analytics option should keep the bootstrap gate disabled.' );
+		} finally {
+			remove_filter( 'woocommerce_admin_disabled', $filter, PHP_INT_MAX );
+		}
+
+		$this->assertSame( 1, $filter_call_count, 'The bootstrap gate should preserve the canonical legacy-filter evaluation order.' );
+	}
+
+	/**
+	 * @testdox The bootstrap analytics gate respects the legacy WooCommerce Admin disabled filter.
+	 */
+	public function test_analytics_gate_respects_admin_disabled_filter(): void {
+		add_filter( 'woocommerce_admin_disabled', '__return_true', PHP_INT_MAX );
+
+		$this->assertFalse( $this->is_analytics_enabled_during_bootstrap(), 'Disabling WooCommerce Admin should disable the Analytics bootstrap gate.' );
+	}
+
+	/**
+	 * @testdox The bootstrap analytics gate respects removal from the legacy feature list.
+	 */
+	public function test_analytics_gate_respects_legacy_feature_filter(): void {
+		add_filter( 'woocommerce_admin_features', array( $this, 'disable_analytics_feature' ), PHP_INT_MAX );
+
+		$this->assertFalse( $this->is_analytics_enabled_during_bootstrap(), 'Removing Analytics from the legacy feature list should disable its bootstrap gate.' );
+	}
+
+	/**
+	 * @testdox The bootstrap analytics gate does not translate WooCommerce strings.
+	 */
+	public function test_analytics_gate_does_not_translate_strings(): void {
+		$translation_count = 0;
+		$gettext_filter    = static function ( $translation, $text, $domain ) use ( &$translation_count ) {
+			if ( 'woocommerce' === $domain ) {
+				++$translation_count;
+			}
+
+			return $translation;
+		};
+		add_filter( 'gettext', $gettext_filter, 10, 3 );
+
+		try {
+			$this->assertTrue( $this->is_analytics_enabled_during_bootstrap(), 'Analytics should remain enabled without invoking translations.' );
+		} finally {
+			remove_filter( 'gettext', $gettext_filter, 10 );
+		}
+
+		$this->assertSame( 0, $translation_count, 'The bootstrap gate should not translate WooCommerce feature definitions.' );
+	}
+
+	/**
+	 * Disable Analytics in the legacy feature list.
+	 *
+	 * @param array $features Feature slugs.
+	 * @return array
+	 */
+	public function disable_analytics_feature( array $features ): array {
+		return array_values( array_diff( $features, array( 'analytics' ) ) );
+	}
+
+	/**
+	 * Invoke the private bootstrap analytics gate.
+	 */
+	private function is_analytics_enabled_during_bootstrap(): bool {
+		$method = new \ReflectionMethod( FeaturePlugin::class, 'is_analytics_enabled_during_bootstrap' );
+		$method->setAccessible( true );
+
+		return (bool) $method->invoke( FeaturePlugin::instance() );
+	}
+}