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