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.'
+ );
+ }
+}