Commit e9dcc012506 for woocommerce
commit e9dcc012506281da6248ab5ad83af7dc3d2e4a4a
Author: Abdullah Ayman <63800091+AbdullahAymanMSRE@users.noreply.github.com>
Date: Wed Jun 3 06:24:11 2026 +0000
Fix/64057/product filters full page reload (#64247)
* Fix product filters not working when forcePageReload is enabled
* Add test and changelog for product filters forcePageReload fix
* fix(product-filters): resolve forcePageReload from parent block context
Product Filters as a descendant of Product Collection couldn't reach
the per-instance forcePageReload setting via the global interactivity
config (which only covers the sibling-block layout). Plumb it through
block context so the frontend prefers the per-instance value and
falls back to config when not nested.
* fix(product-filters): drop duplicate type decls after trunk merge
Trunk moved FilterItem/ActiveFilterItem/ProductFiltersContext into
./types.ts; the merge kept both the new import and the old local
declarations, causing TS2300. Remove the locals and add the
forcePageReload field to the canonical type in types.ts.
* Address review feedback on product filters page reload
Scope the global interactivity config to the inherit case, dedupe the
getConfig() call in the navigate action, and type-guard $block in
ProductFilters::render() to avoid growing the PHPStan baseline.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: use query inherit for product filters reload
* fix: remove stale product filters phpstan baseline
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
diff --git a/plugins/woocommerce/changelog/64057-fix-product-filters-full-page-reload b/plugins/woocommerce/changelog/64057-fix-product-filters-full-page-reload
new file mode 100644
index 00000000000..71639ac0868
--- /dev/null
+++ b/plugins/woocommerce/changelog/64057-fix-product-filters-full-page-reload
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix product filters not applying when Full Page Reload is enabled in the Product Collection block.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json
index e2249961afc..d58a2a06904 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json
@@ -71,6 +71,7 @@
"dimensions": "dimensions",
"queryContextIncludes": "queryContextIncludes",
"collection": "collection",
+ "forcePageReload": "forcePageReload",
"__privateProductCollectionPreviewState": "__privatePreviewState"
},
"usesContext": [ "templateSlug", "postId" ],
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
index c08621ccfc7..b012db19b69 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
@@ -284,4 +284,219 @@ describe( 'product filters interactivity store', () => {
} );
}
);
+
+ it( 'calls window.location.assign instead of router when forcePageReload is true', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+
+ const originalLocation = window.location;
+ const assignMock = jest.fn();
+
+ delete ( window as unknown as Record< string, unknown > ).location;
+ Object.defineProperty( window, 'location', {
+ value: {
+ href: 'https://example.com/shop/',
+ assign: assignMock,
+ },
+ writable: true,
+ configurable: true,
+ } );
+
+ const canonicalUrl = 'https://example.com/shop/';
+
+ const context = {
+ isOverlayOpened: false,
+ params: { color: 'blue' },
+ activeFilters: [],
+ item: {
+ type: 'attribute/color',
+ label: 'Blue',
+ value: 'blue',
+ selected: true,
+ count: 1,
+ attributeQueryType: 'or' as const,
+ },
+ activeLabelTemplate: '{{label}}',
+ filterType: 'attribute/color',
+ };
+
+ mockGetContext.mockReturnValue( context );
+ mockGetServerContext.mockReturnValue( context );
+
+ mockGetConfig.mockImplementation( ( key: string ) => {
+ if ( key === 'woocommerce/product-filters' ) {
+ return { canonicalUrl, forcePageReload: true };
+ }
+ return {};
+ } );
+
+ Object.defineProperty( mockRegisteredStore.state, 'params', {
+ get: () => ( { color: 'blue' } ),
+ } );
+
+ try {
+ const iterator = mockRegisteredStore.actions.navigate();
+
+ // forcePageReload exits early before yielding the router import
+ const result = iterator.next();
+ expect( result.done ).toBe( true );
+
+ expect( assignMock ).toHaveBeenCalledTimes( 1 );
+ expect( assignMock ).toHaveBeenCalledWith(
+ 'https://example.com/shop/?color=blue'
+ );
+ } finally {
+ Object.defineProperty( window, 'location', {
+ value: originalLocation,
+ writable: true,
+ configurable: true,
+ } );
+ }
+ } );
+
+ describe( 'forcePageReload context resolution', () => {
+ const setupNavigate = ( {
+ contextForcePageReload,
+ configForcePageReload,
+ }: {
+ contextForcePageReload: boolean | null | undefined;
+ configForcePageReload: boolean | undefined;
+ } ) => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+
+ const originalLocation = window.location;
+ const assignMock = jest.fn();
+
+ delete ( window as unknown as Record< string, unknown > ).location;
+ Object.defineProperty( window, 'location', {
+ value: {
+ href: 'https://example.com/shop/',
+ assign: assignMock,
+ },
+ writable: true,
+ configurable: true,
+ } );
+
+ const context = {
+ isOverlayOpened: false,
+ params: { color: 'blue' },
+ activeFilters: [],
+ item: {
+ type: 'attribute/color',
+ label: 'Blue',
+ value: 'blue',
+ selected: true,
+ count: 1,
+ attributeQueryType: 'or' as const,
+ },
+ activeLabelTemplate: '{{label}}',
+ filterType: 'attribute/color',
+ forcePageReload: contextForcePageReload,
+ };
+
+ mockGetContext.mockReturnValue( context );
+ mockGetServerContext.mockReturnValue( context );
+
+ mockGetConfig.mockImplementation( ( key: string ) => {
+ if ( key === 'woocommerce/product-filters' ) {
+ return {
+ canonicalUrl: 'https://example.com/shop/',
+ forcePageReload: configForcePageReload,
+ };
+ }
+ return {};
+ } );
+
+ Object.defineProperty( mockRegisteredStore.state, 'params', {
+ get: () => ( { color: 'blue' } ),
+ } );
+
+ return {
+ store: mockRegisteredStore,
+ assignMock,
+ cleanup: () => {
+ Object.defineProperty( window, 'location', {
+ value: originalLocation,
+ writable: true,
+ configurable: true,
+ } );
+ },
+ };
+ };
+
+ it( 'reloads when context.forcePageReload is true (descendant case, no config)', () => {
+ const {
+ store: registeredStore,
+ assignMock,
+ cleanup,
+ } = setupNavigate( {
+ contextForcePageReload: true,
+ configForcePageReload: undefined,
+ } );
+
+ try {
+ const iterator = registeredStore.actions.navigate();
+ const result = iterator.next();
+
+ expect( result.done ).toBe( true );
+ expect( assignMock ).toHaveBeenCalledWith(
+ 'https://example.com/shop/?color=blue'
+ );
+ } finally {
+ cleanup();
+ }
+ } );
+
+ it( 'context.forcePageReload=true overrides config.forcePageReload=false', () => {
+ const {
+ store: registeredStore,
+ assignMock,
+ cleanup,
+ } = setupNavigate( {
+ contextForcePageReload: true,
+ configForcePageReload: false,
+ } );
+
+ try {
+ const iterator = registeredStore.actions.navigate();
+ const result = iterator.next();
+
+ expect( result.done ).toBe( true );
+ expect( assignMock ).toHaveBeenCalledTimes( 1 );
+ } finally {
+ cleanup();
+ }
+ } );
+
+ it( 'context.forcePageReload=false overrides config.forcePageReload=true (uses router)', () => {
+ const {
+ store: registeredStore,
+ assignMock,
+ cleanup,
+ } = setupNavigate( {
+ contextForcePageReload: false,
+ configForcePageReload: true,
+ } );
+
+ const routerNavigate = jest.fn();
+
+ try {
+ const iterator = registeredStore.actions.navigate();
+ const firstYield = iterator.next();
+
+ expect( firstYield.done ).toBe( false );
+ iterator.next( {
+ actions: { navigate: routerNavigate },
+ } );
+
+ expect( assignMock ).not.toHaveBeenCalled();
+ expect( routerNavigate ).toHaveBeenCalledTimes( 1 );
+ } finally {
+ cleanup();
+ }
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/block.json
index 83a583382c9..2d0ef2f6add 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/block.json
@@ -35,7 +35,7 @@
}
},
"textdomain": "woocommerce",
- "usesContext": [ "postId", "query", "queryId" ],
+ "usesContext": [ "postId", "query", "queryId", "forcePageReload" ],
"attributes": {
"isPreview": {
"type": "boolean",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
index 33c95093d76..63a6b6dff8e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
@@ -214,8 +214,8 @@ const productFiltersStore = {
? getServerContext< ProductFiltersContext >()
: getContext< ProductFiltersContext >();
- const canonicalUrl = getConfig( BLOCK_NAME ).canonicalUrl;
- const url = new URL( canonicalUrl );
+ const config = getConfig( BLOCK_NAME );
+ const url = new URL( config.canonicalUrl );
const { searchParams } = url;
for ( const key in context.params ) {
@@ -246,6 +246,19 @@ const productFiltersStore = {
return;
}
+ // Per-instance context (set when Product Filters is a descendant
+ // of Product Collection) wins over the global config, which is
+ // the fallback for the sibling-block layout.
+ const forcePageReload =
+ typeof context.forcePageReload === 'boolean'
+ ? context.forcePageReload
+ : config?.forcePageReload;
+
+ if ( forcePageReload ) {
+ window.location.assign( url.href );
+ return;
+ }
+
const routerModule: typeof import('@wordpress/interactivity-router') =
yield import( '@wordpress/interactivity-router' );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
index 88a11dc90b5..4fcdb870970 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
@@ -42,6 +42,10 @@ export type ProductFiltersContext = {
item: FilterOptionItem;
activeLabelTemplate: string;
filterType: string;
+ // Set when Product Filters is a descendant of Product Collection. Null
+ // signals the frontend to fall back to the global interactivity config
+ // (sibling-block layout).
+ forcePageReload?: boolean | null;
};
// ----------------------------------------
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 0eee3ac5f63..3f7037432e5 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -53595,18 +53595,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductFilterTaxonomy.php
- -
- message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
- identifier: class.notFound
- count: 3
- path: src/Blocks/BlockTypes/ProductFilters.php
-
- -
- message: '#^Access to property \$parsed_block on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
- identifier: class.notFound
- count: 1
- path: src/Blocks/BlockTypes/ProductFilters.php
-
-
message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductFilters\:\:enqueue_data\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -53631,12 +53619,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductFilters.php
- -
- message: '#^Parameter \$block of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductFilters\:\:render\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
- identifier: class.notFound
- count: 1
- path: src/Blocks/BlockTypes/ProductFilters.php
-
-
message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
identifier: class.notFound
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php
index be3b4727d93..3592cc7f606 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php
@@ -340,6 +340,24 @@ class Controller extends AbstractBlock {
*/
$this->asset_data_registry->add( 'isRenderingPhpTemplate', true );
+ /*
+ * When forcePageReload is enabled, the product collection has no data-wp-router-region,
+ * so the Interactivity Router cannot update it client-side. Signal the product-filters
+ * block so its navigate action falls back to a full page reload instead of using the
+ * router, without affecting other blocks on the page.
+ *
+ * This is only needed when the query is inherited from the template, as that's the
+ * only case where the Product Filters block can be a sibling rather than a descendant
+ * of the Product Collection. When it's a descendant, forcePageReload is passed through
+ * the block context instead.
+ */
+ if (
+ ( $parsed_block['attrs']['forcePageReload'] ?? false ) &&
+ ( $parsed_block['attrs']['query']['inherit'] ?? false )
+ ) {
+ wp_interactivity_config( 'woocommerce/product-filters', array( 'forcePageReload' => true ) );
+ }
+
return $pre_render;
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
index 29d45a75a07..82bd379c55e 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
use Automattic\WooCommerce\Internal\ProductFilters\Params;
+use WP_Block;
/**
* ProductFilters class.
@@ -26,7 +27,7 @@ class ProductFilters extends AbstractBlock {
* @return string[]
*/
protected function get_block_type_uses_context() {
- return array( 'postId', 'query', 'queryId' );
+ return array( 'postId', 'query', 'queryId', 'forcePageReload' );
}
/**
@@ -62,6 +63,10 @@ class ProductFilters extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
+ if ( ! $block instanceof WP_Block ) {
+ return $content;
+ }
+
wp_enqueue_script( 'wc-settings' );
$query_id = $block->context['queryId'] ?? 0;
@@ -99,8 +104,11 @@ class ProductFilters extends AbstractBlock {
''
);
$interactivity_context = array(
- 'params' => $filter_params,
- 'activeFilters' => $active_filters,
+ 'params' => $filter_params,
+ 'activeFilters' => $active_filters,
+ // Null when not a descendant of a Product Collection block, so the
+ // frontend can fall back to the global interactivity config.
+ 'forcePageReload' => isset( $block->context['forcePageReload'] ) ? (bool) $block->context['forcePageReload'] : null,
);
$wrapper_attributes = array(
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/ControllerTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/ControllerTest.php
new file mode 100644
index 00000000000..f506c1ee700
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/ControllerTest.php
@@ -0,0 +1,81 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection;
+
+use Automattic\WooCommerce\Blocks\Assets\Api;
+use Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\Controller;
+use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
+use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Product Collection block controller.
+ */
+class ControllerTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Controller
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $asset_api = Package::container()->get( Api::class );
+ $asset_data_registry = new AssetDataRegistryMock( $asset_api );
+ $integration_registry = new IntegrationRegistry();
+
+ $this->sut = new class( $asset_api, $asset_data_registry, $integration_registry ) extends Controller {
+ /**
+ * Skip normal hook registration for unit tests.
+ */
+ protected function initialize() {
+ $this->renderer = new class() {
+ /**
+ * Accept parsed block data from the controller under test.
+ *
+ * @param array $parsed_block The parsed block.
+ */
+ public function set_parsed_block( array $parsed_block ): void {}
+ };
+ }
+ };
+
+ wp_interactivity_config( 'woocommerce/product-filters', array( 'forcePageReload' => false ) );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ wp_interactivity_config( 'woocommerce/product-filters', array( 'forcePageReload' => false ) );
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should configure product filters full page reload for inherited product collections.
+ */
+ public function test_configures_product_filters_full_page_reload_for_inherited_product_collections(): void {
+ $parsed_block = Utils::get_base_parsed_block();
+ $parsed_block['attrs']['forcePageReload'] = true;
+ $parsed_block['attrs']['query']['inherit'] = true;
+
+ $this->sut->add_support_for_filter_blocks( null, $parsed_block );
+
+ $config = wp_interactivity_config( 'woocommerce/product-filters' );
+
+ $this->assertTrue(
+ $config['forcePageReload'] ?? false,
+ 'Product Filters should be configured to reload when the inherited Product Collection forces page reload.'
+ );
+ }
+}