Commit 685197ff559 for woocommerce
commit 685197ff5593bd904b87c5a235d79472ae68860b
Author: Tung Du <dinhtungdu@gmail.com>
Date: Thu Jun 11 10:13:13 2026 +0700
Fix Product Filters sync across multiple instances (#65622)
* fix: sync active filters with server
* test: add product filters sync e2e
* fix: lock product filters store
* fix: address product filters review feedback
* chore: changelog
diff --git a/plugins/woocommerce/changelog/fix-synced-product-filters-instances b/plugins/woocommerce/changelog/fix-synced-product-filters-instances
new file mode 100644
index 00000000000..0b8f21f4068
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-synced-product-filters-instances
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix: Sync active filters across multiple Product Filters blocks on the same page.
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 2d0ef2f6add..212e91f61d1 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
@@ -15,7 +15,6 @@
"enableContrastChecker": false,
"button": true
},
- "multiple": false,
"inserter": true,
"interactivity": true,
"typography": {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts
index 6c26a0f9c2b..61dccfe5f72 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts
@@ -1,5 +1,9 @@
+export const PRODUCT_FILTERS_STORE_NAME = 'woocommerce/product-filters';
+export const PRODUCT_FILTERS_STORE_LOCK =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
export const EXCLUDED_BLOCKS = [
- 'woocommerce/product-filters',
+ PRODUCT_FILTERS_STORE_NAME,
'woocommerce/product-filter-attribute',
'woocommerce/product-filter-active',
'woocommerce/product-filter-price',
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 63a6b6dff8e..120023fa8f4 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
@@ -15,10 +15,14 @@ import type {
ProductFiltersContext,
} from './types';
import { getClosestColor } from './utils/get-closest-color';
+import {
+ PRODUCT_FILTERS_STORE_LOCK,
+ PRODUCT_FILTERS_STORE_NAME,
+} from './constants';
const { getContext, getElement, store, getServerContext, getConfig } = iAPI;
-const BLOCK_NAME = 'woocommerce/product-filters';
+const BLOCK_NAME = PRODUCT_FILTERS_STORE_NAME;
type ValidFilterOptionItem = FilterOptionItem & {
type: string;
@@ -77,7 +81,6 @@ function unselectFilter( item: ValidFilterOptionItem ) {
const productFiltersStore = {
state: {
get params() {
- const { activeFilters } = getContext< ProductFiltersContext >();
const params: Record< string, string > = {};
function addParam( key: string, value: string ) {
@@ -89,7 +92,7 @@ const productFiltersStore = {
const config = getConfig( BLOCK_NAME );
const taxonomyParamsMap = config?.taxonomyParamsMap || {};
- activeFilters.forEach( ( filter ) => {
+ state.activeFilters.forEach( ( filter ) => {
// todo: refactor this to use params data from Automattic\WooCommerce\Internal\ProductFilters\Params.
const { type, value } = filter;
@@ -199,7 +202,7 @@ const productFiltersStore = {
? itemArg
: context.item;
if ( ! item || ! isValidFilterOptionItem( item ) ) return;
- const isSelected = context.activeFilters.some(
+ const isSelected = state.activeFilters.some(
( f ) => f.type === item.type && f.value === item.value
);
if ( isSelected ) {
@@ -302,6 +305,15 @@ const productFiltersStore = {
document.body.style.overflow = 'auto';
}
},
+ syncActiveFiltersWithServer: () => {
+ if ( ! getServerContext ) return;
+ const context = getContext< ProductFiltersContext >();
+ const serverContext = getServerContext< ProductFiltersContext >();
+
+ context.activeFilters = Array.isArray( serverContext.activeFilters )
+ ? serverContext.activeFilters.map( ( item ) => ( { ...item } ) )
+ : [];
+ },
},
};
@@ -313,5 +325,6 @@ export type ProductFiltersStore = typeof productFiltersStore;
const { state, actions } = store< ProductFiltersStore >(
BLOCK_NAME,
- productFiltersStore
+ productFiltersStore,
+ { lock: PRODUCT_FILTERS_STORE_LOCK }
);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/frontend.ts
index 2f2f94df122..c8c6c68e22a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/frontend.ts
@@ -12,6 +12,10 @@ import type {
RemovableItem,
RemovableItemsParentStore,
} from '../../../../types/type-defs/removable-items';
+import {
+ PRODUCT_FILTERS_STORE_LOCK,
+ PRODUCT_FILTERS_STORE_NAME,
+} from '../../constants';
type RemovableItemContext = {
item: RemovableItem;
@@ -19,16 +23,13 @@ type RemovableItemContext = {
const activeFiltersStore = {
state: {
- get removableItems() {
- const { activeFilters } = getContext< ProductFiltersContext >();
- return activeFilters
- .filter( ( f ) => !! f.value )
- .map( ( f ) => ( {
- id: f.type + '_' + f.value,
- type: f.type,
- value: f.value,
- label: f.activeLabel,
- } ) );
+ get removableItems(): RemovableItem[] {
+ return state.activeFilters.map( ( f ) => ( {
+ id: f.type + '_' + f.value,
+ type: f.type,
+ value: f.value,
+ label: f.activeLabel,
+ } ) );
},
get removeItemLabel() {
const { item } = getContext< RemovableItemContext >();
@@ -40,9 +41,8 @@ const activeFiltersStore = {
const label = typeof item?.label === 'string' ? item.label : '';
return template.replace( '{{label}}', label );
},
- get hasActiveFilters() {
- const { activeFilters } = getContext< ProductFiltersContext >();
- return activeFilters.length > 0;
+ get hasActiveFilters(): boolean {
+ return state.activeFilters.length > 0;
},
},
actions: {
@@ -66,7 +66,8 @@ const activeFiltersStore = {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
activeFiltersStore satisfies RemovableItemsParentStore;
-const { actions } = store< ProductFiltersStore & typeof activeFiltersStore >(
- 'woocommerce/product-filters',
- activeFiltersStore
-);
+const { state, actions } = store<
+ ProductFiltersStore & typeof activeFiltersStore
+>( PRODUCT_FILTERS_STORE_NAME, activeFiltersStore, {
+ lock: PRODUCT_FILTERS_STORE_LOCK,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts
index 8e702ae4363..17d7ab4cd38 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts
@@ -15,6 +15,10 @@ import {
isVisualAttributeTermEmpty,
} from '../../../../base/utils/visual-attribute-terms';
import type { VisualAttributeTerm } from '../../../../base/utils/visual-attribute-terms';
+import {
+ PRODUCT_FILTERS_STORE_LOCK,
+ PRODUCT_FILTERS_STORE_NAME,
+} from '../../constants';
type CheckboxListItem = SelectableItem< {
visual?: VisualAttributeTerm;
@@ -44,6 +48,11 @@ type CheckboxListStore = {
function getParentStore( storeNamespace?: string ) {
if ( ! storeNamespace ) return undefined;
+ if ( storeNamespace === PRODUCT_FILTERS_STORE_NAME ) {
+ return store<
+ SelectableItemsParentStore< { visual?: VisualAttributeTerm } >
+ >( storeNamespace, undefined, { lock: PRODUCT_FILTERS_STORE_LOCK } );
+ }
return store<
SelectableItemsParentStore< { visual?: VisualAttributeTerm } >
>( storeNamespace );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
index 309910ccfde..223abbc9316 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
@@ -16,6 +16,10 @@ import {
} from '../../../../base/utils/visual-attribute-terms';
import type { VisualAttributeTerm } from '../../../../base/utils/visual-attribute-terms';
import { getClosestColor } from '../../utils/get-closest-color';
+import {
+ PRODUCT_FILTERS_STORE_LOCK,
+ PRODUCT_FILTERS_STORE_NAME,
+} from '../../constants';
type ChipsItem = SelectableItem< {
visual?: VisualAttributeTerm;
@@ -49,6 +53,11 @@ type ChipsStore = {
function getParentStore( storeNamespace?: string ) {
if ( ! storeNamespace ) return undefined;
+ if ( storeNamespace === PRODUCT_FILTERS_STORE_NAME ) {
+ return store<
+ SelectableItemsParentStore< { visual?: VisualAttributeTerm } >
+ >( storeNamespace, undefined, { lock: PRODUCT_FILTERS_STORE_LOCK } );
+ }
return store<
SelectableItemsParentStore< { visual?: VisualAttributeTerm } >
>( storeNamespace );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/frontend.ts
index 15346432bb8..320920fd01d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/frontend.ts
@@ -11,6 +11,10 @@ import type { ProductFiltersContext } from '../../types';
import type { ProductFiltersStore } from '../../frontend';
import { formatPrice, getCurrency } from '../../utils/price-currency';
import type { RangeInputParentStore } from '../../../../types/type-defs/range-input';
+import {
+ PRODUCT_FILTERS_STORE_LOCK,
+ PRODUCT_FILTERS_STORE_NAME,
+} from '../../constants';
const { store, getContext, getServerContext, getConfig } = iAPI;
@@ -25,12 +29,11 @@ export type ProductFilterPriceContext = {
const productFilterPriceStore = {
state: {
- get minPrice() {
- const { activeFilters } = getContext< ProductFiltersContext >();
+ get minPrice(): number {
const { minRange } = getServerContext
? getServerContext< ProductFilterPriceContext >()
: getContext< ProductFilterPriceContext >();
- const priceFilter = activeFilters.find(
+ const priceFilter = state.activeFilters.find(
( filter ) => filter.type === 'price'
);
if ( priceFilter ) {
@@ -39,12 +42,11 @@ const productFilterPriceStore = {
}
return minRange;
},
- get maxPrice() {
- const { activeFilters } = getContext< ProductFiltersContext >();
+ get maxPrice(): number {
const { maxRange } = getServerContext
? getServerContext< ProductFilterPriceContext >()
: getContext< ProductFilterPriceContext >();
- const priceFilter = activeFilters.find(
+ const priceFilter = state.activeFilters.find(
( filter ) => filter.type === 'price'
);
if ( priceFilter ) {
@@ -181,4 +183,6 @@ export type ProductFilterPriceStore = typeof productFilterPriceStore;
const { state, actions } = store<
ProductFiltersStore & ProductFilterPriceStore
->( 'woocommerce/product-filters', productFilterPriceStore );
+>( PRODUCT_FILTERS_STORE_NAME, productFilterPriceStore, {
+ lock: PRODUCT_FILTERS_STORE_LOCK,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/frontend.ts
index b2b7575530c..fed17cc173a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/frontend.ts
@@ -12,6 +12,10 @@ import type {
ProductFilterPriceContext,
ProductFilterPriceStore,
} from '../price-filter/frontend';
+import {
+ PRODUCT_FILTERS_STORE_LOCK,
+ PRODUCT_FILTERS_STORE_NAME,
+} from '../../constants';
const { store, getContext, getElement, withScope, getServerContext } = iAPI;
@@ -73,4 +77,6 @@ const { state, actions } = store<
ProductFiltersStore &
ProductFilterPriceStore &
typeof productFilterPriceSliderStore
->( 'woocommerce/product-filters', productFilterPriceSliderStore );
+>( PRODUCT_FILTERS_STORE_NAME, productFilterPriceSliderStore, {
+ lock: PRODUCT_FILTERS_STORE_LOCK,
+} );
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_multiple-product-filters.handlebars b/plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_multiple-product-filters.handlebars
new file mode 100644
index 00000000000..08e378ebf53
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_multiple-product-filters.handlebars
@@ -0,0 +1,42 @@
+<!-- wp:woocommerce/product-filters -->
+<div class="wp-block-woocommerce-product-filters wc-block-product-filters" style="--wc-product-filters-text-color:#111;--wc-product-filters-background-color:#fff">
+ <!-- wp:woocommerce/product-filter-active -->
+ <div class="wp-block-woocommerce-product-filter-active">
+ <!-- wp:woocommerce/product-filter-removable-chips {"lock":{"remove":true}} -->
+ <div class="wp-block-woocommerce-product-filter-removable-chips wc-block-product-filter-removable-chips"></div>
+ <!-- /wp:woocommerce/product-filter-removable-chips -->
+ </div>
+ <!-- /wp:woocommerce/product-filter-active -->
+</div>
+<!-- /wp:woocommerce/product-filters -->
+
+<!-- wp:woocommerce/product-filters -->
+<div class="wp-block-woocommerce-product-filters wc-block-product-filters" style="--wc-product-filters-text-color:#111;--wc-product-filters-background-color:#fff">
+{{#> wp-block blockName='woocommerce/product-filter-attribute' attributes=attributes }}
+<div class="wp-block-woocommerce-product-filter-attribute"><!-- wp:group {"metadata":{"name":"Header"},"style":{"spacing":{"blockGap":"0"}},"layout":{"type":"flex","flexWrap":"nowrap"}} -->
+<div class="wp-block-group"><!-- wp:heading {"level":3} -->
+<h3 class="wp-block-heading">Attribute</h3>
+<!-- /wp:heading --></div>
+<!-- /wp:group -->
+
+<!-- wp:woocommerce/product-filter-checkbox-list {"lock":{"remove":true}} -->
+<div class="wp-block-woocommerce-product-filter-checkbox-list wc-block-product-filter-checkbox-list"></div>
+<!-- /wp:woocommerce/product-filter-checkbox-list --></div>
+{{/ wp-block }}
+</div>
+<!-- /wp:woocommerce/product-filters -->
+
+<!-- wp:woocommerce/product-collection {"query":{"perPage":9,"pages":0,"offset":0,"postType":"product","order":"asc","orderBy":"title","search":"","exclude":[],"inherit":true,"taxQuery":[],"isProductCollectionBlock":true,"featured":false,"woocommerceOnSale":false,"woocommerceStockStatus":["instock","outofstock","onbackorder"],"woocommerceAttributes":[],"woocommerceHandPickedProducts":[]},"tagName":"div","displayLayout":{"type":"flex","columns":3,"shrinkColumns":true},"queryContextIncludes":["collection"]} -->
+<div class="wp-block-woocommerce-product-collection">
+ <!-- wp:woocommerce/product-template -->
+ <!-- wp:woocommerce/product-image {"showSaleBadge":false,"imageSizing":"thumbnail","isDescendentOfQueryLoop":true} -->
+ <!-- wp:woocommerce/product-sale-badge {"isDescendentOfQueryLoop":true,"align":"right"} /-->
+ <!-- /wp:woocommerce/product-image -->
+ <!-- wp:post-title {"textAlign":"center","level":3,"isLink":true,"style":{"spacing":{"margin":{"bottom":"0.75rem","top":"0"}}},"fontSize":"medium","__woocommerceNamespace":"woocommerce/product-collection/product-title"} /-->
+
+ <!-- wp:woocommerce/product-price {"isDescendentOfQueryLoop":true,"textAlign":"center","fontSize":"small"} /-->
+
+ <!-- wp:woocommerce/product-button {"textAlign":"center","isDescendentOfQueryLoop":true,"fontSize":"small"} /-->
+ <!-- /wp:woocommerce/product-template -->
+</div>
+<!-- /wp:woocommerce/product-collection -->
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-filters/product-filters-frontend.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-filters/product-filters-frontend.block_theme.spec.ts
index 0f90453cced..73c2152334d 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-filters/product-filters-frontend.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-filters/product-filters-frontend.block_theme.spec.ts
@@ -117,4 +117,75 @@ test.describe( 'woocommerce/product-filters - Frontend', () => {
await expect( products ).toHaveCount( 2 );
} );
} );
+
+ test.describe( 'Multiple instances', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.addInitScript( () => {
+ // Mock the wc global variable.
+ if ( typeof window.wc === 'undefined' ) {
+ window.wc = {
+ wcSettings: {
+ getSetting() {
+ return true;
+ },
+ },
+ };
+ }
+ } );
+ } );
+
+ test( 'syncs active filters between Product Filters blocks', async ( {
+ page,
+ requestUtils,
+ } ) => {
+ const templateCompiler = await requestUtils.createTemplateFromFile(
+ 'archive-product_multiple-product-filters'
+ );
+
+ await templateCompiler.compile( {
+ attributes: {
+ attributeId: 1,
+ },
+ } );
+
+ await page.goto( '/shop' );
+
+ const productFilters = page.locator(
+ '.wp-block-woocommerce-product-filters'
+ );
+ await expect( productFilters ).toHaveCount( 2 );
+
+ const activeFiltersBlock = productFilters.first();
+ const filterOptionsBlock = productFilters.nth( 1 );
+ const grayCheckbox = filterOptionsBlock.getByRole( 'checkbox', {
+ name: 'Gray',
+ } );
+ const grayActiveFilter = activeFiltersBlock.getByText(
+ 'Color: Gray',
+ { exact: true }
+ );
+
+ await expect( grayActiveFilter ).toBeHidden();
+
+ await grayCheckbox.click();
+ await page.waitForURL(
+ ( url ) => url.searchParams.get( 'filter_color' ) === 'gray'
+ );
+
+ await expect( grayActiveFilter ).toBeVisible();
+ await expect( grayCheckbox ).toBeChecked();
+
+ await activeFiltersBlock
+ .getByRole( 'button', {
+ name: 'Remove filter: Color: Gray',
+ } )
+ .click();
+ await page.waitForURL(
+ ( url ) => ! url.searchParams.has( 'filter_color' )
+ );
+
+ await expect( grayActiveFilter ).toBeHidden();
+ await expect( grayCheckbox ).not.toBeChecked();
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
index 82bd379c55e..3f2ce89ac69 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
@@ -116,6 +116,7 @@ class ProductFilters extends AbstractBlock {
'data-wp-interactive' => $this->get_full_block_name(),
'data-wp-init--colors' => 'callbacks.initColors',
'data-wp-watch--scrolling' => 'callbacks.scrollLimit',
+ 'data-wp-watch--active-filters' => 'callbacks.syncActiveFiltersWithServer',
'data-wp-on--keyup' => 'actions.closeOverlayOnEscape',
'data-wp-context' => (string) wp_json_encode( $interactivity_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
'data-wp-class--is-overlay-opened' => 'context.isOverlayOpened',