Commit f615ae500bd for woocommerce

commit f615ae500bdb5d2f2d9e01c3446b33590d3a1e92
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Fri Jul 10 19:40:36 2026 +0200

    Skip block registration on non-rendering requests (#65781)

    * Fix is_store_api_request() detection for plain permalinks

    is_store_api_request() only matched the pretty-permalink path form
    (/wp-json/wc/store/...). When permalinks are disabled the Store API is
    routed via the ?rest_route=/wc/store/ query parameter, which the check
    missed, so those requests were not recognised as Store API requests.

    Also match the rest_route query parameter form, mirroring the detection
    already used in the Store API Authentication class. Adds unit tests
    covering both permalink forms.

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

    * Skip block registration on non-rendering requests

    Block types and patterns were registered for every request except the
    Store API, including contexts that never render or edit blocks: cron,
    AJAX (admin-ajax and wc-ajax), favicon, robots.txt, XML sitemaps,
    XML-RPC, and WooCommerce REST routes (wc/v1-v4, wc/private, wc-admin,
    wc-analytics, wc-telemetry). Each of these paid the full block + pattern
    registration cost for no benefit.

    Add a BlockRegistrationContext class whose should_register() method is a
    blacklist of these known non-rendering contexts, and gate BlockPatterns
    and BlockTypesController on it in Bootstrap. The WooCommerce REST
    namespaces are listed explicitly to mirror wc_rest_should_load_namespace().
    Unrecognised contexts keep registering (the previous behaviour), so a
    missed case is only a minor performance cost, never a rendering
    regression. The wp/v2 REST namespace is intentionally left registering
    because the block and site editors rely on it.

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

    * Add unit tests for BlockRegistrationContext

    Covers should_register() across front-end requests, the Store API (both
    pretty and plain permalink forms), the WooCommerce REST namespaces
    (wc/v1-v4, wc/private, wc-admin, wc-analytics, wc-telemetry), an
    unrelated wc/vendor namespace, the /wp-sitemap-guide/ page-slug
    false-positive guard, wp/v2 editor REST, favicon (including subdirectory
    installs), robots.txt, XML sitemaps and wc-ajax (set and empty).

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

    * Add strict_types declaration to WooCommerce test file

    Comply with the WooCommerce PHPUnit test file coding guideline that test
    files declare strict_types. The existing tests in this file pass no scalar
    values into type-hinted calls, so enabling strict types does not change
    their behaviour.

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

    * Skip block registration on WooCommerce admin pages

    Extend BlockRegistrationContext to also skip block/pattern registration on
    WooCommerce's own admin pages (admin.php?page=wc-admin, wc-settings, wc-orders,
    wc-reports, wc-status, wc-addons), which render no blocks. Only WooCommerce-owned
    pages are matched; core admin screens and the block/site editor keep registering.

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

    * Match REST detection against URL path to avoid query-string false positives

    * Add woocommerce_should_register_blocks filter to opt back into registration

    * Normalize repeated leading slashes in REST and Store API request detection

    * Mention woocommerce_should_register_blocks filter in changelog

    * Fix changelog entries to use body text so they appear in the changelog

    * Mark BlockRegistrationContext as @internal

    * Fix array alignment in BlockRegistrationContextTest to satisfy phpcs

    ---------

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

diff --git a/plugins/woocommerce/changelog/fix-store-api-request-detection-plain-permalinks b/plugins/woocommerce/changelog/fix-store-api-request-detection-plain-permalinks
new file mode 100644
index 00000000000..11c61749451
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-request-detection-plain-permalinks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Detect Store API requests made with plain permalinks (?rest_route=/wc/store/) so they are correctly identified.
diff --git a/plugins/woocommerce/changelog/perf-skip-block-registration-non-rendering-requests b/plugins/woocommerce/changelog/perf-skip-block-registration-non-rendering-requests
new file mode 100644
index 00000000000..ff3e8ab5183
--- /dev/null
+++ b/plugins/woocommerce/changelog/perf-skip-block-registration-non-rendering-requests
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Skip block and pattern registration on requests that never render blocks. Add the woocommerce_should_register_blocks filter to let extensions opt out.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 1ac3e2a4e83..33a18d52624 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -637,8 +637,27 @@ final class WooCommerce {
 		if ( empty( $_SERVER['REQUEST_URI'] ) ) {
 			return false;
 		}
-		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-		return false !== strpos( $_SERVER['REQUEST_URI'], trailingslashit( rest_get_url_prefix() ) . 'wc/store/' );
+
+		// Pretty permalinks: the Store API namespace is part of the path, e.g. /wp-json/wc/store/v1/cart. Match the
+		// path only (a leading slash anchors the prefix) so a REST-like query argument such as
+		// /some-page/?arg=/wp-json/wc/store/ is not mistaken for a Store API request.
+		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		$path = wp_parse_url( '/' . ltrim( (string) wp_unslash( $_SERVER['REQUEST_URI'] ), '/' ), PHP_URL_PATH );
+		if ( is_string( $path ) && false !== strpos( $path, '/' . trailingslashit( rest_get_url_prefix() ) . 'wc/store/' ) ) {
+			return true;
+		}
+
+		// Plain permalinks: the route is passed as a query parameter, e.g. ?rest_route=/wc/store/v1/cart.
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the route only, no state change.
+		if ( isset( $_GET['rest_route'] ) && is_string( $_GET['rest_route'] ) ) {
+			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the route only, no state change.
+			$rest_route = '/' . ltrim( rawurldecode( sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ) ), '/' );
+			if ( 0 === strpos( $rest_route, '/wc/store/' ) ) {
+				return true;
+			}
+		}
+
+		return false;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php b/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php
new file mode 100644
index 00000000000..fc9476ba4f0
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php
@@ -0,0 +1,225 @@
+<?php
+/**
+ * BlockRegistrationContext class.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks\Domain;
+
+/**
+ * Decides whether WooCommerce block types and patterns should be registered for the current request.
+ *
+ * Runs during bootstrap on `plugins_loaded`, before the main query is parsed, so it inspects only $_SERVER,
+ * $_GET and constants set before wp-load — not query-dependent helpers such as is_favicon()/is_robots().
+ *
+ * @internal
+ *
+ * @since 11.1.0
+ */
+class BlockRegistrationContext {
+
+	/**
+	 * Whether block types and patterns should be registered for the current request.
+	 *
+	 * @return bool True unless the request is a known non-rendering context.
+	 */
+	public function should_register(): bool {
+		/**
+		 * Filters whether WooCommerce should register its block types and patterns for the current request.
+		 *
+		 * Registration is skipped on known non-rendering contexts (the Store API and other WooCommerce REST
+		 * namespaces, cron, AJAX, XML-RPC, favicon, robots.txt and XML sitemaps) as a performance optimisation.
+		 * An extension that renders WooCommerce blocks in one of those contexts can return true here to opt back in.
+		 *
+		 * @since 11.1.0
+		 *
+		 * @param bool $should_register Whether block types and patterns should be registered for this request.
+		 */
+		return (bool) apply_filters( 'woocommerce_should_register_blocks', $this->is_rendering_request() );
+	}
+
+	/**
+	 * Whether the current request may render or edit blocks.
+	 *
+	 * Blacklist of known non-rendering contexts: an unrecognised request keeps registering (the previous
+	 * behaviour), so a missed case costs a little performance but never a rendering regression. Front-end,
+	 * admin and wp/v2 (block/site editor) requests therefore keep registering.
+	 *
+	 * @return bool True unless the request is a known non-rendering context.
+	 */
+	private function is_rendering_request(): bool {
+		// Store API renders no blocks.
+		if ( wc()->is_store_api_request() ) {
+			return false;
+		}
+
+		// Cron produces no output.
+		if ( wp_doing_cron() ) {
+			return false;
+		}
+
+		// AJAX (admin-ajax and wc-ajax) renders no blocks. wc-ajax's constants are set too late to use here, so
+		// detect it from the request; ! empty() matches WC_AJAX::set_wc_ajax_argument_in_query().
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the endpoint only, no state change.
+		if ( wp_doing_ajax() || ! empty( $_GET['wc-ajax'] ) ) {
+			return false;
+		}
+
+		// XML-RPC renders no blocks; its constant is set before wp-load, so it is reliable here.
+		if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
+			return false;
+		}
+
+		// Favicon, robots.txt and XML sitemaps render no blocks.
+		if ( $this->is_non_rendering_path_request() ) {
+			return false;
+		}
+
+		// WooCommerce REST namespaces render no blocks (Store API handled above). wp/v2 is left registering for
+		// the block and site editors.
+		if ( $this->is_woocommerce_rest_request() ) {
+			return false;
+		}
+
+		// WooCommerce's own admin pages (Settings, Status, Analytics, Orders, ...) render no blocks. Core admin
+		// screens and the block/site editor are intentionally left registering.
+		if ( $this->is_woocommerce_admin_page() ) {
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Whether the request targets a WooCommerce-owned admin page (admin.php?page=wc-*).
+	 *
+	 * These are WooCommerce's own settings/status/analytics screens, which render no blocks. Only WooCommerce's
+	 * own pages are matched; core admin screens and the block/site editor are left registering. $pagenow is set
+	 * in wp-includes/vars.php before the plugins_loaded action, so it is available here.
+	 *
+	 * @return bool
+	 */
+	private function is_woocommerce_admin_page(): bool {
+		if ( ! is_admin() ) {
+			return false;
+		}
+
+		global $pagenow;
+		if ( 'admin.php' !== $pagenow ) {
+			return false;
+		}
+
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only, no state change.
+		if ( ! isset( $_GET['page'] ) || ! is_string( $_GET['page'] ) ) {
+			return false;
+		}
+
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only, no state change.
+		$page = sanitize_key( wp_unslash( $_GET['page'] ) );
+
+		// WooCommerce-owned admin page slugs. The WooCommerce Admin SPA (home, analytics, marketing) all use the
+		// wc-admin slug with a path query parameter.
+		$woocommerce_pages = array(
+			'wc-admin',
+			'wc-settings',
+			'wc-orders',
+			'wc-reports',
+			'wc-status',
+			'wc-addons',
+		);
+
+		return in_array( $page, $woocommerce_pages, true );
+	}
+
+	/**
+	 * Whether the request targets a WordPress endpoint that renders no block content: the favicon, robots.txt or
+	 * core XML sitemaps. The URI path is inspected directly because is_favicon()/is_robots() are unavailable this
+	 * early. WP core serves these in wp-includes/template-loader.php and the WP_Sitemaps class.
+	 *
+	 * @return bool
+	 */
+	private function is_non_rendering_path_request(): bool {
+		if ( empty( $_SERVER['REQUEST_URI'] ) ) {
+			return false;
+		}
+		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		$path = wp_parse_url( '/' . ltrim( (string) wp_unslash( $_SERVER['REQUEST_URI'] ), '/' ), PHP_URL_PATH );
+		if ( ! is_string( $path ) ) {
+			return false;
+		}
+
+		// Favicon, e.g. /favicon.ico (also matches subdirectory installs).
+		if ( '/favicon.ico' === substr( $path, -12 ) ) {
+			return true;
+		}
+
+		// robots.txt.
+		if ( '/robots.txt' === substr( $path, -11 ) ) {
+			return true;
+		}
+
+		// Core XML sitemaps, e.g. /wp-sitemap.xml or /wp-sitemap.xsl. The suffix check avoids matching a page
+		// slug that merely contains "wp-sitemap".
+		if ( false !== strpos( $path, 'wp-sitemap' ) && ( '.xml' === substr( $path, -4 ) || '.xsl' === substr( $path, -4 ) ) ) {
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Whether the request targets a WooCommerce-owned REST namespace other than the Store API, in either pretty
+	 * (/wp-json/<namespace>) or plain (?rest_route=/<namespace>) permalink form.
+	 *
+	 * @return bool
+	 */
+	private function is_woocommerce_rest_request(): bool {
+		if ( empty( $_SERVER['REQUEST_URI'] ) ) {
+			return false;
+		}
+
+		// WooCommerce-owned namespaces that render no blocks; mirrors wc_rest_should_load_namespace() (add new
+		// versions here too). Store API (wc/store) is handled above; the trailing slash prevents matching a
+		// longer, unrelated namespace.
+		$namespaces = array(
+			'wc/v1/',
+			'wc/v2/',
+			'wc/v3/',
+			'wc/v4/',
+			'wc/private/',
+			'wc-admin/',
+			'wc-analytics/',
+			'wc-telemetry/',
+		);
+
+		// Match against the path only (a leading slash anchors the prefix) so a REST-like query argument such as
+		// /some-page/?arg=/wp-json/wc/v3 is not mistaken for a REST request.
+		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		$path        = wp_parse_url( '/' . ltrim( (string) wp_unslash( $_SERVER['REQUEST_URI'] ), '/' ), PHP_URL_PATH );
+		$rest_prefix = '/' . trailingslashit( rest_get_url_prefix() );
+
+		// Pretty permalinks: /wp-json/<namespace>...
+		if ( is_string( $path ) ) {
+			foreach ( $namespaces as $namespace ) {
+				if ( false !== strpos( $path, $rest_prefix . $namespace ) ) {
+					return true;
+				}
+			}
+		}
+
+		// Plain permalinks: ?rest_route=/<namespace>...
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the route only, no state change.
+		if ( isset( $_GET['rest_route'] ) && is_string( $_GET['rest_route'] ) ) {
+			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the route only, no state change.
+			$rest_route = '/' . ltrim( rawurldecode( sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ) ), '/' );
+			foreach ( $namespaces as $namespace ) {
+				if ( 0 === strpos( $rest_route, '/' . $namespace ) ) {
+					return true;
+				}
+			}
+		}
+
+		return false;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
index 011ed924201..9975adbf51b 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
@@ -145,10 +145,11 @@ class Bootstrap {

 		// Load assets unless this is a request specifically for the store API.
 		if ( ! $is_store_api_request ) {
-			// Template related functionality. These won't be loaded for store API requests, but may be loaded for
-			// regular rest requests to maintain compatibility with the store editor.
-			$this->container->get( BlockPatterns::class );
-			$this->container->get( BlockTypesController::class );
+			// Skip block/pattern registration on non-rendering requests. See BlockRegistrationContext.
+			if ( ( new BlockRegistrationContext() )->should_register() ) {
+				$this->container->get( BlockPatterns::class );
+				$this->container->get( BlockTypesController::class );
+			}
 			$this->container->get( ClassicTemplatesCompatibility::class );
 			$this->container->get( Notices::class )->init();

diff --git a/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php b/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php
index 3d1e92b8d5a..236b03c5ab0 100644
--- a/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php
@@ -1,5 +1,7 @@
 <?php

+declare( strict_types = 1 );
+
 use Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub;

 /**
@@ -71,6 +73,80 @@ class WooCommerce_Test extends \WC_Unit_Test_Case {
 		$this->assertEquals( WC()->is_rest_api_request(), true );
 	}

+	/**
+	 * Restore the request globals after each test.
+	 */
+	public function tearDown(): void {
+		unset( $_GET['rest_route'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should return false when the request is not a Store API request.
+	 */
+	public function test_is_store_api_request_returns_false_for_non_store_request(): void {
+		$_SERVER['REQUEST_URI'] = '/wp-json/wc-admin/options';
+
+		$this->assertFalse( WC()->is_store_api_request(), 'A non-Store API WooCommerce REST request should not be detected as Store API.' );
+	}
+
+	/**
+	 * @testdox Should detect a Store API request that uses pretty permalinks.
+	 */
+	public function test_is_store_api_request_returns_true_for_pretty_permalinks(): void {
+		$_SERVER['REQUEST_URI'] = '/wp-json/wc/store/v1/cart';
+
+		$this->assertTrue( WC()->is_store_api_request(), 'A /wp-json/wc/store/ path should be detected as Store API.' );
+	}
+
+	/**
+	 * @testdox Should detect a Store API request that uses plain permalinks (?rest_route=).
+	 */
+	public function test_is_store_api_request_returns_true_for_plain_permalinks(): void {
+		$_SERVER['REQUEST_URI'] = '/index.php?rest_route=/wc/store/v1/cart';
+		$_GET['rest_route']     = '/wc/store/v1/cart';
+
+		$this->assertTrue( WC()->is_store_api_request(), 'A ?rest_route=/wc/store/ request should be detected as Store API.' );
+	}
+
+	/**
+	 * @testdox Should not detect a non-Store API plain-permalink request as Store API.
+	 */
+	public function test_is_store_api_request_returns_false_for_plain_permalinks_non_store(): void {
+		$_SERVER['REQUEST_URI'] = '/index.php?rest_route=/wc-admin/options';
+		$_GET['rest_route']     = '/wc-admin/options';
+
+		$this->assertFalse( WC()->is_store_api_request(), 'A non-Store API ?rest_route= request should not be detected as Store API.' );
+	}
+
+	/**
+	 * @testdox Should not detect a Store-API-like value in a query argument as a Store API request.
+	 */
+	public function test_is_store_api_request_returns_false_for_rest_like_query_arg(): void {
+		$_SERVER['REQUEST_URI'] = '/some-page/?arg=/wp-json/wc/store/v1/cart';
+
+		$this->assertFalse( WC()->is_store_api_request(), 'A REST-like value in a query argument should not be detected as Store API.' );
+	}
+
+	/**
+	 * @testdox Should detect a Store API request whose URL has repeated leading slashes.
+	 */
+	public function test_is_store_api_request_returns_true_for_repeated_slashes(): void {
+		$_SERVER['REQUEST_URI'] = '///wp-json/wc/store/v1/cart';
+
+		$this->assertTrue( WC()->is_store_api_request(), 'A ///wp-json/wc/store/ path with repeated leading slashes should be detected as Store API.' );
+	}
+
+	/**
+	 * @testdox Should detect a Store API plain-permalink request whose route has repeated leading slashes.
+	 */
+	public function test_is_store_api_request_returns_true_for_repeated_slashes_plain(): void {
+		$_SERVER['REQUEST_URI'] = '/index.php?rest_route=//wc/store/v1/cart';
+		$_GET['rest_route']     = '//wc/store/v1/cart';
+
+		$this->assertTrue( WC()->is_store_api_request(), 'A ?rest_route=//wc/store/ request with repeated leading slashes should be detected as Store API.' );
+	}
+
 	/**
 	 * @testdox Should load WooCommerce includes in post editor load actions.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/BlockRegistrationContextTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/BlockRegistrationContextTest.php
new file mode 100644
index 00000000000..397152244f7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/BlockRegistrationContextTest.php
@@ -0,0 +1,206 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Domain;
+
+use Automattic\WooCommerce\Blocks\Domain\BlockRegistrationContext;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for the BlockRegistrationContext class.
+ */
+class BlockRegistrationContextTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var BlockRegistrationContext
+	 */
+	private BlockRegistrationContext $sut;
+
+	/**
+	 * The original REQUEST_URI, restored after each test.
+	 *
+	 * @var string
+	 */
+	private string $original_uri;
+
+	/**
+	 * The original $pagenow, restored after each test.
+	 *
+	 * @var string
+	 */
+	private string $original_pagenow;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut              = new BlockRegistrationContext();
+		$this->original_uri     = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		$this->original_pagenow = $GLOBALS['pagenow'] ?? '';
+	}
+
+	/**
+	 * Restore the request globals after each test.
+	 */
+	public function tearDown(): void {
+		$_SERVER['REQUEST_URI'] = $this->original_uri;
+		$GLOBALS['pagenow']     = $this->original_pagenow; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		unset( $_GET['rest_route'], $_GET['wc-ajax'], $_GET['page'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		set_current_screen( 'front' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Simulate an admin page request: make is_admin() true and set $pagenow / the page query var.
+	 *
+	 * @param string $screen_id The admin screen id to set as current.
+	 * @param string $pagenow   The $pagenow value to simulate.
+	 * @param string $page      The ?page= query value to simulate (empty to leave unset).
+	 */
+	private function simulate_admin_page( string $screen_id, string $pagenow, string $page = '' ): void {
+		set_current_screen( $screen_id );
+		$GLOBALS['pagenow'] = $pagenow; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		if ( '' !== $page ) {
+			$_GET['page'] = $page;
+		}
+	}
+
+	/**
+	 * @testdox Should register blocks only for requests that may render or edit them.
+	 * @dataProvider request_provider
+	 *
+	 * @param string      $uri        The REQUEST_URI to simulate.
+	 * @param string|null $rest_route The ?rest_route= query value to simulate, if any.
+	 * @param string|null $wc_ajax    The ?wc-ajax= query value to simulate, if any.
+	 * @param bool        $expected   Whether blocks should be registered.
+	 */
+	public function test_should_register( string $uri, ?string $rest_route, ?string $wc_ajax, bool $expected ): void {
+		$_SERVER['REQUEST_URI'] = $uri;
+		unset( $_GET['rest_route'], $_GET['wc-ajax'] );
+		if ( null !== $rest_route ) {
+			$_GET['rest_route'] = $rest_route;
+		}
+		if ( null !== $wc_ajax ) {
+			$_GET['wc-ajax'] = $wc_ajax;
+		}
+
+		$this->assertSame(
+			$expected,
+			$this->sut->should_register(),
+			sprintf( 'Unexpected should_register() result for "%s".', $uri )
+		);
+	}
+
+	/**
+	 * Data provider for test_should_register.
+	 *
+	 * @return array<string, array{0:string,1:?string,2:?string,3:bool}>
+	 */
+	public function request_provider(): array {
+		return array(
+			// label                       => array( uri, rest_route, wc-ajax, expected ).
+			'front-end home'                => array( '/', null, null, true ),
+			'front-end shop page'           => array( '/shop/', null, null, true ),
+			'front-end page named sitemap'  => array( '/wp-sitemap-guide/', null, null, true ),
+
+			'store api (pretty)'            => array( '/wp-json/wc/store/v1/cart', null, null, false ),
+			'store api (plain)'             => array( '/index.php?rest_route=/wc/store/v1/cart', '/wc/store/v1/cart', null, false ),
+
+			'wc/v3 rest (pretty)'           => array( '/wp-json/wc/v3/orders', null, null, false ),
+			'wc/v4 rest (pretty)'           => array( '/wp-json/wc/v4/products', null, null, false ),
+			'wc/v3 rest (plain)'            => array( '/index.php?rest_route=/wc/v3/orders', '/wc/v3/orders', null, false ),
+
+			// Repeated leading slashes must still be recognised (some servers send them; WP trims request URIs too).
+			'wc/v3 rest (leading slashes)'  => array( '///wp-json/wc/v3/orders', null, null, false ),
+			'wc/v3 plain (leading slashes)' => array( '/index.php?rest_route=//wc/v3/orders', '//wc/v3/orders', null, false ),
+			'wc-admin rest'                 => array( '/wp-json/wc-admin/options', null, null, false ),
+			'wc-analytics rest'             => array( '/wp-json/wc-analytics/reports', null, null, false ),
+			'wc-telemetry rest'             => array( '/wp-json/wc-telemetry/tracker', null, null, false ),
+			'wc/private rest (pretty)'      => array( '/wp-json/wc/private/v1/x', null, null, false ),
+			'wc/private rest (plain)'       => array( '/index.php?rest_route=/wc/private/v1/x', '/wc/private/v1/x', null, false ),
+
+			'wc/vendor not a version'       => array( '/wp-json/wc/vendor/x', null, null, true ),
+			'wc/voucher plain not version'  => array( '/index.php?rest_route=/wc/voucher/x', '/wc/voucher/x', null, true ),
+
+			// A REST-like namespace in a query argument must not be mistaken for a REST request; only the path counts.
+			'rest-like arg in query'        => array( '/some-page/?arg=/wp-json/wc/v3', null, null, true ),
+
+			// wp/v2 is WordPress core's namespace, which the block and site editors rely on, so the whole
+			// namespace keeps registering (true) — even endpoints like users/me that render no blocks.
+			'wp/v2 editor rest'             => array( '/wp-json/wp/v2/types', null, null, true ),
+			'wp/v2 users/me'                => array( '/wp-json/wp/v2/users/me', null, null, true ),
+
+			'favicon'                       => array( '/favicon.ico', null, null, false ),
+			'favicon (subdirectory)'        => array( '/blog/favicon.ico', null, null, false ),
+			'favicon (repeated slash)'      => array( '///favicon.ico', null, null, false ),
+			'robots.txt'                    => array( '/robots.txt', null, null, false ),
+			'sitemap index'                 => array( '/wp-sitemap.xml', null, null, false ),
+			'sitemap posts'                 => array( '/wp-sitemap-posts-post-1.xml', null, null, false ),
+			'sitemap stylesheet'            => array( '/wp-sitemap.xsl', null, null, false ),
+
+			'wc-ajax (add to cart)'         => array( '/?wc-ajax=add_to_cart', null, 'add_to_cart', false ),
+			'wc-ajax (empty value)'         => array( '/?wc-ajax=', null, '', true ),
+		);
+	}
+
+	/**
+	 * @testdox Should not register on a WooCommerce admin page (admin.php?page=wc-*).
+	 * @dataProvider woocommerce_admin_page_provider
+	 *
+	 * @param string $page The ?page= query value for the WooCommerce admin screen.
+	 */
+	public function test_should_not_register_on_woocommerce_admin_page( string $page ): void {
+		$_SERVER['REQUEST_URI'] = '/wp-admin/admin.php?page=' . $page;
+		$this->simulate_admin_page( 'woocommerce_page_' . $page, 'admin.php', $page );
+
+		$this->assertFalse(
+			$this->sut->should_register(),
+			sprintf( 'A WooCommerce admin page (page=%s) should not register blocks.', $page )
+		);
+	}
+
+	/**
+	 * Data provider for WooCommerce admin pages.
+	 *
+	 * @return array<string, array{0:string}>
+	 */
+	public function woocommerce_admin_page_provider(): array {
+		return array(
+			'wc-admin'    => array( 'wc-admin' ),
+			'wc-settings' => array( 'wc-settings' ),
+			'wc-orders'   => array( 'wc-orders' ),
+			'wc-reports'  => array( 'wc-reports' ),
+			'wc-status'   => array( 'wc-status' ),
+			'wc-addons'   => array( 'wc-addons' ),
+		);
+	}
+
+	/**
+	 * @testdox Should still register on a non-WooCommerce admin page.
+	 */
+	public function test_should_register_on_non_woocommerce_admin_page(): void {
+		$_SERVER['REQUEST_URI'] = '/wp-admin/admin.php?page=some-other-plugin';
+		$this->simulate_admin_page( 'toplevel_page_some-other-plugin', 'admin.php', 'some-other-plugin' );
+
+		$this->assertTrue(
+			$this->sut->should_register(),
+			'A non-WooCommerce admin page should still register blocks.'
+		);
+	}
+
+	/**
+	 * @testdox Should still register on the block editor screen.
+	 */
+	public function test_should_register_on_block_editor_screen(): void {
+		$_SERVER['REQUEST_URI'] = '/wp-admin/post-new.php';
+		$this->simulate_admin_page( 'post', 'post-new.php' );
+
+		$this->assertTrue(
+			$this->sut->should_register(),
+			'The block editor screen should still register blocks.'
+		);
+	}
+}