Commit 71c60887f74 for woocommerce

commit 71c60887f74bbdad00e81953ba35dff3a66753c1
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 1 13:26:07 2026 +0300

    Fix Variation Selector querying the Store API for placeholder terms (#68188)

    * fix(blocks): stop Variation Selector querying placeholder terms

    The Variation Selector attribute template renders a set of hardcoded
    placeholder attributes whenever no variable product is in context, so
    the editor has something to preview. Those placeholders are not in the
    database.

    Term fetching was guarded by term ID polarity: `edit.tsx` filtered the
    requested IDs to positive values, treating a negative ID as "this term
    is not real". The Color placeholders honoured that convention, but the
    Size placeholders still carried the positive IDs 1, 2 and 3. Those
    survived the filter, so previewing the block issued a real Store API
    request for attribute 2, terms 1-3 - arbitrary real records in the
    merchant's store. Any visual data returned was then painted onto the
    fake Small/Medium/Large chips.

    Renumber the Size placeholder terms to -4, -5 and -6 so every fallback
    term honours the convention the filter already enforces. The guard was
    never wrong, only the data violated it, so `edit.tsx` needs no change
    and the component's props are untouched. Cover both halves with tests:
    that the fallback data stays negative, and that the editor issues no
    request while showing placeholders yet still queries for a real
    attribute.

    Bug introduced in PR #65347, which added the fetch and the polarity
    filter. PR #65180 introduced the negative-ID convention and applied it
    to Color only.

    Refs #65347

    * docs(blocks): cover both placeholder-query outcomes in the changelog

    The changelog entry described only one of the two pre-fix outcomes: a
    store that owned terms 1-3 under global attribute 2 saw those terms
    painted onto the placeholder Size chips.

    That is the narrower case. It needs attribute 2 to exist and to own
    those exact term IDs. A fresh install has no global attributes at all,
    so the Store API returned 404 for the placeholder request,
    useCollection threw into BlockErrorBoundary, and the editor showed
    "This block has encountered an error and cannot be previewed." The
    more common and more severe outcome went unmentioned.

    Reword the entry to cover both, so the release notes describe the
    failure a merchant is most likely to have hit.

    * test(blocks): type-check the variation selector edit fixture

    The edit test built its block props as an object literal cast straight
    to the component's prop type with `as unknown as`. That cast is needed,
    because the fixture deliberately omits `isSelected`, `context` and
    `className`. But it also switched off checking for the props the
    fixture does supply: a wrong type, an invalid enum value or a
    misspelled key inside `attributes` all compiled clean.

    Bind the literal to a `satisfies Pick<...>` first and keep the widening
    cast on the use site. Structural checking is restored for the three
    props the fixture provides, while the omissions stay allowed.

    Verified by mutation: `autoselect: 'false'`, an invalid
    `disabledAttributesAction: 'hidden'` and a misspelled `autoselct` key
    now each fail to compile, where the previous form reported no error for
    any of them.

diff --git a/plugins/woocommerce/changelog/fix-variation-selector-placeholder-terms b/plugins/woocommerce/changelog/fix-variation-selector-placeholder-terms
new file mode 100644
index 00000000000..398de8b144d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-variation-selector-placeholder-terms
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent Variation Selector placeholders from querying the Store API, which could break the editor preview or display unrelated attribute terms.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
index 6cd800f2740..e9188d4f57e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
@@ -46,6 +46,11 @@ export const ATTRIBUTE_ITEM_TEMPLATE: TemplateArray = [
 	],
 ] as const;

+/**
+ * Placeholder attributes rendered in the editor when no variable product is in
+ * context. None of these exist in the database, so term IDs are negative: the
+ * term ID filter in `edit.tsx` relies on that to skip the Store API request.
+ */
 export const DEFAULT_ATTRIBUTES = [
 	{
 		id: 1,
@@ -64,9 +69,9 @@ export const DEFAULT_ATTRIBUTES = [
 		name: __( 'Size', 'woocommerce' ),
 		has_variations: true,
 		terms: [
-			{ id: 1, slug: 'sm', name: __( 'Small', 'woocommerce' ) },
-			{ id: 2, slug: 'md', name: __( 'Medium', 'woocommerce' ) },
-			{ id: 3, slug: 'lg', name: __( 'Large', 'woocommerce' ) },
+			{ id: -4, slug: 'sm', name: __( 'Small', 'woocommerce' ) },
+			{ id: -5, slug: 'md', name: __( 'Medium', 'woocommerce' ) },
+			{ id: -6, slug: 'lg', name: __( 'Large', 'woocommerce' ) },
 		],
 	},
 ] as const;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/test/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/test/constants.ts
new file mode 100644
index 00000000000..8c6051a46f1
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/test/constants.ts
@@ -0,0 +1,23 @@
+/**
+ * Internal dependencies
+ */
+import { DEFAULT_ATTRIBUTES } from '../constants';
+
+describe( 'variation selector fallback attributes', () => {
+	it( 'uses non-persisted term IDs for editor preview data', () => {
+		const termIds = DEFAULT_ATTRIBUTES.flatMap( ( attribute ) =>
+			attribute.terms.map( ( term ) => term.id )
+		);
+
+		expect( termIds ).not.toHaveLength( 0 );
+		expect( termIds.every( ( termId ) => termId < 0 ) ).toBe( true );
+	} );
+
+	it( 'keeps preview term IDs unique across attributes', () => {
+		const termIds = DEFAULT_ATTRIBUTES.flatMap( ( attribute ) =>
+			attribute.terms.map( ( term ) => term.id )
+		);
+
+		expect( new Set( termIds ).size ).toBe( termIds.length );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/test/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/test/edit.tsx
new file mode 100644
index 00000000000..8aa388053e3
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/test/edit.tsx
@@ -0,0 +1,133 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import * as hooks from '@woocommerce/base-context/hooks';
+import type { ComponentProps, ReactNode } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import AttributeItemTemplateEdit from '../edit';
+import { DEFAULT_ATTRIBUTES } from '../constants';
+
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+	__esModule: true,
+	...jest.requireActual( '@woocommerce/base-context/hooks' ),
+} ) );
+
+jest.mock( '@wordpress/block-editor', () => ( {
+	...jest.requireActual( '@wordpress/block-editor' ),
+	BlockContextProvider: ( { children }: { children: ReactNode } ) => (
+		<div>{ children }</div>
+	),
+	InspectorControls: () => null,
+	useBlockProps: jest.fn( () => ( {} ) ),
+	useInnerBlocksProps: jest.fn( () => ( {} ) ),
+	__experimentalUseBlockPreview: jest.fn( () => ( {} ) ),
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	// Runs the selector against a stub store that only answers `getBlocks`. Any
+	// other `useSelect` consumer pulled into this tree then fails loudly instead
+	// of silently receiving this component's return value.
+	useSelect: jest.fn(
+		( mapSelect: ( select: ( store: unknown ) => unknown ) => unknown ) =>
+			mapSelect( () => ( { getBlocks: () => [] } ) )
+	),
+} ) );
+
+jest.mock( '@woocommerce/shared-context', () => ( {
+	...jest.requireActual( '@woocommerce/shared-context' ),
+	useProductDataContext: jest.fn(),
+} ) );
+
+const sharedContext = jest.requireMock( '@woocommerce/shared-context' );
+
+type EditProps = ComponentProps< typeof AttributeItemTemplateEdit >;
+
+// Checked against the props it actually supplies, so a typo inside
+// `attributes` still fails to compile. The widening cast below is only there
+// because the fixture deliberately omits `isSelected`, `context` and
+// `className`.
+const editProps = {
+	attributes: {
+		displayStyle: 'woocommerce/product-filter-chips',
+		autoselect: false,
+		disabledAttributesAction: 'disable',
+	},
+	setAttributes: jest.fn(),
+	clientId: 'test-client-id',
+} satisfies Pick< EditProps, 'attributes' | 'setAttributes' | 'clientId' >;
+
+const renderEdit = () =>
+	render(
+		<AttributeItemTemplateEdit
+			{ ...( editProps as unknown as EditProps ) }
+		/>
+	);
+
+describe( 'Variation Selector attribute template edit', () => {
+	let useCollectionSpy: jest.SpyInstance;
+
+	beforeEach( () => {
+		useCollectionSpy = jest
+			.spyOn( hooks, 'useCollection' )
+			.mockReturnValue( { results: [], isLoading: false } );
+	} );
+
+	afterEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'does not query the Store API while showing placeholder attributes', () => {
+		sharedContext.useProductDataContext.mockReturnValue( { product: {} } );
+
+		renderEdit();
+
+		const calls = useCollectionSpy.mock.calls.map( ( [ args ] ) => args );
+
+		calls.forEach( ( args ) => {
+			expect( args.shouldSelect ).toBe( false );
+		} );
+		// Every placeholder attribute reached the hook, so the assertion above
+		// cannot pass by never running.
+		expect(
+			new Set( calls.map( ( args ) => args.resourceValues[ 0 ] ) )
+		).toEqual(
+			new Set( DEFAULT_ATTRIBUTES.map( ( attribute ) => attribute.id ) )
+		);
+	} );
+
+	it( 'queries the Store API for a real variable product attribute', () => {
+		sharedContext.useProductDataContext.mockReturnValue( {
+			product: {
+				id: 15,
+				name: 'Hoodie',
+				type: 'variable',
+				attributes: [
+					{
+						id: 4,
+						taxonomy: 'pa_color',
+						name: 'Color',
+						has_variations: true,
+						terms: [
+							{ id: 27, slug: 'blue', name: 'Blue' },
+							{ id: 28, slug: 'red', name: 'Red' },
+						],
+					},
+				],
+			},
+		} );
+
+		renderEdit();
+
+		expect( useCollectionSpy ).toHaveBeenCalled();
+		useCollectionSpy.mock.calls.forEach( ( [ args ] ) => {
+			expect( args.shouldSelect ).toBe( true );
+			expect( args.resourceValues ).toEqual( [ 4 ] );
+			expect( args.query.include ).toEqual( [ 27, 28 ] );
+		} );
+	} );
+} );