Commit e7cd01f84c5 for woocommerce

commit e7cd01f84c50373c10b3cdce785720a588fb5839
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Wed May 20 08:58:23 2026 +0700

    Decouple selectable inner block types (#65109)

    * refactor: decouple selectable inner block types

    * fix: keep selectable item base clean

    * refactor: mirror selectable inner items

    * refactor: inline selectable item extras

    * fix: guard mirrored selectable stores

    * simplify type

    * fix: use hidden prop instaed of getter

    * docs: update protocol

    * docs: shorter protocol

    * fix: support hidden in selectableItems

    * fix: normalize selectable display limit

diff --git a/plugins/woocommerce/changelog/35a20742-decouple-filter-inner-block-types b/plugins/woocommerce/changelog/35a20742-decouple-filter-inner-block-types
new file mode 100644
index 00000000000..4035d5c8308
--- /dev/null
+++ b/plugins/woocommerce/changelog/35a20742-decouple-filter-inner-block-types
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Decouple selectable item inner block types from product filter domain types.
+
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 c0d12b62ebd..c08621ccfc7 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
@@ -123,6 +123,37 @@ describe( 'product filters interactivity store', () => {
 		expect( mockRegisteredStore.state.selectableItems ).toEqual( [] );
 	} );

+	it( 'does not add child-owned index metadata to selectable items', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Product filters store was not registered.' );
+		}
+
+		mockGetServerContext.mockReturnValue( {
+			items: [
+				{
+					id: 'attribute-blue',
+					label: 'Blue',
+					value: 'blue',
+					type: 'attribute/color',
+				},
+			],
+			activeFilters: [],
+		} );
+		mockGetContext.mockReturnValue( {
+			activeFilters: [],
+		} );
+
+		expect( mockRegisteredStore.state.selectableItems ).toEqual( [
+			{
+				id: 'attribute-blue',
+				label: 'Blue',
+				value: 'blue',
+				type: 'attribute/color',
+				selected: false,
+			},
+		] );
+	} );
+
 	[
 		{
 			description: 'unicode value',
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 69585113643..69b52e26dde 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
@@ -149,9 +149,8 @@ const productFiltersStore = {
 				: getContext< ProductFiltersContext >();
 			const items = server.items;
 			if ( ! Array.isArray( items ) ) return [];
-			return items.map( ( item, index ) => ( {
+			return items.map( ( item ) => ( {
 				...item,
-				index,
 				selected: state.activeFilters.some(
 					( filter ) =>
 						filter.type === item.type && filter.value === item.value
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/__tests__/frontend.test.ts
new file mode 100644
index 00000000000..3ddee197f15
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/__tests__/frontend.test.ts
@@ -0,0 +1,149 @@
+/**
+ * Internal dependencies
+ */
+import type { CheckboxListStore } from '../frontend';
+
+const mockGetContext = jest.fn();
+const mockParentToggle = jest.fn();
+
+let mockRegisteredStore: CheckboxListStore | null = null;
+
+const mockParentStore = {
+	state: {
+		selectableItems: [
+			{
+				id: 'attribute-blue',
+				label: 'Blue',
+				value: 'blue',
+				selected: false,
+			},
+			{
+				id: 'attribute-red',
+				label: 'Red',
+				value: 'red',
+				selected: true,
+			},
+		],
+	},
+	actions: {
+		toggle: mockParentToggle,
+	},
+};
+
+jest.mock(
+	'@wordpress/interactivity',
+	() => ( {
+		getContext: mockGetContext,
+		store: jest.fn( ( _name, definition ) => {
+			if ( definition ) {
+				mockRegisteredStore = definition;
+				return mockRegisteredStore;
+			}
+			return mockParentStore;
+		} ),
+	} ),
+	{ virtual: true }
+);
+
+describe( 'product filter checkbox list interactivity store', () => {
+	beforeEach( () => {
+		jest.resetModules();
+		mockGetContext.mockReset();
+		mockParentToggle.mockReset();
+		mockRegisteredStore = null;
+
+		jest.isolateModules( () => {
+			require( '../frontend' );
+		} );
+	} );
+
+	it( 'mirrors parent selectable items with child-owned index metadata', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Checkbox list store was not registered.' );
+		}
+
+		mockGetContext.mockReturnValue( {
+			storeNamespace: 'woocommerce/product-filters',
+		} );
+
+		expect( mockRegisteredStore.state.items ).toEqual( [
+			{
+				id: 'attribute-blue',
+				label: 'Blue',
+				value: 'blue',
+				selected: false,
+				index: 0,
+				hidden: false,
+			},
+			{
+				id: 'attribute-red',
+				label: 'Red',
+				value: 'red',
+				selected: true,
+				index: 1,
+				hidden: false,
+			},
+		] );
+	} );
+
+	it( 'uses the default display limit when context limit is invalid', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Checkbox list store was not registered.' );
+		}
+
+		mockGetContext.mockReturnValue( {
+			storeNamespace: 'woocommerce/product-filters',
+			displayLimit: -1,
+			isExpanded: false,
+		} );
+
+		expect( mockRegisteredStore.state.items[ 0 ].hidden ).toBe( false );
+	} );
+
+	it( 'forwards toggle to parent store with current item', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Checkbox list store was not registered.' );
+		}
+
+		const item = {
+			id: 'attribute-blue',
+			label: 'Blue',
+			value: 'blue',
+			selected: false,
+			index: 0,
+		};
+
+		mockGetContext.mockReturnValue( {
+			storeNamespace: 'woocommerce/product-filters',
+			item,
+		} );
+
+		mockRegisteredStore.actions.toggle();
+
+		expect( mockParentToggle ).toHaveBeenCalledWith( item );
+	} );
+
+	it( 'returns empty items when parent store data is missing', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Checkbox list store was not registered.' );
+		}
+
+		mockGetContext.mockReturnValue( {} );
+
+		expect( mockRegisteredStore.state.items ).toEqual( [] );
+	} );
+
+	it( 'does not forward toggle without current item', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Checkbox list store was not registered.' );
+		}
+
+		mockGetContext.mockReturnValue( {
+			storeNamespace: 'woocommerce/product-filters',
+		} );
+
+		mockRegisteredStore.actions.toggle();
+
+		expect( mockParentToggle ).not.toHaveBeenCalled();
+	} );
+} );
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 f247eefc4d4..bf77a032050 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
@@ -6,9 +6,17 @@ import { store, getContext } from '@wordpress/interactivity';
 /**
  * Internal dependencies
  */
-import type { SelectableItem } from '../../../../types/type-defs/selectable-items';
+import type {
+	SelectableItem,
+	SelectableItemsParentStore,
+} from '../../../../types/type-defs/selectable-items';

-type ItemWithIndex = SelectableItem & { index?: number; color?: string };
+type CheckboxListItem = SelectableItem< {
+	color?: string;
+	index?: number;
+} >;
+
+const DEFAULT_DISPLAY_LIMIT = 15;

 type CheckboxListContext = {
 	storeNamespace: string;
@@ -16,60 +24,83 @@ type CheckboxListContext = {
 	isExpanded: boolean;
 };

-type ParentItemContext = {
-	item?: ItemWithIndex;
-};
-
 type CheckboxListStore = {
 	state: {
-		itemHidden: boolean;
+		items: CheckboxListItem[];
 		ratingStyle: string;
 		colorSwatchStyle: string;
 		isColorSwatchEmpty: boolean;
 	};
 	actions: {
+		toggle: () => void;
 		showAll: () => void;
 	};
 };

-function getParentItem( storeNamespace: string ): ItemWithIndex | undefined {
-	const parentCtx = getContext< ParentItemContext >( storeNamespace );
-	return parentCtx.item;
+function getParentStore( storeNamespace?: string ) {
+	if ( ! storeNamespace ) return undefined;
+	return store< SelectableItemsParentStore< { color?: string } > >(
+		storeNamespace
+	);
+}
+
+function normalizeDisplayLimit( displayLimit: number ): number {
+	const limit = Number( displayLimit );
+	if ( ! Number.isFinite( limit ) || limit < 0 ) {
+		return DEFAULT_DISPLAY_LIMIT;
+	}
+	return Math.floor( limit );
+}
+
+function getCurrentItem(): CheckboxListItem | undefined {
+	const context = getContext< { item?: CheckboxListItem } >();
+	return context.item;
 }

 const { state }: CheckboxListStore = store< CheckboxListStore >(
 	'woocommerce/product-filter-checkbox-list',
 	{
 		state: {
-			get itemHidden(): boolean {
-				const { isExpanded, storeNamespace, displayLimit } =
+			get items(): CheckboxListItem[] {
+				const { storeNamespace, isExpanded, displayLimit } =
 					getContext< CheckboxListContext >();
-				if ( isExpanded ) return false;
-				const item = getParentItem( storeNamespace );
-				if ( ! item ) return false;
-				if ( item.selected ) return false;
-				if ( item.index === undefined ) return false;
-				return item.index >= displayLimit;
+				const parentItems =
+					getParentStore( storeNamespace )?.state?.selectableItems;
+				if ( ! Array.isArray( parentItems ) ) return [];
+				const normalizedDisplayLimit =
+					normalizeDisplayLimit( displayLimit );
+				return parentItems.map( ( item, index ) => ( {
+					...item,
+					index,
+					hidden:
+						item.hidden ||
+						( ! isExpanded &&
+							! item.selected &&
+							index >= normalizedDisplayLimit ),
+				} ) );
 			},
 			get ratingStyle(): string {
-				const { storeNamespace } = getContext< CheckboxListContext >();
-				const item = getParentItem( storeNamespace );
+				const item = getCurrentItem();
 				if ( ! item ) return '';
 				return `width: ${ Number( item.value ) * 20 }%`;
 			},
 			get colorSwatchStyle(): string {
-				const { storeNamespace } = getContext< CheckboxListContext >();
-				const item = getParentItem( storeNamespace );
+				const item = getCurrentItem();
 				if ( ! item?.color ) return '';
 				return `background-color: ${ item.color }`;
 			},
 			get isColorSwatchEmpty(): boolean {
-				const { storeNamespace } = getContext< CheckboxListContext >();
-				const item = getParentItem( storeNamespace );
+				const item = getCurrentItem();
 				return ! item?.color;
 			},
 		},
 		actions: {
+			toggle() {
+				const item = getCurrentItem();
+				if ( ! item ) return;
+				const { storeNamespace } = getContext< CheckboxListContext >();
+				getParentStore( storeNamespace )?.actions?.toggle?.( item );
+			},
 			showAll() {
 				const context = getContext< CheckboxListContext >();
 				context.isExpanded = true;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts
index 4985f84b022..73d937dd493 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts
@@ -7,7 +7,13 @@ import { BlockEditProps } from '@wordpress/blocks';
  * Internal dependencies
  */
 import type { SelectableItemsBlockContext } from '../../../../types/type-defs/selectable-items';
-import type { Color, FilterItemFields } from '../../types';
+
+export type Color = {
+	slug?: string;
+	name?: string;
+	class?: string;
+	color: string;
+};

 export type BlockAttributes = {
 	className: string;
@@ -22,7 +28,11 @@ export type BlockAttributes = {
 };

 export type EditProps = BlockEditProps< BlockAttributes > & {
-	context: SelectableItemsBlockContext< FilterItemFields >;
+	context: SelectableItemsBlockContext< {
+		count?: number;
+		color?: string;
+		depth?: number;
+	} >;
 	optionElementBorder: Color;
 	setOptionElementBorder: ( value: string ) => void;
 	optionElementSelected: Color;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts
new file mode 100644
index 00000000000..b031b620259
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts
@@ -0,0 +1,149 @@
+/**
+ * Internal dependencies
+ */
+import type { ChipsStore } from '../frontend';
+
+const mockGetContext = jest.fn();
+const mockParentToggle = jest.fn();
+
+let mockRegisteredStore: ChipsStore | null = null;
+
+const mockParentStore = {
+	state: {
+		selectableItems: [
+			{
+				id: 'attribute-blue',
+				label: 'Blue',
+				value: 'blue',
+				selected: false,
+			},
+			{
+				id: 'attribute-red',
+				label: 'Red',
+				value: 'red',
+				selected: true,
+			},
+		],
+	},
+	actions: {
+		toggle: mockParentToggle,
+	},
+};
+
+jest.mock(
+	'@wordpress/interactivity',
+	() => ( {
+		getContext: mockGetContext,
+		store: jest.fn( ( _name, definition ) => {
+			if ( definition ) {
+				mockRegisteredStore = definition;
+				return mockRegisteredStore;
+			}
+			return mockParentStore;
+		} ),
+	} ),
+	{ virtual: true }
+);
+
+describe( 'product filter chips interactivity store', () => {
+	beforeEach( () => {
+		jest.resetModules();
+		mockGetContext.mockReset();
+		mockParentToggle.mockReset();
+		mockRegisteredStore = null;
+
+		jest.isolateModules( () => {
+			require( '../frontend' );
+		} );
+	} );
+
+	it( 'mirrors parent selectable items with child-owned index metadata', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Chips store was not registered.' );
+		}
+
+		mockGetContext.mockReturnValue( {
+			storeNamespace: 'woocommerce/product-filters',
+		} );
+
+		expect( mockRegisteredStore.state.items ).toEqual( [
+			{
+				id: 'attribute-blue',
+				label: 'Blue',
+				value: 'blue',
+				selected: false,
+				index: 0,
+				hidden: false,
+			},
+			{
+				id: 'attribute-red',
+				label: 'Red',
+				value: 'red',
+				selected: true,
+				index: 1,
+				hidden: false,
+			},
+		] );
+	} );
+
+	it( 'uses the default display limit when context limit is invalid', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Chips store was not registered.' );
+		}
+
+		mockGetContext.mockReturnValue( {
+			storeNamespace: 'woocommerce/product-filters',
+			displayLimit: -1,
+			isExpanded: false,
+		} );
+
+		expect( mockRegisteredStore.state.items[ 0 ].hidden ).toBe( false );
+	} );
+
+	it( 'forwards toggle to parent store with current item', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Chips store was not registered.' );
+		}
+
+		const item = {
+			id: 'attribute-blue',
+			label: 'Blue',
+			value: 'blue',
+			selected: false,
+			index: 0,
+		};
+
+		mockGetContext.mockReturnValue( {
+			storeNamespace: 'woocommerce/product-filters',
+			item,
+		} );
+
+		mockRegisteredStore.actions.toggle();
+
+		expect( mockParentToggle ).toHaveBeenCalledWith( item );
+	} );
+
+	it( 'returns empty items when parent store data is missing', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Chips store was not registered.' );
+		}
+
+		mockGetContext.mockReturnValue( {} );
+
+		expect( mockRegisteredStore.state.items ).toEqual( [] );
+	} );
+
+	it( 'does not forward toggle without current item', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Chips store was not registered.' );
+		}
+
+		mockGetContext.mockReturnValue( {
+			storeNamespace: 'woocommerce/product-filters',
+		} );
+
+		mockRegisteredStore.actions.toggle();
+
+		expect( mockParentToggle ).not.toHaveBeenCalled();
+	} );
+} );
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 f153f9e5713..924bb9f0967 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
@@ -6,9 +6,17 @@ import { store, getContext } from '@wordpress/interactivity';
 /**
  * Internal dependencies
  */
-import type { SelectableItem } from '../../../../types/type-defs/selectable-items';
+import type {
+	SelectableItem,
+	SelectableItemsParentStore,
+} from '../../../../types/type-defs/selectable-items';

-type ChipsItem = SelectableItem< { color?: string } > & { index?: number };
+type ChipsItem = SelectableItem< {
+	color?: string;
+	index?: number;
+} >;
+
+const DEFAULT_DISPLAY_LIMIT = 15;

 type ChipsContext = {
 	storeNamespace: string;
@@ -16,53 +24,77 @@ type ChipsContext = {
 	isExpanded: boolean;
 };

-type ParentItemContext = {
-	item?: ChipsItem;
-};
-
 type ChipsStore = {
 	state: {
-		itemHidden: boolean;
+		items: ChipsItem[];
 		swatchHidden: boolean;
 		swatchStyle: string;
 	};
 	actions: {
+		toggle: () => void;
 		showAll: () => void;
 	};
 };

-function getParentItem( storeNamespace: string ): ChipsItem | undefined {
-	const parentCtx = getContext< ParentItemContext >( storeNamespace );
-	return parentCtx.item;
+function getParentStore( storeNamespace?: string ) {
+	if ( ! storeNamespace ) return undefined;
+	return store< SelectableItemsParentStore< { color?: string } > >(
+		storeNamespace
+	);
+}
+
+function normalizeDisplayLimit( displayLimit: number ): number {
+	const limit = Number( displayLimit );
+	if ( ! Number.isFinite( limit ) || limit < 0 ) {
+		return DEFAULT_DISPLAY_LIMIT;
+	}
+	return Math.floor( limit );
+}
+
+function getCurrentItem(): ChipsItem | undefined {
+	const context = getContext< { item?: ChipsItem } >();
+	return context.item;
 }

 const { state }: ChipsStore = store< ChipsStore >(
 	'woocommerce/product-filter-chips',
 	{
 		state: {
-			get itemHidden(): boolean {
-				const { isExpanded, storeNamespace, displayLimit } =
+			get items(): ChipsItem[] {
+				const { storeNamespace, isExpanded, displayLimit } =
 					getContext< ChipsContext >();
-				if ( isExpanded ) return false;
-				const item = getParentItem( storeNamespace );
-				if ( ! item ) return false;
-				if ( item.selected ) return false;
-				if ( item.index === undefined ) return false;
-				return item.index >= displayLimit;
+				const parentItems =
+					getParentStore( storeNamespace )?.state?.selectableItems;
+				if ( ! Array.isArray( parentItems ) ) return [];
+				const normalizedDisplayLimit =
+					normalizeDisplayLimit( displayLimit );
+				return parentItems.map( ( item, index ) => ( {
+					...item,
+					index,
+					hidden:
+						item.hidden ||
+						( ! isExpanded &&
+							! item.selected &&
+							index >= normalizedDisplayLimit ),
+				} ) );
 			},
 			get swatchHidden(): boolean {
-				const { storeNamespace } = getContext< ChipsContext >();
-				const item = getParentItem( storeNamespace );
+				const item = getCurrentItem();
 				return ! item?.color;
 			},
 			get swatchStyle(): string {
-				const { storeNamespace } = getContext< ChipsContext >();
-				const item = getParentItem( storeNamespace );
+				const item = getCurrentItem();
 				if ( ! item?.color ) return '';
 				return `background-color: ${ item.color }`;
 			},
 		},
 		actions: {
+			toggle() {
+				const item = getCurrentItem();
+				if ( ! item ) return;
+				const { storeNamespace } = getContext< ChipsContext >();
+				getParentStore( storeNamespace )?.actions?.toggle?.( item );
+			},
 			showAll() {
 				const context = getContext< ChipsContext >();
 				context.isExpanded = true;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts
index f70dcb35961..fbdcdc15cd5 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts
@@ -7,7 +7,13 @@ import { BlockEditProps } from '@wordpress/blocks';
  * Internal dependencies
  */
 import type { SelectableItemsBlockContext } from '../../../../types/type-defs/selectable-items';
-import type { Color, FilterItemFields } from '../../types';
+
+export type Color = {
+	slug?: string;
+	name?: string;
+	class?: string;
+	color: string;
+};

 export type BlockAttributes = {
 	className: string;
@@ -27,7 +33,10 @@ export type BlockAttributes = {

 export type EditProps = BlockEditProps< BlockAttributes > & {
 	style: Record< string, string >;
-	context: SelectableItemsBlockContext< FilterItemFields >;
+	context: SelectableItemsBlockContext< {
+		count?: number;
+		color?: string;
+	} >;
 	chipText: Color;
 	setChipText: ( value: string ) => void;
 	chipBackground: Color;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/types.ts
index c02d1b3b132..7a1daaf8efc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/types.ts
@@ -7,7 +7,13 @@ import type { BlockEditProps } from '@wordpress/blocks';
  * Internal dependencies
  */
 import type { RangeInputBlockContext } from '../../../../types/type-defs/range-input';
-import type { Color } from '../../types';
+
+type Color = {
+	slug?: string;
+	class?: string;
+	name?: string;
+	color: string;
+};

 export type BlockAttributes = {
 	showInputFields: boolean;
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 780f01536a0..2432179baa6 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
@@ -52,13 +52,3 @@ export type BlockAttributes = {
 };

 export type EditProps = BlockEditProps< BlockAttributes >;
-
-// ----------------------------------------
-// Editor color picker
-// ----------------------------------------
-export type Color = {
-	slug?: string;
-	class?: string;
-	name?: string;
-	color: string;
-};
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
index bd7140a662e..c98758a1612 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
@@ -11,6 +11,7 @@ export type SelectableItem< T = unknown > = (
 	value: string;
 	selected?: boolean;
 	disabled?: boolean;
+	hidden?: boolean;
 	type?: string;
 } & T;

diff --git a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
index 24bc99f617f..0c9deb05f35 100644
--- a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
+++ b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
@@ -1,289 +1,80 @@
 # Inner Block Protocols

-> **Experimental:** These protocols are internal and subject to change without notice. Block context keys, action names, and type shapes documented here should not be considered a stable public API. They may be renamed, restructured, or removed in future releases.
+> **Experimental:** These protocols are internal and may change without notice.

-## Overview
+WooCommerce reusable inner blocks use small context protocols so they can render UI for different parent blocks without coupling to a specific parent store.

-This document defines the **context protocol pattern** used by WooCommerce blocks to let reusable inner blocks work with any parent store. Three concrete protocols exist today:
-
-| Context Key | Purpose | Inner Blocks |
+| Context key | Purpose | Inner blocks |
 | --- | --- | --- |
-| `woocommerceSelectableItems` | Select/deselect items (filters, variations) | checkbox-list, chips |
-| `woocommerceRemovableItems` | Remove individual items (active filters) | removable-chips |
-| `woocommerceRangeInput` | Numeric range input (price, slider) | price-slider |
-
-Each protocol follows the same pattern — only item shape and action names differ.
-
-## Problem Statement
-
-WooCommerce blocks need reusable UI components (chips, swatches, pills, sliders) that can work inside multiple parent blocks with different Interactivity API stores. Without a protocol, inner blocks get tightly coupled to a single store namespace, preventing true reusability.
-
-## Solution: Context Protocol Pattern
-
-Inner blocks become **presentational** — they read a standardized context protocol and call parent-provided actions instead of directly referencing a specific store.
-
-```text
-┌─────────────────────────────────────────────────────────┐
-│  Protocol Specification (this document)                 │
-│  └── Defines the contract both sides must follow        │
-├─────────────────────────────────────────────────────────┤
-│  Parent Block (implements protocol)                     │
-│  ├── Registers own Interactivity store                  │
-│  ├── Provides context matching protocol shape           │
-│  ├── Implements fixed-name actions/getters              │
-│  └── Handles business logic (filtering, variation, …)   │
-├─────────────────────────────────────────────────────────┤
-│  Reusable Inner Block (consumes protocol)               │
-│  ├── Reads context per protocol specification           │
-│  ├── Renders UI based on context data                   │
-│  ├── Binds fixed-name actions/getters                   │
-│  └── Zero knowledge of parent's store/business logic    │
-└─────────────────────────────────────────────────────────┘
-```
-
-## Shared Conventions
-
-All three protocols follow these rules:
-
-- **Context key** prefixed with `woocommerce` (e.g. `woocommerceSelectableItems`)
-- **`storeNamespace`** field on every context object — tells inner block which parent store to resolve `actions.*` / `state.*` against
-- **Fixed action & getter names** (not configurable via context fields) — inner blocks hardcode them
-- **TS contract interface** (`*ParentStore`) — parents assert conformance via `satisfies`
-- **Items-carrying contexts** (`selectableItems`, `removableItems`) — parent exposes items via a protocol-named getter (`state.selectableItems`, `state.removableItems`). Generic `state.items` is intentionally avoided so multiple protocols can coexist on the same store namespace without collision
-- **Nested `data-wp-interactive`** — inner blocks keep an outer scope under their own namespace (show-more and other presentational state) and nest an inner region under the parent namespace for `data-wp-each` + selection bindings. Presentational bindings on iterated items use cross-namespace `::` syntax back to the inner store (e.g. `data-wp-bind--hidden="<own-ns>::state.itemHidden"`). Inner store reads iteration context via `getContext(storeNamespace)` — no hardcoded parent reference
-- **SSR fallback via `data-wp-each-child`** — PHP renders the initially visible items (first `displayLimit`) once with `data-wp-each-child`, each carrying its own `data-wp-context` + live bindings so hydration wires them up. The template handles the remaining items client-side
-- **Inner block owns show-more** — default `displayLimit = 15`. Inner block exposes `state.itemHidden` (reads iteration `context.item.index` via cross-namespace `getContext(storeNamespace)`) and renders the show-more button. Parent never knows about show-more
-
-## Enforcement via TypeScript `satisfies`
-
-Every protocol ships a `*ParentStore` interface. Parent stores assert:
-
-```typescript
-import type { SelectableItemsParentStore } from '../../types/type-defs/selectable-items';
-// or: RemovableItemsParentStore, RangeInputParentStore
-
-myStore satisfies SelectableItemsParentStore;
-```
+| `woocommerceSelectableItems` | Select/deselect items | checkbox-list, chips |
+| `woocommerceRemovableItems` | Remove active items | removable-chips |
+| `woocommerceRangeInput` | Numeric range input | price-slider |

-Missing method/getter → compile error. No runtime cost.
+## Shared pattern

----
+- Parent provides protocol context and owns business state.
+- Inner block reads the protocol context, renders UI, and calls fixed parent actions.
+- Every context includes `storeNamespace`, which points to the parent Interactivity API store.
+- Parent stores expose protocol-scoped getters such as `state.selectableItems` or `state.removableItems`; generic `state.items` is avoided so protocols can coexist.
+- Parent stores should assert conformance with the matching `*ParentStore` TypeScript interface via `satisfies`.
+- Inner blocks may derive presentation-only data locally. Parent data should not include child-owned fields such as list indexes.
+- Server-rendered fallback items use `data-wp-each-child` with per-item `data-wp-context`; hydration reconciles them with the inner store.

-## Protocol: Selectable Items
+## Selectable Items

-### Context Key
+Context key: `woocommerceSelectableItems`

-```text
-woocommerceSelectableItems
-```
+Used by selectable list UIs such as checkbox-list and chips.

-Items are dynamic (computed at render time from database queries), so parent blocks do **not** use `providesContext` in block.json. Instead, they pass context directly when rendering inner blocks:
+Parents pass the context directly when rendering inner blocks because items are computed dynamically:

 ```php
-// Parent block render():
 ( new \WP_Block( $parsed_block, array(
     'woocommerceSelectableItems' => $context,
 ) ) )->render();
 ```

-In the editor, parent blocks use `BlockContextProvider` to pass the same data:
-
-```jsx
-<BlockContextProvider value={ { 'woocommerceSelectableItems': context } }>
-    { children }
-</BlockContextProvider>
-```
-
-#### Inner block.json (consumer)
-
-Inner blocks declare the context key they consume via `usesContext`, and which parents they can be nested inside via `ancestor`:
-
-```json
-{
-  "name": "woocommerce/product-filter-checkbox-list",
-  "usesContext": ["woocommerceSelectableItems"],
-  "ancestor": [
-    "woocommerce/product-filter-attribute",
-    "woocommerce/product-filter-status",
-    "woocommerce/product-filter-taxonomy",
-    "woocommerce/product-filter-rating"
-  ]
-}
-```
-
-Inner blocks receive the protocol data through `$block->context['woocommerceSelectableItems']` in PHP.
-
-### SelectableItemsContext
-
-The context object that parents MUST provide. Typed as `SelectableItemsContext<T>` where `T` is the extra fields the parent adds to each item (default: `unknown`).
-
-#### Core Fields (Required)
-
-| Field | Type | Required | Description |
-| --- | --- | --- | --- |
-| `items` | `SelectableItem<T>[]` | **Yes** | Items to render |
-| `selectionMode` | `'single' \| 'multiple'` | **Yes** | Selection behavior |
-| `storeNamespace` | `string` | **Yes** | Parent's Interactivity API store |
-
-#### Accessibility Fields (Optional)
-
-| Field | Type | Required | Description |
-| --- | --- | --- | --- |
-| `groupLabel` | `string` | No | Screen reader label for the group. Rendered as `<legend>` in fieldset. Example: "Filter by Color" |
-
-#### Presentation Fields (Optional)
-
-| Field | Type | Default | Description |
-| --- | --- | --- | --- |
-| `isLoading` | `boolean` | `false` | Parent is fetching items. Inner blocks show skeleton/loading state. |
-| `filterType` | `string` | `undefined` | Domain discriminator that inner blocks may use to vary presentation. For example, `'rating'` unlocks star rendering in `checkbox-list`. Values are parent-defined; unknown values fall back to text. |
-
-### SelectableItem
-
-`SelectableItem<T = unknown>` — base fields plus an optional generic extension `T` for domain-specific data.
-
-Each item in the `items` array MUST have:
-
-| Field | Type | Required | Description |
-| --- | --- | --- | --- |
-| `id` | `string` | **Yes** | Unique identifier for DOM element id. Format: `"{type}-{value}"` e.g. `"attribute-red"` |
-| `label` | `string \| HTML` | **Yes** | Display text or HTML (swatches, rating stars). HTML labels are emitted by the SSR `foreach` and preserved by `data-wp-each` via stable keys. |
-| `value` | `string` | **Yes** | Value for selection/submission |
-| `ariaLabel` | `string` | Conditional | **Required** if `label` contains HTML |
-| `selected` | `boolean` | No | Current selection state (default: false). SSR hint only — parent's `state.selectableItems` derives the live `selected` used for bindings. |
-| `disabled` | `boolean` | No | Whether item can be selected (default: false) |
-| `type` | `string` | No | Type discriminator (e.g., `"attribute/color"`) |
-
-Extra fields go in `T`. For product filters, `T = FilterItemFields`:
-
-```typescript
-type FilterItemFields = {
-  count: number;
-  termId?: number;
-  parent?: number;
-  depth?: number;
-  menuOrder?: number;
-};
-
-type FilterOptionItem = SelectableItem<FilterItemFields>;
-```
-
-Inner blocks typed against a specific `T` access extra fields type-safely. Built-in inner blocks ignore unknown fields.
+In the editor, use `BlockContextProvider` with the same key.

-### Parent Store Requirements
-
-The store registered under `storeNamespace` MUST expose:
-
-| Name | Kind | Contract |
-| --- | --- | --- |
-| `state.selectableItems` | getter | Returns iterable of items with `selected: boolean` and `index: number` derived. Items come from `getServerContext().items` (so they refresh after navigation) merged with the client SSOT (`activeFilters`). Reactive — re-evaluates when SSOT changes. |
-| `actions.toggle` | action | Toggles selection for the target item. Accepts an optional `item` argument (used when an inner block proxies the call via its own store); when omitted, falls back to `getContext().item`. Mutates parent's SSOT (e.g. `activeFilters`). |
-
-Fixed names (not configurable). The getter is `selectableItems` (not `items`) to avoid colliding with other protocols (`removableItems`, etc.) when multiple protocols live on the same store namespace.
-
-Inner blocks iterate this getter directly via `data-wp-each--item="state.selectableItems"` inside a region that nests `data-wp-interactive="<parent-ns>"` (so `context.item` is set under the parent namespace, matching where `actions.toggle` and `context.item.selected` expect it). See _Inner Block Own Store_ below.
-
-Enforcement via TypeScript contract:
+### Context

 ```typescript
-import type { SelectableItemsParentStore } from '../../types/type-defs/selectable-items';
-
-const myStore = {
-	state: { get selectableItems() { /* derive selected */ }, /* ... */ },
-	actions: { toggle: ( item? ) => { /* target item, mutate SSOT */ }, /* ... */ },
-};
-
-// Compile-time check — TS error if `state.selectableItems` or `actions.toggle` is missing/wrong-shaped.
-myStore satisfies SelectableItemsParentStore;
+export interface SelectableItemsContext< T = unknown > {
+	items: SelectableItem< T >[];
+	selectionMode: 'single' | 'multiple';
+	storeNamespace: string;
+	groupLabel?: string;
+	isLoading?: boolean;
+	filterType?: string;
+}
 ```

-### Selection State Model
-
-- **SSOT** lives in parent's domain state (e.g. `context.activeFilters` for filters).
-- **Items rendered via `data-wp-each`** iterating the parent's `state.selectableItems` inside a nested-namespace region. PHP `foreach` with `data-wp-each-child` + per-item `data-wp-context` (including `index`) provides an SSR fallback for the items visible on first paint (first `displayLimit`); the rest are rendered client-side via the template.
-- **`item.selected`** on raw items (in `getContext().items`) is only a PHP SSR hint. Parent's `state.selectableItems` re-derives `selected` from SSOT for the live binding source.
-- **`actions.toggle`** mutates SSOT only. Never touches raw `item.selected`. It reads `getContext().item` which is set by `data-wp-each` under the parent namespace (the items region switches to the parent namespace precisely so this works).
-
-External mutations (active-filter removal, cross-block sync) flow through automatically: mutate `activeFilters` → `state.selectableItems` re-evaluates → every `context.item.selected` binding updates across all blocks.
-
-### Rendering Rules
+- `items` is the SSR/editor snapshot.
+- `storeNamespace` points to the live parent store used after hydration.
+- `groupLabel` is used for accessible fieldset labels.
+- `isLoading` and `filterType` are optional rendering hints.

-Inner blocks SHOULD:
-
-1. Render `<input type="radio">` when `selectionMode === 'single'`
-2. Render `<input type="checkbox">` when `selectionMode === 'multiple'`
-3. Set `disabled` attribute and apply reduced-opacity styling when `item.disabled === true`
-4. Use `groupLabel` for fieldset legend (screen reader accessible)
-5. Show skeleton/loading UI when `isLoading === true`
-6. Show items up to `displayLimit` (default 15), render show-more button when exceeded
-
-Inner blocks typed against `FilterItemFields` MAY additionally:
-
-1. Show counts when `item.count` exists
-
----
-
-## Design Rationale
-
-### Generic Extension Pattern
-
-`SelectableItem<T>` uses a generic parameter instead of a flat union of optional fields:
-
-- Base fields are shared by all consumers (id, label, value, selected, disabled, type)
-- Domain-specific fields live in `T` — typed, not untyped `[key: string]: unknown`
-- Filter blocks use `FilterOptionItem = SelectableItem<FilterItemFields>` with count, parent, depth, etc.
-- A variation selector would use `SelectableItem<{ price?: string; stockStatus?: string }>` etc.
-- TypeScript enforces correct shape at each call site with no extra runtime cost
-
-### Backward Compatibility
-
-`SelectableItem<T>` replaces the old flat `FilterOptionItem`. Key changes:
-
-| Old `FilterOptionItem` | New `SelectableItem<FilterItemFields>` |
-| --- | --- |
-| `id?: number` (optional, number) | `id: string` (required, string — used for DOM element id) |
-| `count: number` (required) | `count: number` in `FilterItemFields` (required for filters, absent for other consumers) |
-| No `disabled` | `disabled?: boolean` on base type |
-| No `type` | `type?: string` on base type |
-
----
-
-## Type Definitions
-
-This section provides copy-paste-ready type definitions for both TypeScript and PHP. These definitions enforce the protocol specification above.
-
-### TypeScript
-
-Location: `assets/js/types/type-defs/selectable-items.ts`
+### Item shape

 ```typescript
-import type { ReactNode } from 'react';
-
 export type SelectableItem< T = unknown > = (
 	| { label: string; ariaLabel?: string }
 	| { label: ReactNode; ariaLabel: string }
 ) & {
-	/** Unique key for DOM element id. Format: "{type}-{value}" */
 	id: string;
 	value: string;
 	selected?: boolean;
 	disabled?: boolean;
+	hidden?: boolean;
 	type?: string;
 } & T;
+```

-export interface SelectableItemsContext< T = unknown > {
-	items: SelectableItem< T >[];
-	selectionMode: 'single' | 'multiple';
-	storeNamespace: string;
-	groupLabel?: string;
-	isLoading?: boolean;
-	filterType?: string;
-}
+`hidden` is optional protocol-level visibility metadata. Extra data belongs in `T`. Children may read optional extra fields, but missing fields must degrade safely.

-export type SelectableItemsBlockContext< T = unknown > = {
-	'woocommerceSelectableItems': SelectableItemsContext< T >;
-};
+### Parent store

+```typescript
 export interface SelectableItemsParentStore< T = unknown > {
 	state: {
 		selectableItems: readonly SelectableItem< T >[];
@@ -294,438 +85,114 @@ export interface SelectableItemsParentStore< T = unknown > {
 }
 ```

-Filter blocks extend with `FilterItemFields` (from `product-filters/types.ts`):
+Rules:
+
+- `state.selectableItems` is the live source after hydration.
+- `actions.toggle( item? )` updates the parent source of truth.
+- `hidden` may be provided by parents or derived by children to hide an item without removing it from the collection.
+- Parent must not add child-owned fields such as `index`.
+
+### Built-in consumers
+
+Product filters currently add these optional fields:

 ```typescript
 export type FilterItemFields = {
-	count: number;
+	count?: number;
 	termId?: number;
 	parent?: number;
 	depth?: number;
 	menuOrder?: number;
+	attributeQueryType?: 'and' | 'or';
+	color?: string;
 };
-
-export type FilterOptionItem = SelectableItem< FilterItemFields >;
-```
-
-Inner blocks are typed via `SelectableItemsBlockContext<FilterItemFields>`:
-
-```typescript
-// In checkbox-list/types.ts or chips/types.ts
-export type EditProps = BlockEditProps< BlockAttributes > & {
-	context: SelectableItemsBlockContext< FilterItemFields >;
-	// ...color props
-};
-```
-
-### PHP
-
-No base class or trait needed — parent blocks set `$block->context` directly. PHPStan type aliases (defined below) enforce the structure at CI time.
-
-```php
-class ProductFilterAttribute extends AbstractBlock {
-
-    protected function render( $attributes, $content, $block ) {
-        $show_counts = $attributes['showCounts'] ?? false;
-
-        /** @var SelectableItemsContext $context */
-        $context = [
-            // Items include 'count' only when $show_counts is true
-            'items'          => $this->transform_to_selectable_items( $filter_items, $show_counts ),
-            'selectionMode'  => 'multiple',
-            'storeNamespace' => 'woocommerce/product-filters',
-            'groupLabel'     => $attributes['label'] ?? '',
-        ];
-
-        $block->context['woocommerceSelectableItems'] = $context;
-
-        return sprintf(
-            '<div %s>%s</div>',
-            get_block_wrapper_attributes( [
-                'data-wp-interactive' => 'woocommerce/product-filters',
-            ] ),
-            $content
-        );
-    }
-}
-```
-
-#### PHPStan Type (for static analysis)
-
-Add to a dedicated types file (e.g. `phpstan.neon` or a project-level config). Do **not** add type aliases to `phpstan-baseline.neon` — the baseline is reserved for suppressing existing errors:
-
-```neon
-parameters:
-  typeAliases:
-    SelectableItem: '''
-      array{
-        id: string,
-        label: string,
-        value: string,
-        ariaLabel?: string,
-        selected?: bool,
-        disabled?: bool,
-        type?: string
-      }
-    '''
-    FilterSelectableItem: '''
-      array{
-        id: string,
-        label: string,
-        value: string,
-        ariaLabel?: string,
-        selected?: bool,
-        disabled?: bool,
-        type?: string,
-        count: int,
-        termId?: int,
-        parent?: int,
-        depth?: int,
-        menuOrder?: int
-      }
-    '''
-    SelectableItemsContext: '''
-      array{
-        items: list<SelectableItem>,
-        selectionMode: 'single'|'multiple',
-        storeNamespace: string,
-        groupLabel?: string,
-        isLoading?: bool
-      }
-    '''
-```
-
----
-
-## Implementation Guide
-
-### Implementing as Inner Block (Consumer)
-
-Inner blocks consume the protocol. They render items using a `data-wp-each` template plus a PHP `foreach` SSR fallback, and reuse the parent's store via `storeNamespace` from context for selection bindings.
-
-block.json:
-
-```json
-{
-  "name": "woocommerce/product-filter-checkbox-list",
-  "usesContext": ["woocommerceSelectableItems"],
-  "supports": {
-    "interactivity": true
-  }
-}
-```
-
-**frontend.ts** — Inner blocks need no frontend JS for selection. Selection action (`actions.toggle`) and live `context.item.selected` binding are provided by the parent store.
-
-#### Inner Block Own Store
-
-Inner blocks own presentational state (show-more toggle, per-item visibility, rendering variants). The parent store never learns about inner-block UI concerns. Two patterns are supported depending on how much control the inner block needs over items:
-
-**A. Direct (recommended default)** — iterate the parent's `state.selectableItems` via a nested `data-wp-interactive="<parent-ns>"` on the items region. `context.item` is set under the parent namespace, so `actions.toggle` and `context.item.selected` resolve directly. Presentational bindings use cross-namespace `::` back to the inner store. Simplest to reason about; reactive out of the box. Used by `checkbox-list` and `chips`.
-
-**B. Mirror** — inner store exposes its own `state.items` that wraps `store(storeNamespace).state.selectableItems` (mapping in extra fields like `hidden`) and its own `actions.toggle` that forwards to the parent. The whole template stays under the inner namespace — no nested scope switching. Choose this when the inner block needs to derive per-iteration data that doesn't belong to the parent contract (custom grouping, pre-sorting, enriched item shapes), or when binding values must resolve without cross-namespace syntax. Cross-store reactivity still works because signals chain through `store(ns).state.x`, but the extra indirection is the trade-off.
-
-Both patterns share the same outer wrapper and protocol plumbing. The walkthrough below uses pattern **A** (direct):
-
-- The **outer wrapper** runs in the inner namespace and carries `data-wp-context` with `storeNamespace` + `displayLimit` so the inner store can read them at runtime.
-- The **items region** nests a `data-wp-interactive="<parent-ns>"` so `data-wp-each--item="state.selectableItems"`, `data-wp-on--change="actions.toggle"`, and `data-wp-bind--checked="context.item.selected"` all resolve in the parent store (no cross-namespace syntax inside the iteration, `context.item` is set under the parent namespace which is what `state.selectableItems` and `actions.toggle` expect).
-- **Presentational bindings** that belong to the inner block use cross-namespace syntax back to the inner store: `data-wp-bind--hidden="<own-ns>::state.itemHidden"`, `data-wp-bind--style="<own-ns>::state.ratingStyle"`.
-- The **show-more button** lives outside the items region — under the inner namespace — and binds `data-wp-on--click="actions.showAll"` + `data-wp-bind--hidden="context.isExpanded"` directly.
-
-The inner store derives per-item fields from the iteration `context.item` provided by `data-wp-each`. Since that context is in the parent namespace, the inner getter uses dynamic `getContext(storeNamespace)` — no hardcoded parent reference, keeping the inner block reusable against any parent store.
-
-If pattern **B** (mirror) is chosen, the items region stays under the inner namespace (no nested `data-wp-interactive`), template iterates `data-wp-each--item="state.items"` (inner store), bindings read `context.item.selected` / `context.item.hidden` (fields added by the inner mirror), and the inner store defines `actions.toggle` that forwards to `store(storeNamespace).actions.toggle(item)`. Parent contract `actions.toggle` can accept an optional `item` argument when it expects to be invoked via a mirror. Other sections below stay the same.
-
-```html
-<div
-  data-wp-interactive="woocommerce/product-filter-checkbox-list"
-  data-wp-context='{"storeNamespace":"woocommerce/product-filters","displayLimit":15}'
->
-  <fieldset>
-    <!-- Items region: nested into the parent namespace so wp-each + actions resolve to the parent. -->
-    <div data-wp-interactive="woocommerce/product-filters">
-      <template data-wp-each--item="state.selectableItems" data-wp-each-key="context.item.id">
-        <div data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden">
-          <input
-            data-wp-bind--checked="context.item.selected"
-            data-wp-on--change="actions.toggle"
-          >
-          <span data-wp-text="context.item.label"></span>
-        </div>
-      </template>
-      <!-- foreach SSR fallback below: first N items only, with data-wp-each-child + per-item data-wp-context including `index`. -->
-    </div>
-    <button
-      data-wp-on--click="actions.showAll"
-      data-wp-bind--hidden="context.isExpanded"
-    >
-      Show more
-    </button>
-  </fieldset>
-</div>
-```
-
-```typescript
-// frontend.ts
-import { store, getContext } from '@wordpress/interactivity';
-import type { SelectableItem } from '../../../../types/type-defs/selectable-items';
-
-type ItemWithIndex = SelectableItem & { index: number };
-
-type CheckboxListContext = {
-    storeNamespace: string;
-    displayLimit: number;
-    isExpanded: boolean;
-};
-
-type ParentItemContext = {
-    item?: ItemWithIndex;
-};
-
-const { state } = store( 'woocommerce/product-filter-checkbox-list', {
-    state: {
-        get itemHidden(): boolean {
-            const { isExpanded, storeNamespace, displayLimit } =
-                getContext< CheckboxListContext >();
-            if ( isExpanded ) return false;
-            // Cross-namespace context read: pulls the wp-each iteration
-            // `context.item` (stored under the parent ns) without
-            // hardcoding the parent namespace.
-            const parentCtx =
-                getContext< ParentItemContext >( storeNamespace );
-            if ( ! parentCtx.item ) return false;
-            return parentCtx.item.index >= displayLimit;
-        },
-    },
-    actions: {
-        showAll() {
-            const context = getContext< CheckboxListContext >();
-            context.isExpanded = true;
-        },
-    },
-}, { lock: true } );
-```
-
-**Why nested namespaces:** `data-wp-each` stores `context.item` under the namespace active at the `<template>` element. Parent's `actions.toggle` and `context.item.selected` resolve under the parent namespace, so the items region must switch to it. Presentational bindings (`itemHidden`, `ratingStyle`) live in the inner store and use cross-namespace `::` syntax to read iteration context through `getContext(storeNamespace)`.
-
-**Why protocol-specific getter names (`selectableItems` / `removableItems`):** multiple protocols frequently share the same store namespace (e.g. `woocommerce/product-filters` hosts both the selectable-items store and the active-filters removable-items store). A generic `state.items` name collides across protocols and silently overrides. Protocol-aligned names (`selectableItems`, `removableItems`) make both live on the same store without interference.
-
-**PHP Renderer** — Template for `data-wp-each` plus `foreach` for SSR of the first `displayLimit` items. Each SSR item carries its `index` in `data-wp-context` so the inner store's `itemHidden` getter can decide visibility on hydration.
-
-```php
-protected function render( $attributes, $content, $block ) {
-    if ( empty( $block->context['woocommerceSelectableItems'] ) ) {
-        return '';
-    }
-
-    $block_context   = $block->context['woocommerceSelectableItems'];
-    $items           = $block_context['items'] ?? array();
-    $store_namespace = $block_context['storeNamespace'] ?? 'woocommerce/product-filters';
-    $display_limit   = 15;
-    $visible_items   = array_slice( $items, 0, $display_limit, true );
-    $has_more_items  = count( $items ) > $display_limit;
-
-    $wrapper_attributes = array(
-        'data-wp-interactive' => 'woocommerce/product-filter-checkbox-list',
-        'data-wp-context'     => wp_json_encode( array(
-            'storeNamespace' => $store_namespace,
-            'displayLimit'   => $display_limit,
-        ) ),
-    );
-
-    ob_start();
-    ?>
-    <div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); ?>>
-        <fieldset>
-            <div data-wp-interactive="<?php echo esc_attr( $store_namespace ); ?>">
-                <template data-wp-each--item="state.selectableItems" data-wp-each-key="context.item.id">
-                    <div data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden">
-                        <input
-                            type="checkbox"
-                            data-wp-bind--id="context.item.id"
-                            data-wp-bind--value="context.item.value"
-                            data-wp-bind--checked="context.item.selected"
-                            data-wp-on--change="actions.toggle"
-                        >
-                        <span data-wp-text="context.item.label"></span>
-                    </div>
-                </template>
-                <?php foreach ( $visible_items as $index => $item ) :
-                    $context_item = array_merge( $item, array( 'index' => $index ) );
-                    ?>
-                    <div
-                        data-wp-each-child
-                        <?php echo wp_interactivity_data_wp_context( array( 'item' => $context_item ) ); ?>
-                        data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden"
-                    >
-                        <input
-                            type="checkbox"
-                            id="<?php echo esc_attr( $item['id'] ); ?>"
-                            value="<?php echo esc_attr( $item['value'] ); ?>"
-                            <?php checked( ! empty( $item['selected'] ) ); ?>
-                            data-wp-bind--checked="context.item.selected"
-                            data-wp-on--change="actions.toggle"
-                        >
-                        <span><?php echo esc_html( $item['label'] ); ?></span>
-                    </div>
-                <?php endforeach; ?>
-            </div>
-            <?php if ( $has_more_items ) : ?>
-                <button
-                    data-wp-on--click="actions.showAll"
-                    data-wp-bind--hidden="context.isExpanded"
-                >
-                    Show more
-                </button>
-            <?php endif; ?>
-        </fieldset>
-    </div>
-    <?php
-    return ob_get_clean();
-}
 ```

-Key points:
-
-- **`data-wp-each` template + `foreach` SSR fallback for first `displayLimit` items** — the template renders the rest client-side, so the initial HTML stays small while the full list is still available post-hydration
-- **Per-item `data-wp-context` on SSR items includes `index`** — the inner store's `state.itemHidden` reads it via `getContext(storeNamespace).item.index` to decide visibility; hydration then attaches live bindings (`checked`, `hidden`, `toggle`) to the exact DOM wp-each reconciles
-- **Nested `data-wp-interactive`** — outer wrapper under the inner namespace, items region switches to the parent namespace so wp-each + parent selection bindings resolve there; presentational bindings (`itemHidden`, `ratingStyle`) use cross-namespace `::` back to the inner store
-- **`filterType` discriminator** — inner block can branch rendering (e.g. stars for `'rating'`) without leaking presentation into the parent store
-
-Reference implementation: `ProductFilterCheckboxList.php`, `ProductFilterChips.php`, `checkbox-list/frontend.ts`, `chips/frontend.ts`
-
-### Implementing as Parent Block (Provider)
-
-Parent blocks provide the protocol context to their inner blocks.
-
-#### Filter Example (ProductFilterAttribute.php)
-
-```php
-class ProductFilterAttribute extends AbstractBlock {
-
-    protected function render($attributes, $content, $block) {
-        // ... existing filter logic to get items ...
-        $show_counts = $attributes['showCounts'] ?? false;
-
-        // Transform filter items to standardized context
-        $selectable_context = [
-            'items'          => $this->transform_to_selectable_items($filter_items, $attribute_name, $show_counts),
-            'selectionMode'  => 'multiple',
-            'storeNamespace' => 'woocommerce/product-filters',
-            'groupLabel'     => $attribute_label,
-        ];
-
-        // Provide context to inner blocks
-        $block->context['woocommerceSelectableItems'] = $selectable_context;
-
-        // Render inner blocks
-        return sprintf(
-            '<div %s>%s</div>',
-            get_block_wrapper_attributes([
-                'data-wp-interactive' => 'woocommerce/product-filters',
-            ]),
-            $content
-        );
-    }
-}
-```
+| Consumer | Optional fields read | Fallback |
+| --- | --- | --- |
+| `checkbox-list` | `count`, `color`, `depth`, `filterType === 'rating'` | Text label, no count, no swatch, no indent |
+| `chips` | `count`, `color` | Text label, no count, no swatch |

----
+Checkbox-list and chips mirror parent items into child `state.items`, adding local `index` for show-more and setting `hidden` when an item should be hidden. Their templates bind overflow visibility with `context.item.hidden`.

-## Protocol: Removable Items
+## Removable Items

 Context key: `woocommerceRemovableItems`

-Used for lists of items that can be removed individually (active filter chips) with a "clear all" control.
+Used by active-filter chips and similar removable item lists.

-### Context Shape
+### Context

 ```typescript
 export interface RemovableItem {
-  type: string;   // domain discriminator (e.g. "attribute/color", "price")
-  value: string;
-  label: string;  // display text
+	type: string;
+	value: string;
+	label: string;
 }

 export interface RemovableItemsContext {
-  items: RemovableItem[];   // SSR snapshot — parent's state.removableItems is SSOT post-hydration
-  storeNamespace: string;
+	items: RemovableItem[];
+	storeNamespace: string;
 }
 ```

-### Parent Store Requirements
+### Parent store

 ```typescript
 export interface RemovableItemsParentStore {
-  state: {
-    removableItems: readonly RemovableItem[];   // derived from parent's SSOT; reactive
-  };
-  actions: {
-    remove: () => void;                          // remove current getContext().item
-    removeAll: () => void;                       // clear all items
-  };
+	state: {
+		removableItems: readonly RemovableItem[];
+	};
+	actions: {
+		remove: () => void;
+		removeAll: () => void;
+	};
 }
 ```

-The getter is `removableItems` (not `items`) for the same reason `selectableItems` is protocol-scoped — multiple protocols (removable-items + selectable-items) routinely live on the same store namespace (e.g. `woocommerce/product-filters`).
-
-Parents assert: `myStore satisfies RemovableItemsParentStore;`
+Rendering pattern:

-### Rendering Pattern
+- Inner block iterates `state.removableItems` from the parent store.
+- SSR fallback renders `context.items` with `data-wp-each-child` and per-item context.
+- Per-item remove calls `actions.remove`; clear-all calls `actions.removeAll`.

-Inner block (`removable-chips`):
+Reference implementations: `ProductFilterRemovableChips.php`, `ProductFilterClearButton.php`, `inner-blocks/active-filters/frontend.ts`.

-- Wrap in `data-wp-interactive="<storeNamespace>"`
-- Iterate `state.removableItems` via `data-wp-each` for reactive rendering (items can be added/removed dynamically)
-- SSR fallback: `foreach` over `context.items` with per-item `data-wp-context` and `data-wp-each-child`
-- Per-item binding: `data-wp-on--click="actions.remove"`, label via `data-wp-text="context.item.label"`
-- Clear-all button: `data-wp-on--click="actions.removeAll"`
-
-Reference implementation: `ProductFilterRemovableChips.php`, `ProductFilterClearButton.php`, `inner-blocks/active-filters/frontend.ts`.
-
----
-
-## Protocol: Range Input
+## Range Input

 Context key: `woocommerceRangeInput`

-Used for two-ended numeric range controls (price slider, generic range).
+Used by two-ended numeric controls such as price sliders.

-### Context Shape
+### Context

 ```typescript
 export interface RangeInputContext {
-  min: number;
-  max: number;
-  currentMin: number;
-  currentMax: number;
-  step?: number;
-  storeNamespace: string;
-  isLoading?: boolean;
+	min: number;
+	max: number;
+	currentMin: number;
+	currentMax: number;
+	step?: number;
+	storeNamespace: string;
+	isLoading?: boolean;
 }
 ```

-### Parent Store Requirements
+### Parent store

 ```typescript
 export interface RangeInputParentStore {
-  actions: {
-    setMin: ( event: Event ) => void;
-    setMax: ( event: Event ) => void;
-  };
+	actions: {
+		setMin: ( event: Event ) => void;
+		setMax: ( event: Event ) => void;
+	};
 }
 ```

-Generic names (`setMin`/`setMax`) — not price-specific — so the protocol can host non-price range inputs in the future. Parents assert: `myStore satisfies RangeInputParentStore;`
-
-### Rendering Pattern
-
-Inner block (`price-slider`):
+Rendering pattern:

-- Wrap in `data-wp-interactive="<storeNamespace>"`
-- Two `<input type="range">`, one per bound
-- Min input: `data-wp-on--input="actions.setMin"`, `data-wp-bind--value="state.<minGetter>"` (parent decides getter — e.g. `state.minPrice`)
-- Max input: `data-wp-on--input="actions.setMax"`, analogous for max
-- Parent owns display formatting (currency, locale) via its own state getters
+- Inner block renders min/max inputs.
+- Inputs call parent `actions.setMin` and `actions.setMax`.
+- Parent owns formatting, validation, and display state.

-Reference implementation: `ProductFilterPriceSlider.php`, `inner-blocks/price-filter/frontend.ts`, `inner-blocks/price-slider/frontend.ts`.
+Reference implementations: `ProductFilterPriceSlider.php`, `inner-blocks/price-filter/frontend.ts`, `inner-blocks/price-slider/frontend.ts`.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
index c5561e88bed..624bc998a54 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
@@ -94,19 +94,15 @@ final class ProductFilterCheckboxList extends AbstractBlock {
 				<?php if ( ! empty( $block_context['groupLabel'] ) ) : ?>
 					<legend class="screen-reader-text"><?php echo esc_html( $block_context['groupLabel'] ); ?></legend>
 				<?php endif; ?>
-				<div
-					class="wc-block-product-filter-checkbox-list__items"
-					data-wp-interactive="<?php echo esc_attr( $store_namespace ); ?>"
-				>
+				<div class="wc-block-product-filter-checkbox-list__items">
 					<?php
-					foreach ( $visible_items as $index => $item ) :
-						$context_item = array_merge( $item, array( 'index' => $index ) );
+					foreach ( $visible_items as $item ) :
 						?>
 						<div
 							class="wc-block-product-filter-checkbox-list__item"
 							data-wp-each-child
-							<?php echo wp_interactivity_data_wp_context( array( 'item' => $context_item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
-							data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden"
+							<?php echo wp_interactivity_data_wp_context( array( 'item' => $item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+							data-wp-bind--hidden="context.item.hidden"
 						>
 							<label
 								class="wc-block-product-filter-checkbox-list__label"
@@ -164,12 +160,12 @@ final class ProductFilterCheckboxList extends AbstractBlock {
 						</div>
 					<?php endforeach; ?>
 					<template
-						data-wp-each--item="state.selectableItems"
+						data-wp-each--item="state.items"
 						data-wp-each-key="context.item.id"
 					>
 						<div
 							class="wc-block-product-filter-checkbox-list__item"
-							data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden"
+							data-wp-bind--hidden="context.item.hidden"
 						>
 							<label
 								class="wc-block-product-filter-checkbox-list__label"
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
index 3d6adddff27..a0a87c1add5 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
@@ -90,13 +90,9 @@ final class ProductFilterChips extends AbstractBlock {
 				<?php if ( ! empty( $block_context['groupLabel'] ) ) : ?>
 					<legend class="screen-reader-text"><?php echo esc_html( $block_context['groupLabel'] ); ?></legend>
 				<?php endif; ?>
-				<div
-					class="wc-block-product-filter-chips__items"
-					data-wp-interactive="<?php echo esc_attr( $store_namespace ); ?>"
-				>
+				<div class="wc-block-product-filter-chips__items">
 					<?php
-					foreach ( $visible_items as $index => $item ) :
-						$context_item = array_merge( $item, array( 'index' => $index ) );
+					foreach ( $visible_items as $item ) :
 						?>
 						<button
 							class="wc-block-product-filter-chips__item"
@@ -110,10 +106,10 @@ final class ProductFilterChips extends AbstractBlock {
 							aria-checked="<?php echo ! empty( $item['selected'] ) ? 'true' : 'false'; ?>"
 							<?php disabled( ! empty( $item['disabled'] ) ); ?>
 							data-wp-each-child
-							<?php echo wp_interactivity_data_wp_context( array( 'item' => $context_item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+							<?php echo wp_interactivity_data_wp_context( array( 'item' => $item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
 							data-wp-bind--aria-checked="context.item.selected"
 							data-wp-bind--disabled="context.item.disabled"
-							data-wp-bind--hidden="woocommerce/product-filter-chips::state.itemHidden"
+							data-wp-bind--hidden="context.item.hidden"
 							data-wp-on--click="actions.toggle"
 						>
 							<span class="wc-block-product-filter-chips__label">
@@ -138,7 +134,7 @@ final class ProductFilterChips extends AbstractBlock {
 						</button>
 					<?php endforeach; ?>
 					<template
-						data-wp-each--item="state.selectableItems"
+						data-wp-each--item="state.items"
 						data-wp-each-key="context.item.id"
 					>
 						<button
@@ -150,7 +146,7 @@ final class ProductFilterChips extends AbstractBlock {
 							data-wp-bind--value="context.item.value"
 							data-wp-bind--aria-checked="context.item.selected"
 							data-wp-bind--disabled="context.item.disabled"
-							data-wp-bind--hidden="woocommerce/product-filter-chips::state.itemHidden"
+							data-wp-bind--hidden="context.item.hidden"
 							data-wp-on--click="actions.toggle"
 						>
 							<span class="wc-block-product-filter-chips__label">