Commit fb5bd749619 for woocommerce

commit fb5bd749619ccacecae44715fa168ca532fd14c7
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Aug 21 18:11:00 2026 +0300

    Fix rewrite corruption during installing-mode requests (#67741)

    * fix: preserve rewrite state during installing requests

    Context: Network-active WooCommerce can load during WordPress installing-mode requests while the active theme is intentionally skipped.

    Problem: The request-local absence of theme support was persisted globally and could consume a queued rewrite flush, saving incomplete rules and causing race-sensitive 404s.

    Solution: Defer theme-support persistence and queued rewrite flushing while wp_installing() is true, while preserving post type registration, public hooks, and explicit installer flushes.

    Refs #36937

    * chore: add installing rewrite changelog

    Context: The installing-mode rewrite-state fix changes merchant-facing request behavior in WooCommerce Core.

    Problem: The package requires a patch changelog entry before review and release.

    Solution: Add the WooCommerce changelog entry for preserving rewrite state during WordPress installing-mode requests.

    Refs #36937

    * test: harden installing rewrite coverage

    The installing-mode regression tests bypassed the registered flush callback, allowed a hard rewrite flush, accepted arbitrary replacement rules, and left in-memory registration state behind.

    Exercise the real lifecycle callback, contain filesystem writes, require a concrete product rewrite on normal requests, and restore theme, product, permalink, option, and installing state exactly.

    Refs #36937

    * test: restore product taxonomy associations

    The post-type lifecycle tests unregister the product post type so they can exercise registration under different request states. WordPress removes every taxonomy association during that unregister operation, but the fixture teardown previously restored only the post type. This leaked invalid global state into later PHPUnit classes and caused taxonomy templates, term counts, collection queries, and brand routes to fail.

    Capture the product's original taxonomy associations during setup and restore them after teardown re-registers the post type. This keeps the test fixture isolated without changing production behavior.

    Refs #36937

    * fix: defer rewrite flushes raised during installing-mode requests

    When `WP_INSTALLING` is true, WordPress skips the active theme and regular
    plugins while still loading network-activated WooCommerce. Any rewrite flush
    that runs on such a request regenerates rules from an incomplete registration
    graph and persists them, dropping the product archive along with every rule
    owned by a theme or plugin that was not loaded.

    Guarding only the queued flush left `WC_Install::install()` able to corrupt the
    stored rules: `check_version()` runs at `init:5`, and the version bump it
    detects fires `woocommerce_flush_rewrite_rules` directly. Because the guard also
    stops the request from recording missing theme support, the corrective flush
    that used to be queued on the next normal request never happened, so the
    incomplete rules stayed until something else flushed them.

    `WC_Post_Types::flush_rewrite_rules()` now queues the flush and returns while
    WordPress is installing, so the next normal request regenerates the rules from a
    complete graph. Product archive registration falls back to the trusted stored
    theme support on those requests as well, keeping the registration graph
    consistent with what a normal request would produce.

    `woocommerce_flush_rewrite_rules` still fires and remains available to explicit
    installer flows; only the moment the rules are written changes.

    Refs #36937

    * fix: evict the queued rewrite flush from the object cache during install

    `WC_Post_Types::flush_rewrite_rules()` defers a flush raised through
    `woocommerce_flush_rewrite_rules` while WordPress is installing by writing
    `woocommerce_queue_flush_rewrite_rules`. Core's `update_option()` and
    `add_option()` wrap their entire cache-update block in `! wp_installing()`, so
    that write lands in the database and leaves the object cache untouched.

    With a persistent object cache the next request reads the stale value and never
    sees the queued flush, so the deferral silently drops it. On multisite the gap
    is wider: `wp_load_alloptions()` neither reads nor writes the `alloptions`
    bundle while installing, so the bundle cached before the request survives
    verbatim.

    Evict the option from both places it can be cached — its own key, and the
    autoloaded bundle — right after the write. This is a targeted eviction rather
    than a full cache flush, so it costs one extra options query on the next read
    and nothing on requests that are not installing.

    The regression coverage no longer calls `wp_cache_flush()` between simulated
    requests, since a new PHP request does not clear a persistent object cache and
    the hand-rolled invalidation was hiding this bug.

    Refs #36937

    * test: cover per-site scoping of the deferred rewrite flush

    The deferred flush writes an option and evicts two object-cache keys. Nothing in
    that path is network-scoped, but nothing asserted it either, so a later change to
    `update_site_option()` or a network-wide cache group would queue work on every
    site in a network and go unnoticed.

    Add a multisite test that gives a neighbouring site its own settled queue value
    and rewrite rules, raises a deferred flush on the current site, and asserts the
    neighbour's option and rules are untouched and the original site context is
    restored.

    Follows the existing convention for multisite coverage in this suite:
    `skipWithoutMultisite()` plus the `ms-required` group, so the test runs under
    `WP_MULTISITE=1` and skips cleanly on the default single-site pass.

    Refs #36937

diff --git a/plugins/woocommerce/changelog/fix-36937-installing-rewrite-flush b/plugins/woocommerce/changelog/fix-36937-installing-rewrite-flush
new file mode 100644
index 00000000000..517c2bb8d82
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-36937-installing-rewrite-flush
@@ -0,0 +1,3 @@
+Significance: patch
+Type: fix
+Comment: Defer rewrite rule flushes raised during WordPress installing-mode requests, when themes and plugins may not be loaded, to the next normal request.
diff --git a/plugins/woocommerce/includes/class-wc-post-types.php b/plugins/woocommerce/includes/class-wc-post-types.php
index d64ac91955f..d65d77e02c8 100644
--- a/plugins/woocommerce/includes/class-wc-post-types.php
+++ b/plugins/woocommerce/includes/class-wc-post-types.php
@@ -344,20 +344,20 @@ class WC_Post_Types {
 			$supports[] = 'comments';
 		}

-		$shop_page_id         = wc_get_page_id( 'shop' );
-		$theme_support        = wc_current_theme_supports_woocommerce_or_fse();
-		$active_theme_skipped = self::wp_cli_skips_active_theme();
+		$shop_page_id      = wc_get_page_id( 'shop' );
+		$theme_support     = wc_current_theme_supports_woocommerce_or_fse();
+		$theme_unavailable = self::wp_cli_skips_active_theme() || wp_installing();

-		if ( self::should_register_product_archive( $theme_support, $active_theme_skipped ) ) {
+		if ( self::should_register_product_archive( $theme_support, $theme_unavailable ) ) {
 			$has_archive = $shop_page_id && get_post( $shop_page_id ) ? urldecode( get_page_uri( $shop_page_id ) ) : 'shop';
 		} else {
 			$has_archive = false;
 		}

 		// If theme support changes, we may need to flush permalinks since some are changed based on this flag.
-		// Skip this check in WP-CLI and cron contexts: themes may not be loaded (e.g. --skip-themes),
-		// which would incorrectly record theme support as "no" and corrupt rewrite rules on the frontend.
-		if ( ! ( defined( 'WP_CLI' ) && WP_CLI ) && ! wp_doing_cron() ) {
+		// Skip this check in contexts where themes may not be loaded, which would incorrectly record
+		// theme support as "no" and corrupt rewrite rules on the frontend.
+		if ( ! wp_installing() && ! ( defined( 'WP_CLI' ) && WP_CLI ) && ! wp_doing_cron() ) {
 			$theme_support = $theme_support ? 'yes' : 'no';
 			if ( get_option( 'current_theme_supports_woocommerce' ) !== $theme_support && update_option( 'current_theme_supports_woocommerce', $theme_support ) ) {
 				update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
@@ -546,12 +546,12 @@ class WC_Post_Types {
 	/**
 	 * Resolve theme support used to register the product archive.
 	 *
-	 * @param bool $theme_support        Whether the current request reports theme support.
-	 * @param bool $active_theme_skipped Whether WP-CLI skipped the active theme.
+	 * @param bool $theme_support     Whether the current request reports theme support.
+	 * @param bool $theme_unavailable Whether the active theme may not be loaded on this request.
 	 * @return bool
 	 */
-	private static function should_register_product_archive( bool $theme_support, bool $active_theme_skipped ): bool {
-		if ( $theme_support || ! $active_theme_skipped ) {
+	private static function should_register_product_archive( bool $theme_support, bool $theme_unavailable ): bool {
+		if ( $theme_support || ! $theme_unavailable ) {
 			return $theme_support;
 		}

@@ -728,6 +728,10 @@ class WC_Post_Types {
 	 * @since 3.3.0
 	 */
 	public static function maybe_flush_rewrite_rules() {
+		if ( wp_installing() ) {
+			return;
+		}
+
 		if ( 'yes' === get_option( 'woocommerce_queue_flush_rewrite_rules' ) ) {
 			update_option( 'woocommerce_queue_flush_rewrite_rules', 'no' );
 			self::flush_rewrite_rules();
@@ -763,8 +767,28 @@ class WC_Post_Types {

 	/**
 	 * Flush rewrite rules.
+	 *
+	 * While WordPress is installing, the registration graph can be incomplete because themes and
+	 * regular plugins may not be loaded, so flushing now would persist rules that are missing
+	 * third-party post types, taxonomies, and the product archive. Queue the flush instead and let
+	 * the next normal request regenerate the rules from a complete graph.
 	 */
 	public static function flush_rewrite_rules() {
+		if ( wp_installing() ) {
+			update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+
+			/*
+			 * While WordPress is installing, update_option() writes the row but skips every cache
+			 * update, so a persistent object cache keeps serving the previous value and the next
+			 * request never sees the queued flush. Evict both places the option can be cached: on
+			 * its own key, and inside the autoloaded bundle.
+			 */
+			wp_cache_delete( 'woocommerce_queue_flush_rewrite_rules', 'options' );
+			wp_cache_delete( 'alloptions', 'options' );
+
+			return;
+		}
+
 		flush_rewrite_rules();
 	}

diff --git a/plugins/woocommerce/tests/php/includes/class-wc-post-types-test.php b/plugins/woocommerce/tests/php/includes/class-wc-post-types-test.php
index 7ca0094401e..13beadefaae 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-post-types-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-post-types-test.php
@@ -13,46 +13,158 @@ declare( strict_types = 1 );
 class WC_Post_Types_Test extends WC_Unit_Test_Case {

 	/**
-	 * Original stored theme support value.
+	 * Post type standing in for one owned by a third-party plugin.
+	 */
+	private const THIRD_PARTY_POST_TYPE = 'wc_test_third_party';
+
+	/**
+	 * Original active theme stylesheet.
+	 *
+	 * @var string
+	 */
+	private $original_theme;
+
+	/**
+	 * Original WooCommerce theme support arguments.
 	 *
-	 * @var mixed
+	 * @var array|bool
 	 */
 	private $original_theme_support;

 	/**
-	 * Set up test fixtures.
+	 * Original installing state.
+	 *
+	 * @var bool
+	 */
+	private $original_installing;
+
+	/**
+	 * Original in-memory rewrite rules.
+	 *
+	 * @var array|null
+	 */
+	private $original_rewrite_rules;
+
+	/**
+	 * Original permalink structure.
+	 *
+	 * @var string
+	 */
+	private $original_permalink_structure;
+
+	/**
+	 * Original option values.
+	 *
+	 * @var array<string, array{exists: bool, value: mixed}>
+	 */
+	private $original_options;
+
+	/**
+	 * Original taxonomies associated with products.
+	 *
+	 * @var string[]
+	 */
+	private $original_product_taxonomies;
+
+	/**
+	 * Whether the queued flush callback was initially registered.
+	 *
+	 * @var bool
+	 */
+	private $flush_hook_was_registered;
+
+	/**
+	 * Set up the test state.
 	 */
 	public function setUp(): void {
 		parent::setUp();

-		$this->original_theme_support = get_option( 'current_theme_supports_woocommerce', '__missing__' );
+		global $wp_rewrite;
+
+		$this->original_theme               = get_stylesheet();
+		$this->original_theme_support       = get_theme_support( 'woocommerce' );
+		$this->original_installing          = wp_installing();
+		$this->original_rewrite_rules       = $wp_rewrite->rules;
+		$this->original_permalink_structure = $wp_rewrite->permalink_structure;
+		$this->original_options             = array();
+		$this->original_product_taxonomies  = get_object_taxonomies( 'product' );
+		$missing_option                     = new stdClass();
+
+		foreach ( array( 'current_theme_supports_woocommerce', 'woocommerce_queue_flush_rewrite_rules', 'rewrite_rules', 'permalink_structure' ) as $option_name ) {
+			$value                                  = get_option( $option_name, $missing_option );
+			$this->original_options[ $option_name ] = array(
+				'exists' => $missing_option !== $value,
+				'value'  => $value,
+			);
+		}
+
+		$this->flush_hook_was_registered = false !== has_action( 'woocommerce_after_register_post_type', array( 'WC_Post_Types', 'maybe_flush_rewrite_rules' ) );
+
+		remove_action( 'woocommerce_after_register_post_type', array( 'WC_Post_Types', 'maybe_flush_rewrite_rules' ) );
+		add_filter( 'flush_rewrite_rules_hard', '__return_false' );
 	}

 	/**
-	 * Restore test fixtures.
+	 * Restore global test state.
 	 */
 	public function tearDown(): void {
-		if ( '__missing__' === $this->original_theme_support ) {
-			delete_option( 'current_theme_supports_woocommerce' );
+		global $_wp_theme_features, $wp_rewrite;
+
+		wp_installing( false );
+
+		if ( post_type_exists( self::THIRD_PARTY_POST_TYPE ) ) {
+			unregister_post_type( self::THIRD_PARTY_POST_TYPE );
+		}
+
+		if ( get_stylesheet() !== $this->original_theme ) {
+			switch_theme( $this->original_theme );
+		}
+
+		if ( false !== $this->original_theme_support ) {
+			// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact global state changed by the test.
+			$_wp_theme_features['woocommerce'] = $this->original_theme_support;
 		} else {
-			update_option( 'current_theme_supports_woocommerce', $this->original_theme_support );
+			unset( $_wp_theme_features['woocommerce'] );
+		}
+
+		unregister_post_type( 'product' );
+		WC_Post_Types::register_post_types();
+		foreach ( $this->original_product_taxonomies as $taxonomy ) {
+			register_taxonomy_for_object_type( $taxonomy, 'product' );
+		}
+
+		$wp_rewrite->set_permalink_structure( $this->original_permalink_structure );
+
+		foreach ( $this->original_options as $option_name => $option ) {
+			if ( ! $option['exists'] ) {
+				delete_option( $option_name );
+			} else {
+				update_option( $option_name, $option['value'] );
+			}
 		}
+		$wp_rewrite->rules = $this->original_rewrite_rules;
+
+		if ( $this->flush_hook_was_registered ) {
+			add_action( 'woocommerce_after_register_post_type', array( 'WC_Post_Types', 'maybe_flush_rewrite_rules' ) );
+		}
+		remove_filter( 'flush_rewrite_rules_hard', '__return_false' );
+		wp_installing( $this->original_installing );

 		parent::tearDown();
 	}

 	/**
-	 * @testdox Product archive registration uses runtime support unless WP-CLI skipped the active theme.
+	 * @testdox Product archive registration uses runtime support unless the active theme may not be loaded.
 	 * @dataProvider provide_product_archive_theme_support_cases
 	 *
 	 * @param bool   $runtime_support      Whether the current request reports theme support.
-	 * @param bool   $active_theme_skipped Whether WP-CLI skipped the active theme.
+	 * @param bool   $theme_unavailable    Whether the active theme may not be loaded on this request.
 	 * @param string $stored_support       Stored support from the last trusted request.
-	 * @param bool   $expected              Expected resolved support.
+	 * @param bool   $expected             Expected resolved support.
 	 */
 	public function test_should_register_product_archive(
 		bool $runtime_support,
-		bool $active_theme_skipped,
+		bool $theme_unavailable,
 		string $stored_support,
 		bool $expected
 	): void {
@@ -63,8 +175,8 @@ class WC_Post_Types_Test extends WC_Unit_Test_Case {

 		$this->assertSame(
 			$expected,
-			$method->invoke( null, $runtime_support, $active_theme_skipped ),
-			'Product archive support should only fall back to trusted stored support when WP-CLI skipped the active theme.'
+			$method->invoke( null, $runtime_support, $theme_unavailable ),
+			'Product archive support should only fall back to trusted stored support when the active theme may not be loaded.'
 		);
 	}

@@ -77,9 +189,302 @@ class WC_Post_Types_Test extends WC_Unit_Test_Case {
 		return array(
 			'supported runtime'                    => array( true, false, 'no', true ),
 			'ordinary cron ignores stored support' => array( false, false, 'yes', false ),
-			'WP-CLI skipped supported theme'       => array( false, true, 'yes', true ),
-			'WP-CLI skipped unsupported theme'     => array( false, true, 'no', false ),
-			'supported WP-CLI runtime'             => array( true, true, 'no', true ),
+			'unavailable theme, stored support'    => array( false, true, 'yes', true ),
+			'unavailable theme, stored no support' => array( false, true, 'no', false ),
+			'supported runtime, unavailable theme' => array( true, true, 'no', true ),
+		);
+	}
+
+	/**
+	 * @testdox Installing mode preserves stored theme support while continuing post type registration and hooks.
+	 */
+	public function test_installing_mode_preserves_theme_support_state_during_registration(): void {
+		$sentinel_rules = array( '^rewrite-state-verification/?$' => 'index.php?rewrite-state-verification=1' );
+		$this->prepare_unsupported_classic_theme();
+		update_option( 'current_theme_supports_woocommerce', 'yes' );
+		update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+		update_option( 'rewrite_rules', $sentinel_rules );
+
+		$before_hook_count = 0;
+		$after_hook_count  = 0;
+		$before_hook       = static function () use ( &$before_hook_count ): void {
+			++$before_hook_count;
+		};
+		$after_hook        = static function () use ( &$after_hook_count ): void {
+			++$after_hook_count;
+		};
+
+		$this->assertTrue( $this->flush_hook_was_registered, 'The queued flush callback should be registered on the post-type lifecycle hook.' );
+		add_action( 'woocommerce_after_register_post_type', array( 'WC_Post_Types', 'maybe_flush_rewrite_rules' ) );
+		add_action( 'woocommerce_register_post_type', $before_hook );
+		add_action( 'woocommerce_after_register_post_type', $after_hook );
+		wp_installing( true );
+
+		unregister_post_type( 'product' );
+		WC_Post_Types::register_post_types();
+
+		remove_action( 'woocommerce_register_post_type', $before_hook );
+		remove_action( 'woocommerce_after_register_post_type', $after_hook );
+
+		$this->assertTrue( post_type_exists( 'product' ), 'Product registration should continue while WordPress is installing.' );
+		$this->assertNotFalse(
+			get_post_type_object( 'product' )->has_archive,
+			'Installing mode should register the product archive from trusted stored support.'
+		);
+		$this->assertSame( 1, $before_hook_count, 'The pre-registration hook should still fire.' );
+		$this->assertSame( 1, $after_hook_count, 'The post-registration hook should still fire.' );
+		$this->assertSame( 'yes', get_option( 'current_theme_supports_woocommerce' ), 'Installing mode should not persist request-local missing theme support.' );
+		$this->assertSame( 'yes', get_option( 'woocommerce_queue_flush_rewrite_rules' ), 'Installing mode should preserve the queued flush for a normal request.' );
+		$this->assertSame( $sentinel_rules, get_option( 'rewrite_rules' ), 'Installing mode should not persist a replacement rewrite rule set.' );
+	}
+
+	/**
+	 * @testdox Normal requests persist legitimate theme support changes and queue a rewrite flush.
+	 */
+	public function test_normal_request_tracks_theme_support_changes_during_registration(): void {
+		$this->prepare_unsupported_classic_theme();
+		update_option( 'current_theme_supports_woocommerce', 'yes' );
+		delete_option( 'woocommerce_queue_flush_rewrite_rules' );
+		wp_installing( false );
+
+		unregister_post_type( 'product' );
+		WC_Post_Types::register_post_types();
+
+		$this->assertSame( 'no', get_option( 'current_theme_supports_woocommerce' ), 'Normal requests should persist a legitimate theme support change.' );
+		$this->assertSame( 'yes', get_option( 'woocommerce_queue_flush_rewrite_rules' ), 'Normal requests should queue a rewrite flush after theme support changes.' );
+	}
+
+	/**
+	 * @testdox Normal requests consume an existing rewrite queue and persist current rules.
+	 */
+	public function test_normal_request_consumes_queued_rewrite_flush(): void {
+		global $wp_rewrite;
+
+		$sentinel_rules = array( '^rewrite-state-verification/?$' => 'index.php?rewrite-state-verification=1' );
+		$wp_rewrite->set_permalink_structure( '/%postname%/' );
+		unregister_post_type( 'product' );
+		WC_Post_Types::register_post_types();
+		update_option( 'rewrite_rules', $sentinel_rules );
+		update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+		wp_installing( false );
+
+		WC_Post_Types::maybe_flush_rewrite_rules();
+
+		$rules         = (array) get_option( 'rewrite_rules' );
+		$product_rules = array_filter(
+			$rules,
+			static function ( $query ): bool {
+				return str_contains( $query, 'index.php?product=' );
+			}
+		);
+
+		$this->assertSame( 'no', get_option( 'woocommerce_queue_flush_rewrite_rules' ), 'Normal requests should consume the queued flush.' );
+		$this->assertNotEmpty( $rules, 'Normal requests should persist regenerated rewrite rules.' );
+		$this->assertNotEmpty( $product_rules, 'Regenerated rules should include WooCommerce product rewrites.' );
+	}
+
+	/**
+	 * @testdox An installer flush during installing mode is deferred and the next normal request restores complete rules.
+	 */
+	public function test_installer_flush_during_installing_mode_recovers_on_next_normal_request(): void {
+		global $wp_rewrite;
+
+		$wp_rewrite->set_permalink_structure( '/%postname%/' );
+
+		// Phase 1 - healthy baseline: supported theme, a third-party post type, complete persisted rules.
+		switch_theme( 'storefront' );
+		add_theme_support( 'woocommerce' );
+		update_option( 'current_theme_supports_woocommerce', 'yes' );
+		update_option( 'woocommerce_queue_flush_rewrite_rules', 'no' );
+		$this->register_third_party_post_type();
+		unregister_post_type( 'product' );
+		WC_Post_Types::register_post_types();
+		WC_Post_Types::flush_rewrite_rules();
+
+		$baseline_archive     = $this->count_rules_matching( 'post_type=product' );
+		$baseline_third_party = $this->count_rules_matching( self::THIRD_PARTY_POST_TYPE );
+
+		$this->assertGreaterThan( 0, $baseline_archive, 'The baseline must contain product archive rewrite rules.' );
+		$this->assertGreaterThan( 0, $baseline_third_party, 'The baseline must contain the third-party rewrite rules.' );
+
+		// Phase 2 - installing mode: no theme, no third-party plugin, then the installer flush.
+		remove_theme_support( 'woocommerce' );
+		unregister_post_type( self::THIRD_PARTY_POST_TYPE );
+		wp_installing( true );
+
+		unregister_post_type( 'product' );
+		WC_Post_Types::register_post_types();
+
+		/**
+		 * Simulate the installer flush that WC_Install::install() fires.
+		 *
+		 * @since 2.7.0
+		 */
+		do_action( 'woocommerce_flush_rewrite_rules' );
+
+		wp_installing( false );
+
+		$this->assertSame(
+			$baseline_archive,
+			$this->count_rules_matching( 'post_type=product' ),
+			'An installer flush during installing mode must not drop the persisted product archive rules.'
+		);
+		$this->assertSame(
+			$baseline_third_party,
+			$this->count_rules_matching( self::THIRD_PARTY_POST_TYPE ),
+			'An installer flush during installing mode must not drop rules owned by plugins that were not loaded.'
+		);
+		$this->assertSame(
+			'yes',
+			get_option( 'woocommerce_queue_flush_rewrite_rules' ),
+			'An installer flush during installing mode should be deferred to the next normal request.'
+		);
+
+		// Phase 3 - next normal request with the theme and the third-party plugin back.
+		add_theme_support( 'woocommerce' );
+		$this->register_third_party_post_type();
+		unregister_post_type( 'product' );
+		WC_Post_Types::register_post_types();
+		WC_Post_Types::maybe_flush_rewrite_rules();
+
+		$this->assertSame(
+			'no',
+			get_option( 'woocommerce_queue_flush_rewrite_rules' ),
+			'The next normal request should consume the deferred flush.'
+		);
+		$this->assertSame(
+			$baseline_archive,
+			$this->count_rules_matching( 'post_type=product' ),
+			'The next normal request must serve complete product archive rules.'
+		);
+		$this->assertSame(
+			$baseline_third_party,
+			$this->count_rules_matching( self::THIRD_PARTY_POST_TYPE ),
+			'The next normal request must restore rules owned by plugins that were not loaded.'
+		);
+	}
+
+	/**
+	 * @testdox A flush deferred during installing mode survives a persistent object cache.
+	 * @dataProvider provide_queue_option_autoload_cases
+	 *
+	 * @param bool $autoload Whether the queue option is autoloaded.
+	 */
+	public function test_deferred_flush_is_visible_to_the_next_request( bool $autoload ): void {
+		// update_option() skips every cache write while WordPress is installing, and a new PHP
+		// request does not clear a persistent object cache, so the queue has to be evicted wherever
+		// the option happens to be cached.
+		delete_option( 'woocommerce_queue_flush_rewrite_rules' );
+		add_option( 'woocommerce_queue_flush_rewrite_rules', 'no', '', $autoload );
+
+		$this->assertSame( 'no', get_option( 'woocommerce_queue_flush_rewrite_rules' ), 'The queue should start cached and unset.' );
+
+		wp_installing( true );
+		WC_Post_Types::flush_rewrite_rules();
+		wp_installing( false );
+
+		$this->assertSame(
+			'yes',
+			get_option( 'woocommerce_queue_flush_rewrite_rules' ),
+			'The next request should read the queued flush rather than a stale cached value.'
 		);
 	}
+
+	/**
+	 * Data provider covering both places the queue option can be cached.
+	 *
+	 * @return array<string, array{bool}>
+	 */
+	public function provide_queue_option_autoload_cases(): array {
+		return array(
+			'autoloaded, cached in the alloptions bundle' => array( true ),
+			'not autoloaded, cached on its own key'       => array( false ),
+		);
+	}
+
+	/**
+	 * @testdox A flush deferred during installing mode stays scoped to the site that raised it.
+	 * @group ms-required
+	 */
+	public function test_deferred_flush_is_scoped_to_the_current_site(): void {
+		$this->skipWithoutMultisite();
+
+		$original_blog_id = get_current_blog_id();
+		$other_blog_id    = $this->factory->blog->create( array( 'path' => '/wc-flush-scope/' ) );
+		$sentinel_rules   = array( '^wc-other-site-sentinel/?$' => 'index.php?wc-other-site-sentinel=1' );
+
+		// Give the neighbouring site its own settled state, read once so both option caches are warm.
+		switch_to_blog( $other_blog_id );
+		update_option( 'woocommerce_queue_flush_rewrite_rules', 'no' );
+		update_option( 'rewrite_rules', $sentinel_rules );
+		get_option( 'woocommerce_queue_flush_rewrite_rules' );
+		get_option( 'rewrite_rules' );
+		restore_current_blog();
+
+		update_option( 'woocommerce_queue_flush_rewrite_rules', 'no' );
+
+		wp_installing( true );
+		WC_Post_Types::flush_rewrite_rules();
+		wp_installing( false );
+
+		$this->assertSame(
+			'yes',
+			get_option( 'woocommerce_queue_flush_rewrite_rules' ),
+			'The site that raised the flush should carry the queue.'
+		);
+		$this->assertSame( $original_blog_id, get_current_blog_id(), 'Queueing the flush should not switch sites.' );
+
+		switch_to_blog( $other_blog_id );
+		$other_queue = get_option( 'woocommerce_queue_flush_rewrite_rules' );
+		$other_rules = get_option( 'rewrite_rules' );
+		restore_current_blog();
+
+		$this->assertSame( 'no', $other_queue, 'A deferred flush should not queue work on a neighbouring site.' );
+		$this->assertSame( $sentinel_rules, $other_rules, 'A deferred flush should not disturb a neighbouring site\'s rules.' );
+		$this->assertSame( $original_blog_id, get_current_blog_id(), 'The original site context should be restored.' );
+	}
+
+	/**
+	 * Register a public post type standing in for one owned by a third-party plugin.
+	 */
+	private function register_third_party_post_type(): void {
+		register_post_type(
+			self::THIRD_PARTY_POST_TYPE,
+			array(
+				'public'      => true,
+				'has_archive' => true,
+				'rewrite'     => array( 'slug' => self::THIRD_PARTY_POST_TYPE ),
+			)
+		);
+	}
+
+	/**
+	 * Count persisted rewrite rules whose query string contains the given needle.
+	 *
+	 * @param string $needle Query string fragment to match.
+	 * @return int
+	 */
+	private function count_rules_matching( string $needle ): int {
+		$rules = (array) get_option( 'rewrite_rules' );
+
+		return count(
+			array_filter(
+				$rules,
+				static function ( $query ) use ( $needle ): bool {
+					return str_contains( (string) $query, $needle );
+				}
+			)
+		);
+	}
+
+	/**
+	 * Switch to a classic theme without runtime WooCommerce support.
+	 */
+	private function prepare_unsupported_classic_theme(): void {
+		switch_theme( 'storefront' );
+		remove_theme_support( 'woocommerce' );
+
+		$this->assertFalse( wp_is_block_theme(), 'The test requires a classic theme.' );
+		$this->assertFalse( wc_current_theme_supports_woocommerce_or_fse(), 'The test requires missing runtime WooCommerce theme support.' );
+	}
 }