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