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