Commit aec362ebbd6 for woocommerce
commit aec362ebbd6e453be9e6a063da707a7b819c41ce
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 1 14:38:40 2026 +0300
Fix unselectable and colliding Variation Selector attribute rows (#68198)
* fix(blocks): identify Variation Selector rows by position
The Variation Selector attribute template renders one row per product
attribute in the editor, and used `attribute.id` as each row's identity
- for the React key, for the selection state, and for the comparison
that decides which row is editable.
That identity is not unique. `ProductSchema::get_attributes()` reports
`$attribute->get_id()`, and `WC_Product_Attribute` defaults that to 0
for every attribute that is not taxonomy backed, exactly as the schema
documents: "The attribute ID, or 0 if the attribute is not taxonomy
based". So every custom (local) attribute arrives as id 0. Two
consequences: clicking a custom attribute row stored 0, which the `||`
fallback then discarded in favour of the first attribute's id, so the
click did nothing; and two custom attributes shared the React key 0,
producing a duplicate-key warning while both rows matched the selection
test and mounted the inner blocks for the same clientId.
Identify rows by their position in the list instead. Position is unique
by construction and does not depend on what the Store API reports, so
both symptoms go away without touching the fetch or the data. The
initial render is unchanged - the first row is still the editable one.
The selection state and `AttributeItem` are module-private, so no
exported surface changes.
Refs #68197
* test(blocks): pin the fixture attribute id to the reported value
The fixture for "selects a custom attribute row when it is clicked"
paired a global attribute carrying id 4 with a custom one carrying id 0.
Both are plausible shapes, but 4 was arbitrary - the store captured in
the issue reports id 1 for that attribute.
The arbitrary value cost the test its teeth. Rows are identified by list
position now, and a tempting future refactor is to prefer the reported
id and fall back to the position: `attribute.id || index`. Under that
refactor the two rows resolve to 4 and 1, stay distinct, and every
assertion still passes, so the regression would land unnoticed.
Use 1, the id the Store API actually returned. The global row then
resolves to 1 and the custom row falls back to its index, also 1, so the
two collide - both render inner blocks, no preview is left to click, and
the test fails. Confirmed by applying that refactor: it escapes at 4 and
is caught at 1. A comment above the fixture records why the value
matters, so a later cleanup does not hand it back to an arbitrary
number.
Refs #68197
* test(blocks): query variation selector rows explicitly
The helper that reports which attribute row is editable collected rows
with a regular expression test-ID query and read the matched elements in
document order, then looked for the one carrying the inner-blocks
marker.
Testing Library matches a regular expression unanchored, so the query
was really "any test ID containing these words". Any marker added later
whose name merely contains `attribute-preview` or `attribute-inner-
blocks` joins the list and shifts every index after it, and because the
helper returns a position rather than failing, the tests report the
wrong row instead of erroring. Rendering a nested
`attribute-preview-toolbar` marker demonstrates it: the helper claims
row 1 is editable when row 0 is, and two of the three tests fail for a
reason that has nothing to do with the code under test. This was also
the only regular expression test-ID query in the Blocks suite, which
reaches for `within()` in eight other files.
Tag each row on the mocked `BlockContextProvider` and ask each row
directly whether it holds the inner-blocks marker. Both queries are
exact, so an unrelated marker can no longer join the row list, and the
returned index counts rows rather than whichever markers happened to
match. The same nested-marker case now passes untouched, and the
fixture's guard against an `attribute.id || index` identity still fails
as intended.
Refs #68197
diff --git a/plugins/woocommerce/changelog/fix-variation-selector-custom-attribute-identity b/plugins/woocommerce/changelog/fix-variation-selector-custom-attribute-identity
new file mode 100644
index 00000000000..4ee5f24fcb4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-variation-selector-custom-attribute-identity
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Variation Selector attribute rows being unselectable, and colliding with each other, for custom (non-taxonomy) product attributes in the editor.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
index cc4b06d4e71..9aea8d01fc6 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
@@ -343,22 +343,21 @@ export default function AttributeItemTemplateEdit(
</InspectorControls>
<div { ...blockProps }>
- { productAttributes.map( ( attribute ) => (
+ { productAttributes.map( ( attribute, index ) => (
+ // Identify rows by position, not by `attribute.id`: the
+ // Store API reports 0 for every non-taxonomy attribute, so
+ // custom attributes would all share the same identity.
<CustomDataProvider
- key={ attribute.id }
+ key={ index }
id="attribute"
data={ attribute }
>
<AttributeItem
blocks={ blocks }
isSelected={
- ( selectedAttributeItem ||
- productAttributes[ 0 ]?.id ) ===
- attribute.id
- }
- onSelect={ () =>
- setSelectedAttributeItem( attribute.id )
+ ( selectedAttributeItem ?? 0 ) === index
}
+ onSelect={ () => setSelectedAttributeItem( index ) }
/>
</CustomDataProvider>
) ) }
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
index 8aa388053e3..987b0c3cd42 100644
--- 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
@@ -1,7 +1,8 @@
/**
* External dependencies
*/
-import { render } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import * as hooks from '@woocommerce/base-context/hooks';
import type { ComponentProps, ReactNode } from 'react';
@@ -16,15 +17,23 @@ jest.mock( '@woocommerce/base-context/hooks', () => ( {
...jest.requireActual( '@woocommerce/base-context/hooks' ),
} ) );
+// A selected row renders the inner blocks; an unselected one renders a
+// clickable preview. Tagging each row, and both of those, lets the tests tell
+// them apart, since the component renders no attribute-identifying markup of
+// its own.
jest.mock( '@wordpress/block-editor', () => ( {
...jest.requireActual( '@wordpress/block-editor' ),
BlockContextProvider: ( { children }: { children: ReactNode } ) => (
- <div>{ children }</div>
+ <div data-testid="attribute-row">{ children }</div>
),
InspectorControls: () => null,
useBlockProps: jest.fn( () => ( {} ) ),
- useInnerBlocksProps: jest.fn( () => ( {} ) ),
- __experimentalUseBlockPreview: jest.fn( () => ( {} ) ),
+ useInnerBlocksProps: jest.fn( () => ( {
+ 'data-testid': 'attribute-inner-blocks',
+ } ) ),
+ __experimentalUseBlockPreview: jest.fn( () => ( {
+ 'data-testid': 'attribute-preview',
+ } ) ),
} ) );
jest.mock( '@wordpress/data', () => ( {
@@ -68,6 +77,49 @@ const renderEdit = () =>
/>
);
+/**
+ * Builds an attribute in the shape the Store API returns. Non-taxonomy
+ * ("custom") attributes always carry id 0, and so do their terms.
+ *
+ * @param name Attribute label.
+ * @param taxonomy Taxonomy name, or null for a custom attribute.
+ * @param id Attribute ID; 0 for custom attributes.
+ */
+const attribute = ( name: string, taxonomy: string | null, id: number ) => ( {
+ id,
+ taxonomy,
+ name,
+ has_variations: true,
+ terms: [
+ { id: taxonomy ? id * 10 + 1 : 0, slug: 'one', name: 'One' },
+ { id: taxonomy ? id * 10 + 2 : 0, slug: 'two', name: 'Two' },
+ ],
+} );
+
+const renderWithAttributes = (
+ attributes: ReturnType< typeof attribute >[]
+) => {
+ sharedContext.useProductDataContext.mockReturnValue( {
+ product: { id: 15, name: 'Hoodie', type: 'variable', attributes },
+ } );
+
+ return renderEdit();
+};
+
+/**
+ * Reports which row is currently editable, by position.
+ *
+ * Exactly one row renders inner blocks; every other row renders a preview. The
+ * returned index is the position of the editable one.
+ */
+const selectedRowIndex = () =>
+ screen
+ .getAllByTestId( 'attribute-row' )
+ .findIndex(
+ ( row ) =>
+ !! within( row ).queryByTestId( 'attribute-inner-blocks' )
+ );
+
describe( 'Variation Selector attribute template edit', () => {
let useCollectionSpy: jest.SpyInstance;
@@ -95,9 +147,7 @@ describe( 'Variation Selector attribute template edit', () => {
// cannot pass by never running.
expect(
new Set( calls.map( ( args ) => args.resourceValues[ 0 ] ) )
- ).toEqual(
- new Set( DEFAULT_ATTRIBUTES.map( ( attribute ) => attribute.id ) )
- );
+ ).toEqual( new Set( DEFAULT_ATTRIBUTES.map( ( attr ) => attr.id ) ) );
} );
it( 'queries the Store API for a real variable product attribute', () => {
@@ -130,4 +180,61 @@ describe( 'Variation Selector attribute template edit', () => {
expect( args.query.include ).toEqual( [ 27, 28 ] );
} );
} );
+
+ it( 'selects a custom attribute row when it is clicked', async () => {
+ const user = userEvent.setup();
+ // A global attribute followed by a custom one, which the Store API
+ // reports as id 0 - the payload from #68197. The global id is 1 on
+ // purpose: it collides with the custom row's index, so this case also
+ // fails if the identity ever becomes `attribute.id || index`.
+ renderWithAttributes( [
+ attribute( 'Color', 'pa_color', 1 ),
+ attribute( 'Fit', null, 0 ),
+ ] );
+
+ expect( selectedRowIndex() ).toBe( 0 );
+
+ await user.click( screen.getByTestId( 'attribute-preview' ) );
+
+ expect( selectedRowIndex() ).toBe( 1 );
+ } );
+
+ it( 'keeps exactly one row editable when every attribute is custom', async () => {
+ const user = userEvent.setup();
+ renderWithAttributes( [
+ attribute( 'Size', null, 0 ),
+ attribute( 'Fit', null, 0 ),
+ ] );
+
+ expect(
+ screen.getAllByTestId( 'attribute-inner-blocks' )
+ ).toHaveLength( 1 );
+ expect( selectedRowIndex() ).toBe( 0 );
+
+ await user.click( screen.getByTestId( 'attribute-preview' ) );
+
+ expect(
+ screen.getAllByTestId( 'attribute-inner-blocks' )
+ ).toHaveLength( 1 );
+ expect( selectedRowIndex() ).toBe( 1 );
+ } );
+
+ it( 'does not render duplicate React keys for custom attributes', () => {
+ const errorSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation( () => undefined );
+
+ renderWithAttributes( [
+ attribute( 'Size', null, 0 ),
+ attribute( 'Fit', null, 0 ),
+ ] );
+
+ const duplicateKeyWarnings = errorSpy.mock.calls.filter( ( call ) =>
+ String( call[ 0 ] ).includes( 'same key' )
+ );
+
+ expect( duplicateKeyWarnings ).toHaveLength( 0 );
+
+ errorSpy.mockRestore();
+ } );
} );